csrd-utils 0.3.55__tar.gz → 0.3.57__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/PKG-INFO +1 -1
  2. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/pyproject.toml +1 -1
  3. csrd_utils-0.3.57/src/csrd_utils/__init__.py +14 -0
  4. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/__main__.py +101 -4
  5. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/cluster.py +27 -27
  6. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/generator.py +100 -0
  7. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/cookiecutter.json +6 -0
  8. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/.dockerignore +7 -0
  9. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/Dockerfile +10 -0
  10. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/README.md +28 -0
  11. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/__init__.py +23 -0
  12. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/dependencies/__init__.py +25 -0
  13. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/repositories/__init__.py +3 -0
  14. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/repositories/token_repository.py +32 -0
  15. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/requirements.txt +9 -0
  16. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/settings.py +20 -0
  17. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/views/__init__.py +3 -0
  18. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/views/unversioned/__init__.py +10 -0
  19. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/views/unversioned/auth_view.py +93 -0
  20. csrd_utils-0.3.57/src/csrd_utils/templates/platform-auth-service/{{cookiecutter.__service_name_snake}}/views/unversioned/health_view.py +8 -0
  21. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/cookiecutter.json +5 -0
  22. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/.dockerignore +7 -0
  23. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/Dockerfile +10 -0
  24. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/README.md +25 -0
  25. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/__init__.py +23 -0
  26. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/dependencies/__init__.py +25 -0
  27. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/repositories/__init__.py +3 -0
  28. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/repositories/user_repository.py +123 -0
  29. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/requirements.txt +6 -0
  30. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/settings.py +12 -0
  31. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/views/__init__.py +3 -0
  32. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/views/unversioned/__init__.py +10 -0
  33. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/views/unversioned/health_view.py +8 -0
  34. csrd_utils-0.3.57/src/csrd_utils/templates/platform-identity-service/{{cookiecutter.__service_name_snake}}/views/unversioned/users_view.py +63 -0
  35. csrd_utils-0.3.55/src/csrd_utils/__init__.py +0 -8
  36. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/.gitignore +0 -0
  37. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/README.md +0 -0
  38. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/audit.py +0 -0
  39. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/augmentor.py +0 -0
  40. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/doctor.py +0 -0
  41. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/conftest.fragment.py +0 -0
  42. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/docker-compose.fragment.yaml +0 -0
  43. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/manifest.yaml +0 -0
  44. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/requirements.fragment +0 -0
  45. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/settings.fragment.py +0 -0
  46. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/src/app/cache.py +0 -0
  47. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/caching/tests/test_cache.py +0 -0
  48. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/logging/conftest.fragment.py +0 -0
  49. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/logging/manifest.yaml +0 -0
  50. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/logging/requirements.fragment +0 -0
  51. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/logging/settings.fragment.py +0 -0
  52. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/logging/src/app/middleware/logging.py +0 -0
  53. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/logging/tests/test_logging_middleware.py +0 -0
  54. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/conftest.fragment.py +0 -0
  55. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +0 -0
  56. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/manifest.yaml +0 -0
  57. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/requirements.fragment +0 -0
  58. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/settings.fragment.py +0 -0
  59. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +0 -0
  60. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +0 -0
  61. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/tracing/conftest.fragment.py +0 -0
  62. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/tracing/manifest.yaml +0 -0
  63. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/tracing/requirements.fragment +0 -0
  64. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/tracing/settings.fragment.py +0 -0
  65. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +0 -0
  66. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +0 -0
  67. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  68. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  69. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  70. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  71. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  72. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  73. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  74. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  75. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  76. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/fragments.py +0 -0
  77. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/resources.py +0 -0
  78. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  79. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  80. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  81. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/.cookiecutterignore +0 -0
  82. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/.dockerignore +0 -0
  83. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/Dockerfile +0 -0
  84. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/README.md +0 -0
  85. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/__init__.py +0 -0
  86. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/cache.py +0 -0
  87. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/delegates/__init__.py +0 -0
  88. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/delegates/upstreams.py +0 -0
  89. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/__init__.py +0 -0
  90. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/delegates.py +0 -0
  91. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/item_repository.py +0 -0
  92. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/worker_broker.py +0 -0
  93. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/docker-compose.yml +0 -0
  94. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/middleware/__init__.py +0 -0
  95. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/middleware/logging.py +0 -0
  96. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/repositories/__init__.py +0 -0
  97. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/repositories/item_repository.py +0 -0
  98. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/requirements.txt +0 -0
  99. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/settings.py +0 -0
  100. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/__init__.py +0 -0
  101. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/acceptance/__init__.py +0 -0
  102. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/acceptance/test_logging_middleware.py +0 -0
  103. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/conftest.py +0 -0
  104. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/test_items.py +0 -0
  105. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/__init__.py +0 -0
  106. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_cache.py +0 -0
  107. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_database_settings.py +0 -0
  108. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_delegates.py +0 -0
  109. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_settings_contracts.py +0 -0
  110. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_worker_broker.py +0 -0
  111. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/__init__.py +0 -0
  112. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/__init__.py +0 -0
  113. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/health_view.py +0 -0
  114. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/items_view.py +0 -0
  115. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/workers/__init__.py +0 -0
  116. {csrd_utils-0.3.55 → csrd_utils-0.3.57}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/workers/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csrd-utils
