Use invoke for backend workflows and CI
This commit is contained in:
parent
eca1ba5622
commit
986ef9e925
|
|
@ -18,8 +18,9 @@ rails spec:
|
||||||
- when: always
|
- when: always
|
||||||
script:
|
script:
|
||||||
- cd /app
|
- cd /app
|
||||||
- bundle exec rails db:migrate
|
- python3 -m pip install invoke
|
||||||
- bundle exec rspec
|
- inv db-migrate
|
||||||
|
- inv test
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- specs_with_runtime.txt
|
- specs_with_runtime.txt
|
||||||
|
|
@ -35,13 +36,8 @@ rails e2e spec:
|
||||||
when: never
|
when: never
|
||||||
- when: always
|
- when: always
|
||||||
script:
|
script:
|
||||||
- 'cd /app'
|
- cd /app
|
||||||
- 'bundle exec rails db:migrate'
|
- python3 -m pip install invoke
|
||||||
- 'bundle exec rails runner -e test "user = User.find_or_initialize_by(email: ''e2e@example.com''); user.username = ''e2e-user''; user.password = ''password123''; user.password_confirmation = ''password123''; user.confirmed_at = Time.current; user.uid = user.email; user.provider = ''email''; user.save!"'
|
- inv verify-http
|
||||||
- 'bundle exec rails s -e test -p 3000 -b 0.0.0.0 > /tmp/turniere-e2e-server.log 2>&1 &'
|
|
||||||
- 'SERVER_PID=$!'
|
|
||||||
- 'ruby script/wait_for_http.rb http://127.0.0.1:3000 60'
|
|
||||||
- 'TURNIERE_E2E_BASE_URL=http://127.0.0.1:3000 TURNIERE_E2E_EMAIL=e2e@example.com TURNIERE_E2E_PASSWORD=password123 TURNIERE_E2E_USERNAME=e2e-user bundle exec rspec spec/e2e/http'
|
|
||||||
- 'kill $SERVER_PID'
|
|
||||||
after_script:
|
after_script:
|
||||||
- 'test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true'
|
- 'test -f /tmp/turniere-e2e-server.log && tail -n 200 /tmp/turniere-e2e-server.log || true'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# turniere-backend Agent Notes
|
||||||
|
|
||||||
|
This repository should be operated through `invoke` tasks by default.
|
||||||
|
|
||||||
|
For routine development, verification, Docker, and E2E work, agents should use
|
||||||
|
`inv ...` rather than ad-hoc shell commands unless there is a strong reason not to.
|
||||||
|
|
||||||
|
## Default Workflow
|
||||||
|
|
||||||
|
Use `tasks.py` and run commands through `inv ...` instead of ad-hoc shell commands for routine work.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `inv db-migrate`
|
||||||
|
- `inv test`
|
||||||
|
- `inv lint`
|
||||||
|
- `inv verify-http`
|
||||||
|
- `inv scenario-main-usecase`
|
||||||
|
- `inv docker-build-all`
|
||||||
|
- `inv docker-test-http-e2e`
|
||||||
|
|
||||||
|
The GitLab CI pipeline is expected to use these tasks as well.
|
||||||
|
|
||||||
|
This is not just a convenience preference. The task layer is the operational
|
||||||
|
contract for this repo and should stay aligned across local use, CI, and
|
||||||
|
cross-repo consumers.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The point of the task layer is:
|
||||||
|
|
||||||
|
- one stable entrypoint for local development
|
||||||
|
- one stable entrypoint for CI
|
||||||
|
- one stable entrypoint for frontend and cross-repo test setup
|
||||||
|
- fewer undocumented command variants
|
||||||
|
|
||||||
|
If a workflow matters often enough that a human or CI needs to remember it, it
|
||||||
|
should usually become an `invoke` task.
|
||||||
|
|
||||||
|
## Maintenance Rule
|
||||||
|
|
||||||
|
When new functionality or recurring maintenance work is added, extend `tasks.py`
|
||||||
|
so the new workflow stays easy to discover and easy to run.
|
||||||
|
|
||||||
|
Do not leave important multi-step flows only in:
|
||||||
|
|
||||||
|
- CI YAML
|
||||||
|
- MR descriptions
|
||||||
|
- shell history
|
||||||
|
- team memory
|
||||||
|
|
||||||
|
Instead, add or update an `inv` task and then have CI or docs call that task.
|
||||||
|
|
||||||
|
If a new backend capability introduces a meaningful setup, verification, fixture,
|
||||||
|
or scenario workflow, add or extend a task for it as part of the same change.
|
||||||
|
|
||||||
|
## HTTP E2E
|
||||||
|
|
||||||
|
The backend HTTP E2E flow is intended to be reusable outside this repo, especially by frontend tests that need realistic backend state.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- scenario creation should stay HTTP-driven
|
||||||
|
- reusable scenario setup should be exposed through `inv` tasks and `script/e2e_scenarios.rb`
|
||||||
|
- new commonly needed backend states should be added to the scenario/task layer, not recreated ad hoc in each consuming test suite
|
||||||
|
|
||||||
|
## Practical Expectation
|
||||||
|
|
||||||
|
If you touch test, verification, boot, Docker, or scenario setup workflows,
|
||||||
|
check whether `tasks.py` also needs to change.
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
from invoke import task
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_HOST = "0.0.0.0"
|
||||||
|
SERVER_PORT = "3000"
|
||||||
|
TEST_BASE_URL = f"http://127.0.0.1:{SERVER_PORT}"
|
||||||
|
|
||||||
|
E2E_EMAIL = "e2e@example.com"
|
||||||
|
E2E_PASSWORD = "password123"
|
||||||
|
E2E_USERNAME = "e2e-user"
|
||||||
|
|
||||||
|
PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local"
|
||||||
|
DEVELOPMENT_TAG = "registry.gitlab.com/turniere/turniere-backend/development/commits:local"
|
||||||
|
TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local"
|
||||||
|
|
||||||
|
|
||||||
|
def _env(**overrides):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update({key: str(value) for key, value in overrides.items()})
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_http(base_url=TEST_BASE_URL, timeout=60):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
healthz_url = f"{base_url}/healthz"
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(healthz_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
raise RuntimeError(f"healthcheck did not become ready within {timeout}s: {healthz_url}")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_test_server():
|
||||||
|
log_handle = open("/tmp/turniere-e2e-server.log", "w")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
f"bundle exec rails s -e test -b {SERVER_HOST} -p {SERVER_PORT}",
|
||||||
|
shell=True,
|
||||||
|
stdout=log_handle,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
return process, log_handle
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_process(process, log_handle):
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
log_handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def setup(c):
|
||||||
|
"""Install gem dependencies locally."""
|
||||||
|
c.run("bundle install", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="db-migrate")
|
||||||
|
def db_migrate(c):
|
||||||
|
"""Run database migrations locally."""
|
||||||
|
c.run("bundle exec rails db:migrate", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def server(c, host=SERVER_HOST, port=SERVER_PORT):
|
||||||
|
"""Start the backend locally in development."""
|
||||||
|
c.run(f"bundle exec rails s -b {host} -p {port}", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="server-test")
|
||||||
|
def server_test(c, host=SERVER_HOST, port=SERVER_PORT):
|
||||||
|
"""Start the backend locally in test mode."""
|
||||||
|
c.run(f"bundle exec rails s -e test -b {host} -p {port}", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def test(c):
|
||||||
|
"""Run the normal RSpec suite."""
|
||||||
|
c.run("bundle exec rspec", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def lint(c):
|
||||||
|
"""Run RuboCop."""
|
||||||
|
c.run("bundle exec rubocop", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="bootstrap-e2e-user")
|
||||||
|
def bootstrap_e2e_user(c, email=E2E_EMAIL, password=E2E_PASSWORD, username=E2E_USERNAME):
|
||||||
|
"""Create or update the confirmed HTTP E2E user in test env."""
|
||||||
|
command = (
|
||||||
|
"bundle exec rails runner -e test "
|
||||||
|
f"\"user = User.find_or_initialize_by(email: '{email}'); "
|
||||||
|
f"user.username = '{username}'; "
|
||||||
|
f"user.password = '{password}'; "
|
||||||
|
f"user.password_confirmation = '{password}'; "
|
||||||
|
"user.confirmed_at = Time.current; "
|
||||||
|
"user.uid = user.email; "
|
||||||
|
"user.provider = 'email'; "
|
||||||
|
"user.save!\""
|
||||||
|
)
|
||||||
|
c.run(command, pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="test-http-e2e")
|
||||||
|
def test_http_e2e(
|
||||||
|
c,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
):
|
||||||
|
"""Run HTTP E2E specs against an already running backend."""
|
||||||
|
c.run(
|
||||||
|
"bundle exec rspec spec/e2e/http",
|
||||||
|
env=_env(
|
||||||
|
TURNIERE_E2E_BASE_URL=base_url,
|
||||||
|
TURNIERE_E2E_EMAIL=email,
|
||||||
|
TURNIERE_E2E_PASSWORD=password,
|
||||||
|
TURNIERE_E2E_USERNAME=username,
|
||||||
|
),
|
||||||
|
pty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="verify-http")
|
||||||
|
def verify_http(
|
||||||
|
c,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
):
|
||||||
|
"""Boot a local test server, then run the HTTP E2E suite against it."""
|
||||||
|
db_migrate(c)
|
||||||
|
bootstrap_e2e_user(c, email=email, password=password, username=username)
|
||||||
|
|
||||||
|
process, log_handle = _start_test_server()
|
||||||
|
try:
|
||||||
|
_wait_for_http(base_url=base_url, timeout=60)
|
||||||
|
test_http_e2e(c, base_url=base_url, email=email, password=password, username=username)
|
||||||
|
finally:
|
||||||
|
_stop_process(process, log_handle)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def verify(c):
|
||||||
|
"""Run lint, normal specs, and HTTP E2E locally."""
|
||||||
|
lint(c)
|
||||||
|
test(c)
|
||||||
|
verify_http(c)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="scenario-main-usecase")
|
||||||
|
def scenario_main_usecase(
|
||||||
|
c,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
stop_at="final_finish_attempted",
|
||||||
|
):
|
||||||
|
"""Materialize the main 4x4 group-stage scenario over HTTP."""
|
||||||
|
c.run(
|
||||||
|
f"ruby script/e2e_scenarios.rb group_stage_main_usecase --stop-at {stop_at}",
|
||||||
|
env=_env(
|
||||||
|
TURNIERE_E2E_BASE_URL=base_url,
|
||||||
|
TURNIERE_E2E_EMAIL=email,
|
||||||
|
TURNIERE_E2E_PASSWORD=password,
|
||||||
|
TURNIERE_E2E_USERNAME=username,
|
||||||
|
),
|
||||||
|
pty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="scenario-intermediate")
|
||||||
|
def scenario_intermediate(
|
||||||
|
c,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
stop_at="final_finish_attempted",
|
||||||
|
):
|
||||||
|
"""Materialize the 3-group intermediate-round scenario over HTTP."""
|
||||||
|
c.run(
|
||||||
|
f"ruby script/e2e_scenarios.rb group_stage_intermediate_round --stop-at {stop_at}",
|
||||||
|
env=_env(
|
||||||
|
TURNIERE_E2E_BASE_URL=base_url,
|
||||||
|
TURNIERE_E2E_EMAIL=email,
|
||||||
|
TURNIERE_E2E_PASSWORD=password,
|
||||||
|
TURNIERE_E2E_USERNAME=username,
|
||||||
|
),
|
||||||
|
pty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="scenario-playoff")
|
||||||
|
def scenario_playoff(
|
||||||
|
c,
|
||||||
|
team_count=8,
|
||||||
|
base_url=TEST_BASE_URL,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
stop_at="completed",
|
||||||
|
):
|
||||||
|
"""Materialize a playoff-only scenario over HTTP."""
|
||||||
|
c.run(
|
||||||
|
f"ruby script/e2e_scenarios.rb playoff_only --team-count {team_count} --stop-at {stop_at}",
|
||||||
|
env=_env(
|
||||||
|
TURNIERE_E2E_BASE_URL=base_url,
|
||||||
|
TURNIERE_E2E_EMAIL=email,
|
||||||
|
TURNIERE_E2E_PASSWORD=password,
|
||||||
|
TURNIERE_E2E_USERNAME=username,
|
||||||
|
),
|
||||||
|
pty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="docker-build-production")
|
||||||
|
def docker_build_production(c, tag=PRODUCTION_TAG):
|
||||||
|
"""Build the production Docker image."""
|
||||||
|
c.run(f"docker build -t {tag} -f docker/production/Dockerfile .", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="docker-build-development")
|
||||||
|
def docker_build_development(c, tag=DEVELOPMENT_TAG):
|
||||||
|
"""Build the development Docker image."""
|
||||||
|
c.run(
|
||||||
|
f"docker build --build-arg base_commit=local -t {tag} -f docker/development/Dockerfile .",
|
||||||
|
pty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="docker-build-test")
|
||||||
|
def docker_build_test(c, tag=TEST_TAG):
|
||||||
|
"""Build the test Docker image."""
|
||||||
|
c.run(f"docker build --build-arg base_commit=local -t {tag} -f docker/test/Dockerfile .", pty=True)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="docker-build-all")
|
||||||
|
def docker_build_all(c):
|
||||||
|
"""Build the production, development, and test Docker images."""
|
||||||
|
docker_build_production(c)
|
||||||
|
docker_build_development(c)
|
||||||
|
docker_build_test(c)
|
||||||
|
|
||||||
|
|
||||||
|
@task(name="docker-test-http-e2e")
|
||||||
|
def docker_test_http_e2e(
|
||||||
|
c,
|
||||||
|
image=TEST_TAG,
|
||||||
|
email=E2E_EMAIL,
|
||||||
|
password=E2E_PASSWORD,
|
||||||
|
username=E2E_USERNAME,
|
||||||
|
):
|
||||||
|
"""Run the HTTP E2E suite inside the test Docker image against a live Rails server."""
|
||||||
|
command = (
|
||||||
|
f"docker run --rm {image} bash -lc "
|
||||||
|
f"\"cd /app && "
|
||||||
|
"bundle exec rails db:migrate && "
|
||||||
|
"bundle exec rails runner -e test "
|
||||||
|
f"\\\"user = User.find_or_initialize_by(email: '{email}'); "
|
||||||
|
f"user.username = '{username}'; "
|
||||||
|
f"user.password = '{password}'; "
|
||||||
|
f"user.password_confirmation = '{password}'; "
|
||||||
|
"user.confirmed_at = Time.current; "
|
||||||
|
"user.uid = user.email; "
|
||||||
|
"user.provider = 'email'; "
|
||||||
|
"user.save!\\\" && "
|
||||||
|
f"bundle exec rails s -e test -p {SERVER_PORT} -b 0.0.0.0 >/tmp/turniere-e2e-server.log 2>&1 & "
|
||||||
|
"SERVER_PID=\\$! && "
|
||||||
|
f"ruby script/wait_for_http.rb {TEST_BASE_URL} 60 && "
|
||||||
|
f"TURNIERE_E2E_BASE_URL={TEST_BASE_URL} "
|
||||||
|
f"TURNIERE_E2E_EMAIL={email} "
|
||||||
|
f"TURNIERE_E2E_PASSWORD={password} "
|
||||||
|
f"TURNIERE_E2E_USERNAME={username} "
|
||||||
|
"bundle exec rspec spec/e2e/http && "
|
||||||
|
"kill \\$SERVER_PID\""
|
||||||
|
)
|
||||||
|
c.run(command, pty=True)
|
||||||
Loading…
Reference in New Issue