izinum-devops-builder 0.19.0__py3-none-any.whl

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 (140) hide show
  1. devops_builder/__init__.py +6 -0
  2. devops_builder/__main__.py +6 -0
  3. devops_builder/appscan.py +523 -0
  4. devops_builder/catalog.py +359 -0
  5. devops_builder/catalog_edit.py +200 -0
  6. devops_builder/cli.py +410 -0
  7. devops_builder/compat.py +95 -0
  8. devops_builder/data/catalog/10_source_build.yaml +336 -0
  9. devops_builder/data/catalog/20_test_security.yaml +226 -0
  10. devops_builder/data/catalog/30_artifacts_infra.yaml +584 -0
  11. devops_builder/data/catalog/35_providers.yaml +130 -0
  12. devops_builder/data/catalog/40_deploy_platform.yaml +420 -0
  13. devops_builder/data/catalog/45_data.yaml +181 -0
  14. devops_builder/data/catalog/50_observability.yaml +194 -0
  15. devops_builder/data/examples/01-web-hote-docker.devops.yaml +75 -0
  16. devops_builder/data/examples/02-api-k3s-gitops.devops.yaml +110 -0
  17. devops_builder/data/examples/03-iac-aws-eks.devops.yaml +98 -0
  18. devops_builder/data/examples/04-boutique-donnees.devops.yaml +87 -0
  19. devops_builder/data/landscape_index.json +10847 -0
  20. devops_builder/data/params.yaml +910 -0
  21. devops_builder/data/ports.yaml +36 -0
  22. devops_builder/data/rules/R00_fiche_non_verifiee.yaml +6 -0
  23. devops_builder/data/rules/R01_kubernetes_obligatoire.yaml +8 -0
  24. devops_builder/data/rules/R02_un_seul_gitops.yaml +8 -0
  25. devops_builder/data/rules/R03_un_seul_mesh.yaml +8 -0
  26. devops_builder/data/rules/R04_un_seul_cni.yaml +8 -0
  27. devops_builder/data/rules/R05_un_seul_cluster.yaml +6 -0
  28. devops_builder/data/rules/R06_ci_et_forge.yaml +8 -0
  29. devops_builder/data/rules/R07_thanos_prometheus.yaml +10 -0
  30. devops_builder/data/rules/R08_velero_stockage_objet.yaml +10 -0
  31. devops_builder/data/rules/R09_service_sans_fournisseur.yaml +6 -0
  32. devops_builder/data/rules/R10_aws_app_mesh.yaml +8 -0
  33. devops_builder/data/rules/R11_google_container_registry.yaml +8 -0
  34. devops_builder/data/rules/R12_secnumcloud.yaml +8 -0
  35. devops_builder/data/rules/R13_ingress_nginx.yaml +8 -0
  36. devops_builder/data/rules/R14_kaniko.yaml +8 -0
  37. devops_builder/data/rules/R15_deux_controleurs_admission.yaml +8 -0
  38. devops_builder/data/rules/R16_terraform_opentofu.yaml +8 -0
  39. devops_builder/data/rules/R17_compose_sur_kubernetes.yaml +9 -0
  40. devops_builder/data/rules/R18_cluster_de_dev_en_prod.yaml +9 -0
  41. devops_builder/data/rules/R19_jaeger_sans_stockage.yaml +11 -0
  42. devops_builder/data/rules/R20_registre_autre_cloud.yaml +6 -0
  43. devops_builder/data/rules/R21_cles_statiques.yaml +6 -0
  44. devops_builder/data/rules/R22_github_actions_sur_gitea.yaml +8 -0
  45. devops_builder/data/rules/R23_harbor_trivy.yaml +9 -0
  46. devops_builder/data/rules/R24_licence.yaml +8 -0
  47. devops_builder/data/rules/R25_etape_incompatible.yaml +6 -0
  48. devops_builder/data/rules/R26_build_sans_registre.yaml +6 -0
  49. devops_builder/data/rules/R27_deploiement_sans_build.yaml +6 -0
  50. devops_builder/data/rules/R28_etapes_sans_ci.yaml +6 -0
  51. devops_builder/data/rules/R29_capacite_requise.yaml +12 -0
  52. devops_builder/data/rules/R30_cible_et_cluster.yaml +6 -0
  53. devops_builder/data/rules/R31_sans_generateur.yaml +6 -0
  54. devops_builder/data/rules/R32_parametre_invalide.yaml +8 -0
  55. devops_builder/data/rules/R33_parametre_inconnu.yaml +8 -0
  56. devops_builder/data/rules/R34_valeur_exemple.yaml +8 -0
  57. devops_builder/data/rules/R35_production_sans_approbation.yaml +6 -0
  58. devops_builder/data/rules/R36_cluster_des_environnements.yaml +6 -0
  59. devops_builder/data/rules/R37_environnements_invalides.yaml +6 -0
  60. devops_builder/data/rules/R38_collision_entre_environnements.yaml +6 -0
  61. devops_builder/data/rules/R39_conflit_de_ports.yaml +6 -0
  62. devops_builder/data/rules/R40_base_sans_sauvegarde.yaml +6 -0
  63. devops_builder/data/rules/R41_base_en_statefulset.yaml +6 -0
  64. devops_builder/data/rules/R42_deux_bases_relationnelles.yaml +8 -0
  65. devops_builder/data/rules/R43_deux_stockages_cle_valeur.yaml +8 -0
  66. devops_builder/data/rules/R44_etat_terraform_local.yaml +6 -0
  67. devops_builder/data/rules/R45_coffre_injoignable.yaml +6 -0
  68. devops_builder/data/rules/R46_format_de_connexion.yaml +6 -0
  69. devops_builder/data/rules/R47_sauvegarde_sans_cert_manager.yaml +6 -0
  70. devops_builder/data/rules/R48_deux_fournisseurs_identite.yaml +8 -0
  71. devops_builder/data/rules/R49_fournisseur_identite_prerequis.yaml +6 -0
  72. devops_builder/data/rules/R50_brique_experimentale.yaml +6 -0
  73. devops_builder/data/rules/R51_drone.yaml +8 -0
  74. devops_builder/data/rules/R52_dimensionnement.yaml +6 -0
  75. devops_builder/data/sizing.yaml +120 -0
  76. devops_builder/data/suggestions.yaml +66 -0
  77. devops_builder/databases.py +205 -0
  78. devops_builder/datainfo.py +142 -0
  79. devops_builder/generators/__init__.py +127 -0
  80. devops_builder/generators/ansible.py +187 -0
  81. devops_builder/generators/ci.py +551 -0
  82. devops_builder/generators/ciaccess.py +120 -0
  83. devops_builder/generators/civars.py +229 -0
  84. devops_builder/generators/common.py +104 -0
  85. devops_builder/generators/dast.py +143 -0
  86. devops_builder/generators/data.py +1105 -0
  87. devops_builder/generators/deploy.py +337 -0
  88. devops_builder/generators/extras.py +217 -0
  89. devops_builder/generators/floci.py +150 -0
  90. devops_builder/generators/hosts.py +599 -0
  91. devops_builder/generators/iac.py +507 -0
  92. devops_builder/generators/identity.py +592 -0
  93. devops_builder/generators/localtest.py +226 -0
  94. devops_builder/generators/platform.py +269 -0
  95. devops_builder/generators/report.py +160 -0
  96. devops_builder/generators/runbook.py +751 -0
  97. devops_builder/generators/secrets.py +843 -0
  98. devops_builder/generators/zabbix.py +97 -0
  99. devops_builder/gitexport.py +289 -0
  100. devops_builder/health.py +382 -0
  101. devops_builder/ir.py +775 -0
  102. devops_builder/landscape.py +134 -0
  103. devops_builder/model.py +450 -0
  104. devops_builder/params.py +135 -0
  105. devops_builder/ports.py +227 -0
  106. devops_builder/rules.py +1040 -0
  107. devops_builder/sizing.py +148 -0
  108. devops_builder/starter.py +334 -0
  109. devops_builder/suggest.py +49 -0
  110. devops_builder/support.py +48 -0
  111. devops_builder/sync.py +161 -0
  112. devops_builder/ui/__init__.py +1 -0
  113. devops_builder/ui/app.py +49 -0
  114. devops_builder/ui/application.py +158 -0
  115. devops_builder/ui/canvas.py +398 -0
  116. devops_builder/ui/data.py +155 -0
  117. devops_builder/ui/diagnostics.py +115 -0
  118. devops_builder/ui/dialogs.py +461 -0
  119. devops_builder/ui/export.py +406 -0
  120. devops_builder/ui/fiche.py +269 -0
  121. devops_builder/ui/main_window.py +767 -0
  122. devops_builder/ui/maintenance.py +358 -0
  123. devops_builder/ui/palette.py +289 -0
  124. devops_builder/ui/param_form.py +197 -0
  125. devops_builder/ui/ports.py +117 -0
  126. devops_builder/ui/properties.py +328 -0
  127. devops_builder/ui/theme.py +144 -0
  128. devops_builder/ui/themes/devops_dark.xml +10 -0
  129. devops_builder/ui/themes/devops_light.xml +10 -0
  130. devops_builder/ui/validation.py +323 -0
  131. devops_builder/ui/widgets.py +94 -0
  132. devops_builder/ui/wizard.py +347 -0
  133. devops_builder/userdata.py +37 -0
  134. devops_builder/validate.py +419 -0
  135. izinum_devops_builder-0.19.0.dist-info/METADATA +297 -0
  136. izinum_devops_builder-0.19.0.dist-info/RECORD +140 -0
  137. izinum_devops_builder-0.19.0.dist-info/WHEEL +4 -0
  138. izinum_devops_builder-0.19.0.dist-info/entry_points.txt +2 -0
  139. izinum_devops_builder-0.19.0.dist-info/licenses/LICENSE +202 -0
  140. izinum_devops_builder-0.19.0.dist-info/licenses/NOTICE +12 -0
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """DevOps Builder : composer des pipelines DevOps cohérents à partir des briques du CNCF Landscape."""
3
+
4
+ __version__ = "0.19.0"
5
+ # nom du paquet sur PyPI (« devops-builder » y était déjà pris) ; la commande reste devops-builder
6
+ DIST_NAME = "izinum-devops-builder"
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,523 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Analyse du dossier d'une application : ce qu'il faut savoir pour lui construire un pipeline.
3
+
4
+ Lecture seule et sans exécuter de code : les fichiers sont lus tels quels (pas de `git`,
5
+ pas d'installation de dépendances). Chaque détection garde sa preuve (« Python : pyproject.toml »)
6
+ pour que l'utilisateur sache d'où vient la proposition.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import configparser
12
+ import json
13
+ import re
14
+ import tomllib
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "env", "__pycache__", "dist", "build", "target", "vendor",
19
+ ".idea", ".vscode", ".terraform", ".tox", ".mypy_cache", ".pytest_cache", "bin", "obj", ".next"}
20
+ MAX_DEPTH = 4
21
+ MAX_FILES = 20000
22
+
23
+ # Port d'écoute par défaut des cadres applicatifs courants
24
+ FRAMEWORK_PORTS = {"fastapi": 8000, "uvicorn": 8000, "django": 8000, "flask": 5000, "streamlit": 8501,
25
+ "express": 3000, "next": 3000, "@nestjs/core": 3000, "nuxt": 3000, "fastify": 3000,
26
+ "spring-boot": 8080, "quarkus": 8080, "aspnet": 8080, "gin": 8080, "echo": 8080}
27
+ LANGUAGE_PORTS = {"python": 8000, "node": 3000, "java": 8080, "go": 8080, "dotnet": 8080}
28
+ CI_FILES = [(".gitlab-ci.yml", "gitlab-ci"), (".github/workflows", "github-actions"), (".gitea/workflows", "gitea-actions"),
29
+ ("Jenkinsfile", "jenkins"), (".woodpecker.yml", "woodpecker"), (".woodpecker", "woodpecker"),
30
+ ("azure-pipelines.yml", "azure-pipelines"), ("buildspec.yml", "aws-codebuild"),
31
+ ("cloudbuild.yaml", "google-cloud-build"), (".tekton", "tekton")]
32
+ # Pilotes et bibliothèques qui trahissent une base : (moteur, motif cherché dans les dépendances)
33
+ DB_DRIVERS: dict[str, list[tuple[str, str]]] = {
34
+ "python": [("postgresql", r"\b(psycopg2?(-binary)?|asyncpg|pg8000)\b"), ("mysql", r"\b(mysqlclient|pymysql|aiomysql|mysql-connector-python)\b"),
35
+ ("mariadb", r"^\s*\"?mariadb\b"), ("mongodb", r"\b(pymongo|motor|mongoengine|beanie)\b"),
36
+ ("valkey", r"\b(redis|valkey|django-redis|celery\[redis\])\b")],
37
+ "node": [("postgresql", r'"(pg|postgres|pg-promise)"'), ("mysql", r'"(mysql2?)"'), ("mariadb", r'"mariadb"'),
38
+ ("mongodb", r'"(mongodb|mongoose)"'), ("valkey", r'"(redis|ioredis|iovalkey|@valkey/valkey-glide)"')],
39
+ "java": [("postgresql", r"<artifactId>postgresql</artifactId>|org\.postgresql"),
40
+ ("mysql", r"mysql-connector-j|mysql-connector-java"), ("mariadb", r"mariadb-java-client"),
41
+ ("mongodb", r"mongodb-driver|spring-boot-starter-data-mongodb"),
42
+ ("valkey", r"jedis|lettuce-core|spring-boot-starter-data-redis")],
43
+ "go": [("postgresql", r"jackc/pgx|lib/pq"), ("mysql", r"go-sql-driver/mysql"),
44
+ ("mongodb", r"go\.mongodb\.org/mongo-driver"), ("valkey", r"redis/go-redis|go-redis/redis|valkey-io/valkey-go")],
45
+ "dotnet": [("postgresql", r"Npgsql"), ("mysql", r"MySql\.Data|MySqlConnector|Pomelo\.EntityFrameworkCore\.MySql"),
46
+ ("mongodb", r"MongoDB\.Driver"), ("valkey", r"StackExchange\.Redis")],
47
+ }
48
+ COMPOSE_DB_IMAGES = [("postgresql", r"image:\s*['\"]?(\S*/)?postgres(ql)?[:'\"\s]"), ("mariadb", r"image:\s*['\"]?(\S*/)?mariadb[:'\"\s]"),
49
+ ("mysql", r"image:\s*['\"]?(\S*/)?mysql[:'\"\s]"), ("mongodb", r"image:\s*['\"]?(\S*/)?mongo(db)?[:'\"\s]"),
50
+ ("valkey", r"image:\s*['\"]?(\S*/)?(valkey|redis)[:'\"\s]")]
51
+ AWS_SDKS = {"python": r"\b(boto3|aioboto3|botocore)\b", "node": r'"@aws-sdk/|"aws-sdk"',
52
+ "java": r"software\.amazon\.awssdk|aws-java-sdk", "go": r"aws/aws-sdk-go", "dotnet": r"AWSSDK\."}
53
+ EMULATORS = [("floci", r"floci/floci|testcontainers-floci|@floci/testcontainers|io\.floci"),
54
+ ("localstack", r"localstack")]
55
+ FORGE_HOSTS = [("github.com", "github"), ("gitlab", "gitlab"), ("bitbucket.org", "bitbucket"),
56
+ ("dev.azure.com", "azure-repos"), ("visualstudio.com", "azure-repos"), ("codeberg.org", "gitea"),
57
+ ("gitea", "gitea")]
58
+
59
+
60
+ @dataclass
61
+ class AppProfile:
62
+ path: Path
63
+ name: str = ""
64
+ image_name: str = ""
65
+ language: str = "other"
66
+ framework: str = ""
67
+ port: int | None = None
68
+ port_source: str = ""
69
+ dockerfile: bool = False
70
+ base_image: str = ""
71
+ compose: bool = False
72
+ ci: str | None = None
73
+ forge: str | None = None
74
+ remote: str = ""
75
+ helm: bool = False
76
+ kustomize: bool = False
77
+ k8s_manifests: bool = False
78
+ iac: str | None = None
79
+ dependencies: str | None = None
80
+ has_tests: bool = False
81
+ databases: list[str] = field(default_factory=list) # moteurs détectés, le principal d'abord
82
+ aws_sdk: bool = False # l'application appelle des API AWS
83
+ aws_emulator: str | None = None # émulateur déjà utilisé : floci ou localstack
84
+ migrations: str | None = None # commande de migration de schéma
85
+ # variable et format de connexion lus par l'application, par famille (sql, document, key-value)
86
+ connections: dict[str, dict] = field(default_factory=dict)
87
+ evidence: list[str] = field(default_factory=list)
88
+
89
+ @property
90
+ def hosting_hint(self) -> str | None:
91
+ if self.helm or self.kustomize or self.k8s_manifests:
92
+ return "kubernetes"
93
+ if self.compose:
94
+ return "docker-host"
95
+ return None
96
+
97
+ def summary(self) -> list[str]:
98
+ """Lignes lisibles : ce qui a été trouvé, avec sa preuve."""
99
+ return list(self.evidence)
100
+
101
+
102
+ def slug(name: str) -> str:
103
+ s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
104
+ return (s or "mon-app")[:40].rstrip("-")
105
+
106
+
107
+ def _files(root: Path) -> list[Path]:
108
+ out: list[Path] = []
109
+ stack = [(root, 0)]
110
+ while stack and len(out) < MAX_FILES:
111
+ folder, depth = stack.pop()
112
+ try:
113
+ entries = sorted(folder.iterdir())
114
+ except OSError:
115
+ continue
116
+ for entry in entries:
117
+ if entry.is_dir():
118
+ if entry.name not in SKIP_DIRS and depth < MAX_DEPTH and not entry.is_symlink():
119
+ stack.append((entry, depth + 1))
120
+ elif entry.is_file():
121
+ out.append(entry)
122
+ return out
123
+
124
+
125
+ def _read(path: Path, limit: int = 200_000) -> str:
126
+ try:
127
+ return path.read_text(encoding="utf-8", errors="replace")[:limit]
128
+ except OSError:
129
+ return ""
130
+
131
+
132
+ def _rel(root: Path, path: Path) -> str:
133
+ return str(path.relative_to(root))
134
+
135
+
136
+ def scan(path: str | Path) -> AppProfile:
137
+ root = Path(path).expanduser().resolve()
138
+ if not root.is_dir():
139
+ raise ValueError(f"{root} n'est pas un dossier.")
140
+ p = AppProfile(path=root, name=root.name)
141
+ files = _files(root)
142
+ names = {f.name for f in files}
143
+ top = {f.name: f for f in files if f.parent == root}
144
+ _language(root, p, top, files)
145
+ _dockerfile(root, p, files)
146
+ _deploy(root, p, files, names)
147
+ _ci_and_forge(root, p)
148
+ _iac(root, p, files)
149
+ _databases(root, p, files, top)
150
+ if any(n in names for n in ("renovate.json", "renovate.json5", ".renovaterc")):
151
+ p.dependencies = "renovate"
152
+ p.evidence.append("Renovate : configuration présente")
153
+ elif (root / ".github" / "dependabot.yml").exists():
154
+ p.dependencies = "dependabot"
155
+ p.evidence.append("Dependabot : .github/dependabot.yml")
156
+ if p.port is None and p.framework in FRAMEWORK_PORTS:
157
+ p.port, p.port_source = FRAMEWORK_PORTS[p.framework], f"port par défaut de {p.framework}"
158
+ if p.port is None and p.language in LANGUAGE_PORTS:
159
+ p.port, p.port_source = LANGUAGE_PORTS[p.language], "port usuel du langage"
160
+ if p.port is not None:
161
+ p.evidence.append(f"Port {p.port} : {p.port_source}")
162
+ p.image_name = slug(p.name)
163
+ return p
164
+
165
+
166
+ def _language(root: Path, p: AppProfile, top: dict[str, Path], files: list[Path]) -> None:
167
+ def deps_text(*paths: Path) -> str:
168
+ return "\n".join(_read(x).lower() for x in paths if x)
169
+
170
+ if "pyproject.toml" in top or "requirements.txt" in top or "setup.py" in top or "Pipfile" in top:
171
+ p.language = "python"
172
+ proof = next(n for n in ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile") if n in top)
173
+ if "pyproject.toml" in top:
174
+ try:
175
+ data = tomllib.loads(_read(top["pyproject.toml"]))
176
+ p.name = data.get("project", {}).get("name") or data.get("tool", {}).get("poetry", {}).get("name") or p.name
177
+ except tomllib.TOMLDecodeError:
178
+ pass
179
+ text = deps_text(*(top.get(n) for n in ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile")))
180
+ p.framework = next((f for f in ("fastapi", "django", "flask", "streamlit") if re.search(rf"\b{f}\b", text)), "")
181
+ p.has_tests = any(n in top for n in ("pytest.ini", "tox.ini", "conftest.py")) or (root / "tests").is_dir() \
182
+ or "pytest" in text
183
+ p.evidence.append(f"Python : {proof}" + (f", cadre {p.framework}" if p.framework else ""))
184
+ elif "package.json" in top:
185
+ p.language = "node"
186
+ try:
187
+ data = json.loads(_read(top["package.json"]))
188
+ except ValueError:
189
+ data = {}
190
+ p.name = (data.get("name") or p.name).split("/")[-1]
191
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
192
+ p.framework = next((f for f in ("next", "@nestjs/core", "nuxt", "fastify", "express") if f in deps), "")
193
+ test = (data.get("scripts") or {}).get("test", "")
194
+ p.has_tests = bool(test) and "no test specified" not in test
195
+ p.evidence.append("Node.js : package.json" + (f", cadre {p.framework}" if p.framework else ""))
196
+ elif "pom.xml" in top or "build.gradle" in top or "build.gradle.kts" in top:
197
+ p.language = "java"
198
+ proof = next(n for n in ("pom.xml", "build.gradle", "build.gradle.kts") if n in top)
199
+ text = _read(top[proof])
200
+ if "spring-boot" in text:
201
+ p.framework = "spring-boot"
202
+ elif "quarkus" in text:
203
+ p.framework = "quarkus"
204
+ m = re.search(r"<artifactId>([^<]+)</artifactId>", text)
205
+ if proof == "pom.xml" and m:
206
+ p.name = m.group(1)
207
+ p.has_tests = (root / "src" / "test").is_dir()
208
+ p.evidence.append(f"Java : {proof}" + (f", cadre {p.framework}" if p.framework else ""))
209
+ elif "go.mod" in top:
210
+ p.language = "go"
211
+ m = re.search(r"^module\s+(\S+)", _read(top["go.mod"]), re.M)
212
+ if m:
213
+ p.name = m.group(1).rstrip("/").split("/")[-1]
214
+ text = _read(top["go.mod"])
215
+ p.framework = "gin" if "gin-gonic/gin" in text else "echo" if "labstack/echo" in text else ""
216
+ p.has_tests = any(f.name.endswith("_test.go") for f in files)
217
+ p.evidence.append("Go : go.mod" + (f", cadre {p.framework}" if p.framework else ""))
218
+ elif any(f.suffix in (".csproj", ".sln", ".fsproj") for f in files):
219
+ p.language = "dotnet"
220
+ proj = next(f for f in files if f.suffix in (".csproj", ".sln", ".fsproj"))
221
+ p.name = proj.stem
222
+ if "Microsoft.NET.Sdk.Web" in _read(proj):
223
+ p.framework = "aspnet"
224
+ p.has_tests = any("test" in f.stem.lower() for f in files if f.suffix == ".csproj")
225
+ p.evidence.append(f".NET : {_rel(root, proj)}" + (", ASP.NET" if p.framework else ""))
226
+ if p.has_tests:
227
+ p.evidence.append("Tests présents")
228
+
229
+
230
+ def _dockerfile(root: Path, p: AppProfile, files: list[Path]) -> None:
231
+ candidates = [f for f in files if f.name == "Dockerfile" or f.name.startswith("Dockerfile.") or f.name == "Containerfile"]
232
+ if not candidates:
233
+ p.evidence.append("Pas de Dockerfile : l'image peut être construite par Buildpacks")
234
+ return
235
+ docker = min(candidates, key=lambda f: (len(f.relative_to(root).parts), f.name != "Dockerfile"))
236
+ p.dockerfile = True
237
+ text = _read(docker)
238
+ froms = re.findall(r"^\s*FROM\s+(\S+)", text, re.M | re.I)
239
+ p.base_image = froms[-1] if froms else ""
240
+ exposes = re.findall(r"^\s*EXPOSE\s+(\d+)", text, re.M | re.I)
241
+ env_port = re.search(r"^\s*ENV\s+PORT[=\s]+(\d+)", text, re.M | re.I)
242
+ if exposes:
243
+ p.port, p.port_source = int(exposes[-1]), f"EXPOSE de {_rel(root, docker)}"
244
+ elif env_port:
245
+ p.port, p.port_source = int(env_port.group(1)), f"ENV PORT de {_rel(root, docker)}"
246
+ p.evidence.append(f"Dockerfile : {_rel(root, docker)}" + (f" (image de base {p.base_image})" if p.base_image else ""))
247
+
248
+
249
+ def _deploy(root: Path, p: AppProfile, files: list[Path], names: set[str]) -> None:
250
+ chart = next((f for f in files if f.name == "Chart.yaml"), None)
251
+ if chart:
252
+ p.helm = True
253
+ p.evidence.append(f"Chart Helm : {_rel(root, chart.parent)}")
254
+ kust = next((f for f in files if f.name in ("kustomization.yaml", "kustomization.yml")), None)
255
+ if kust:
256
+ p.kustomize = True
257
+ p.evidence.append(f"Kustomize : {_rel(root, kust)}")
258
+ if not (chart or kust):
259
+ for f in files:
260
+ if f.suffix in (".yaml", ".yml") and "kind: Deployment" in _read(f, 20_000) and "apiVersion: apps/v1" in _read(f, 20_000):
261
+ p.k8s_manifests = True
262
+ p.evidence.append(f"Manifeste Kubernetes : {_rel(root, f)}")
263
+ break
264
+ compose = next((f for f in files if f.name in ("docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml")), None)
265
+ if compose:
266
+ p.compose = True
267
+ p.evidence.append(f"Docker Compose : {_rel(root, compose)}")
268
+
269
+
270
+ def _ci_and_forge(root: Path, p: AppProfile) -> None:
271
+ for rel, tool in CI_FILES:
272
+ target = root / rel
273
+ if target.exists() and (target.is_file() or any(target.iterdir())):
274
+ p.ci = tool
275
+ p.evidence.append(f"CI existante : {rel}")
276
+ break
277
+ config = root / ".git" / "config"
278
+ if config.is_file():
279
+ parser = configparser.ConfigParser(strict=False, interpolation=None)
280
+ try:
281
+ parser.read_string(_read(config))
282
+ except configparser.Error:
283
+ return
284
+ section = 'remote "origin"' if parser.has_section('remote "origin"') else next(
285
+ (s for s in parser.sections() if s.startswith("remote ")), None)
286
+ if section and parser.has_option(section, "url"):
287
+ p.remote = parser.get(section, "url").strip()
288
+ host = re.sub(r"^[a-z+]+://", "", p.remote).split("@")[-1].split("/")[0].split(":")[0].lower()
289
+ p.forge = next((forge for needle, forge in FORGE_HOSTS if needle in host), None)
290
+ p.evidence.append(f"Dépôt distant : {p.remote}" + (f" (forge {p.forge})" if p.forge else ""))
291
+ if p.forge is None and p.ci in ("gitlab-ci", "github-actions", "gitea-actions", "azure-pipelines"):
292
+ p.forge = {"gitlab-ci": "gitlab", "github-actions": "github", "gitea-actions": "gitea",
293
+ "azure-pipelines": "azure-repos"}[p.ci]
294
+
295
+
296
+ def _iac(root: Path, p: AppProfile, files: list[Path]) -> None:
297
+ tf = [f for f in files if f.suffix == ".tf"]
298
+ if not tf:
299
+ return
300
+ lock = next((f for f in files if f.name == ".terraform.lock.hcl"), None)
301
+ tofu = (lock and "registry.opentofu.org" in _read(lock)) or any(f.name.endswith(".tofu") for f in files)
302
+ p.iac = "opentofu" if tofu else "terraform"
303
+ folder = _rel(root, tf[0].parent) if tf[0].parent != root else "."
304
+ p.evidence.append(f"{'OpenTofu' if tofu else 'Terraform'} : fichiers .tf dans {folder}")
305
+
306
+
307
+ def _dependency_text(root: Path, p: AppProfile, top: dict[str, Path], files: list[Path]) -> str:
308
+ names = {"python": ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile", "poetry.lock"),
309
+ "node": ("package.json",), "java": ("pom.xml", "build.gradle", "build.gradle.kts"), "go": ("go.mod",)}
310
+ if p.language == "dotnet":
311
+ return "\n".join(_read(f) for f in files if f.suffix in (".csproj", ".fsproj"))
312
+ text = "\n".join(_read(top[n]) for n in names.get(p.language, ()) if n in top)
313
+ if p.language == "python":
314
+ text += "\n".join(_read(f) for f in files if f.parent == root and re.match(r"requirements.*\.txt$", f.name))
315
+ return text
316
+
317
+
318
+ def _databases(root: Path, p: AppProfile, files: list[Path], top: dict[str, Path]) -> None:
319
+ """Bases utilisées par l'application (pilotes, schéma Prisma, Compose) et ses migrations."""
320
+ deps = _dependency_text(root, p, top, files)
321
+ found: list[tuple[str, str]] = []
322
+
323
+ def hit(engine: str, proof: str) -> None:
324
+ if engine not in [e for e, _ in found]:
325
+ found.append((engine, proof))
326
+
327
+ prisma = next((f for f in files if f.name == "schema.prisma"), None)
328
+ if prisma:
329
+ m = re.search(r'provider\s*=\s*"(postgresql|mysql|mongodb|cockroachdb)"', _read(prisma))
330
+ if m and m.group(1) != "cockroachdb":
331
+ hit({"postgresql": "postgresql", "mysql": "mysql", "mongodb": "mongodb"}[m.group(1)],
332
+ f"schéma Prisma {_rel(root, prisma)}")
333
+ flags = re.M | (re.I if p.language == "python" else 0)
334
+ for engine, pattern in DB_DRIVERS.get(p.language, []):
335
+ if re.search(pattern, deps, flags):
336
+ hit(engine, "pilote dans les dépendances")
337
+ compose = next((f for f in files if f.name in ("docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml")), None)
338
+ if compose:
339
+ text = _read(compose)
340
+ for engine, pattern in COMPOSE_DB_IMAGES:
341
+ if re.search(pattern, text, re.I):
342
+ hit(engine, f"service de {_rel(root, compose)}")
343
+ found.sort(key=lambda x: x[0] == "valkey") # la base principale avant le cache
344
+ labels = {"postgresql": "PostgreSQL", "mysql": "MySQL", "mariadb": "MariaDB", "mongodb": "MongoDB",
345
+ "valkey": "Redis / Valkey"}
346
+ for engine, proof in found:
347
+ p.databases.append(engine)
348
+ p.evidence.append(f"Base {labels[engine]} : {proof}")
349
+ _migrations(root, p, files, top, deps, prisma)
350
+ _connections(root, p, files)
351
+ _aws(p, deps, _read(compose) if compose else "")
352
+
353
+
354
+ ENV_READS = re.compile(
355
+ r"""(?:os\.environ\.get|os\.environ|os\.getenv|getenv|os\.Getenv|GetEnvironmentVariable|System\.getenv|"""
356
+ r"""env(?:\.\w+)?|config)\s*[\(\[]\s*["']([A-Z][A-Z0-9_]{2,})["']"""
357
+ r"""|process\.env\.([A-Z][A-Z0-9_]{2,})|process\.env\[["']([A-Z][A-Z0-9_]{2,})["']\]"""
358
+ r"""|\$\{([A-Z][A-Z0-9_]{2,})(?::[^}]*)?\}""")
359
+ SQL_PREFIX = r"(?:[A-Z0-9]+_)*(?:DATABASE|DB|POSTGRES(?:QL)?|PG|MYSQL|MARIADB|SQL)"
360
+ FAMILY_URL = {
361
+ "sql": re.compile(rf"^{SQL_PREFIX}(?:_[A-Z0-9]+)*_(?:URL|URI|DSN)$"),
362
+ "document": re.compile(r"^(?:[A-Z0-9]+_)*MONGO(?:DB)?(?:_[A-Z0-9]+)*_(?:URL|URI)$"),
363
+ "key-value": re.compile(r"^(?:[A-Z0-9]+_)*(?:REDIS|VALKEY|CACHE)(?:_[A-Z0-9]+)*_(?:URL|URI)$"),
364
+ }
365
+ CODE_SUFFIXES = (".py", ".js", ".mjs", ".cjs", ".ts", ".go", ".java", ".kt", ".cs", ".rb", ".php", ".properties",
366
+ ".yml", ".yaml", ".toml", ".env", ".example")
367
+
368
+
369
+ def _connections(root: Path, p: AppProfile, files: list[Path]) -> None:
370
+ """Variable (et format) dans laquelle l'application lit la connexion à chaque base."""
371
+ from .databases import ENGINES
372
+ families = {ENGINES[e].family for e in p.databases if e in ENGINES}
373
+ if not families:
374
+ return
375
+ names: dict[str, int] = {}
376
+ for f in files:
377
+ if not f.name.endswith(CODE_SUFFIXES) or any(part in SKIP_DIRS for part in f.parts):
378
+ continue
379
+ for m in ENV_READS.finditer(_read(f, 100_000)):
380
+ name = next(g for g in m.groups() if g)
381
+ names[name] = names.get(name, 0) + 1
382
+ java = p.framework in ("spring-boot", "quarkus")
383
+ if "sql" in families and java:
384
+ default = "SPRING_DATASOURCE_URL" if p.framework == "spring-boot" else "QUARKUS_DATASOURCE_JDBC_URL"
385
+ var = next((n for n in names if FAMILY_URL["sql"].match(n)), default)
386
+ p.connections["sql"] = {"url_var": var, "url_format": "jdbc"}
387
+ p.evidence.append(f"Connexion JDBC dans {var} ({'Spring Boot' if p.framework == 'spring-boot' else 'Quarkus'})")
388
+ for family in sorted(families - ({"sql"} if java else set())):
389
+ found = sorted((n for n in names if FAMILY_URL[family].match(n)), key=lambda n: (-names[n], len(n)))
390
+ if found:
391
+ p.connections[family] = {"url_var": found[0]}
392
+ p.evidence.append(f"Connexion lue dans {found[0]}")
393
+ elif family == "sql":
394
+ prefix = next((pre for pre in ("DATABASE", "DB", "POSTGRES", "PG", "MYSQL")
395
+ if f"{pre}_HOST" in names and (f"{pre}_PASSWORD" in names or f"{pre}_PASS" in names)), None)
396
+ if prefix:
397
+ p.connections["sql"] = {"url_var": f"{prefix}_URL", "url_format": "parts"}
398
+ p.evidence.append(f"Connexion lue en variables séparées {prefix}_HOST, {prefix}_PASSWORD…")
399
+
400
+
401
+ def _aws(p: AppProfile, deps: str, compose: str) -> None:
402
+ if re.search(AWS_SDKS.get(p.language, r"$^"), deps, re.I):
403
+ p.aws_sdk = True
404
+ p.evidence.append("SDK AWS dans les dépendances")
405
+ for name, pattern in EMULATORS:
406
+ if re.search(pattern, deps + "\n" + compose, re.I):
407
+ p.aws_emulator = name
408
+ p.evidence.append(f"Émulateur AWS : {'Floci' if name == 'floci' else 'LocalStack'}")
409
+ break
410
+
411
+
412
+ def _migrations(root: Path, p: AppProfile, files: list[Path], top: dict[str, Path], deps: str,
413
+ prisma: Path | None) -> None:
414
+ names = {f.name for f in files}
415
+ found: tuple[str, str] | None = None
416
+ if p.language == "python":
417
+ if "alembic.ini" in names or re.search(r"\balembic\b", deps, re.I):
418
+ found = ("alembic upgrade head", "Alembic")
419
+ elif p.framework == "django" and "manage.py" in top:
420
+ found = ("python manage.py migrate --noinput", "Django")
421
+ elif p.language == "node":
422
+ if prisma and re.search(r'"prisma"', deps):
423
+ found = ("npx prisma migrate deploy", "Prisma")
424
+ elif any(n.startswith("knexfile.") for n in names):
425
+ found = ("npx knex migrate:latest", "Knex")
426
+ elif ".sequelizerc" in names or '"sequelize-cli"' in deps:
427
+ found = ("npx sequelize-cli db:migrate", "Sequelize")
428
+ elif p.language == "go":
429
+ if "pressly/goose" in deps:
430
+ found = ('goose -dir migrations postgres "$DATABASE_URL" up', "goose")
431
+ elif "golang-migrate/migrate" in deps:
432
+ found = ('migrate -path migrations -database "$DATABASE_URL" up', "golang-migrate")
433
+ elif p.language == "java" and re.search(r"flyway-core|liquibase-core", deps):
434
+ tool = "Flyway" if "flyway" in deps else "Liquibase"
435
+ p.evidence.append(f"Migrations {tool} : appliquées par l'application à son démarrage")
436
+ return
437
+ if found:
438
+ p.migrations = found[0]
439
+ p.evidence.append(f"Migrations {found[1]} : {found[0]}")
440
+
441
+
442
+ def to_json(p: AppProfile) -> dict:
443
+ data = {k: v for k, v in p.__dict__.items() if k != "path"}
444
+ data["path"] = str(p.path)
445
+ data["hosting_hint"] = p.hosting_hint
446
+ return data
447
+
448
+
449
+ def proposed_bricks(profile: AppProfile, project, catalog) -> list[tuple[str, str]]:
450
+ """Briques que l'analyse suggère d'ajouter à un projet existant : (identifiant, raison)."""
451
+ from .compat import incompatibility
452
+ from .starter import NATIVE_CI
453
+
454
+ roles = {r for _, b in project.all_bricks() if (t := catalog.get(b.tool)) for r in t.roles}
455
+ out: list[tuple[str, str]] = []
456
+
457
+ def offer(tool_id: str | None, why: str) -> None:
458
+ tool = catalog.get(tool_id) if tool_id else None
459
+ if tool is None or any(t == tool_id for t, _ in out):
460
+ return
461
+ if incompatibility(tool, project, catalog) is None:
462
+ out.append((tool_id, why))
463
+
464
+ if "scm" not in roles and profile.forge:
465
+ offer(profile.forge, "dépôt distant de l'application")
466
+ if "ci" not in roles:
467
+ if profile.ci:
468
+ offer(profile.ci, "CI déjà présente dans le dépôt")
469
+ else:
470
+ forge = profile.forge or next((b.tool for _, b in project.all_bricks()
471
+ if (t := catalog.get(b.tool)) and "scm" in t.roles), None)
472
+ if forge in NATIVE_CI:
473
+ offer(NATIVE_CI[forge], "CI native de la forge")
474
+ if "image-build" not in roles:
475
+ offer("buildkit" if profile.dockerfile else "buildpacks",
476
+ "Dockerfile présent" if profile.dockerfile else "pas de Dockerfile : image construite par Buildpacks")
477
+ if profile.has_tests:
478
+ offer("unit-tests", "tests présents dans l'application")
479
+ if profile.helm:
480
+ offer("helm", "chart Helm présent")
481
+ if profile.kustomize:
482
+ offer("kustomize", "configuration Kustomize présente")
483
+ if profile.compose:
484
+ offer("docker-compose", "fichier Compose présent")
485
+ if profile.iac and "iac" not in roles:
486
+ offer(profile.iac, "fichiers .tf présents")
487
+ if profile.dependencies and "dependency-update" not in roles:
488
+ offer(profile.dependencies, "mises à jour de dépendances déjà configurées")
489
+ from .databases import ENGINES
490
+ from .starter import database_brick
491
+ families = {ENGINES[e].family for e in profile.databases if e in ENGINES}
492
+ held = {f for f, role in (("sql", "sql-database"), ("document", "document-database"),
493
+ ("key-value", "key-value-store")) if role in roles}
494
+ for engine in profile.databases:
495
+ family = ENGINES[engine].family
496
+ if family in held:
497
+ continue
498
+ tool_id, _ = database_brick(engine, project, catalog)
499
+ offer(tool_id, f"{ENGINES[engine].label} utilisé par l'application" if tool_id == engine
500
+ else f"{ENGINES[engine].label} utilisé par l'application, porté par {catalog[tool_id].name}")
501
+ held.add(family)
502
+ if profile.migrations and (families or "database" in roles):
503
+ offer("db-migrations", f"migrations « {profile.migrations} »")
504
+ if profile.aws_emulator == "floci":
505
+ offer("floci", "Floci déjà utilisé par l'application")
506
+ elif profile.aws_emulator == "localstack":
507
+ offer("floci", "LocalStack utilisé : Floci le remplace, libre et sans jeton d'accès")
508
+ elif profile.aws_sdk:
509
+ offer("floci", "SDK AWS : tests des appels AWS sur un émulateur local, sans compte")
510
+ return out
511
+
512
+
513
+ def proposed_params(profile: AppProfile, tool_id: str, project, catalog) -> dict:
514
+ """Paramètres à poser avec une brique proposée (moteur d'une base managée, commande de migration)."""
515
+ from .starter import database_brick
516
+ if tool_id == "db-migrations" and profile.migrations:
517
+ return {"command": profile.migrations}
518
+ from .databases import ENGINES
519
+ for engine in profile.databases:
520
+ tid, params = database_brick(engine, project, catalog)
521
+ if tid == tool_id:
522
+ return {**params, **profile.connections.get(ENGINES[engine].family, {})}
523
+ return {}