3
- Version: 0.3.55
3
+ Version: 0.3.57
4
4
  Summary: CLI utilities for csrd service generation and feature augmentation
5
5
  Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
6
  Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.55"
3
+ version = "0.3.57"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,14 @@
1
+ """csrd-utils: template and feature tooling for csrd services."""
2
+
3
+ from .audit import run_audit
4
+ from .augmentor import ServiceAugmentor
5
+ from .doctor import run_doctor
6
+ from .generator import generate_platform_service, generate_service
7
+
8
+ __all__ = [
9
+ "ServiceAugmentor",
10
+ "generate_platform_service",
11
+ "generate_service",
12
+ "run_audit",
13
+ "run_doctor",
14
+ ]
@@ -22,7 +22,7 @@ from .cluster import (
22
22
  normalize_cluster_spec,
23
23
  )
24
24
  from .doctor import run_doctor
25
- from .generator import generate_service
25
+ from .generator import generate_platform_service, generate_service
26
26
  from .resources import features_path
27
27
 
28
28
 
@@ -346,7 +346,9 @@ def _collect_new_cluster_config(args: argparse.Namespace) -> GenerateClusterArgs
346
346
  )
347
347
 
348
348
  print("Interactive cluster mode: press Enter to accept defaults.")
349
- cluster_name = _prompt_text("Cluster name", args.name or "my-cluster")
349
+ cluster_name = (
350
+ _prompt_text("Cluster name", args.name or "my-cluster") if not args.name else args.name
351
+ )
350
352
  preset = _prompt_choice("Preset", list(PRESETS.keys()), args.preset)
351
353
  services: list[ServiceSpec] = []
352
354
 
@@ -436,6 +438,94 @@ def _create_workspace(config: GenerateWorkspaceArgs) -> Path:
436
438
  return workspace_root
437
439
 
438
440
 
