diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f0b033b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI (make tests, stable, publish) + +on: + push: + branches: ["**"] + tags: ["v*.*.*"] # SemVer tags like v1.2.3 + pull_request: + +permissions: + contents: write # push/update 'stable' tag + packages: write # push to GHCR + +env: + IMAGE_NAME: baudolo + REGISTRY: ghcr.io + IMAGE_REPO: ${{ github.repository }} + +jobs: + test: + name: make test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show docker info + run: | + docker version + docker info + + - name: Run all tests via Makefile + run: | + make test + + - name: Upload E2E artifacts (always) + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts + path: artifacts + if-no-files-found: ignore + + stable_and_publish: + name: Mark stable + publish image (SemVer tags only) + needs: [test] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout (full history for tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Derive version from tag + id: ver + run: | + TAG="${GITHUB_REF#refs/tags/}" # v1.2.3 + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Mark 'stable' git tag (force update) + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -f stable "${GITHUB_SHA}" + git push -f origin stable + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build image (Makefile) + run: | + make build + + - name: Tag image for registry + run: | + # local image built by Makefile is: baudolo:local + docker tag "${IMAGE_NAME}:local" "${REGISTRY}/${IMAGE_REPO}:${{ steps.ver.outputs.tag }}" + docker tag "${IMAGE_NAME}:local" "${REGISTRY}/${IMAGE_REPO}:stable" + docker tag "${IMAGE_NAME}:local" "${REGISTRY}/${IMAGE_REPO}:sha-${GITHUB_SHA::12}" + + - name: Push image + run: | + docker push "${REGISTRY}/${IMAGE_REPO}:${{ steps.ver.outputs.tag }}" + docker push "${REGISTRY}/${IMAGE_REPO}:stable" + docker push "${REGISTRY}/${IMAGE_REPO}:sha-${GITHUB_SHA::12}" diff --git a/Makefile b/Makefile index f16b0ba..c49b9e3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PHONY: install build test-e2e +.PHONY: install build \ + test-e2e test test-unit test-integration # Default python if no venv is active PY_DEFAULT ?= python3 @@ -41,4 +42,16 @@ clean: # - loads the freshly built image into DinD # - runs the unittest suite inside a container that talks to DinD via DOCKER_HOST test-e2e: clean build - @bash scripts/test-e2e.sh \ No newline at end of file + @bash scripts/test-e2e.sh + +test: test-unit test-integration test-e2e + +test-unit: clean build + @echo ">> Running unit tests" + @docker run --rm -t $(IMAGE) \ + sh -lc 'python -m unittest discover -t . -s tests/unit -p "test_*.py" -v' + +test-integration: clean build + @echo ">> Running integration tests" + @docker run --rm -t $(IMAGE) \ + sh -lc 'python -m unittest discover -t . -s tests/integration -p "test_*.py" -v' \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_seed_integration.py b/tests/integration/test_seed_integration.py new file mode 100644 index 0000000..4eda56d --- /dev/null +++ b/tests/integration/test_seed_integration.py @@ -0,0 +1,88 @@ +import csv +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +def run_seed(csv_path: Path, instance: str, database: str, username: str, password: str = "") -> subprocess.CompletedProcess: + # Run the real CLI module (integration-style). + return subprocess.run( + [ + sys.executable, + "-m", + "baudolo.seed", + str(csv_path), + instance, + database, + username, + password, + ], + text=True, + capture_output=True, + check=True, + ) + + +def read_csv_semicolon(path: Path) -> list[dict]: + with path.open("r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f, delimiter=";") + return list(reader) + + +class TestSeedIntegration(unittest.TestCase): + def test_creates_file_and_adds_entry_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + self.assertFalse(p.exists()) + + cp = run_seed(p, "docker.test", "appdb", "alice", "secret") + + self.assertEqual(cp.returncode, 0, cp.stderr) + self.assertTrue(p.exists()) + + rows = read_csv_semicolon(p) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["instance"], "docker.test") + self.assertEqual(rows[0]["database"], "appdb") + self.assertEqual(rows[0]["username"], "alice") + self.assertEqual(rows[0]["password"], "secret") + + def test_replaces_existing_entry_same_keys(self) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + + # First add + run_seed(p, "docker.test", "appdb", "alice", "oldpw") + rows = read_csv_semicolon(p) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["password"], "oldpw") + + # Replace (same instance+database+username) + run_seed(p, "docker.test", "appdb", "alice", "newpw") + rows = read_csv_semicolon(p) + + self.assertEqual(len(rows), 1, "Expected replacement, not a duplicate row") + self.assertEqual(rows[0]["instance"], "docker.test") + self.assertEqual(rows[0]["database"], "appdb") + self.assertEqual(rows[0]["username"], "alice") + self.assertEqual(rows[0]["password"], "newpw") + + def test_database_empty_string_matches_existing_empty_database(self) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + + # Add with empty database + run_seed(p, "docker.test", "", "alice", "pw1") + rows = read_csv_semicolon(p) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["database"], "") + + # Replace with empty database again + run_seed(p, "docker.test", "", "alice", "pw2") + rows = read_csv_semicolon(p) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["database"], "") + self.assertEqual(rows[0]["password"], "pw2")