diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..dd8fc18 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,69 @@ +name: Build and Deploy PunktFri + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: debian-host + + env: + PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin + DOCKER_HOST: unix:///var/run/docker.sock + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify Docker + run: docker --version + + - name: Log in to Harbor Registry + run: | + echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin + + - name: Build and push Docker image + run: | + docker build \ + --build-arg BUILD_VERSION=${{ github.ref_name }}-${{ github.sha }} \ + --build-arg GIT_COMMIT=${{ github.sha }} \ + --build-arg BUILD_TIME=${{ github.event.head_commit.timestamp }} \ + -t registry.i80.dk/gitea/punktfri:latest \ + -t registry.i80.dk/gitea/punktfri:${{ github.sha }} \ + -f ./Dockerfile \ + . + docker push registry.i80.dk/gitea/punktfri:latest + docker push registry.i80.dk/gitea/punktfri:${{ github.sha }} + + - name: Validate Nomad job + run: nomad job validate punktfri.nomad + env: + NOMAD_ADDR: "https://nomad.i80.dk:4646" + + - name: Deploy to Nomad + run: nomad job run punktfri.nomad + env: + NOMAD_ADDR: "https://nomad.i80.dk:4646" + + - name: Wait for deployment + run: | + sleep 15 + nomad job status punktfri + echo "=== Allocations ===" + nomad job allocs punktfri + env: + NOMAD_ADDR: "https://nomad.i80.dk:4646" + + - name: Health check + run: | + sleep 20 + curl -f https://punktfri.i80.dk/health || echo "Service not yet available" + + - name: Notify deployment status + run: | + echo "Deployment completed!" + echo "Service: https://punktfri.i80.dk" + echo "Health: https://punktfri.i80.dk/health" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..166a153 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +ARG BUILD_VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_APP=app.py \ + BUILD_VERSION=${BUILD_VERSION} \ + GIT_COMMIT=${GIT_COMMIT} \ + BUILD_TIME=${BUILD_TIME} \ + PORT=5000 \ + HOST=0.0.0.0 \ + DATABASE=/app/instance/punktfri.db \ + LOG_FILE=/app/instance/signups.log + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/instance + +EXPOSE 5000 + +CMD ["sh", "-c", "gunicorn --bind ${HOST}:${PORT} --workers 2 --timeout 60 app:app"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aedbd6a --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +VENV := .venv +PYTHON := $(VENV)/bin/python +PIP := $(VENV)/bin/pip +PYTEST := $(VENV)/bin/pytest + +.PHONY: install run test build + +$(VENV)/bin/activate: + python3 -m venv $(VENV) + +install: $(VENV)/bin/activate + $(PIP) install --quiet -r requirements-dev.txt + +run: install + PORT=5000 $(PYTHON) app.py + +test: install + $(PYTEST) tests/ -v + +build: + docker build -t punktfri:local . diff --git a/app.py b/app.py index 7e1eb5f..83ff702 100644 --- a/app.py +++ b/app.py @@ -128,7 +128,11 @@ def admin(): @app.route("/health") def health(): - return {"status": "ok"}, 200 + return { + "status": "ok", + "version": os.environ.get("BUILD_VERSION", "dev"), + "commit": os.environ.get("GIT_COMMIT", "unknown"), + }, 200 init_db() diff --git a/punktfri.nomad b/punktfri.nomad new file mode 100644 index 0000000..12487c0 --- /dev/null +++ b/punktfri.nomad @@ -0,0 +1,110 @@ +job "punktfri" { + region = "global" + datacenters = ["dc1"] + type = "service" + + update { + stagger = "30s" + max_parallel = 1 + canary = 1 + min_healthy_time = "10s" + healthy_deadline = "5m" + auto_revert = true + auto_promote = true + progress_deadline = "10m" + } + + group "punktfri" { + count = 1 + + constraint { + attribute = "${node.unique.name}" + value = "autobox.i80.dk" + } + + network { + port "http" {} + } + + reschedule { + attempts = 5 + interval = "10m" + delay = "30s" + delay_function = "exponential" + max_delay = "120s" + unlimited = false + } + + volume "punktfri-data" { + type = "host" + source = "punktfri-data" + read_only = false + } + + service { + provider = "consul" + name = "punktfri" + port = "http" + + tags = [ + "traefik.enable=true", + "traefik.http.routers.punktfri.rule=Host(`punktfri.i80.dk`)", + "traefik.http.routers.punktfri.tls=true" + ] + + canary_tags = [ + "traefik.enable=false" + ] + + check { + name = "http_health_check" + type = "http" + path = "/health" + interval = "10s" + timeout = "5s" + + check_restart { + limit = 3 + grace = "10s" + } + } + } + + task "web" { + driver = "docker" + + volume_mount { + volume = "punktfri-data" + destination = "/app/instance" + read_only = false + } + + config { + image = "registry.i80.dk/gitea/punktfri:latest" + ports = ["http"] + force_pull = true + } + + restart { + attempts = 10 + interval = "10m" + delay = "10s" + mode = "fail" + } + + env { + APP_ENV = "production" + PORT = "${NOMAD_PORT_http}" + HOST = "0.0.0.0" + DATABASE = "/app/instance/punktfri.db" + LOG_FILE = "/app/instance/signups.log" + TZ = "Europe/Copenhagen" + } + + resources { + cpu = 200 + memory = 256 + } + } + } +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..31c406a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==8.3.5 diff --git a/requirements.txt b/requirements.txt index 22c34c3..7f53415 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask==3.1.0 Werkzeug==3.1.3 +gunicorn==23.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..44c8e6e --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,92 @@ +import os +import base64 +import pytest + +import app as app_module +from app import app as flask_app, init_db + + +@pytest.fixture +def app(tmp_path): + db_path = str(tmp_path / "test.db") + app_module.DATABASE = db_path + flask_app.config["TESTING"] = True + with flask_app.app_context(): + init_db() + yield flask_app + app_module.DATABASE = os.environ.get("DATABASE", "punktfri.db") + + +@pytest.fixture +def client(app): + return app.test_client() + + +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "ok" + + +def test_index_get(client): + r = client.get("/") + assert r.status_code == 200 + assert b"PunktFri" in r.data + + +def test_tak_get(client): + r = client.get("/tak") + assert r.status_code == 200 + assert b"tak" in r.data.lower() + + +def test_admin_unauthorized(client): + r = client.get("/admin") + assert r.status_code == 401 + + +def test_admin_authorized(client): + creds = base64.b64encode(b"admin:punktfri2024").decode() + r = client.get("/admin", headers={"Authorization": f"Basic {creds}"}) + assert r.status_code == 200 + + +def test_signup_missing_fields(client): + r = client.post("/", data={}) + assert r.status_code == 200 + assert "felter" in r.data.decode("utf-8") + + +def test_signup_invalid_domains(client): + r = client.post("/", data={ + "navn": "Test", "email": "test@test.dk", + "domaener": "0", "egne_ns": "ja", + }) + assert r.status_code == 200 + assert "gyldigt antal" in r.data.decode("utf-8") + + +def test_signup_success(client): + r = client.post("/", data={ + "navn": "Test Bruger", + "email": "test@test.dk", + "domaener": "3", + "egne_ns": "ja", + "kommentar": "Test kommentar", + }, follow_redirects=False) + assert r.status_code == 302 + assert "/tak" in r.location + + +def test_signup_duplicate_email(client): + data = { + "navn": "Test Bruger", + "email": "dup@test.dk", + "domaener": "2", + "egne_ns": "nej", + } + client.post("/", data=data) + r = client.post("/", data=data) + assert r.status_code == 200 + assert "allerede" in r.data.decode("utf-8")