Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ app_name:

apps:
default:
- api
- api

observability:
type: bool
default: false
5 changes: 5 additions & 0 deletions src/platform_service_framework/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
observability: bool = False,
):
"""Initialize a new Django Project.

Expand All @@ -48,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("-", "_")
Expand All @@ -73,6 +75,7 @@ def init(
"src_branch": vcs_ref,
"apps": all_apps,
"app_name": "",
"observability": observability,
},
)
print("Main project created.")
Expand All @@ -90,6 +93,7 @@ def init(
"template": "templates/core",
"src_branch": vcs_ref,
"apps": all_apps,
"observability": observability,
},
)
print("Created core app")
Expand All @@ -106,6 +110,7 @@ def init(
"template": "templates/app",
"src_branch": vcs_ref,
"apps": all_apps,
"observability": observability,
},
)
print(f"Created app {app_name}")
Expand Down
3 changes: 2 additions & 1 deletion templates/core/models/organization.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 2 additions & 1 deletion templates/core/models/team.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
3 changes: 2 additions & 1 deletion templates/core/models/user.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
15 changes: 15 additions & 0 deletions templates/core/tests/test_activitystream.py
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions templates/project/README.md.jinja
Original file line number Diff line number Diff line change
@@ -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 %}
12 changes: 9 additions & 3 deletions templates/project/apps/settings/defaults.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"],
Expand Down
12 changes: 12 additions & 0 deletions templates/project/apps/settings/production.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
19 changes: 16 additions & 3 deletions templates/project/apps/settings/test.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
5 changes: 1 addition & 4 deletions templates/project/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 = [
Expand Down
25 changes: 21 additions & 4 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the init command."""

import subprocess
from pathlib import Path

import pytest
from git import Repo
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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}"
Expand All @@ -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}"
Expand Down Expand Up @@ -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}"
)

24 changes: 9 additions & 15 deletions tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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."""
Expand Down Expand Up @@ -57,11 +60,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)
Expand Down Expand Up @@ -90,10 +90,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)
Expand Down Expand Up @@ -136,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)
Expand Down