lnp-devopscli 1.0.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.
dc_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DevOps CLI - LNP Consulting TI."""
2
+
3
+ __version__ = "1.0.0"
File without changes
dc_cli/groups/bw.py ADDED
@@ -0,0 +1,430 @@
1
+ """Grupo: dc bw - Bitwarden Secrets Manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import zipfile
13
+ from pathlib import Path
14
+ from urllib.request import urlopen
15
+
16
+ import click
17
+
18
+ from ..utils.config import (
19
+ get_global_config_path,
20
+ get_section,
21
+ get_workspace_root,
22
+ load_dotenv,
23
+ parse_env_line,
24
+ )
25
+
26
+ BWS_INSTALL_DIR = Path.home() / ".local" / "bin"
27
+ BWS_PREFERRED_VERSION = (
28
+ "1.0.0" # v2.0.0 tem incompatibilidade de token com alguns planos
29
+ )
30
+
31
+
32
+ def _detect_bws_asset_name(version: str) -> str | None:
33
+ """Detecta o nome do asset correto para a plataforma atual."""
34
+ system = platform.system().lower()
35
+ machine = platform.machine().lower()
36
+
37
+ if machine in ("x86_64", "amd64"):
38
+ arch = "x86_64"
39
+ elif machine in ("aarch64", "arm64"):
40
+ arch = "aarch64"
41
+ else:
42
+ return None
43
+
44
+ if system == "linux":
45
+ target = f"{arch}-unknown-linux-gnu"
46
+ elif system == "darwin":
47
+ target = f"{arch}-apple-darwin"
48
+ else:
49
+ return None
50
+
51
+ return f"bws-{target}-{version}.zip"
52
+
53
+
54
+ def _install_bws() -> bool:
55
+ """Baixa e instala o bws CLI automaticamente."""
56
+ click.echo("[>] Instalando bws CLI...")
57
+
58
+ version = BWS_PREFERRED_VERSION
59
+ asset_name = _detect_bws_asset_name(version)
60
+ if not asset_name:
61
+ click.echo(
62
+ f"[ERRO] Plataforma nao suportada: {platform.system()} {platform.machine()}",
63
+ err=True,
64
+ )
65
+ return False
66
+
67
+ download_url = (
68
+ f"https://github.com/bitwarden/sdk-sm/releases/download/"
69
+ f"bws-v{version}/{asset_name}"
70
+ )
71
+
72
+ click.echo(f"[>] Baixando bws v{version} ({asset_name})...")
73
+
74
+ try:
75
+ with tempfile.TemporaryDirectory() as tmpdir:
76
+ zip_path = Path(tmpdir) / asset_name
77
+ with urlopen(download_url) as resp:
78
+ zip_path.write_bytes(resp.read())
79
+
80
+ with zipfile.ZipFile(zip_path) as zf:
81
+ zf.extractall(tmpdir)
82
+
83
+ bws_bin = Path(tmpdir) / "bws"
84
+ if not bws_bin.exists():
85
+ click.echo("[ERRO] Binario 'bws' nao encontrado no zip.", err=True)
86
+ return False
87
+
88
+ BWS_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
89
+ dest = BWS_INSTALL_DIR / "bws"
90
+ shutil.copy2(bws_bin, dest)
91
+ dest.chmod(0o755)
92
+
93
+ except Exception as e:
94
+ click.echo(f"[ERRO] Falha na instalacao: {e}", err=True)
95
+ return False
96
+
97
+ click.echo(f"[OK] bws v{version} instalado em {dest}")
98
+ return True
99
+
100
+
101
+ def _check_bws() -> None:
102
+ """Verifica se bws CLI esta instalado (instala se necessario) e token configurado."""
103
+ load_dotenv()
104
+
105
+ if not shutil.which("bws"):
106
+ click.echo("[!] bws CLI nao encontrado.")
107
+ if not _install_bws():
108
+ sys.exit(1)
109
+ if not shutil.which("bws"):
110
+ click.echo(
111
+ f"[!] bws instalado em {BWS_INSTALL_DIR}/bws mas nao esta no PATH.\n"
112
+ f' Adicione ao PATH: export PATH="{BWS_INSTALL_DIR}:$PATH"',
113
+ err=True,
114
+ )
115
+ sys.exit(1)
116
+
117
+ if os.environ.get("BW_ACCESS_TOKEN") and not os.environ.get("BWS_ACCESS_TOKEN"):
118
+ os.environ["BWS_ACCESS_TOKEN"] = os.environ.get("BW_ACCESS_TOKEN", "")
119
+
120
+ if not os.environ.get("BWS_ACCESS_TOKEN"):
121
+ click.echo(
122
+ "[ERRO] BW_ACCESS_TOKEN nao definido.\n"
123
+ " Exporte a variavel ou adicione no .env.",
124
+ err=True,
125
+ )
126
+ sys.exit(1)
127
+
128
+
129
+ def _resolve_env_path(env_path: str) -> Path:
130
+ """Resolve env_path: relativo a raiz do projeto ou absoluto."""
131
+ path = Path(env_path).expanduser()
132
+ if not path.is_absolute():
133
+ path = get_workspace_root() / path
134
+ return path
135
+
136
+
137
+ def _bws_run(args: list[str]) -> subprocess.CompletedProcess:
138
+ """Executa comando bws e retorna resultado."""
139
+ return subprocess.run(
140
+ ["bws"] + args,
141
+ capture_output=True,
142
+ text=True,
143
+ )
144
+
145
+
146
+ def _find_existing_project(name: str) -> str | None:
147
+ """Busca projeto existente no Bitwarden pelo nome. Retorna ID ou None."""
148
+ result = _bws_run(["project", "list", "--output", "json"])
149
+ if result.returncode != 0:
150
+ return None
151
+ try:
152
+ for proj in json.loads(result.stdout):
153
+ if proj["name"] == name:
154
+ return proj["id"]
155
+ except (json.JSONDecodeError, KeyError):
156
+ pass
157
+ return None
158
+
159
+
160
+ def _save_project_id(project_name: str, project_id: str) -> None:
161
+ """Salva project_id no config.yaml para o projeto especificado."""
162
+ import re
163
+
164
+ config_path = get_global_config_path()
165
+ if not config_path.exists():
166
+ root = get_workspace_root()
167
+ config_path = root / "config.yaml"
168
+ content = config_path.read_text()
169
+
170
+ pattern = (
171
+ r"(- name: " + re.escape(project_name) + r"\s+"
172
+ r"project_id: ).*"
173
+ )
174
+ replacement = rf'\g<1>"{project_id}"'
175
+ new_content = re.sub(pattern, replacement, content)
176
+
177
+ if new_content == content:
178
+ click.echo(
179
+ f"[!] Nao foi possivel salvar project_id no config.yaml automaticamente.",
180
+ err=True,
181
+ )
182
+ click.echo(f'[i] Adicione manualmente: project_id: "{project_id}"')
183
+ return
184
+
185
+ config_path.write_text(new_content)
186
+
187
+
188
+ def _init_project(project: dict) -> bool:
189
+ """Cria projeto no Bitwarden (ou usa existente) e faz upload do .env local."""
190
+ name = project["name"]
191
+ env_dir = _resolve_env_path(project["env_path"])
192
+ env_file = env_dir / ".env"
193
+
194
+ click.echo(f"[>] Buscando projeto '{name}' no Bitwarden...")
195
+ project_id = _find_existing_project(name)
196
+
197
+ if project_id:
198
+ click.echo(f"[OK] Projeto encontrado (id: {project_id})")
199
+ else:
200
+ click.echo(f"[>] Criando projeto '{name}'...")
201
+ result = _bws_run(["project", "create", name])
202
+ if result.returncode != 0:
203
+ click.echo(
204
+ f"[ERRO] Falha ao criar projeto: {result.stderr.strip()}", err=True
205
+ )
206
+ return False
207
+
208
+ try:
209
+ project_data = json.loads(result.stdout)
210
+ project_id = project_data["id"]
211
+ except (json.JSONDecodeError, KeyError):
212
+ click.echo(f"[ERRO] Resposta inesperada do bws: {result.stdout}", err=True)
213
+ return False
214
+ click.echo(f"[OK] Projeto criado (id: {project_id})")
215
+
216
+ _save_project_id(name, project_id)
217
+
218
+ if not env_file.exists():
219
+ click.echo(f"[!] Arquivo {env_file} nao encontrado, nenhum secret enviado.")
220
+ return True
221
+
222
+ secrets_count = 0
223
+ with open(env_file) as f:
224
+ for line in f:
225
+ parsed = parse_env_line(line)
226
+ if not parsed:
227
+ continue
228
+ key, value = parsed
229
+
230
+ result = _bws_run(["secret", "create", key, value, project_id])
231
+ if result.returncode != 0:
232
+ click.echo(
233
+ f"[ERRO] Falha ao criar secret '{key}': {result.stderr.strip()}",
234
+ err=True,
235
+ )
236
+ else:
237
+ secrets_count += 1
238
+
239
+ click.echo(f"[OK] {secrets_count} secrets enviados para '{name}'")
240
+ return True
241
+
242
+
243
+ def _sync_project(project: dict) -> bool:
244
+ """Baixa secrets do Bitwarden e salva no .env."""
245
+ name = project["name"]
246
+ project_id = project["project_id"]
247
+ env_dir = _resolve_env_path(project["env_path"])
248
+ env_file = env_dir / ".env"
249
+
250
+ click.echo(f"[>] Sincronizando '{name}'...")
251
+
252
+ result = _bws_run(["secret", "list", project_id, "--output", "env"])
253
+ if result.returncode != 0:
254
+ stderr = result.stderr.strip()
255
+ if "404" in stderr or "Resource not found" in stderr:
256
+ click.echo(
257
+ f"[WARN] project_id invalido para '{name}'. Recriando projeto..."
258
+ )
259
+ project["project_id"] = ""
260
+ return _init_project(project)
261
+ click.echo(f"[ERRO] Falha ao listar secrets: {stderr}", err=True)
262
+ return False
263
+
264
+ raw_output = result.stdout
265
+ if not raw_output.strip():
266
+ click.echo(f"[!] Nenhum secret encontrado para '{name}'")
267
+ return True
268
+
269
+ lines = []
270
+ for line in raw_output.splitlines():
271
+ parsed = parse_env_line(line)
272
+ if parsed:
273
+ lines.append(f"{parsed[0]}={parsed[1]}")
274
+ else:
275
+ lines.append(line)
276
+
277
+ env_dir.mkdir(parents=True, exist_ok=True)
278
+ with open(env_file, "w") as f:
279
+ f.write("\n".join(lines) + "\n")
280
+
281
+ click.echo(f"[OK] {name} -> {env_file}")
282
+ return True
283
+
284
+
285
+ def _push_project(project: dict) -> bool:
286
+ """Faz upload do .env local para o Bitwarden (cria ou atualiza secrets)."""
287
+ name = project["name"]
288
+ project_id = project["project_id"]
289
+ env_dir = _resolve_env_path(project["env_path"])
290
+ env_file = env_dir / ".env"
291
+
292
+ if not project_id:
293
+ click.echo(f"[SKIP] '{name}' sem project_id (use sync para inicializar)")
294
+ return False
295
+
296
+ if not env_file.exists():
297
+ click.echo(f"[ERRO] Arquivo {env_file} nao encontrado.", err=True)
298
+ return False
299
+
300
+ click.echo(f"[>] Enviando secrets de '{name}'...")
301
+
302
+ result = _bws_run(["secret", "list", project_id, "--output", "json"])
303
+ existing = {}
304
+ if result.returncode == 0 and result.stdout.strip():
305
+ try:
306
+ for secret in json.loads(result.stdout):
307
+ existing[secret["key"]] = secret["id"]
308
+ except (json.JSONDecodeError, KeyError):
309
+ pass
310
+
311
+ updated = 0
312
+ created = 0
313
+ with open(env_file) as f:
314
+ for line in f:
315
+ parsed = parse_env_line(line)
316
+ if not parsed:
317
+ continue
318
+ key, value = parsed
319
+
320
+ if key in existing:
321
+ r = _bws_run(["secret", "edit", existing[key], "--value", value])
322
+ if r.returncode == 0:
323
+ updated += 1
324
+ else:
325
+ click.echo(
326
+ f"[ERRO] Falha ao atualizar '{key}': {r.stderr.strip()}",
327
+ err=True,
328
+ )
329
+ else:
330
+ r = _bws_run(["secret", "create", key, value, project_id])
331
+ if r.returncode == 0:
332
+ created += 1
333
+ else:
334
+ click.echo(
335
+ f"[ERRO] Falha ao criar '{key}': {r.stderr.strip()}", err=True
336
+ )
337
+
338
+ click.echo(f"[OK] '{name}': {created} criados, {updated} atualizados")
339
+ return True
340
+
341
+
342
+ def _filter_projects(projects: list[dict], name: str | None) -> list[dict]:
343
+ """Filtra projetos por nome, se especificado."""
344
+ if not name:
345
+ return projects
346
+ matched = [p for p in projects if p["name"] == name]
347
+ if not matched:
348
+ click.echo(f"[ERRO] Projeto '{name}' nao encontrado no config.yaml.", err=True)
349
+ click.echo(
350
+ f"[i] Projetos disponiveis: {', '.join(p['name'] for p in projects)}"
351
+ )
352
+ sys.exit(1)
353
+ return matched
354
+
355
+
356
+ @click.group(name="bw")
357
+ def bw_group():
358
+ """Bitwarden Secrets Manager — sincroniza .env entre projetos."""
359
+
360
+
361
+ @bw_group.command()
362
+ @click.argument("project", required=False, default=None)
363
+ def sync(project):
364
+ """Sincroniza .env do Bitwarden (cria projeto se nao existir).
365
+
366
+ Sem argumento sincroniza todos. Com argumento, apenas o projeto especificado.
367
+
368
+ \b
369
+ Uso:
370
+ dc bw sync # todos os projetos
371
+ dc bw sync meu-projeto # apenas um
372
+ """
373
+ _check_bws()
374
+ config = get_section("bw")
375
+ projects = _filter_projects(config["projects"], project)
376
+
377
+ ok = 0
378
+ fail = 0
379
+ for proj in projects:
380
+ if not proj.get("project_id"):
381
+ success = _init_project(proj)
382
+ else:
383
+ success = _sync_project(proj)
384
+ if success:
385
+ ok += 1
386
+ else:
387
+ fail += 1
388
+
389
+ click.echo(f"\n[i] Resultado: {ok} ok, {fail} falhas")
390
+ sys.exit(1 if fail else 0)
391
+
392
+
393
+ @bw_group.command(name="push")
394
+ @click.argument("project", required=False, default=None)
395
+ def push(project):
396
+ """Envia .env local para o Bitwarden (cria ou atualiza secrets).
397
+
398
+ \b
399
+ Uso:
400
+ dc bw push # todos os projetos
401
+ dc bw push meu-projeto # apenas um
402
+ """
403
+ _check_bws()
404
+ config = get_section("bw")
405
+ projects = _filter_projects(config["projects"], project)
406
+
407
+ ok = 0
408
+ fail = 0
409
+ for proj in projects:
410
+ if _push_project(proj):
411
+ ok += 1
412
+ else:
413
+ fail += 1
414
+
415
+ click.echo(f"\n[i] Resultado: {ok} ok, {fail} falhas")
416
+ sys.exit(1 if fail else 0)
417
+
418
+
419
+ @bw_group.command(name="list")
420
+ def list_projects():
421
+ """Lista projetos configurados e status."""
422
+ config = get_section("bw")
423
+
424
+ click.echo(f"{'Nome':<25} {'ID':<40} {'Path'}")
425
+ click.echo("-" * 90)
426
+ for proj in config["projects"]:
427
+ name = proj["name"]
428
+ pid = proj.get("project_id") or "(nao inicializado)"
429
+ env_path = str(_resolve_env_path(proj["env_path"]) / ".env")
430
+ click.echo(f"{name:<25} {pid:<40} {env_path}")