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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ 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.

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

Expand Down Expand Up @@ -55,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?

Expand Down
7 changes: 6 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ app_name:
type: str
default: ""

service_type:
type: str
default: "{{ project_name }}"
when: "{{ app_name == 'core' }}"

apps:
default:
- api
- api
105 changes: 59 additions & 46 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"],
service_type: str | None = None,
):
"""Initialize a new Django Project.

Expand All @@ -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():
Expand All @@ -73,6 +76,7 @@ def init(
"src_branch": vcs_ref,
"apps": all_apps,
"app_name": "",
"service_type": service_type,
},
)
print("Main project created.")
Expand All @@ -90,6 +94,7 @@ def init(
"template": "templates/core",
"src_branch": vcs_ref,
"apps": all_apps,
"service_type": service_type,
},
)
print("Created core app")
Expand Down Expand Up @@ -155,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}")
Expand Down Expand Up @@ -283,106 +288,114 @@ 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}
New source: {src_path}
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)

Expand Down
2 changes: 1 addition & 1 deletion templates/core/resource_api.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
6 changes: 3 additions & 3 deletions templates/core/tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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()
16 changes: 12 additions & 4 deletions templates/core/views/health.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,25 +15,32 @@ 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]
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)
2 changes: 1 addition & 1 deletion templates/project/.github/workflows/framework-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
4 changes: 2 additions & 2 deletions templates/project/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
Loading