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.
- devops_builder/__init__.py +6 -0
- devops_builder/__main__.py +6 -0
- devops_builder/appscan.py +523 -0
- devops_builder/catalog.py +359 -0
- devops_builder/catalog_edit.py +200 -0
- devops_builder/cli.py +410 -0
- devops_builder/compat.py +95 -0
- devops_builder/data/catalog/10_source_build.yaml +336 -0
- devops_builder/data/catalog/20_test_security.yaml +226 -0
- devops_builder/data/catalog/30_artifacts_infra.yaml +584 -0
- devops_builder/data/catalog/35_providers.yaml +130 -0
- devops_builder/data/catalog/40_deploy_platform.yaml +420 -0
- devops_builder/data/catalog/45_data.yaml +181 -0
- devops_builder/data/catalog/50_observability.yaml +194 -0
- devops_builder/data/examples/01-web-hote-docker.devops.yaml +75 -0
- devops_builder/data/examples/02-api-k3s-gitops.devops.yaml +110 -0
- devops_builder/data/examples/03-iac-aws-eks.devops.yaml +98 -0
- devops_builder/data/examples/04-boutique-donnees.devops.yaml +87 -0
- devops_builder/data/landscape_index.json +10847 -0
- devops_builder/data/params.yaml +910 -0
- devops_builder/data/ports.yaml +36 -0
- devops_builder/data/rules/R00_fiche_non_verifiee.yaml +6 -0
- devops_builder/data/rules/R01_kubernetes_obligatoire.yaml +8 -0
- devops_builder/data/rules/R02_un_seul_gitops.yaml +8 -0
- devops_builder/data/rules/R03_un_seul_mesh.yaml +8 -0
- devops_builder/data/rules/R04_un_seul_cni.yaml +8 -0
- devops_builder/data/rules/R05_un_seul_cluster.yaml +6 -0
- devops_builder/data/rules/R06_ci_et_forge.yaml +8 -0
- devops_builder/data/rules/R07_thanos_prometheus.yaml +10 -0
- devops_builder/data/rules/R08_velero_stockage_objet.yaml +10 -0
- devops_builder/data/rules/R09_service_sans_fournisseur.yaml +6 -0
- devops_builder/data/rules/R10_aws_app_mesh.yaml +8 -0
- devops_builder/data/rules/R11_google_container_registry.yaml +8 -0
- devops_builder/data/rules/R12_secnumcloud.yaml +8 -0
- devops_builder/data/rules/R13_ingress_nginx.yaml +8 -0
- devops_builder/data/rules/R14_kaniko.yaml +8 -0
- devops_builder/data/rules/R15_deux_controleurs_admission.yaml +8 -0
- devops_builder/data/rules/R16_terraform_opentofu.yaml +8 -0
- devops_builder/data/rules/R17_compose_sur_kubernetes.yaml +9 -0
- devops_builder/data/rules/R18_cluster_de_dev_en_prod.yaml +9 -0
- devops_builder/data/rules/R19_jaeger_sans_stockage.yaml +11 -0
- devops_builder/data/rules/R20_registre_autre_cloud.yaml +6 -0
- devops_builder/data/rules/R21_cles_statiques.yaml +6 -0
- devops_builder/data/rules/R22_github_actions_sur_gitea.yaml +8 -0
- devops_builder/data/rules/R23_harbor_trivy.yaml +9 -0
- devops_builder/data/rules/R24_licence.yaml +8 -0
- devops_builder/data/rules/R25_etape_incompatible.yaml +6 -0
- devops_builder/data/rules/R26_build_sans_registre.yaml +6 -0
- devops_builder/data/rules/R27_deploiement_sans_build.yaml +6 -0
- devops_builder/data/rules/R28_etapes_sans_ci.yaml +6 -0
- devops_builder/data/rules/R29_capacite_requise.yaml +12 -0
- devops_builder/data/rules/R30_cible_et_cluster.yaml +6 -0
- devops_builder/data/rules/R31_sans_generateur.yaml +6 -0
- devops_builder/data/rules/R32_parametre_invalide.yaml +8 -0
- devops_builder/data/rules/R33_parametre_inconnu.yaml +8 -0
- devops_builder/data/rules/R34_valeur_exemple.yaml +8 -0
- devops_builder/data/rules/R35_production_sans_approbation.yaml +6 -0
- devops_builder/data/rules/R36_cluster_des_environnements.yaml +6 -0
- devops_builder/data/rules/R37_environnements_invalides.yaml +6 -0
- devops_builder/data/rules/R38_collision_entre_environnements.yaml +6 -0
- devops_builder/data/rules/R39_conflit_de_ports.yaml +6 -0
- devops_builder/data/rules/R40_base_sans_sauvegarde.yaml +6 -0
- devops_builder/data/rules/R41_base_en_statefulset.yaml +6 -0
- devops_builder/data/rules/R42_deux_bases_relationnelles.yaml +8 -0
- devops_builder/data/rules/R43_deux_stockages_cle_valeur.yaml +8 -0
- devops_builder/data/rules/R44_etat_terraform_local.yaml +6 -0
- devops_builder/data/rules/R45_coffre_injoignable.yaml +6 -0
- devops_builder/data/rules/R46_format_de_connexion.yaml +6 -0
- devops_builder/data/rules/R47_sauvegarde_sans_cert_manager.yaml +6 -0
- devops_builder/data/rules/R48_deux_fournisseurs_identite.yaml +8 -0
- devops_builder/data/rules/R49_fournisseur_identite_prerequis.yaml +6 -0
- devops_builder/data/rules/R50_brique_experimentale.yaml +6 -0
- devops_builder/data/rules/R51_drone.yaml +8 -0
- devops_builder/data/rules/R52_dimensionnement.yaml +6 -0
- devops_builder/data/sizing.yaml +120 -0
- devops_builder/data/suggestions.yaml +66 -0
- devops_builder/databases.py +205 -0
- devops_builder/datainfo.py +142 -0
- devops_builder/generators/__init__.py +127 -0
- devops_builder/generators/ansible.py +187 -0
- devops_builder/generators/ci.py +551 -0
- devops_builder/generators/ciaccess.py +120 -0
- devops_builder/generators/civars.py +229 -0
- devops_builder/generators/common.py +104 -0
- devops_builder/generators/dast.py +143 -0
- devops_builder/generators/data.py +1105 -0
- devops_builder/generators/deploy.py +337 -0
- devops_builder/generators/extras.py +217 -0
- devops_builder/generators/floci.py +150 -0
- devops_builder/generators/hosts.py +599 -0
- devops_builder/generators/iac.py +507 -0
- devops_builder/generators/identity.py +592 -0
- devops_builder/generators/localtest.py +226 -0
- devops_builder/generators/platform.py +269 -0
- devops_builder/generators/report.py +160 -0
- devops_builder/generators/runbook.py +751 -0
- devops_builder/generators/secrets.py +843 -0
- devops_builder/generators/zabbix.py +97 -0
- devops_builder/gitexport.py +289 -0
- devops_builder/health.py +382 -0
- devops_builder/ir.py +775 -0
- devops_builder/landscape.py +134 -0
- devops_builder/model.py +450 -0
- devops_builder/params.py +135 -0
- devops_builder/ports.py +227 -0
- devops_builder/rules.py +1040 -0
- devops_builder/sizing.py +148 -0
- devops_builder/starter.py +334 -0
- devops_builder/suggest.py +49 -0
- devops_builder/support.py +48 -0
- devops_builder/sync.py +161 -0
- devops_builder/ui/__init__.py +1 -0
- devops_builder/ui/app.py +49 -0
- devops_builder/ui/application.py +158 -0
- devops_builder/ui/canvas.py +398 -0
- devops_builder/ui/data.py +155 -0
- devops_builder/ui/diagnostics.py +115 -0
- devops_builder/ui/dialogs.py +461 -0
- devops_builder/ui/export.py +406 -0
- devops_builder/ui/fiche.py +269 -0
- devops_builder/ui/main_window.py +767 -0
- devops_builder/ui/maintenance.py +358 -0
- devops_builder/ui/palette.py +289 -0
- devops_builder/ui/param_form.py +197 -0
- devops_builder/ui/ports.py +117 -0
- devops_builder/ui/properties.py +328 -0
- devops_builder/ui/theme.py +144 -0
- devops_builder/ui/themes/devops_dark.xml +10 -0
- devops_builder/ui/themes/devops_light.xml +10 -0
- devops_builder/ui/validation.py +323 -0
- devops_builder/ui/widgets.py +94 -0
- devops_builder/ui/wizard.py +347 -0
- devops_builder/userdata.py +37 -0
- devops_builder/validate.py +419 -0
- izinum_devops_builder-0.19.0.dist-info/METADATA +297 -0
- izinum_devops_builder-0.19.0.dist-info/RECORD +140 -0
- izinum_devops_builder-0.19.0.dist-info/WHEEL +4 -0
- izinum_devops_builder-0.19.0.dist-info/entry_points.txt +2 -0
- izinum_devops_builder-0.19.0.dist-info/licenses/LICENSE +202 -0
- 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,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 {}
|