Add Gitea build, container tests and registry publication
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
This commit is contained in:
29
ci/cleanup.sh
Normal file
29
ci/cleanup.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(dirname -- "$0")/common.sh"
|
||||
status=0
|
||||
# Copy only test diagnostics, never environment dumps, Docker inspect or credentials.
|
||||
for report in agiledancers-be/target/surefire-reports agiledancers-be/target/failsafe-reports \
|
||||
agiledancers-fe/unit-results agiledancers-fe/test-results agiledancers-fe/playwright-report; do
|
||||
if [[ -d "$report" ]]; then
|
||||
destination="$ARTIFACTS/$report"
|
||||
mkdir -p "$destination"
|
||||
cp -R "$report/." "$destination/" || status=1
|
||||
fi
|
||||
done
|
||||
if docker info >/dev/null 2>&1; then
|
||||
for role in backend frontend browser; do
|
||||
container="$RUN_NAME-$role"
|
||||
if docker container inspect "$container" >/dev/null 2>&1; then
|
||||
docker logs "$container" > "$ARTIFACTS/$role.log" 2>&1 || status=1
|
||||
docker rm -f "$container" >/dev/null || status=1
|
||||
fi
|
||||
done
|
||||
if docker network inspect "$RUN_NAME" >/dev/null 2>&1; then
|
||||
docker network rm "$RUN_NAME" >/dev/null || status=1
|
||||
fi
|
||||
rm -f "$ARTIFACTS/containers-started"
|
||||
elif [[ -f "$ARTIFACTS/containers-started" ]]; then
|
||||
echo "Docker is unavailable; cleanup could not be confirmed" >&2
|
||||
status=1
|
||||
fi
|
||||
exit "$status"
|
||||
40
ci/common.sh
Normal file
40
ci/common.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared by host-runner scripts. No credentials are persisted in the workspace.
|
||||
set -Eeuo pipefail
|
||||
CI_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$CI_ROOT"
|
||||
ARTIFACTS="$CI_ROOT/ci/artifacts"
|
||||
mkdir -p "$ARTIFACTS"
|
||||
: "${CI_COMMIT:=$(git rev-parse HEAD)}"
|
||||
: "${CI_RUN_ID:=local}"
|
||||
: "${CI_RUN_ATTEMPT:=1}"
|
||||
[[ "$CI_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid commit SHA' >&2; exit 1; }
|
||||
[[ "$CI_RUN_ID-$CI_RUN_ATTEMPT" =~ ^[a-zA-Z0-9-]+$ ]] || { echo 'Invalid run identifier' >&2; exit 1; }
|
||||
RUN_NAME="agiledancers-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
|
||||
BACKEND_IMAGE="agiledancers-ci-backend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
|
||||
FRONTEND_IMAGE="agiledancers-ci-frontend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
|
||||
|
||||
require_platform() {
|
||||
: "${TARGET_PLATFORM:?Set TARGET_PLATFORM to the verified Synology platform (for example linux/amd64)}"
|
||||
local arch native
|
||||
arch=$(docker info --format '{{.Architecture}}')
|
||||
case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
|
||||
native="$(docker info --format '{{.OSType}}')/$arch"
|
||||
[[ "$TARGET_PLATFORM" == "$native" ]] || {
|
||||
echo "Target $TARGET_PLATFORM differs from Docker host $native. Resolve and test the architecture before running CI." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# Anonymous build pulls must not invoke the developer's desktop credential helper.
|
||||
# Publication uses the same isolation and logs in only to our registry.
|
||||
isolate_docker_config() {
|
||||
if [[ -z "${DOCKER_HOST:-}" ]]; then
|
||||
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
|
||||
export DOCKER_HOST
|
||||
fi
|
||||
unset DOCKER_CONTEXT
|
||||
CI_DOCKER_CONFIG=$(mktemp -d)
|
||||
DOCKER_CONFIG=$CI_DOCKER_CONFIG
|
||||
export DOCKER_CONFIG
|
||||
}
|
||||
67
ci/configure-gitea.py
Normal file
67
ci/configure-gitea.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure repository Actions through the Gitea API; read tokens from local files."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = 'http://192.168.2.28:3000/api/v1/repos/wanjakrah/agiledancers'
|
||||
|
||||
def read_secret(variable):
|
||||
path = Path(os.environ[variable]).expanduser()
|
||||
if path.stat().st_mode & 0o077:
|
||||
raise ValueError(f'{variable} must refer to a file readable only by its owner (chmod 600)')
|
||||
value = path.read_text().strip()
|
||||
if not value:
|
||||
raise ValueError(f'{variable} is empty')
|
||||
return value
|
||||
|
||||
def main():
|
||||
admin = read_secret('GITEA_ADMIN_TOKEN_FILE')
|
||||
registry = read_secret('REGISTRY_TOKEN_FILE')
|
||||
user = os.environ['REGISTRY_USER']
|
||||
platform = os.environ['TARGET_PLATFORM']
|
||||
if platform not in ('linux/amd64', 'linux/arm64'):
|
||||
raise ValueError('Set TARGET_PLATFORM from the verified Synology architecture')
|
||||
|
||||
def api(method, path, body):
|
||||
request = urllib.request.Request(BASE + path, method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None, headers={
|
||||
'Authorization': 'token ' + admin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
data = response.read()
|
||||
return json.loads(data) if data else None
|
||||
|
||||
api('PATCH', '', {'has_actions': True})
|
||||
for name, value in [('REGISTRY_USER', user), ('REGISTRY_TOKEN', registry)]:
|
||||
api('PUT', '/actions/secrets/' + name, {'data': value})
|
||||
try:
|
||||
api('GET', '/actions/variables/TARGET_PLATFORM', None)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
api('POST', '/actions/variables/TARGET_PLATFORM', {'value': platform})
|
||||
else:
|
||||
api('PUT', '/actions/variables/TARGET_PLATFORM', {'value': platform})
|
||||
# Store registration credentials outside Git; never print API responses/tokens.
|
||||
result = api('POST', '/actions/runners/registration-token', None)
|
||||
token_path = Path.home() / '.config/agiledancers-runner/registration-token'
|
||||
token_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
output.write(result['token'])
|
||||
print('Actions, registry secrets and target platform configured; runner token saved outside Git.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as error:
|
||||
print(f'Gitea configuration failed: HTTP {error.code}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (OSError, ValueError, KeyError, urllib.error.URLError) as error:
|
||||
# Do not include request bodies, headers, response bodies or token values.
|
||||
print(f'Gitea configuration failed ({type(error).__name__}); check local files and connection.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
16
ci/main-head.py
Normal file
16
ci/main-head.py
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the current main head without placing an access token in arguments/logs."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = os.environ['GITEA_SERVER_URL'].rstrip('/') + '/api/v1/repos/' + os.environ['CI_REPOSITORY'] + '/branches/main'
|
||||
request = urllib.request.Request(url, headers={'Authorization': 'token ' + os.environ['REGISTRY_TOKEN']})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
print(json.load(response)['commit']['id'])
|
||||
except (urllib.error.URLError, KeyError, ValueError) as error:
|
||||
print(f'Cannot verify current main head ({type(error).__name__})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
76
ci/publish.sh
Normal file
76
ci/publish.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(dirname -- "$0")/common.sh"
|
||||
# Also enforce this inside the script, independently of the workflow expression.
|
||||
[[ "${CI_REF:-}" == refs/heads/main ]] || { echo 'Branch is not main; no publication'; exit 0; }
|
||||
[[ -f "$ARTIFACTS/verified-commit" && "$(cat "$ARTIFACTS/verified-commit")" == "$CI_COMMIT" ]] || {
|
||||
echo 'This commit has not passed all verification steps' >&2; exit 1;
|
||||
}
|
||||
: "${REGISTRY_USER:?Missing REGISTRY_USER secret}"
|
||||
: "${REGISTRY_TOKEN:?Missing REGISTRY_TOKEN secret}"
|
||||
: "${GITEA_SERVER_URL:?Missing Gitea server URL}"
|
||||
: "${CI_REPOSITORY:?Missing repository}"
|
||||
require_platform
|
||||
exec > >(tee "$ARTIFACTS/publish.log") 2>&1
|
||||
REGISTRY=${GITEA_SERVER_URL#http://}
|
||||
REGISTRY=${REGISTRY#https://}
|
||||
REGISTRY=${REGISTRY%/}
|
||||
[[ "$REGISTRY" != */* ]] || { echo 'Registry must be hosted at the Gitea server root'; exit 1; }
|
||||
OWNER=${CI_REPOSITORY%%/*}
|
||||
OWNER=${OWNER,,}
|
||||
[[ "$OWNER" =~ ^[a-z0-9][a-z0-9_.-]*$ ]] || { echo 'Invalid registry namespace'; exit 1; }
|
||||
# Gitea 1.25 ignores workflow concurrency. All publications on the sole runner
|
||||
# share this lock, outside checkout directories and across workflow attempts.
|
||||
lock_dir=${CI_PUBLISH_LOCK_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/agiledancers-ci}
|
||||
mkdir -p "$lock_dir"
|
||||
exec 9>"$lock_dir/publish.lock"
|
||||
flock -w 600 9
|
||||
# Preserve the selected daemon and isolate credentials from the developer's config.
|
||||
isolate_docker_config
|
||||
trap 'rm -rf -- "$DOCKER_CONFIG"' EXIT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
|
||||
|
||||
ensure_commit_image() {
|
||||
local role=$1 source=$2 repository="$REGISTRY/$OWNER/agiledancers-$1" ref error
|
||||
ref="$repository:$CI_COMMIT"
|
||||
error=$(mktemp)
|
||||
if docker manifest inspect --insecure "$ref" > /dev/null 2>"$error"; then
|
||||
echo "Preserving existing $ref"
|
||||
elif grep -Eqi 'manifest unknown|no such manifest' "$error"; then
|
||||
docker tag "$source" "$ref"
|
||||
docker push "$ref"
|
||||
else
|
||||
# Authentication/network/server errors are never treated as missing tags.
|
||||
cat "$error" >&2
|
||||
rm -f "$error"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$error"
|
||||
docker pull --platform "$TARGET_PLATFORM" "$ref"
|
||||
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$ref")" == "$CI_COMMIT" ]] || {
|
||||
echo "Existing $role image has an unexpected source revision" >&2; return 1;
|
||||
}
|
||||
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$ref" \
|
||||
> "$ARTIFACTS/$role-digests.txt"
|
||||
grep -Fq "$repository@sha256:" "$ARTIFACTS/$role-digests.txt" || { echo 'Missing registry digest'; return 1; }
|
||||
}
|
||||
ensure_commit_image backend "$BACKEND_IMAGE"
|
||||
ensure_commit_image frontend "$FRONTEND_IMAGE"
|
||||
# Both immutable images are now pull-verified. Only current main may move aliases.
|
||||
head=$(python3 ci/main-head.py)
|
||||
if [[ "$head" != "$CI_COMMIT" ]]; then
|
||||
echo 'A newer main commit exists; leaving moving tags unchanged.'
|
||||
exit 0
|
||||
fi
|
||||
for role in backend frontend; do
|
||||
repository="$REGISTRY/$OWNER/agiledancers-$role"
|
||||
# Recheck under the publication lock before each moving-tag write.
|
||||
[[ "$(python3 ci/main-head.py)" == "$CI_COMMIT" ]] || {
|
||||
echo 'Main advanced during publication; leaving remaining moving tags unchanged.'
|
||||
exit 0
|
||||
}
|
||||
docker tag "$repository:$CI_COMMIT" "$repository:main"
|
||||
docker push "$repository:main"
|
||||
docker pull --platform "$TARGET_PLATFORM" "$repository:main"
|
||||
done
|
||||
17
ci/runner/agiledancers-runner.service
Normal file
17
ci/runner/agiledancers-runner.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Agiledancers Gitea Actions runner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/.local/share/agiledancers-runner
|
||||
EnvironmentFile=%h/.config/agiledancers-runner/environment
|
||||
ExecStart=%h/.local/bin/act_runner daemon --config %h/.local/share/agiledancers-runner/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=150
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
11
ci/runner/config.yaml
Normal file
11
ci/runner/config.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
log:
|
||||
level: info
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 1
|
||||
timeout: 2h
|
||||
shutdown_timeout: 2m
|
||||
labels:
|
||||
- agiledancers-mini-pc:host
|
||||
cache:
|
||||
enabled: false
|
||||
32
ci/runner/configure-docker.py
Normal file
32
ci/runner/configure-docker.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge this project's HTTP registry into a selected daemon JSON file."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit('Usage: python3 ci/runner/configure-docker.py <daemon.json>')
|
||||
path = Path(sys.argv[1]).expanduser()
|
||||
data = json.loads(path.read_text()) if path.exists() else {}
|
||||
registries = data.setdefault('insecure-registries', [])
|
||||
if not isinstance(registries, list):
|
||||
sys.exit('Existing insecure-registries must be an array; configuration left unchanged')
|
||||
if '192.168.2.28:3000' in registries:
|
||||
print('Internal registry already configured; no change')
|
||||
sys.exit(0)
|
||||
registries.append('192.168.2.28:3000')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists():
|
||||
backup = path.with_name(path.name + '.before-agiledancers')
|
||||
if not backup.exists():
|
||||
shutil.copy2(path, backup)
|
||||
# Write in the same directory, then replace atomically.
|
||||
temporary = path.with_name(path.name + '.agiledancers.tmp')
|
||||
with temporary.open('x') as output:
|
||||
json.dump(data, output, indent=2)
|
||||
output.write('\n')
|
||||
if path.exists():
|
||||
shutil.copymode(path, temporary)
|
||||
temporary.replace(path)
|
||||
print('Internal registry added; restart/apply Docker Engine configuration to activate')
|
||||
3
ci/runner/docker-daemon.json
Normal file
3
ci/runner/docker-daemon.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"insecure-registries": ["192.168.2.28:3000"]
|
||||
}
|
||||
52
ci/runner/install.sh
Normal file
52
ci/runner/install.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run as the development user, not root. Configuration stays outside the checkout.
|
||||
set -Eeuo pipefail
|
||||
script_dir=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
runner_dir="$HOME/.local/share/agiledancers-runner"
|
||||
config_dir="$HOME/.config/agiledancers-runner"
|
||||
install -d -m 700 "$runner_dir" "$config_dir" "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
||||
[[ "$(uname -s)/$(uname -m)" == Linux/x86_64 ]] || { echo 'This installer targets the existing Linux x86_64 mini-PC'; exit 1; }
|
||||
for tool in node java mvn docker curl python3 flock timeout; do command -v "$tool" >/dev/null; done
|
||||
: "${JAVA_HOME:?Set JAVA_HOME to your JDK 25 directory}"
|
||||
"$JAVA_HOME/bin/java" -version 2>&1 | grep -Eq 'version "25[.\"]' || { echo 'JDK 25 required'; exit 1; }
|
||||
node -e 'const [a,b,c]=process.versions.node.split(".").map(Number); if (!((a===22&&(b>22||(b===22&&c>=3)))||(a===24&&(b>15||(b===15&&c>=0)))||a>=26)) process.exit(1)'
|
||||
if [[ ! -x "$HOME/.local/bin/act_runner" ]]; then
|
||||
download_dir=$(mktemp -d)
|
||||
trap 'rm -rf -- "$download_dir"' EXIT
|
||||
curl --fail --location --retry 3 \
|
||||
https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64 -o "$download_dir/act_runner"
|
||||
printf '%s %s\n' 3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2 "$download_dir/act_runner" | sha256sum --check
|
||||
install -m 755 "$download_dir/act_runner" "$HOME/.local/bin/act_runner"
|
||||
fi
|
||||
"$HOME/.local/bin/act_runner" --version | grep -F 'v0.2.13'
|
||||
install -m 600 "$script_dir/config.yaml" "$runner_dir/config.yaml"
|
||||
install -m 644 "$script_dir/agiledancers-runner.service" "$HOME/.config/systemd/user/agiledancers-runner.service"
|
||||
# Resolve paths now: user services do not inherit an interactive shell's PATH/context.
|
||||
node_dir=$(dirname -- "$(command -v node)")
|
||||
maven_dir=$(dirname -- "$(command -v mvn)")
|
||||
docker_host=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}
|
||||
umask 077
|
||||
printf 'JAVA_HOME=%s\nPATH=%s/bin:%s:%s:/usr/local/bin:/usr/bin:/bin\nDOCKER_HOST=%s\n' \
|
||||
"$JAVA_HOME" "$JAVA_HOME" "$node_dir" "$maven_dir" "$docker_host" > "$config_dir/environment"
|
||||
systemctl --user daemon-reload
|
||||
if [[ "${1:-}" == --prepare-only ]]; then
|
||||
echo 'Runner installed; registration and startup still required.'
|
||||
exit 0
|
||||
fi
|
||||
docker info >/dev/null
|
||||
if [[ ! -f "$runner_dir/.runner" ]]; then
|
||||
token_file=${RUNNER_REGISTRATION_TOKEN_FILE:-$config_dir/registration-token}
|
||||
[[ -f "$token_file" ]] || { echo "Place the repository runner registration token in $token_file (mode 600), then rerun."; exit 1; }
|
||||
registration_token=$(cat "$token_file")
|
||||
[[ -n "$registration_token" ]] || { echo 'Empty registration token'; exit 1; }
|
||||
(
|
||||
cd "$runner_dir"
|
||||
"$HOME/.local/bin/act_runner" register --no-interactive \
|
||||
--instance http://192.168.2.28:3000 --name agiledancers-mini-pc \
|
||||
--labels agiledancers-mini-pc:host --token "$registration_token"
|
||||
chmod 600 .runner
|
||||
)
|
||||
unset registration_token
|
||||
fi
|
||||
systemctl --user enable --now agiledancers-runner.service
|
||||
echo 'Runner registered and started. The mini-PC and Docker must remain available.'
|
||||
29
ci/tests/test_docker_configuration.py
Normal file
29
ci/tests/test_docker_configuration.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'runner/configure-docker.py'
|
||||
|
||||
class DockerConfigurationTest(unittest.TestCase):
|
||||
def test_merge_preserves_existing_settings_and_is_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'daemon.json'
|
||||
original = {'features': {'buildkit': True}, 'insecure-registries': ['existing:5000']}
|
||||
path.write_text(json.dumps(original))
|
||||
for _ in range(2):
|
||||
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
actual = json.loads(path.read_text())
|
||||
self.assertEqual(original['features'], actual['features'])
|
||||
self.assertEqual(['existing:5000', '192.168.2.28:3000'], actual['insecure-registries'])
|
||||
self.assertEqual(original, json.loads(path.with_name('daemon.json.before-agiledancers').read_text()))
|
||||
def test_invalid_configuration_is_left_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'daemon.json'
|
||||
original = '{"insecure-registries": "invalid"}'
|
||||
path.write_text(original)
|
||||
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual(original, path.read_text())
|
||||
115
ci/tests/test_publication.py
Normal file
115
ci/tests/test_publication.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Exercise real shell control flow against a stateful registry/daemon double."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SHA = 'a' * 40
|
||||
REGISTRY = '192.168.2.28:3000/wanjakrah/agiledancers-'
|
||||
|
||||
DOCKER = r'''#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys
|
||||
p = pathlib.Path(os.environ['FAKE_STATE'])
|
||||
s = json.loads(p.read_text())
|
||||
a = sys.argv[1:]
|
||||
s['calls'].append(a)
|
||||
p.write_text(json.dumps(s))
|
||||
if a[0] == 'info':
|
||||
print('amd64' if 'Architecture' in ' '.join(a) else 'linux')
|
||||
elif a[0] == 'context': print('unix:///tmp/test-docker.sock')
|
||||
elif a[0] == 'login': sys.stdin.read()
|
||||
elif a[:2] == ['manifest', 'inspect']:
|
||||
if os.environ.get('FAKE_MANIFEST_ERROR'):
|
||||
print('unauthorized: authentication required', file=sys.stderr); sys.exit(1)
|
||||
if a[-1] not in s['images']:
|
||||
print('manifest unknown', file=sys.stderr); sys.exit(1)
|
||||
elif a[0] == 'push':
|
||||
if os.environ.get('FAKE_PUSH_FAILURE', '') in a[-1] and os.environ.get('FAKE_PUSH_FAILURE'):
|
||||
sys.exit(1)
|
||||
s['images'][a[-1]] = os.environ['CI_COMMIT']; p.write_text(json.dumps(s))
|
||||
elif a[0] == 'pull':
|
||||
if os.environ.get('FAKE_PULL_FAILURE', '') in a[-1] and os.environ.get('FAKE_PULL_FAILURE'):
|
||||
sys.exit(1)
|
||||
elif a[:2] == ['image', 'inspect']:
|
||||
if 'RepoDigests' in ' '.join(a): print(a[-1].rsplit(':', 1)[0] + '@sha256:' + 'b' * 64)
|
||||
else: print(s['images'].get(a[-1], 'missing'))
|
||||
elif a[:2] == ['container', 'inspect'] or a[:2] == ['network', 'inspect']: sys.exit(1)
|
||||
'''
|
||||
|
||||
class PublicationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
shutil.copytree(ROOT / 'ci', self.root / 'ci', ignore=shutil.ignore_patterns('artifacts', '__pycache__'))
|
||||
(self.root / 'ci/main-head.py').write_text("import os; print(os.environ.get('FAKE_MAIN_HEAD', os.environ['CI_COMMIT']))\n")
|
||||
self.bin = self.root / 'bin'; self.bin.mkdir()
|
||||
(self.bin / 'docker').write_text(DOCKER); (self.bin / 'docker').chmod(0o755)
|
||||
self.state = self.root / 'state.json'
|
||||
self.state.write_text(json.dumps({'images': {}, 'calls': []}))
|
||||
artifacts = self.root / 'ci/artifacts'; artifacts.mkdir()
|
||||
(artifacts / 'verified-commit').write_text(SHA + '\n')
|
||||
self.env = dict(os.environ, PATH=str(self.bin) + ':' + os.environ['PATH'],
|
||||
CI_COMMIT=SHA, CI_REF='refs/heads/main', CI_REPOSITORY='wanjakrah/agiledancers',
|
||||
CI_RUN_ID='test', CI_RUN_ATTEMPT='1', TARGET_PLATFORM='linux/amd64',
|
||||
GITEA_SERVER_URL='http://192.168.2.28:3000', REGISTRY_USER='test', REGISTRY_TOKEN='not-a-secret',
|
||||
CI_PUBLISH_LOCK_DIR=str(self.root / 'lock'), FAKE_STATE=str(self.state))
|
||||
def run_script(self, script='publish.sh', **extra):
|
||||
return subprocess.run(['bash', str(self.root / 'ci' / script)], cwd=self.root,
|
||||
env=dict(self.env, **extra), capture_output=True, text=True, timeout=20)
|
||||
def calls(self, command):
|
||||
return [a for a in json.loads(self.state.read_text())['calls'] if a[0] == command]
|
||||
def test_feature_branch_does_not_contact_registry(self):
|
||||
result = self.run_script(CI_REF='refs/heads/feature/test')
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual([], self.calls('login'))
|
||||
self.assertEqual([], self.calls('push'))
|
||||
def test_unverified_commit_cannot_publish(self):
|
||||
(self.root / 'ci/artifacts/verified-commit').unlink()
|
||||
self.assertNotEqual(0, self.run_script().returncode)
|
||||
self.assertEqual([], self.calls('push'))
|
||||
def test_publish_pull_and_rerun_preserves_commit_tags(self):
|
||||
result = self.run_script()
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertEqual(4, len(self.calls('push')))
|
||||
result = self.run_script()
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertEqual(6, len(self.calls('push'))) # only main aliases on rerun
|
||||
self.assertEqual(8, len(self.calls('pull')))
|
||||
def test_partial_publication_is_completed_without_overwriting_backend(self):
|
||||
self.assertNotEqual(0, self.run_script(FAKE_PUSH_FAILURE='frontend:' + SHA).returncode)
|
||||
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
|
||||
result = self.run_script()
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
backend_pushes = [a for a in self.calls('push') if a[-1] == REGISTRY + 'backend:' + SHA]
|
||||
self.assertEqual(1, len(backend_pushes))
|
||||
def test_registry_auth_failure_is_not_treated_as_missing_image(self):
|
||||
self.assertNotEqual(0, self.run_script(FAKE_MANIFEST_ERROR='1').returncode)
|
||||
self.assertEqual([], self.calls('push'))
|
||||
def test_pull_failure_blocks_main_aliases(self):
|
||||
self.assertNotEqual(0, self.run_script(FAKE_PULL_FAILURE='backend:' + SHA).returncode)
|
||||
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
|
||||
def test_old_commit_does_not_move_main(self):
|
||||
result = self.run_script(FAKE_MAIN_HEAD='c' * 40)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertEqual(2, len(self.calls('push')))
|
||||
self.assertTrue(all(a[-1].endswith(':' + SHA) for a in self.calls('push')))
|
||||
def test_architecture_mismatch_fails_before_publication(self):
|
||||
self.assertNotEqual(0, self.run_script(TARGET_PLATFORM='linux/arm64').returncode)
|
||||
self.assertEqual([], self.calls('push'))
|
||||
def test_failed_verification_removes_stale_success_marker_and_never_builds_images(self):
|
||||
for command, content in [('java', 'exit 0'), ('node', 'exit 0'), ('mvn', 'exit 42'),
|
||||
('git', 'echo ' + SHA)]:
|
||||
path = self.bin / command; path.write_text('#!/bin/sh\n' + content + '\n'); path.chmod(0o755)
|
||||
result = self.run_script('verify.sh')
|
||||
self.assertEqual(42, result.returncode, result.stdout + result.stderr)
|
||||
self.assertFalse((self.root / 'ci/artifacts/verified-commit').exists())
|
||||
self.assertEqual([], self.calls('build'))
|
||||
self.assertEqual([], self.calls('push'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
58
ci/verify.sh
Normal file
58
ci/verify.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
source "$(dirname -- "$0")/common.sh"
|
||||
export CI=true
|
||||
# Start fresh: an earlier attempt's success marker must never authorize publishing.
|
||||
rm -f "$ARTIFACTS/verified-commit"
|
||||
exec > >(tee "$ARTIFACTS/verify.log") 2>&1
|
||||
finish() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
bash "$CI_ROOT/ci/cleanup.sh" || { (( status != 0 )) || status=1; }
|
||||
if [[ -n "${CI_DOCKER_CONFIG:-}" ]]; then rm -rf -- "$CI_DOCKER_CONFIG"; fi
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
require_platform
|
||||
isolate_docker_config
|
||||
[[ "$(git rev-parse HEAD)" == "$CI_COMMIT" ]] || { echo 'Checkout does not match pushed commit'; exit 1; }
|
||||
java -version
|
||||
node --version
|
||||
mvn -B -ntp clean verify
|
||||
python3 -m unittest discover -s ci/tests -v
|
||||
(
|
||||
cd agiledancers-fe
|
||||
npm ci
|
||||
npm test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml
|
||||
npm run build
|
||||
)
|
||||
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
|
||||
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$BACKEND_IMAGE" .
|
||||
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
|
||||
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$FRONTEND_IMAGE" agiledancers-fe
|
||||
|
||||
touch "$ARTIFACTS/containers-started"
|
||||
docker network create --label "agiledancers.ci.run=$RUN_NAME" "$RUN_NAME"
|
||||
docker run -d --name "$RUN_NAME-backend" --label "agiledancers.ci.run=$RUN_NAME" \
|
||||
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias backend \
|
||||
--memory 1g --cpus 2 "$BACKEND_IMAGE"
|
||||
docker run -d --name "$RUN_NAME-frontend" --label "agiledancers.ci.run=$RUN_NAME" \
|
||||
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias frontend \
|
||||
--memory 256m --cpus 1 "$FRONTEND_IMAGE"
|
||||
# The browser, tests, readiness checks and API cleanup all use the frontend proxy.
|
||||
# Install this test container's browser dependencies for the exact lockfile version.
|
||||
docker run --name "$RUN_NAME-browser" --label "agiledancers.ci.run=$RUN_NAME" \
|
||||
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --memory 2g --cpus 2 --shm-size=1g \
|
||||
--user "$(id -u):$(id -g)" --env HOME=/tmp --env CI=true \
|
||||
--env PLAYWRIGHT_BASE_URL=http://frontend:8080 \
|
||||
--volume "$CI_ROOT/agiledancers-fe:/workspace" --workdir /workspace \
|
||||
mcr.microsoft.com/playwright:v1.64.1-noble bash -euc '
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent http://frontend:8080/api/dance-schools >/dev/null; then break; fi
|
||||
if [ "$attempt" = 60 ]; then echo "Application readiness timed out" >&2; exit 1; fi
|
||||
sleep 2
|
||||
done
|
||||
npm run e2e
|
||||
'
|
||||
printf '%s\n' "$CI_COMMIT" > "$ARTIFACTS/verified-commit"
|
||||
Reference in New Issue
Block a user