441
+ def _generate_services_from_cluster_spec(
442
+ spec: ClusterSpec, workspace_root: Path, force: bool = False
443
+ ) -> list[Path]:
444
+ """Scaffold source trees for every service in a cluster spec that lacks one.
445
+
446
+ Derives generate_service arguments from each ServiceSpec so the resulting
447
+ workspace has src/<service>/ code alongside the compose wiring.
448
+ """
449
+
450
+ normalized = normalize_cluster_spec(spec)
451
+ services = normalized["services"]
452
+ port_map = {str(svc["name"]): int(svc["port"]) for svc in services}
453
+
454
+ generated: list[Path] = []
455
+ for service in services:
456
+ name = str(service["name"])
457
+ service_type = str(service["type"])
458
+ port = int(service["port"])
459
+ features = list(service.get("features", []))
460
+ delegates_list = list(service.get("delegates", []))
461
+ broker = str(service.get("broker", "redis"))
462
+
463
+ # Derive upstreams from delegate targets + their ports
464
+ upstreams = ",".join(f"http://{t}:{port_map[t]}" for t in delegates_list if t in port_map)
465
+
466
+ has_database = service_type == "db"
467
+ has_delegates = bool(delegates_list) or service_type == "delegate"
468
+ has_workers = service_type == "worker" or "workers" in features
469
+ has_caching = "caching" in features
470
+ has_structured_logging = "logging" in features
471
+ database = str(service.get("database", "sqlite")) if has_database else "sqlite"
472
+
473
+ src_dir = workspace_root / "src" / name.replace("-", "_")
474
+ if src_dir.exists() and any(src_dir.iterdir()) and not force:
475
+ print(f"Skipped existing service: {name}")
476
+ continue
477
+
478
+ try:
479
+ if service_type == "platform":
480
+ role = str(service.get("role", "custom")).strip()
481
+ # Build extra_context for platform templates that have additional variables
482
+ extra: dict[str, str] = {}
483
+ if role == "auth":
484
+ # Resolve the identity-service URL from the port map if present
485
+ identity_port = next(
486
+ (port_map[n] for n in port_map if "identity" in n),
487
+ 8082,
488
+ )
489
+ identity_name = next(
490
+ (n for n in port_map if "identity" in n),
491
+ "identity-service",
492
+ )
493
+ extra["identity_service_url"] = f"http://{identity_name}:{identity_port}"
494
+ result = generate_platform_service(
495
+ name=name,
496
+ output_dir=workspace_root,
497
+ port=port,
498
+ role=role,
499
+ extra_context=extra if extra else None,
500
+ overwrite_if_exists=force,
501
+ )
502
+ else:
503
+ result = generate_service(
504
+ name=name,
505
+ output_dir=workspace_root,
506
+ description=f"{name} service",
507
+ port=port,
508
+ model_name="Item",
509
+ model_name_plural="items",
510
+ has_database=has_database,
511
+ database=database,
512
+ has_delegates=has_delegates,
513
+ upstreams=upstreams,
514
+ has_workers=has_workers,
515
+ broker=broker,
516
+ has_caching=has_caching,
517
+ cache_backend="redis",
518
+ has_structured_logging=has_structured_logging,
519
+ overwrite_if_exists=force,
520
+ )
521
+ generated.append(result)
522
+ print(f"Generated service: {name}")
523
+ except Exception as exc:
524
+ print(f"WARN: could not scaffold service '{name}': {exc}")
525
+
526
+ return generated
527
+
528
+
439
529
  def _default_service_namespace(output_dir: Path) -> argparse.Namespace:
440
530
  return argparse.Namespace(
441
531
  interactive=True,
@@ -461,7 +551,7 @@ def _default_service_namespace(output_dir: Path) -> argparse.Namespace:
461
551
  def _default_cluster_namespace(output_dir: Path) -> argparse.Namespace:
462
552
  return argparse.Namespace(
463
553
  spec=None,
464
- name=None,
554
+ name=output_dir.name, # Default cluster name to workspace folder name
465
555
  output=output_dir,
466
556
  preset="none",
467
557
  force=False,
@@ -1293,7 +1383,14 @@ def main() -> int:
1293
1383
  cluster_config["output_dir"],
1294
1384
  force=cluster_config["overwrite_if_exists"],
1295
1385
  )
1296
- print(f"Generated cluster at: {generated_cluster}")
1386
+ _save_workspace_cluster_spec(workspace_root, cluster_config["spec"])
1387
+ print(f"Generated cluster scaffold at: {generated_cluster}")
1388
+ scaffolded = _generate_services_from_cluster_spec(
1389
+ cluster_config["spec"],
1390
+ workspace_root,
1391
+ force=cluster_config["overwrite_if_exists"],
1392
+ )
1393
+ print(f"Scaffolded {len(scaffolded)} service(s) under src/.")
1297
1394
  except (FileExistsError, FileNotFoundError, ValueError) as exc:
1298
1395
  print(f"ERROR: {exc}")
1299
1396
  return 1
@@ -39,33 +39,33 @@ PRESETS: dict[str, list[ServiceSpec]] = {
39
39
  "requires": ["auth-service"],
40
40
  },
41
41
  ],
