From c84d94324fbf6e3313316ed5b79fc24395386b2e Mon Sep 17 00:00:00 2001 From: Ciaran Shiels Date: Fri, 11 Sep 2026 11:59:33 +0100 Subject: [PATCH 1/3] feat: align framework updates with core templates - update generated automation with --core - support configurable service types - document sanitized health extensions Assisted-by: Codex --- README.md | 5 +++ copier.yml | 7 +++- src/platform_service_framework/cli.py | 5 +++ templates/core/resource_api.py.jinja | 2 +- templates/core/views/health.py | 4 +++ .../.github/workflows/framework-update.yml | 2 +- tests/test_init.py | 28 +++++++++++++-- tests/test_update.py | 35 +++++++++++++++++-- 8 files changed, 80 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c19e63c..7f28071 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ to follow the defined standards. - Consolidates meta files such as pyproject, sonar, pre-commit, github actions, settings based on template standards + apps customizations. - Validate the whole project structure. +Health endpoints should expose only coarse-grained check results such as +`ok` or `error`. Services that add custom health checks must log exception +details server-side and sanitize the response; health endpoints are public and +must not return database, connection, or other internal error details. + ## Requirements diff --git a/copier.yml b/copier.yml index eb9a61d..46d4e06 100644 --- a/copier.yml +++ b/copier.yml @@ -17,6 +17,11 @@ app_name: type: str default: "" +service_type: + type: str + default: "{{ project_name }}" + when: "{{ app_name == 'core' }}" + apps: default: - - api \ No newline at end of file + - api diff --git a/src/platform_service_framework/cli.py b/src/platform_service_framework/cli.py index e632a76..6b9d3fb 100644 --- a/src/platform_service_framework/cli.py +++ b/src/platform_service_framework/cli.py @@ -28,6 +28,7 @@ def init( destination: Path | None = None, project: Annotated[str | None, Parameter(alias="-p")] = None, apps: Annotated[list[str], Parameter(consume_multiple=True)] = ["api"], + service_type: str | None = None, ): """Initialize a new Django Project. @@ -48,9 +49,11 @@ def init( destination: The root of the repository project: project name [default to destination folder name] apps: names for each app to be initialized + service_type: resource registry service type [default to project name] """ destination = destination or Path.cwd() project = project or destination.name.replace("-", "_") + service_type = service_type or project if not destination.exists(): destination.mkdir(parents=True, exist_ok=True) if not Path(destination / ".git").exists(): @@ -73,6 +76,7 @@ def init( "src_branch": vcs_ref, "apps": all_apps, "app_name": "", + "service_type": service_type, }, ) print("Main project created.") @@ -90,6 +94,7 @@ def init( "template": "templates/core", "src_branch": vcs_ref, "apps": all_apps, + "service_type": service_type, }, ) print("Created core app") diff --git a/templates/core/resource_api.py.jinja b/templates/core/resource_api.py.jinja index d67144e..b6b5c22 100644 --- a/templates/core/resource_api.py.jinja +++ b/templates/core/resource_api.py.jinja @@ -15,7 +15,7 @@ from apps.core.models import Organization, Team, User class APIConfig(ServiceAPIConfig): """API configuration for the resource registry.""" - service_type = "{{ project_name }}" + service_type = "{{ service_type }}" RESOURCE_LIST = [ diff --git a/templates/core/views/health.py b/templates/core/views/health.py index b62ada3..59bc2b2 100644 --- a/templates/core/views/health.py +++ b/templates/core/views/health.py @@ -14,6 +14,10 @@ class HealthView(AnsibleBaseView): Health check endpoint to verify service health. Checks database connectivity and returns overall health status. + + Extensions that add service-specific checks must return sanitized status + values. Health responses are public and must not expose exception details, + connection strings, or other internal implementation data. """ permission_classes = [AllowAny] diff --git a/templates/project/.github/workflows/framework-update.yml b/templates/project/.github/workflows/framework-update.yml index 75f124f..6a94665 100644 --- a/templates/project/.github/workflows/framework-update.yml +++ b/templates/project/.github/workflows/framework-update.yml @@ -27,7 +27,7 @@ jobs: run: | ORG="${{ github.repository_owner }}" BRANCH="${{ github.ref_name }}" - uvx --refresh git+https://github.com/${ORG}/platform-service-framework@${BRANCH} update + uvx --refresh git+https://github.com/${ORG}/platform-service-framework@${BRANCH} update --core - name: Check for changes id: check_changes run: | diff --git a/tests/test_init.py b/tests/test_init.py index e008f35..5ae5297 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,8 @@ """Tests for the init command.""" + import subprocess from pathlib import Path +from unittest.mock import patch import pytest from git import Repo @@ -70,6 +72,26 @@ def test_init_with_custom_project_name(isolated_env, capsys): assert "custom_project" in captured.out +def test_init_with_custom_service_type(isolated_env): + """Test that the core resource registry accepts a service type override.""" + tmp_path, _ = isolated_env + (tmp_path / "apps").mkdir() + + with ( + patch( + "platform_service_framework.cli.get_repo", + return_value=(str(Path(__file__).parent.parent), None), + ), + patch("platform_service_framework.cli.run_copy") as run_copy, + ): + with pytest.raises(SystemExit) as exc_info: + app(["init", "-p", "metrics_service", "--service-type", "metrics"]) + + assert exc_info.value.code == 0 + core_copy_data = run_copy.call_args_list[1].kwargs["data"] + assert core_copy_data["service_type"] == "metrics" + + def test_init_with_multiple_apps(isolated_env): """Test init command with multiple apps.""" tmp_path, _ = isolated_env @@ -163,6 +185,7 @@ def test_init_without_apps(isolated_env): # The apps directory might still be created by the template assert (tmp_path / "apps").exists() + def test_init_run_all_project_checks(isolated_env, capsys): """Test init command with default parameters and run all unit tests and linters.""" tmp_path, _ = isolated_env @@ -186,7 +209,7 @@ def test_init_run_all_project_checks(isolated_env, capsys): capture_output=True, text=True, ) - assert lint_exec.returncode == 0 and 'All checks passed!' in lint_exec.stdout, ( + assert lint_exec.returncode == 0 and "All checks passed!" in lint_exec.stdout, ( f"poe lint failed with exit code {lint_exec.returncode}\n" f"stdout: {lint_exec.stdout}\n" f"stderr: {lint_exec.stderr}" @@ -199,7 +222,7 @@ def test_init_run_all_project_checks(isolated_env, capsys): capture_output=True, text=True, ) - assert format_exec.returncode == 0 and 'reformatted' not in format_exec.stdout, ( + assert format_exec.returncode == 0 and "reformatted" not in format_exec.stdout, ( f"poe format failed with exit code {format_exec.returncode}\n" f"stdout: {format_exec.stdout}\n" f"stderr: {format_exec.stderr}" @@ -241,4 +264,3 @@ def test_init_run_all_project_checks(isolated_env, capsys): f"stdout: {test_exec.stdout}\n" f"stderr: {test_exec.stderr}" ) - diff --git a/tests/test_update.py b/tests/test_update.py index 2b94a2e..b2c4aaf 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,5 +1,6 @@ """Tests for the update command.""" +from pathlib import Path from unittest.mock import ANY, patch import pytest @@ -70,6 +71,34 @@ def test_update_with_specific_destination(isolated_dir, local_repo_url, capsys): assert str(destination) in captured.out +def test_update_with_core_updates_core_template(isolated_env): + """Test that --core invokes the separate core template update.""" + tmp_path, _ = isolated_env + repo = Repo.init(tmp_path) + (tmp_path / ".copier-answers.yml").write_text( + "_commit: test\n_src_path: /tmp/template\nsrc_branch: devel\n" + ) + repo.index.add([".copier-answers.yml"]) + repo.index.commit("Initialize test project") + + with ( + patch( + "platform_service_framework.cli.get_repo", + return_value=(str(Path(__file__).parent.parent), None), + ), + patch("platform_service_framework.cli.validate", return_value=True), + ): + with ( + patch("platform_service_framework.cli.run_update"), + patch("platform_service_framework.cli._update_core_app") as update_core, + ): + with pytest.raises(SystemExit) as exc_info: + app(["update", "--core"]) + + assert exc_info.value.code == 0 + update_core.assert_called_once_with(tmp_path, ANY, ANY) + + def test_update_non_git_repository(isolated_env, capsys): """Test that update command triggers an error in case it is executed in a non-git repository.""" tmp_path, _ = isolated_env @@ -87,7 +116,9 @@ def test_update_non_git_repository(isolated_env, capsys): # Check output (now comes from validate command) captured = capsys.readouterr() assert "Updating your app" in captured.out - assert "Platform service framework is only supported in git-tracked repositories" in captured.out + assert ( + "Platform service framework is only supported in git-tracked repositories" in captured.out + ) assert "Validation failed" in captured.out @@ -114,4 +145,4 @@ def test_update_dirty_git_repository(isolated_env, capsys): captured = capsys.readouterr() assert "Updating your app" in captured.out assert "Working tree has uncommitted changes" in captured.out - assert "Please commit or stash your changes before running update" in captured.out \ No newline at end of file + assert "Please commit or stash your changes before running update" in captured.out From 6c44635296c81a1c228cd1a9b2d32e6beb1a0987 Mon Sep 17 00:00:00 2001 From: Ciaran Shiels Date: Fri, 11 Sep 2026 12:56:38 +0100 Subject: [PATCH 2/3] feat: update all managed apps --- README.md | 8 +- src/platform_service_framework/cli.py | 100 +++++++++++++------------ templates/project/pyproject.toml.jinja | 4 +- tests/test_update.py | 30 ++++++-- 4 files changed, 86 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 7f28071..40cf175 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ to follow the defined standards. - Bootstrap new projects with `init`. - Bootstrap new apps for the project with `init --apps`. - Keep project updated with latest changes. -- Leave `apps/*` untouched so developers can edit it. +- Update every `apps/*` directory created from a framework template while + preserving custom changes through Copier's conflict handling. - Consolidates meta files such as pyproject, sonar, pre-commit, github actions, settings based on template standards + apps customizations. - Validate the whole project structure. @@ -60,7 +61,10 @@ my-project └── sonar-project.properties ``` -> Developers now can edit any file inside `apps` folder, this is the only folder unmanaged by subsequent framework updates, framework will consolidate the content of `apps/metadata` into the respective root folder file. +> Developers can edit files inside `apps`. Each app generated from a framework +> template records its own Copier answers, so subsequent updates can update +> the project and all managed apps. App-specific customizations may require +> conflict resolution during an update. ## What is included? diff --git a/src/platform_service_framework/cli.py b/src/platform_service_framework/cli.py index 6b9d3fb..e1e05af 100644 --- a/src/platform_service_framework/cli.py +++ b/src/platform_service_framework/cli.py @@ -160,13 +160,13 @@ def update( ```bash # Update project to detected template version: platform-service-framework update - # Update project and core app: - platform-service-framework update --core + # Update project and all framework-managed apps: + platform-service-framework update ``` --- Args: destination: The root of the repository - core: Also update the core app from templates/core + core: Backwards-compatible flag; managed apps are always updated """ destination = destination or Path.cwd() print(f"Updating your app on {destination}") @@ -288,59 +288,67 @@ def update( print("Please review and commit changes manually.") sys.exit(1) - # Update core app if requested - if core: - _update_core_app(destination, src_path, vcs_ref) - - -def _update_core_app(destination: Path, src_path: str, vcs_ref: str | None): - """Update the core app from templates/core.""" - core_path = destination / "apps" / "core" + # Update every app created from a framework app template. Keep --core as a + # backwards-compatible flag; core is now part of the normal update set. + for app_path in _managed_app_paths(destination): + _update_managed_app(destination, app_path, src_path, vcs_ref) + + +def _managed_app_paths(destination: Path) -> list[Path]: + """Return app directories managed by a Copier framework template.""" + apps_path = destination / "apps" + if not apps_path.exists(): + return [] + return sorted( + path + for path in apps_path.iterdir() + if path.is_dir() and (path / ".copier-answers.yml").exists() + ) - if not core_path.exists(): - print("\nWarning: Core app not found at apps/core/. Skipping core update.") - return - core_answers_file = core_path / ".copier-answers.yml" - if not core_answers_file.exists(): - print("\nWarning: Core app missing .copier-answers.yml. Skipping core update.") - return +def _update_managed_app( + destination: Path, + app_path: Path, + src_path: str, + vcs_ref: str | None, +): + """Update one Copier-managed app from its recorded framework template.""" + app_name = app_path.name print("\n" + "=" * 40) - print("Updating core app...") + print(f"Updating {app_name} app...") print("=" * 40) repo = Repo(destination) - # Read core app's copier answers - with open(core_answers_file) as f: - core_answers = yaml.safe_load(f) + with open(app_path / ".copier-answers.yml") as f: + app_answers = yaml.safe_load(f) - old_src = core_answers.get("_src_path") - old_branch = core_answers.get("src_branch") + old_src = app_answers.get("_src_path") + old_branch = app_answers.get("src_branch") # Update source in answers if changed source_changed = old_src != src_path branch_changed = old_branch != vcs_ref if source_changed or branch_changed: if source_changed: - print(f"Updating core template source to: {src_path}") - core_answers["_src_path"] = src_path + print(f"Updating {app_name} template source to: {src_path}") + app_answers["_src_path"] = src_path if branch_changed: - print(f"Updating core template branch to: {vcs_ref}") - core_answers["src_branch"] = vcs_ref + print(f"Updating {app_name} template branch to: {vcs_ref}") + app_answers["src_branch"] = vcs_ref # Write updated answers - with open(core_answers_file, "w") as f: + with open(app_path / ".copier-answers.yml", "w") as f: f.write("# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY\n") - yaml.dump(core_answers, f, default_flow_style=False, sort_keys=False) + yaml.dump(app_answers, f, default_flow_style=False, sort_keys=False) - print("✓ Updated core .copier-answers.yml") + print(f"✓ Updated {app_name} .copier-answers.yml") # Commit the source change try: - repo.git.add(str(core_answers_file)) - commit_msg = f"""[platform-service-framework] Update core app template source + repo.git.add(str(app_path / ".copier-answers.yml")) + commit_msg = f"""[platform-service-framework] Update {app_name} app template source Old source: {old_src} Old branch: {old_branch} @@ -348,46 +356,46 @@ def _update_core_app(destination: Path, src_path: str, vcs_ref: str | None): New branch: {vcs_ref} """ repo.index.commit(commit_msg) - print("✓ Committed core .copier-answers.yml changes") + print(f"✓ Committed {app_name} .copier-answers.yml changes") except Exception as e: - print(f"Warning: Could not commit core .copier-answers.yml: {e}") + print(f"Warning: Could not commit {app_name} .copier-answers.yml: {e}") - # Run copier update on core app - print("\nRunning copier update on core app...") + # Run copier update on the app + print(f"\nRunning copier update on {app_name} app...") run_update( - core_path, + app_path, vcs_ref=vcs_ref, overwrite=True, skip_answered=True, ) - # Commit core app changes + # Commit app changes try: # Check for merge conflicts if repo.index.unmerged_blobs(): - print("\nError: Merge conflicts detected during core app update.") + print(f"\nError: Merge conflicts detected during {app_name} app update.") print("Please resolve conflicts manually and commit the changes.") sys.exit(1) # Check if there are changes to commit if repo.is_dirty(untracked_files=True): - print("\nCommitting core app update changes...") + print(f"\nCommitting {app_name} app update changes...") repo.git.add(A=True) - commit_msg = f"""[platform-service-framework] Update core app from template + commit_msg = f"""[platform-service-framework] Update {app_name} app from template Template source: {src_path} Template version: {vcs_ref or "HEAD"} -This commit applies updates from templates/core. +This commit applies updates from the app's recorded framework template. """ repo.index.commit(commit_msg) - print("✓ Core app update committed successfully") + print(f"✓ {app_name} app update committed successfully") else: - print("\nNo changes from core app update") + print(f"\nNo changes from {app_name} app update") except Exception as e: - print(f"\nError: Could not commit core app update: {e}") + print(f"\nError: Could not commit {app_name} app update: {e}") print("Please review and commit changes manually.") sys.exit(1) diff --git a/templates/project/pyproject.toml.jinja b/templates/project/pyproject.toml.jinja index b9612e9..96fe283 100644 --- a/templates/project/pyproject.toml.jinja +++ b/templates/project/pyproject.toml.jinja @@ -108,9 +108,9 @@ with open('.copier-answers.yml') as f: """ [tool.poe.tasks.update] -help = "Update the project from template (use --core to also update core templates)" +help = "Update the project and all framework-managed apps" args = [ - { name = "core", help = "Also update core templates", type = "boolean" } + { name = "core", help = "Deprecated compatibility flag", type = "boolean" } ] shell = """ python -c " diff --git a/tests/test_update.py b/tests/test_update.py index b2c4aaf..e1aed0f 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -19,7 +19,10 @@ def test_update_default_destination(isolated_env, capsys, local_repo_url): assert exc_info.value.code == 0 # Mock run_update to avoid actual copier execution - with patch("platform_service_framework.cli.run_update") as mock_update: + with ( + patch("platform_service_framework.cli.run_update") as mock_update, + patch("platform_service_framework.cli._update_managed_app") as mock_app_update, + ): # Run update command - expect SystemExit(0) with pytest.raises(SystemExit) as exc_info: app(["update"]) @@ -33,6 +36,7 @@ def test_update_default_destination(isolated_env, capsys, local_repo_url): overwrite=True, skip_answered=True, ) + assert mock_app_update.call_count == 2 # Check output captured = capsys.readouterr() @@ -50,7 +54,10 @@ def test_update_with_specific_destination(isolated_dir, local_repo_url, capsys): assert exc_info.value.code == 0 # Mock run_update - with patch("platform_service_framework.cli.run_update") as mock_update: + with ( + patch("platform_service_framework.cli.run_update") as mock_update, + patch("platform_service_framework.cli._update_managed_app") as mock_app_update, + ): # Run update with destination - expect SystemExit(0) with pytest.raises(SystemExit) as exc_info: app(["update", str(destination)]) @@ -64,6 +71,7 @@ def test_update_with_specific_destination(isolated_dir, local_repo_url, capsys): overwrite=True, skip_answered=True, ) + assert mock_app_update.call_count == 2 # Check output captured = capsys.readouterr() @@ -71,14 +79,22 @@ def test_update_with_specific_destination(isolated_dir, local_repo_url, capsys): assert str(destination) in captured.out -def test_update_with_core_updates_core_template(isolated_env): - """Test that --core invokes the separate core template update.""" +def test_update_updates_all_managed_apps(isolated_env): + """Test that update invokes the template update for every managed app.""" tmp_path, _ = isolated_env repo = Repo.init(tmp_path) (tmp_path / ".copier-answers.yml").write_text( "_commit: test\n_src_path: /tmp/template\nsrc_branch: devel\n" ) + for app_name in ("core", "metrics"): + app_path = tmp_path / "apps" / app_name + app_path.mkdir(parents=True) + (app_path / ".copier-answers.yml").write_text( + f"_commit: test\n_src_path: /tmp/template\nsrc_branch: devel\n" + f"app_name: {app_name}\n" + ) repo.index.add([".copier-answers.yml"]) + repo.index.add(["apps"]) repo.index.commit("Initialize test project") with ( @@ -90,13 +106,15 @@ def test_update_with_core_updates_core_template(isolated_env): ): with ( patch("platform_service_framework.cli.run_update"), - patch("platform_service_framework.cli._update_core_app") as update_core, + patch("platform_service_framework.cli._update_managed_app") as update_app, ): with pytest.raises(SystemExit) as exc_info: app(["update", "--core"]) assert exc_info.value.code == 0 - update_core.assert_called_once_with(tmp_path, ANY, ANY) + assert update_app.call_count == 2 + updated_paths = {call.args[1] for call in update_app.call_args_list} + assert updated_paths == {tmp_path / "apps" / "core", tmp_path / "apps" / "metrics"} def test_update_non_git_repository(isolated_env, capsys): From d32f5d5ededb286eb60b86d46611f9498348f7a7 Mon Sep 17 00:00:00 2001 From: Ciaran Shiels Date: Fri, 11 Sep 2026 13:01:32 +0100 Subject: [PATCH 3/3] fix: align health status with metrics service --- templates/core/tests/test_health.py | 6 +++--- templates/core/views/health.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/templates/core/tests/test_health.py b/templates/core/tests/test_health.py index fc51bfa..edb9233 100644 --- a/templates/core/tests/test_health.py +++ b/templates/core/tests/test_health.py @@ -19,11 +19,11 @@ class TestHealthEndpoint(TestCase): def setUp(self): self.client = APIClient() - def test_health_returns_healthy(self): + def test_health_returns_good(self): response = self.client.get("/health/") self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - self.assertEqual(data["status"], "healthy") + self.assertEqual(data["status"], "good") self.assertIn("database", data["checks"]) self.assertEqual(data["checks"]["database"], "ok") @@ -35,5 +35,5 @@ def test_health_does_not_expose_database_error(self, ensure_connection): response = self.client.get("/health/") self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) - self.assertEqual(response.json(), {"status": "unhealthy", "checks": {"database": "error"}}) + self.assertEqual(response.json(), {"status": "degraded", "checks": {"database": "error"}}) ensure_connection.assert_called_once_with() diff --git a/templates/core/views/health.py b/templates/core/views/health.py index 59bc2b2..aa77945 100644 --- a/templates/core/views/health.py +++ b/templates/core/views/health.py @@ -1,7 +1,8 @@ import logging +from ansible_base.lib.constants import STATUS_DEGRADED, STATUS_GOOD from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView -from django.db import connection +from django.db import close_old_connections, connection from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -24,19 +25,22 @@ class HealthView(AnsibleBaseView): authentication_classes = [] def get(self, request): - health_status: dict = {"status": "healthy", "checks": {}} + health_status: dict = {"status": STATUS_GOOD, "checks": {}} # Database check try: + close_old_connections() connection.ensure_connection() health_status["checks"]["database"] = "ok" except Exception: logger.exception("Health check database connection failed") - health_status["status"] = "unhealthy" + health_status["status"] = STATUS_DEGRADED health_status["checks"]["database"] = "error" http_status = ( - status.HTTP_200_OK if health_status["status"] == "healthy" else status.HTTP_503_SERVICE_UNAVAILABLE + status.HTTP_200_OK + if health_status["status"] == STATUS_GOOD + else status.HTTP_503_SERVICE_UNAVAILABLE ) return Response(health_status, status=http_status)