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 +3 -0
- dc_cli/groups/__init__.py +0 -0
- dc_cli/groups/bw.py +430 -0
- dc_cli/groups/gl.py +300 -0
- dc_cli/groups/ws.py +1249 -0
- dc_cli/main.py +54 -0
- dc_cli/utils/__init__.py +0 -0
- dc_cli/utils/config.py +83 -0
- dc_cli/utils/gitlab.py +165 -0
- lnp_devopscli-1.0.0.dist-info/METADATA +136 -0
- lnp_devopscli-1.0.0.dist-info/RECORD +14 -0
- lnp_devopscli-1.0.0.dist-info/WHEEL +5 -0
- lnp_devopscli-1.0.0.dist-info/entry_points.txt +3 -0
- lnp_devopscli-1.0.0.dist-info/top_level.txt +1 -0
dc_cli/__init__.py
ADDED
|
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}")
|