42
- "minimal-cluster": [
43
- {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
44
- {
45
- "name": "core-db",
46
- "type": "db",
47
- "database": "sqlite",
48
- "port": 8091,
49
- "requires": ["auth-service"],
50
- },
51
- ],
52
- "event-driven-cluster": [
53
- {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
54
- {
55
- "name": "events",
56
- "type": "db",
57
- "database": "postgres",
58
- "port": 8091,
59
- "requires": ["auth-service"],
60
- },
61
- {
62
- "name": "worker-consume",
63
- "type": "worker",
64
- "broker": "redis",
65
- "port": 8093,
66
- "requires": ["events", "redis"],
67
- },
68
- ],
42
+ # "minimal-cluster": [
43
+ # {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
44
+ # {
45
+ # "name": "core-db",
46
+ # "type": "db",
47
+ # "database": "sqlite",
48
+ # "port": 8091,
49
+ # "requires": ["auth-service"],
50
+ # },
51
+ # ],
52
+ # "event-driven-cluster": [
53
+ # {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
54
+ # {
55
+ # "name": "events",
56
+ # "type": "db",
57
+ # "database": "postgres",
58
+ # "port": 8091,
59
+ # "requires": ["auth-service"],
60
+ # },
61
+ # {
62
+ # "name": "worker-consume",
63
+ # "type": "worker",
64
+ # "broker": "redis",
65
+ # "port": 8093,
66
+ # "requires": ["events", "redis"],
67
+ # },
68
+ # ],
69
69
  }
70
70
 
71
71
 
@@ -244,6 +244,106 @@ def generate_service(
244
244
  return generated_path
245
245
 
246
246
 
247
+ # ---------------------------------------------------------------------------
248
+ # Platform-specific service generation
249
+ # ---------------------------------------------------------------------------
250
+
251
+ #: Maps role names to the cookiecutter template directory names bundled under templates/.
252
+ _PLATFORM_ROLE_TEMPLATES: dict[str, str] = {
253
+ "auth": "platform-auth-service",
254
+ "identity": "platform-identity-service",
255
+ }
256
+
257
+
258
+ def generate_platform_service(
259
+ *,
260
+ name: str,
261
+ output_dir: Path,
262
+ port: int,
263
+ role: str,
264
+ extra_context: dict[str, Any] | None = None,
265
+ overwrite_if_exists: bool = False,
266
+ ) -> Path:
267
+ """Generate a platform service (auth/identity) from a dedicated cookiecutter template.
268
+
269
+ Unlike :func:`generate_service` this function uses a role-specific template that
270
+ ships purpose-built source code rather than the generic item-CRUD stub.
271
+
272
+ Parameters
273
+ ----------
274
+ name:
275
+ Kebab-case service name (e.g. ``"auth-service"``).
276
+ output_dir:
277
+ Workspace root. The service source lands at ``output_dir/src/<name_snake>/``.
278
+ port:
279
+ Port the service will listen on.
280
+ role:
281
+ Platform role — currently ``"auth"`` or ``"identity"``.
282
+ extra_context:
283
+ Additional cookiecutter variables merged on top of the defaults.
284
+ overwrite_if_exists:
285
+ When *True*, an existing service directory is overwritten.
286
+
287
+ Returns
288
+ -------
289
+ Path
290
+ Absolute path to the generated service package (``output_dir/src/<name_snake>``).
291
+ """
292
+ if role not in _PLATFORM_ROLE_TEMPLATES:
293
+ raise ValueError(
294
+ f"Unknown platform role '{role}'. Supported roles: {sorted(_PLATFORM_ROLE_TEMPLATES)}"
295
+ )
296
+
297
+ template_name = _PLATFORM_ROLE_TEMPLATES[role]
298
+ output_root = output_dir.resolve()
299
+ src_dir = output_root / "src"
300
+ src_dir.mkdir(parents=True, exist_ok=True)
301
+
302
+ service_name_snake = name.replace("-", "_")
303
+ target = src_dir / service_name_snake
304
+
305
+ if target.exists() and any(target.iterdir()) and not overwrite_if_exists:
306
+ raise FileExistsError(
307
+ f"Target directory already exists and is not empty: {target}. Use --force to overwrite."
308
+ )
309
+
310
+ context: dict[str, Any] = {
311
+ "service_name": name,
312
+ "port": str(port),
313
+ "__service_name_snake": service_name_snake,
314
+ }
315
+ if extra_context:
316
+ context.update(extra_context)
317
+
318
+ old_dontwritebytecode = os.environ.get("PYTHONDONTWRITEBYTECODE")
319
+ try:
320
+ os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
321
+ with templates_path() as template_root:
322
+ template = template_root / template_name
323
+ generated = cookiecutter(
324
+ str(template),
325
+ no_input=True,
326
+ extra_context=context,
327
+ output_dir=str(src_dir),
328
+ overwrite_if_exists=overwrite_if_exists,
329
+ )
330
+ finally:
331
+ if old_dontwritebytecode is None:
332
+ os.environ.pop("PYTHONDONTWRITEBYTECODE", None)
333
+ else:
334
+ os.environ["PYTHONDONTWRITEBYTECODE"] = old_dontwritebytecode
335
+
336
+ generated_path = Path(generated).resolve()
337
+ for pycache_dir in generated_path.rglob("__pycache__"):
338
+ shutil.rmtree(pycache_dir, ignore_errors=True)
339
+
340
+ _move_docker_files_to_workspace_root(generated_path, output_root, name)
341
+ _write_workspace_pyproject(output_root)
342
+ _write_workspace_readme(output_root)
343
+
344
+ return generated_path
345
+
346
+
247
347
  def _move_docker_files_to_workspace_root(
248
348
  service_path: Path, workspace_root: Path, service_name: str
249
349
  ) -> None:
@@ -0,0 +1,6 @@
1
+ {
2
+ "service_name": "auth-service",
3
+ "port": "8081",
4
+ "identity_service_url": "http://identity-service:8082",
5
+ "__service_name_snake": "{{ cookiecutter.service_name | replace('-', '_') }}"
6
+ }
@@ -0,0 +1,10 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . /app
6
+ RUN pip install --no-cache-dir -r /app/requirements.txt
7
+
8
+ ENV PORT={{ cookiecutter.port }}
9
+
10
+ CMD uvicorn {{cookiecutter.__service_name_snake}}:build_app --factory --host 0.0.0.0 --port ${PORT}
@@ -0,0 +1,28 @@
1
+ # {{ cookiecutter.service_name }}
2
+
3
+ Platform auth service — issues and verifies JWT tokens.
4
+
5
+ ## Endpoints
6
+
7
+ - `POST /api/token` — issue a JWT (validates credentials via identity-service)
8
+ - `POST /api/verify` — verify a JWT and return claims
9
+ - `GET /health` — liveness probe
10
+
11
+ ## Configuration
12
+
13
+ | Variable | Default | Description |
14
+ |---|---|---|
15
+ | `JWT_SECRET` | `dev-secret-change-me-please-32-bytes` | Signing key |
16
+ | `JWT_ALGORITHM` | `HS256` | JWT algorithm |
17
+ | `JWT_ISSUER` | `{{ cookiecutter.service_name }}` | Token issuer |
18
+ | `JWT_AUDIENCE` | `cluster` | Token audience |
19
+ | `JWT_TTL_SECONDS` | `3600` | Token TTL |
20
+ | `DB_PATH` | `./{{ cookiecutter.__service_name_snake }}.db` | SQLite database path |
21
+ | `IDENTITY_SERVICE_URL` | `{{ cookiecutter.identity_service_url }}` | Identity service URL |
22
+
23
+ ## Run
24
+
25
+ ```bash
26
+ pip install -r requirements.txt
27
+ uvicorn {{ cookiecutter.__service_name_snake }}:build_app --factory --port {{ cookiecutter.port }}
28
+ ```
@@ -0,0 +1,23 @@
1
+ from fastapi import FastAPI
2
+
3
+ from csrd.versioning import (
4
+ UNVERSIONED,
5
+ VersionedApiConfig,
6
+ VersionedAppComposeConfig,
7
+ compose_versioned_apps,
8
+ )
9
+
10
+ from .views import unversioned_app
11
+
12
+
13
+ def build_app() -> FastAPI:
14
+ return compose_versioned_apps(
15
+ version_mapping={UNVERSIONED: unversioned_app},
16
+ config=VersionedAppComposeConfig(
17
+ title="{{ cookiecutter.service_name }}",
18
+ api=VersionedApiConfig(prefix="/", include_actuator_endpoints=False),
19
+ ),
20
+ )
21
+
22
+
23
+ __all__ = ("build_app",)
@@ -0,0 +1,25 @@
1
+ from collections.abc import AsyncIterator
2
+ from typing import Annotated
3
+
4
+ from fastapi import Depends
5
+
6
+ from csrd.repository import SQLiteAdapter
7
+
8
+ from ..repositories.token_repository import TokenRepository
9
+ from ..settings import settings
10
+
11
+
12
+ async def token_repository_factory() -> AsyncIterator[TokenRepository]:
13
+ adapter = SQLiteAdapter(settings.db_path)
14
+ await adapter.connect()
15
+ repo = TokenRepository(adapter)
16
+ await repo._init_schema()
17
+ try:
18
+ yield repo
19
+ finally:
20
+ await adapter.close()
21
+
22
+
23
+ TokenRepositoryDep = Annotated[TokenRepository, Depends(token_repository_factory)]
24
+
25
+ __all__ = ("TokenRepositoryDep", "token_repository_factory")
@@ -0,0 +1,3 @@
1
+ from .token_repository import TokenRepository
2
+
3
+ __all__ = ("TokenRepository",)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from csrd.repository import BaseRepository, DBProtocol
6
+
7
+
8
+ class TokenRepository(BaseRepository):
9
+ """Repository backed by a pluggable DBProtocol adapter."""
10
+
11
+ def __init__(self, adapter: DBProtocol) -> None:
12
+ super().__init__(adapter)
13
+
14
+ async def _init_schema(self) -> None:
15
+ await self.execute(
16
+ """
17
+ CREATE TABLE IF NOT EXISTS issued_tokens (
18
+ token TEXT PRIMARY KEY,
19
+ subject TEXT NOT NULL,
20
+ issued_at TEXT NOT NULL
21
+ )
22
+ """
23
+ )
24
+
25
+ async def record_issued_token(self, token: str, subject: str) -> None:
26
+ issued_at = datetime.now(UTC).isoformat()
27
+ await self._init_schema()
28
+ await self.execute(
29
+ "INSERT OR REPLACE INTO issued_tokens (token, subject, issued_at) "
30
+ "VALUES (:token, :subject, :issued_at)",
31
+ {"token": token, "subject": subject, "issued_at": issued_at},
32
+ )
@@ -0,0 +1,9 @@
1
+ fastapi>=0.115,<1
2
+ uvicorn[standard]>=0.30,<1
3
+ pyjwt>=2.8,<3
4
+ pydantic-settings>=2.6,<3
5
+ httpx>=0.27,<1
6
+ aiosqlite>=0.21,<1
7
+ csrd-versioning>=0.1.0,<1
8
+ csrd-repository>=0.1.0,<1
9
+ csrd-auth>=0.1.0,<1
@@ -0,0 +1,20 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
6
+
7
+ app_name: str = "{{ cookiecutter.service_name }}"
8
+ port: int = {{ cookiecutter.port }}
9
+
10
+ jwt_secret: str = "dev-secret-change-me-please-32-bytes"
11
+ jwt_algorithm: str = "HS256"
12
+ jwt_issuer: str = "{{ cookiecutter.service_name }}"
13
+ jwt_audience: str = "cluster"
14
+ jwt_ttl_seconds: int = 3600
15
+
16
+ db_path: str = "./{{ cookiecutter.__service_name_snake }}.db"
17
+ identity_service_url: str = "{{ cookiecutter.identity_service_url }}"
18
+
19
+
20
+ settings = Settings()
@@ -0,0 +1,3 @@
1
+ from .unversioned import unversioned_app
2
+
3
+ __all__ = ("unversioned_app",)
@@ -0,0 +1,10 @@
1
+ from fastapi import FastAPI
2
+
3
+ from . import auth_view, health_view
4
+
5
+ unversioned_app = FastAPI(title="{{ cookiecutter.service_name }}")
6
+
7
+ unversioned_app.include_router(health_view.router)
8
+ unversioned_app.include_router(auth_view.router)
9
+
10
+ __all__ = ("unversioned_app",)
@@ -0,0 +1,93 @@
1
+ from datetime import UTC, datetime, timedelta
2
+
3
+ import httpx
4
+ import jwt
5
+ from fastapi import APIRouter, HTTPException, Request
6
+ from pydantic import BaseModel
7
+
8
+ from csrd.auth import JWTAuthenticator, StaticKeyProvider
9
+
10
+ from ...dependencies import TokenRepositoryDep
11
+ from ...settings import settings
12
+
13
+ router = APIRouter(prefix="/api", tags=["Auth Endpoints"])
14
+
15
+
16
+ class TokenRequest(BaseModel):
17
+ username: str
18
+ password: str
19
+
20
+
21
+ class VerifyRequest(BaseModel):
22
+ token: str
23
+
24
+
25
+ def _make_verifier() -> JWTAuthenticator:
26
+ return JWTAuthenticator(
27
+ key=StaticKeyProvider(settings.jwt_secret),
28
+ algorithms=[settings.jwt_algorithm],
29
+ audience=settings.jwt_audience,
30
+ issuer=settings.jwt_issuer,
31
+ )
32
+
33
+
34
+ async def _validate_credentials(username: str, password: str) -> list[str]:
35
+ """Validate username/password against identity-service; raises 401 on failure.
36
+ Returns the list of roles assigned to the user."""
37
+ if not settings.identity_service_url:
38
+ return [] # identity not configured — allow any username (fallback)
39
+
40
+ async with httpx.AsyncClient(timeout=5.0) as client:
41
+ try:
42
+ response = await client.post(
43
+ f"{settings.identity_service_url}/api/verify-credentials",
44
+ json={"username": username, "password": password},
45
+ )
46
+ except httpx.ConnectError as exc:
47
+ raise HTTPException(status_code=503, detail="identity service unavailable") from exc
48
+
49
+ if response.status_code == 401:
50
+ raise HTTPException(status_code=401, detail="invalid credentials")
51
+
52
+ if response.status_code != 200:
53
+ raise HTTPException(status_code=502, detail="identity service error")
54
+
55
+ data = response.json()
56
+ user = data.get("user", {})
57
+ return list(user.get("roles", []))
58
+
59
+
60
+ @router.post("/token")
61
+ async def issue_token(
62
+ payload: TokenRequest, token_repo: TokenRepositoryDep
63
+ ) -> dict[str, str | int | list[str]]:
64
+ roles = await _validate_credentials(payload.username, payload.password)
65
+
66
+ now = datetime.now(UTC)
67
+ claims = {
68
+ "sub": payload.username,
69
+ "iss": settings.jwt_issuer,
70
+ "aud": settings.jwt_audience,
71
+ "iat": int(now.timestamp()),
72
+ "exp": int((now + timedelta(seconds=settings.jwt_ttl_seconds)).timestamp()),
73
+ "authorities": roles,
74
+ "roles": roles,
75
+ }
76
+ token = jwt.encode(claims, settings.jwt_secret, algorithm=settings.jwt_algorithm)
77
+ await token_repo.record_issued_token(token=token, subject=payload.username)
78
+ return {"access_token": token, "token_type": "bearer", "expires_in": settings.jwt_ttl_seconds}
79
+
80
+
81
+ @router.post("/verify")
82
+ async def verify_token(request: Request, payload: VerifyRequest) -> dict[str, object]:
83
+ verifier = _make_verifier()
84
+ claims = await verifier(request, payload.token)
85
+ return {
86
+ "valid": True,
87
+ "claims": {
88
+ "sub": claims.sub,
89
+ "user_name": claims.user_name,
90
+ "authorities": claims.authorities,
91
+ "roles": claims.authorities,
92
+ },
93
+ }
@@ -0,0 +1,8 @@
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter(tags=["Health Endpoints"])
4
+
5
+
6
+ @router.get("/health")
7
+ def health() -> dict[str, str]:
8
+ return {"status": "ok", "service": "{{ cookiecutter.service_name }}"}
@@ -0,0 +1,5 @@
1
+ {
2
+ "service_name": "identity-service",
3
+ "port": "8082",
4
+ "__service_name_snake": "{{ cookiecutter.service_name | replace('-', '_') }}"
5
+ }
@@ -0,0 +1,10 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . /app
6
+ RUN pip install --no-cache-dir -r /app/requirements.txt
7
+
8
+ ENV PORT={{ cookiecutter.port }}
9
+
10
+ CMD uvicorn {{cookiecutter.__service_name_snake}}:build_app --factory --host 0.0.0.0 --port ${PORT}