From 767d2be4b6745da4b44cf6408b93d48d6fb8a356 Mon Sep 17 00:00:00 2001 From: Ciaran Shiels Date: Thu, 10 Sep 2026 21:00:46 +0100 Subject: [PATCH] feat: extend DAB service framework templates Add gateway shared-resource registration, DAB filtering and pagination defaults, optional observability, activity-stream coverage, and Dependabot-safe validation. Assisted-by: OpenCode --- README.md | 10 ++++ copier.yml | 6 +- src/platform_service_framework/cli.py | 59 +++++++++++++++++-- templates/core/models/organization.py | 3 +- templates/core/models/team.py | 3 +- templates/core/models/user.py | 3 +- templates/core/resource_api.py.jinja | 10 ++-- templates/core/tests/test_activitystream.py | 15 +++++ templates/project/README.md.jinja | 27 +++++++++ .../project/apps/settings/defaults.py.jinja | 12 +++- .../project/apps/settings/production.py.jinja | 12 ++++ templates/project/apps/settings/test.py.jinja | 19 +++++- templates/project/pyproject.toml.jinja | 5 +- tests/test_init.py | 25 ++++++-- tests/test_validate.py | 47 +++++++++------ 15 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 templates/core/tests/test_activitystream.py diff --git a/README.md b/README.md index c19e63c..a45e682 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,13 @@ Framework init finished Created project at my-project/my_project Created apps at my-project/apps/[api] ``` + +Enable DAB OpenTelemetry instrumentation when the service is deployed with an +OTLP collector: + +```console +$ uvx git+https://github.com/ansible/platform-service-framework init my-project --observability +``` ``` my-project # Editable by developers @@ -62,6 +69,9 @@ my-project - UV based project - Django > 5 - Django Ansible Base (dynamic) +- DAB REST filtering and bounded pagination +- DAB gateway-shared Organization and Team resource registration +- Optional DAB OpenTelemetry observability (`--observability`) - pytest - ruff - ty diff --git a/copier.yml b/copier.yml index eb9a61d..166d866 100644 --- a/copier.yml +++ b/copier.yml @@ -19,4 +19,8 @@ app_name: apps: default: - - api \ No newline at end of file + - api + +observability: + type: bool + default: false diff --git a/src/platform_service_framework/cli.py b/src/platform_service_framework/cli.py index a60d294..9fc235d 100644 --- a/src/platform_service_framework/cli.py +++ b/src/platform_service_framework/cli.py @@ -1,6 +1,9 @@ import io import os +import re +import shutil import sys +import tempfile from contextlib import redirect_stderr from importlib.metadata import distribution from pathlib import Path @@ -25,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"], + observability: bool = False, ): """Initialize a new Django Project. @@ -45,6 +49,7 @@ def init( destination: The root of the repository project: project name [default to destination folder name] apps: names for each app to be initialized + observability: Enable DAB OpenTelemetry instrumentation """ destination = destination or Path.cwd() project = project or destination.name.replace("-", "_") @@ -70,6 +75,7 @@ def init( "src_branch": vcs_ref, "apps": all_apps, "app_name": "", + "observability": observability, }, ) print("Main project created.") @@ -87,6 +93,7 @@ def init( "template": "templates/core", "src_branch": vcs_ref, "apps": all_apps, + "observability": observability, }, ) print("Created core app") @@ -103,6 +110,7 @@ def init( "template": "templates/app", "src_branch": vcs_ref, "apps": all_apps, + "observability": observability, }, ) print(f"Created app {app_name}") @@ -384,6 +392,38 @@ def _update_core_app(destination: Path, src_path: str, vcs_ref: str | None): sys.exit(1) +_ACTION_PIN_RE = re.compile(r"(uses:\s+\S+)@\S+", re.MULTILINE) + + +def _normalize_action_pins(content: str) -> str: + """Remove GitHub Action revisions when comparing managed workflows.""" + return _ACTION_PIN_RE.sub(r"\1", content) + + +def _is_only_action_pin_change(destination: Path, file_path: str, copier_answers: dict) -> bool: + """Return whether a workflow differs from the template only by action pins.""" + if ".github/workflows" not in file_path or not file_path.endswith(".yml"): + return False + + current_content = (destination / file_path).read_text() + try: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dest = Path(tmp_dir) / "dest" + shutil.copytree(destination, tmp_dest) + with redirect_stderr(io.StringIO()): + run_recopy( + tmp_dest, + skip_answered=True, + overwrite=True, + vcs_ref=copier_answers.get("_commit"), + ) + rendered_content = (tmp_dest / file_path).read_text() + except (OSError, RuntimeError): + return False + + return _normalize_action_pins(current_content) == _normalize_action_pins(rendered_content) + + @app.command def validate( destination: Path | None = None, @@ -460,14 +500,23 @@ def validate( infractions = list( {conflict for conflict in conflicts for file in protected_files if file in conflict} ) - if infractions: + real_infractions = [ + infraction + for infraction in infractions + if not _is_only_action_pin_change( + destination, + next((file for file in protected_files if file in infraction), ""), + copier_answers, + ) + ] + if real_infractions: print("✗ The following files should not be modified or deleted: ✗") - print("\n".join(f"{i}" for i in infractions)) + print("\n".join(f"{i}" for i in real_infractions)) print("✗ Please undo these changes and run the command again ✗") return False - else: - print("✓ No framework infractions found, your project is ready to be updated! ✓") - return True + + print("✓ No framework infractions found, your project is ready to be updated! ✓") + return True @app.command diff --git a/templates/core/models/organization.py b/templates/core/models/organization.py index 9fc9ebe..9a273bf 100644 --- a/templates/core/models/organization.py +++ b/templates/core/models/organization.py @@ -1,8 +1,9 @@ +from ansible_base.activitystream.models import AuditableModel from ansible_base.lib.abstract_models.organization import AbstractOrganization from django.db import models -class Organization(AbstractOrganization): +class Organization(AuditableModel, AbstractOrganization): """ Organization model using DAB's AbstractOrganization. diff --git a/templates/core/models/team.py b/templates/core/models/team.py index a17df69..8c4ccca 100644 --- a/templates/core/models/team.py +++ b/templates/core/models/team.py @@ -1,8 +1,9 @@ +from ansible_base.activitystream.models import AuditableModel from ansible_base.lib.abstract_models.team import AbstractTeam from django.db import models -class Team(AbstractTeam): +class Team(AuditableModel, AbstractTeam): """ Team model using DAB's AbstractTeam. diff --git a/templates/core/models/user.py b/templates/core/models/user.py index 97edb57..5611809 100644 --- a/templates/core/models/user.py +++ b/templates/core/models/user.py @@ -1,7 +1,8 @@ +from ansible_base.activitystream.models import AuditableModel from ansible_base.lib.abstract_models.user import AbstractDABUser -class User(AbstractDABUser): +class User(AuditableModel, AbstractDABUser): """ Custom User model extending DAB's AbstractDABUser. diff --git a/templates/core/resource_api.py.jinja b/templates/core/resource_api.py.jinja index ff19754..d67144e 100644 --- a/templates/core/resource_api.py.jinja +++ b/templates/core/resource_api.py.jinja @@ -7,7 +7,7 @@ from ansible_base.resource_registry.registry import ( ServiceAPIConfig, SharedResource, ) -from ansible_base.resource_registry.shared_types import FeatureFlagType, UserType +from ansible_base.resource_registry.shared_types import FeatureFlagType, OrganizationType, TeamType, UserType from apps.core.models import Organization, Team, User @@ -22,15 +22,15 @@ RESOURCE_LIST = [ ResourceConfig( Organization, shared_resource=SharedResource( - serializer=None, - is_provider=True, + serializer=OrganizationType, + is_provider=False, ), ), ResourceConfig( Team, shared_resource=SharedResource( - serializer=None, - is_provider=True, + serializer=TeamType, + is_provider=False, ), parent_resources=[ ParentResource(model=Organization, field_name="organization"), diff --git a/templates/core/tests/test_activitystream.py b/templates/core/tests/test_activitystream.py new file mode 100644 index 0000000..776f74e --- /dev/null +++ b/templates/core/tests/test_activitystream.py @@ -0,0 +1,15 @@ +"""Tests for DAB activity-stream integration.""" + +import pytest + +from apps.core.models import Organization + + +@pytest.mark.django_db +def test_auditable_models_create_activity_stream_entries(): + organization = Organization.objects.create(name="Audited organization") + + entries = organization.activity_stream_entries + + assert entries.count() == 1 + assert entries.first().operation == "create" diff --git a/templates/project/README.md.jinja b/templates/project/README.md.jinja index 276efe4..757d499 100644 --- a/templates/project/README.md.jinja +++ b/templates/project/README.md.jinja @@ -1 +1,28 @@ # {{project_name}} + +This service is generated and maintained by the Platform Service Framework. + +## Django Ansible Base + +The generated service uses Django Ansible Base for gateway integration, +service JWT authentication, RBAC, resource registration, feature flags, +REST filtering, bounded pagination, and API documentation. + +Organization and Team are registered as shared-resource consumers. The gateway +remains the source of user authentication and role claims. + +The default paginator uses a page size of 25 and caps requests at 200 items. + +{%- if observability %} +## Observability + +OpenTelemetry instrumentation is enabled through `ansible_base.observability`. +Configure `OTEL_SERVICE_NAME` and `OTEL_EXPORTER_OTLP_ENDPOINT` for the +deployment. Production logs exporter failures at warning level; tests suppress +expected collector connection errors. +{%- else %} +## Optional Observability + +Run the framework with `init --observability` to enable DAB OpenTelemetry +instrumentation and the `django-ansible-base[observability]` dependency. +{%- endif %} diff --git a/templates/project/apps/settings/defaults.py.jinja b/templates/project/apps/settings/defaults.py.jinja index dd90d54..b7f0b7a 100644 --- a/templates/project/apps/settings/defaults.py.jinja +++ b/templates/project/apps/settings/defaults.py.jinja @@ -17,6 +17,9 @@ dab_applications = [ "ansible_base.resource_registry", "ansible_base.rest_filters", "ansible_base.rest_pagination", +{%- if observability %} + "ansible_base.observability", +{%- endif %} ] """Default DAB applications layd out from PSF, add/remove according to the project needs, adjust `pyproject` dab extra dependencies acording to apps added/removed here. @@ -49,15 +52,18 @@ REST_FRAMEWORK = { "UNAUTHENTICATED_USER": None, "UNAUTHENTICATED_TOKEN": None, "DEFAULT_FILTER_BACKENDS": [ + "ansible_base.rest_filters.rest_framework.type_filter_backend.TypeFilterBackend", + "ansible_base.rest_filters.rest_framework.field_lookup_backend.FieldLookupBackend", "rest_framework.filters.SearchFilter", - "rest_framework.filters.OrderingFilter", + "ansible_base.rest_filters.rest_framework.order_backend.OrderByBackend", ], "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", "apps.core.renderers.ServiceBrowsableAPIRenderer", ], - "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", - "PAGE_SIZE": 25, + "DEFAULT_PAGINATION_CLASS": "ansible_base.rest_pagination.DefaultPaginator", + "DEFAULT_PAGE_SIZE": 25, + "MAX_PAGE_SIZE": 200, "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", "DEFAULT_VERSION": "v1", "ALLOWED_VERSIONS": ["v1"], diff --git a/templates/project/apps/settings/production.py.jinja b/templates/project/apps/settings/production.py.jinja index d188076..309917c 100644 --- a/templates/project/apps/settings/production.py.jinja +++ b/templates/project/apps/settings/production.py.jinja @@ -171,3 +171,15 @@ validators.append( # Production login/logout URLs for gateway integration LOGIN_URL = "/api/gateway/v1/login/" LOGOUT_URL = "/api/gateway/v1/logout/" + +{%- if observability %} +# Keep exporter failures visible in production without logging retry noise. +LOGGING__loggers = { + "dynaconf_merge": True, + "opentelemetry.exporter.otlp.proto.grpc.exporter": { + "handlers": ["console"], + "level": "WARNING", + "propagate": False, + }, +} +{%- endif %} diff --git a/templates/project/apps/settings/test.py.jinja b/templates/project/apps/settings/test.py.jinja index d3004d0..fd8e6b9 100644 --- a/templates/project/apps/settings/test.py.jinja +++ b/templates/project/apps/settings/test.py.jinja @@ -70,13 +70,26 @@ REST_FRAMEWORK = { "rest_framework.permissions.AllowAny", ], "DEFAULT_FILTER_BACKENDS": [ + "ansible_base.rest_filters.rest_framework.type_filter_backend.TypeFilterBackend", + "ansible_base.rest_filters.rest_framework.field_lookup_backend.FieldLookupBackend", "rest_framework.filters.SearchFilter", - "rest_framework.filters.OrderingFilter", + "ansible_base.rest_filters.rest_framework.order_backend.OrderByBackend", ], - "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", - "PAGE_SIZE": 25, + "DEFAULT_PAGINATION_CLASS": "ansible_base.rest_pagination.DefaultPaginator", + "DEFAULT_PAGE_SIZE": 25, + "MAX_PAGE_SIZE": 200, "TEST_REQUEST_DEFAULT_FORMAT": "json", } +{%- if observability %} +LOGGING__loggers = { + "dynaconf_merge": True, + "opentelemetry.exporter.otlp.proto.grpc.exporter": { + "handlers": ["null"], + "propagate": False, + }, +} +{%- endif %} + # Test database settings TEST_DATABASE_PREFIX = "test_" diff --git a/templates/project/pyproject.toml.jinja b/templates/project/pyproject.toml.jinja index b9612e9..d5dcb40 100644 --- a/templates/project/pyproject.toml.jinja +++ b/templates/project/pyproject.toml.jinja @@ -7,7 +7,7 @@ requires-python = ">=3.12,<3.13" dependencies = [ "django>=5.2.7", "psycopg[binary]>=3.3.1", - "django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac,feature_flags,api_documentation]", + "django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac,feature_flags,api_documentation{% if observability %},observability{% endif %}]", ] [dependency-groups] @@ -22,9 +22,6 @@ dev = [ "django-extensions>=4.1", "ipython>=9.7.0", "ipdb>=0.13.13", - "django-extensions>=4.1", - "ipython>=9.7.0", - "ipdb>=0.13.13", "django-debug-toolbar>=6.1.0", ] doc = [ diff --git a/tests/test_init.py b/tests/test_init.py index e008f35..ac5444f 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,6 @@ """Tests for the init command.""" + import subprocess -from pathlib import Path import pytest from git import Repo @@ -36,6 +36,23 @@ def test_init_default(isolated_env, capsys): assert "Framework init finished" in captured.out +def test_init_with_observability(isolated_env): + """Test that the optional DAB observability profile is rendered.""" + tmp_path, _ = isolated_env + + with pytest.raises(SystemExit) as exc_info: + app(["init", "--observability"]) + + assert exc_info.value.code == 0 + pyproject = (tmp_path / "pyproject.toml").read_text() + settings = (tmp_path / "apps" / "settings" / "defaults.py").read_text() + assert ( + "django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac,feature_flags," + "api_documentation,observability]" + ) in pyproject + assert '"ansible_base.observability"' in settings + + def test_init_with_destination(isolated_dir, local_repo_url): """Test init command with specific destination.""" destination = isolated_dir / "my-service" @@ -163,6 +180,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 +204,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 +217,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 +259,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_validate.py b/tests/test_validate.py index b6187fd..bb132fd 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -1,6 +1,6 @@ """Tests for the validate command.""" -from pathlib import Path +import re import pytest @@ -20,7 +20,10 @@ def test_validate_empty_project(isolated_env, capsys): # Check output - git check happens first, so expect git error captured = capsys.readouterr() assert "Validating 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 + ) + def test_validate_on_initialized_project(isolated_env, capsys): """Test validate on an initialized project.""" @@ -56,11 +59,8 @@ def test_validate_protected_file_modification(isolated_env, capsys): # Clear the captured output capsys.readouterr() - # Modify manage.py and project_name/settings.py, configured as protected under src/config/protected_files.yaml - files_to_modify = [ - tmp_path / "manage.py", - tmp_path / tmp_path.name / "settings.py" - ] + # Modify protected files from the generated project. + files_to_modify = [tmp_path / "manage.py", tmp_path / tmp_path.name / "settings.py"] for file in files_to_modify: file.write_text("test") # Run validate - expect SystemExit(1) @@ -89,10 +89,7 @@ def test_validate_allowed_file_modification(isolated_env, capsys): # Clear the captured output capsys.readouterr() # Modify .github/dependabot.yml and README.md, shouldn't trigger any infractions - files_to_modify = [ - tmp_path / ".github" / "dependabot.yml", - tmp_path / "README.md" - ] + files_to_modify = [tmp_path / ".github" / "dependabot.yml", tmp_path / "README.md"] for file in files_to_modify: file.write_text("test") # Run validate - expect SystemExit(0) @@ -105,6 +102,26 @@ def test_validate_allowed_file_modification(isolated_env, capsys): assert "Validating your app" in captured.out assert "No framework infractions found, your project is ready to be updated!" in captured.out + +def test_validate_allows_action_sha_updates(isolated_env, capsys): + """Dependabot action SHA updates must not block framework validation.""" + tmp_path, _ = isolated_env + + with pytest.raises(SystemExit) as exc_info: + app(["init"]) + assert exc_info.value.code == 0 + capsys.readouterr() + + workflow = tmp_path / ".github" / "workflows" / "framework-validation.yml" + content = workflow.read_text() + replacement = r"\1@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + workflow.write_text(re.sub(r"(uses:\s+\S+)@\S+", replacement, content)) + + with pytest.raises(SystemExit) as exc_info: + app(["validate"]) + assert exc_info.value.code == 0 + + def test_validate_protected_file_deletion(isolated_env, capsys): """Test validate on an initialized project.""" tmp_path, _ = isolated_env @@ -116,11 +133,8 @@ def test_validate_protected_file_deletion(isolated_env, capsys): # Clear the captured output capsys.readouterr() - # Modify manage.py and project_name/settings.py, configured as protected under src/config/protected_files.yaml - files_to_delete = [ - tmp_path / "manage.py", - tmp_path / tmp_path.name / "settings.py" - ] + # Delete protected files from the generated project. + files_to_delete = [tmp_path / "manage.py", tmp_path / tmp_path.name / "settings.py"] for file in files_to_delete: file.unlink() # Run validate - expect SystemExit(1) @@ -135,4 +149,3 @@ def test_validate_protected_file_deletion(isolated_env, capsys): assert "manage.py" in captured.out assert f"{tmp_path.name}/settings.py" in captured.out assert "Please undo these changes and run the command again" in captured.out -