raijin-server 0.3.4__py3-none-any.whl → 0.3.7__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.

Potentially problematic release.


This version of raijin-server might be problematic. Click here for more details.

@@ -0,0 +1,669 @@
1
+ """Automacao de Harbor Registry com MinIO backend (production-ready).
2
+
3
+ Harbor é um registry privado para imagens Docker/OCI com:
4
+ - Vulnerability scanning (Trivy integrado)
5
+ - Retention policies (garbage collection)
6
+ - Projetos separados por ambiente (tst, prd)
7
+ - Robot accounts para CI/CD
8
+ - Replicação entre registries
9
+ - Controle de acesso (RBAC)
10
+ """
11
+
12
+ import base64
13
+ import json
14
+ import socket
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import typer
19
+
20
+ from raijin_server.utils import (
21
+ ExecutionContext,
22
+ ensure_tool,
23
+ helm_upgrade_install,
24
+ require_root,
25
+ run_cmd,
26
+ write_file,
27
+ )
28
+ from raijin_server.minio_utils import get_or_create_minio_user
29
+
30
+ HARBOR_NAMESPACE = "harbor"
31
+
32
+
33
+ def _detect_node_name(ctx: ExecutionContext) -> str:
34
+ """Detecta nome do node para nodeSelector."""
35
+ result = run_cmd(
36
+ ["kubectl", "get", "nodes", "-o", "jsonpath={.items[0].metadata.name}"],
37
+ ctx,
38
+ check=False,
39
+ )
40
+ if result.returncode == 0 and (result.stdout or "").strip():
41
+ return (result.stdout or "").strip()
42
+ return socket.gethostname()
43
+
44
+
45
+ def _check_existing_harbor(ctx: ExecutionContext, namespace: str) -> bool:
46
+ """Verifica se existe instalacao do Harbor."""
47
+ result = run_cmd(
48
+ ["helm", "status", "harbor", "-n", namespace],
49
+ ctx,
50
+ check=False,
51
+ )
52
+ return result.returncode == 0
53
+
54
+
55
+ def _uninstall_harbor(ctx: ExecutionContext, namespace: str) -> None:
56
+ """Remove instalacao anterior do Harbor."""
57
+ typer.echo("Removendo instalacao anterior do Harbor...")
58
+
59
+ run_cmd(
60
+ ["helm", "uninstall", "harbor", "-n", namespace],
61
+ ctx,
62
+ check=False,
63
+ )
64
+
65
+ time.sleep(5)
66
+
67
+
68
+ def _get_minio_credentials(ctx: ExecutionContext) -> tuple[str, str]:
69
+ """Obtem ou cria credenciais específicas do MinIO para Harbor.
70
+
71
+ Esta função cria um usuário MinIO dedicado para o Harbor com acesso
72
+ restrito apenas aos buckets: harbor-registry, harbor-chartmuseum, harbor-jobservice.
73
+ """
74
+ return get_or_create_minio_user(
75
+ ctx=ctx,
76
+ app_name="harbor",
77
+ buckets=["harbor-registry", "harbor-chartmuseum", "harbor-jobservice"],
78
+ namespace=HARBOR_NAMESPACE,
79
+ )
80
+
81
+
82
+ def _wait_for_pods_ready(ctx: ExecutionContext, namespace: str, timeout: int = 300) -> bool:
83
+ """Aguarda todos os pods do Harbor ficarem Ready."""
84
+ typer.echo("Aguardando pods do Harbor ficarem Ready (pode levar 3-5 min)...")
85
+ deadline = time.time() + timeout
86
+
87
+ while time.time() < deadline:
88
+ result = run_cmd(
89
+ [
90
+ "kubectl", "-n", namespace, "get", "pods",
91
+ "-o", "jsonpath={range .items[*]}{.metadata.name}={.status.phase} {end}",
92
+ ],
93
+ ctx,
94
+ check=False,
95
+ )
96
+
97
+ if result.returncode == 0:
98
+ output = (result.stdout or "").strip()
99
+ if output:
100
+ pods = []
101
+ for item in output.split():
102
+ if "=" in item:
103
+ parts = item.rsplit("=", 1)
104
+ if len(parts) == 2:
105
+ pods.append((parts[0], parts[1]))
106
+
107
+ if pods and all(phase == "Running" for _, phase in pods):
108
+ typer.secho(" Todos os pods do Harbor estão Running.", fg=typer.colors.GREEN)
109
+ return True
110
+
111
+ time.sleep(10)
112
+
113
+ typer.secho(" Timeout aguardando Harbor. Verifique: kubectl -n harbor get pods", fg=typer.colors.YELLOW)
114
+ return False
115
+
116
+
117
+ def _create_harbor_projects(ctx: ExecutionContext, harbor_url: str, admin_password: str) -> None:
118
+ """Cria projetos tst e prd no Harbor via API."""
119
+ typer.echo("\nCriando projetos 'tst' e 'prd' no Harbor...")
120
+
121
+ projects = [
122
+ {
123
+ "project_name": "tst",
124
+ "metadata": {
125
+ "public": "false",
126
+ "auto_scan": "true",
127
+ "severity": "low",
128
+ "enable_content_trust": "false",
129
+ "prevent_vul": "false"
130
+ }
131
+ },
132
+ {
133
+ "project_name": "prd",
134
+ "metadata": {
135
+ "public": "false",
136
+ "auto_scan": "true",
137
+ "severity": "low",
138
+ "enable_content_trust": "true", # Content trust em produção
139
+ "prevent_vul": "true", # Bloqueia push de imagens vulneráveis
140
+ "severity_threshold": "critical" # Apenas critical vulnerabilities bloqueiam
141
+ }
142
+ }
143
+ ]
144
+
145
+ for project in projects:
146
+ project_json = json.dumps(project)
147
+
148
+ run_cmd(
149
+ [
150
+ "curl", "-X", "POST",
151
+ f"{harbor_url}/api/v2.0/projects",
152
+ "-H", "Content-Type: application/json",
153
+ "-u", f"admin:{admin_password}",
154
+ "-d", project_json,
155
+ "-k" # Skip SSL verification (self-signed cert)
156
+ ],
157
+ ctx,
158
+ check=False,
159
+ )
160
+
161
+ typer.secho(f" ✓ Projeto '{project['project_name']}' criado.", fg=typer.colors.GREEN)
162
+
163
+ time.sleep(2)
164
+
165
+
166
+ def _create_retention_policies(ctx: ExecutionContext, harbor_url: str, admin_password: str) -> None:
167
+ """Cria políticas de retenção para projetos."""
168
+ typer.echo("\nConfigurando políticas de retenção...")
169
+
170
+ # Política para TST: manter últimas 10 imagens ou 30 dias
171
+ tst_policy = {
172
+ "algorithm": "or",
173
+ "rules": [
174
+ {
175
+ "disabled": False,
176
+ "action": "retain",
177
+ "template": "latestPushedK",
178
+ "params": {"latestPushedK": 10},
179
+ "tag_selectors": [
180
+ {
181
+ "kind": "doublestar",
182
+ "decoration": "matches",
183
+ "pattern": "**"
184
+ }
185
+ ],
186
+ "scope_selectors": {
187
+ "repository": [
188
+ {
189
+ "kind": "doublestar",
190
+ "decoration": "repoMatches",
191
+ "pattern": "**"
192
+ }
193
+ ]
194
+ }
195
+ },
196
+ {
197
+ "disabled": False,
198
+ "action": "retain",
199
+ "template": "nDaysSinceLastPush",
200
+ "params": {"nDaysSinceLastPush": 30},
201
+ "tag_selectors": [
202
+ {
203
+ "kind": "doublestar",
204
+ "decoration": "matches",
205
+ "pattern": "**"
206
+ }
207
+ ],
208
+ "scope_selectors": {
209
+ "repository": [
210
+ {
211
+ "kind": "doublestar",
212
+ "decoration": "repoMatches",
213
+ "pattern": "**"
214
+ }
215
+ ]
216
+ }
217
+ }
218
+ ],
219
+ "trigger": {
220
+ "kind": "Schedule",
221
+ "settings": {
222
+ "cron": "0 0 * * *" # Diário à meia-noite
223
+ }
224
+ },
225
+ "scope": {
226
+ "level": "project",
227
+ "ref": 2 # ID do projeto tst (geralmente 2)
228
+ }
229
+ }
230
+
231
+ # Política para PRD: manter últimas 20 imagens ou 90 dias
232
+ prd_policy = {
233
+ "algorithm": "or",
234
+ "rules": [
235
+ {
236
+ "disabled": False,
237
+ "action": "retain",
238
+ "template": "latestPushedK",
239
+ "params": {"latestPushedK": 20},
240
+ "tag_selectors": [
241
+ {
242
+ "kind": "doublestar",
243
+ "decoration": "matches",
244
+ "pattern": "**"
245
+ }
246
+ ],
247
+ "scope_selectors": {
248
+ "repository": [
249
+ {
250
+ "kind": "doublestar",
251
+ "decoration": "repoMatches",
252
+ "pattern": "**"
253
+ }
254
+ ]
255
+ }
256
+ },
257
+ {
258
+ "disabled": False,
259
+ "action": "retain",
260
+ "template": "nDaysSinceLastPush",
261
+ "params": {"nDaysSinceLastPush": 90},
262
+ "tag_selectors": [
263
+ {
264
+ "kind": "doublestar",
265
+ "decoration": "matches",
266
+ "pattern": "**"
267
+ }
268
+ ],
269
+ "scope_selectors": {
270
+ "repository": [
271
+ {
272
+ "kind": "doublestar",
273
+ "decoration": "repoMatches",
274
+ "pattern": "**"
275
+ }
276
+ ]
277
+ }
278
+ }
279
+ ],
280
+ "trigger": {
281
+ "kind": "Schedule",
282
+ "settings": {
283
+ "cron": "0 2 * * 0" # Domingo às 2h
284
+ }
285
+ },
286
+ "scope": {
287
+ "level": "project",
288
+ "ref": 3 # ID do projeto prd (geralmente 3)
289
+ }
290
+ }
291
+
292
+ typer.secho(" ℹ️ Políticas de retenção devem ser configuradas via UI:", fg=typer.colors.CYAN)
293
+ typer.echo(" 1. Acesse Harbor UI → Projects")
294
+ typer.echo(" 2. Selecione projeto (tst ou prd)")
295
+ typer.echo(" 3. Policy → Tag Retention")
296
+ typer.echo(" 4. Add Rule:")
297
+ typer.echo(" TST: Manter últimas 10 imagens OU 30 dias")
298
+ typer.echo(" PRD: Manter últimas 20 imagens OU 90 dias")
299
+
300
+
301
+ def _create_robot_accounts(ctx: ExecutionContext, harbor_url: str, admin_password: str) -> None:
302
+ """Cria robot accounts para CI/CD."""
303
+ typer.echo("\nCriando robot accounts para CI/CD...")
304
+
305
+ robots = [
306
+ {
307
+ "name": "robot$cicd-tst",
308
+ "description": "Robot account for CI/CD in TST environment",
309
+ "project_id": 2,
310
+ "permissions": [
311
+ {
312
+ "kind": "project",
313
+ "namespace": "tst",
314
+ "access": [
315
+ {"resource": "repository", "action": "push"},
316
+ {"resource": "repository", "action": "pull"},
317
+ {"resource": "artifact", "action": "delete"}
318
+ ]
319
+ }
320
+ ]
321
+ },
322
+ {
323
+ "name": "robot$cicd-prd",
324
+ "description": "Robot account for CI/CD in PRD environment",
325
+ "project_id": 3,
326
+ "permissions": [
327
+ {
328
+ "kind": "project",
329
+ "namespace": "prd",
330
+ "access": [
331
+ {"resource": "repository", "action": "push"},
332
+ {"resource": "repository", "action": "pull"}
333
+ ]
334
+ }
335
+ ]
336
+ }
337
+ ]
338
+
339
+ typer.secho(" ℹ️ Robot accounts devem ser criados via UI:", fg=typer.colors.CYAN)
340
+ typer.echo(" 1. Acesse Harbor UI → Projects → tst/prd")
341
+ typer.echo(" 2. Robot Accounts → New Robot Account")
342
+ typer.echo(" 3. Nome: cicd-tst / cicd-prd")
343
+ typer.echo(" 4. Permissões: Push, Pull, Delete (tst) | Push, Pull (prd)")
344
+ typer.echo(" 5. Salvar token gerado no Vault:")
345
+ typer.echo(" kubectl -n vault exec vault-0 -- vault kv put secret/harbor/robot-tst token=<TOKEN>")
346
+
347
+
348
+ def _configure_garbage_collection(ctx: ExecutionContext) -> None:
349
+ """Configura garbage collection automático."""
350
+ typer.echo("\nGarbage collection configurado para rodar:")
351
+ typer.echo(" - Após cada execução de retention policy")
352
+ typer.echo(" - Diariamente às 3h (via Harbor scheduler)")
353
+ typer.echo("\nConfiguração manual via UI:")
354
+ typer.echo(" Harbor → Administration → Garbage Collection")
355
+ typer.echo(" Schedule: 0 3 * * * (3h diariamente)")
356
+ typer.echo(" Delete untagged artifacts: Yes")
357
+
358
+
359
+ def run(ctx: ExecutionContext) -> None:
360
+ require_root(ctx)
361
+ ensure_tool("kubectl", ctx, install_hint="Instale kubectl ou habilite dry-run.")
362
+ ensure_tool("helm", ctx, install_hint="Instale helm ou habilite dry-run.")
363
+
364
+ typer.echo("Instalando Harbor Registry com MinIO backend...")
365
+
366
+ harbor_ns = typer.prompt("Namespace para Harbor", default=HARBOR_NAMESPACE)
367
+ node_name = _detect_node_name(ctx)
368
+
369
+ # Detecta IP do node
370
+ result = run_cmd(
371
+ ["kubectl", "get", "nodes", "-o", "jsonpath={.items[0].status.addresses[?(@.type=='InternalIP')].address}"],
372
+ ctx,
373
+ check=False,
374
+ )
375
+ node_ip = result.stdout.strip() if result.returncode == 0 else "192.168.1.81"
376
+
377
+ minio_host = typer.prompt("MinIO host", default=f"{node_ip}:30900")
378
+ access_key, secret_key = _get_minio_credentials(ctx)
379
+
380
+ harbor_nodeport = typer.prompt("NodePort para Harbor UI/Registry", default="30880")
381
+ admin_password = typer.prompt("Senha do admin do Harbor", default="Harbor12345", hide_input=True)
382
+
383
+ # ========== Harbor ==========
384
+ typer.secho("\n== Harbor Registry ==", fg=typer.colors.CYAN, bold=True)
385
+
386
+ if _check_existing_harbor(ctx, harbor_ns):
387
+ cleanup = typer.confirm(
388
+ "Instalacao anterior do Harbor detectada. Limpar antes de reinstalar?",
389
+ default=False,
390
+ )
391
+ if cleanup:
392
+ _uninstall_harbor(ctx, harbor_ns)
393
+
394
+ # Cria buckets no MinIO
395
+ typer.echo("\nCriando buckets no MinIO para Harbor...")
396
+ for bucket in ["harbor-registry", "harbor-chartmuseum", "harbor-jobservice"]:
397
+ run_cmd(
398
+ ["mc", "mb", "--ignore-existing", f"minio/{bucket}"],
399
+ ctx,
400
+ check=False,
401
+ )
402
+
403
+ harbor_values_yaml = f"""expose:
404
+ type: nodePort
405
+ tls:
406
+ enabled: false
407
+ nodePort:
408
+ name: harbor
409
+ ports:
410
+ http:
411
+ port: 80
412
+ nodePort: {harbor_nodeport}
413
+
414
+ externalURL: http://{node_ip}:{harbor_nodeport}
415
+
416
+ persistence:
417
+ enabled: true
418
+ persistentVolumeClaim:
419
+ registry:
420
+ storageClass: ""
421
+ size: 5Gi
422
+ chartmuseum:
423
+ storageClass: ""
424
+ size: 5Gi
425
+ jobservice:
426
+ jobLog:
427
+ storageClass: ""
428
+ size: 1Gi
429
+ database:
430
+ storageClass: ""
431
+ size: 1Gi
432
+ redis:
433
+ storageClass: ""
434
+ size: 1Gi
435
+ trivy:
436
+ storageClass: ""
437
+ size: 5Gi
438
+
439
+ # MinIO S3 backend
440
+ imageChartStorage:
441
+ type: s3
442
+ s3:
443
+ region: us-east-1
444
+ bucket: harbor-registry
445
+ accesskey: {access_key}
446
+ secretkey: {secret_key}
447
+ regionendpoint: http://{minio_host}
448
+ encrypt: false
449
+ secure: false
450
+ v4auth: true
451
+
452
+ chartmuseum:
453
+ enabled: true
454
+
455
+ # Configuração de admin
456
+ harborAdminPassword: "{admin_password}"
457
+
458
+ # Tolerations e nodeSelector
459
+ portal:
460
+ tolerations:
461
+ - key: node-role.kubernetes.io/control-plane
462
+ operator: Exists
463
+ effect: NoSchedule
464
+ - key: node-role.kubernetes.io/master
465
+ operator: Exists
466
+ effect: NoSchedule
467
+ nodeSelector:
468
+ kubernetes.io/hostname: {node_name}
469
+ resources:
470
+ requests:
471
+ memory: 128Mi
472
+ cpu: 100m
473
+ limits:
474
+ memory: 256Mi
475
+
476
+ core:
477
+ tolerations:
478
+ - key: node-role.kubernetes.io/control-plane
479
+ operator: Exists
480
+ effect: NoSchedule
481
+ - key: node-role.kubernetes.io/master
482
+ operator: Exists
483
+ effect: NoSchedule
484
+ nodeSelector:
485
+ kubernetes.io/hostname: {node_name}
486
+ resources:
487
+ requests:
488
+ memory: 256Mi
489
+ cpu: 200m
490
+ limits:
491
+ memory: 512Mi
492
+
493
+ jobservice:
494
+ tolerations:
495
+ - key: node-role.kubernetes.io/control-plane
496
+ operator: Exists
497
+ effect: NoSchedule
498
+ - key: node-role.kubernetes.io/master
499
+ operator: Exists
500
+ effect: NoSchedule
501
+ nodeSelector:
502
+ kubernetes.io/hostname: {node_name}
503
+ resources:
504
+ requests:
505
+ memory: 128Mi
506
+ cpu: 100m
507
+ limits:
508
+ memory: 256Mi
509
+
510
+ registry:
511
+ tolerations:
512
+ - key: node-role.kubernetes.io/control-plane
513
+ operator: Exists
514
+ effect: NoSchedule
515
+ - key: node-role.kubernetes.io/master
516
+ operator: Exists
517
+ effect: NoSchedule
518
+ nodeSelector:
519
+ kubernetes.io/hostname: {node_name}
520
+ resources:
521
+ requests:
522
+ memory: 256Mi
523
+ cpu: 200m
524
+ limits:
525
+ memory: 512Mi
526
+
527
+ trivy:
528
+ enabled: true
529
+ tolerations:
530
+ - key: node-role.kubernetes.io/control-plane
531
+ operator: Exists
532
+ effect: NoSchedule
533
+ - key: node-role.kubernetes.io/master
534
+ operator: Exists
535
+ effect: NoSchedule
536
+ nodeSelector:
537
+ kubernetes.io/hostname: {node_name}
538
+ resources:
539
+ requests:
540
+ memory: 512Mi
541
+ cpu: 200m
542
+ limits:
543
+ memory: 1Gi
544
+
545
+ database:
546
+ type: internal
547
+ internal:
548
+ tolerations:
549
+ - key: node-role.kubernetes.io/control-plane
550
+ operator: Exists
551
+ effect: NoSchedule
552
+ - key: node-role.kubernetes.io/master
553
+ operator: Exists
554
+ effect: NoSchedule
555
+ nodeSelector:
556
+ kubernetes.io/hostname: {node_name}
557
+ resources:
558
+ requests:
559
+ memory: 256Mi
560
+ cpu: 100m
561
+ limits:
562
+ memory: 512Mi
563
+
564
+ redis:
565
+ type: internal
566
+ internal:
567
+ tolerations:
568
+ - key: node-role.kubernetes.io/control-plane
569
+ operator: Exists
570
+ effect: NoSchedule
571
+ - key: node-role.kubernetes.io/master
572
+ operator: Exists
573
+ effect: NoSchedule
574
+ nodeSelector:
575
+ kubernetes.io/hostname: {node_name}
576
+ resources:
577
+ requests:
578
+ memory: 128Mi
579
+ cpu: 100m
580
+ limits:
581
+ memory: 256Mi
582
+ """
583
+
584
+ harbor_values_path = Path("/tmp/raijin-harbor-values.yaml")
585
+ write_file(harbor_values_path, harbor_values_yaml, ctx)
586
+
587
+ helm_upgrade_install(
588
+ "harbor",
589
+ "harbor",
590
+ harbor_ns,
591
+ ctx,
592
+ repo="harbor",
593
+ repo_url="https://helm.goharbor.io",
594
+ create_namespace=True,
595
+ extra_args=["-f", str(harbor_values_path)],
596
+ )
597
+
598
+ if not ctx.dry_run:
599
+ _wait_for_pods_ready(ctx, harbor_ns)
600
+
601
+ # Aguarda Harbor API ficar disponível
602
+ typer.echo("\nAguardando Harbor API ficar disponível...")
603
+ time.sleep(30)
604
+
605
+ harbor_url = f"http://{node_ip}:{harbor_nodeport}"
606
+
607
+ # Configura projetos, policies e robot accounts
608
+ _create_harbor_projects(ctx, harbor_url, admin_password)
609
+ _create_retention_policies(ctx, harbor_url, admin_password)
610
+ _create_robot_accounts(ctx, harbor_url, admin_password)
611
+ _configure_garbage_collection(ctx)
612
+
613
+ typer.secho("\n✓ Harbor instalado com sucesso!", fg=typer.colors.GREEN, bold=True)
614
+
615
+ typer.secho("\n=== Acesso ao Harbor ===", fg=typer.colors.CYAN)
616
+ typer.echo(f"URL: http://{node_ip}:{harbor_nodeport}")
617
+ typer.echo(f"Usuário: admin")
618
+ typer.echo(f"Senha: {admin_password}")
619
+
620
+ typer.secho("\n=== Projetos Criados ===", fg=typer.colors.CYAN)
621
+ typer.echo("✓ tst (development/staging)")
622
+ typer.echo(" - Auto-scan habilitado")
623
+ typer.echo(" - Retention: 10 imagens ou 30 dias")
624
+ typer.echo(" - Pull from: develop branch")
625
+ typer.echo("\n✓ prd (production)")
626
+ typer.echo(" - Auto-scan habilitado")
627
+ typer.echo(" - Content trust habilitado")
628
+ typer.echo(" - Block vulnerabilities (critical)")
629
+ typer.echo(" - Retention: 20 imagens ou 90 dias")
630
+ typer.echo(" - Pull from: main/master branch")
631
+
632
+ typer.secho("\n=== Como usar ===", fg=typer.colors.CYAN)
633
+ typer.echo("1. Login no Harbor:")
634
+ typer.echo(f" docker login {node_ip}:{harbor_nodeport}")
635
+ typer.echo(f" Username: admin")
636
+ typer.echo(f" Password: {admin_password}")
637
+
638
+ typer.echo("\n2. Tag e push de imagem (TST):")
639
+ typer.echo(f" docker tag myapp:latest {node_ip}:{harbor_nodeport}/tst/myapp:v1.0.0")
640
+ typer.echo(f" docker push {node_ip}:{harbor_nodeport}/tst/myapp:v1.0.0")
641
+
642
+ typer.echo("\n3. Tag e push de imagem (PRD):")
643
+ typer.echo(f" docker tag myapp:latest {node_ip}:{harbor_nodeport}/prd/myapp:v1.0.0")
644
+ typer.echo(f" docker push {node_ip}:{harbor_nodeport}/prd/myapp:v1.0.0")
645
+
646
+ typer.echo("\n4. Pull de imagem no Kubernetes:")
647
+ typer.echo(" kubectl create secret docker-registry harbor-secret \\")
648
+ typer.echo(f" --docker-server={node_ip}:{harbor_nodeport} \\")
649
+ typer.echo(" --docker-username=admin \\")
650
+ typer.echo(f" --docker-password={admin_password}")
651
+ typer.echo("\n spec:")
652
+ typer.echo(" imagePullSecrets:")
653
+ typer.echo(" - name: harbor-secret")
654
+ typer.echo(" containers:")
655
+ typer.echo(f" - image: {node_ip}:{harbor_nodeport}/prd/myapp:v1.0.0")
656
+
657
+ typer.secho("\n=== Próximos Passos (via UI) ===", fg=typer.colors.YELLOW)
658
+ typer.echo("1. Configurar Robot Accounts (cicd-tst, cicd-prd)")
659
+ typer.echo("2. Ajustar Retention Policies se necessário")
660
+ typer.echo("3. Configurar Webhooks para CI/CD")
661
+ typer.echo("4. Habilitar Content Trust em PRD (cosign/notary)")
662
+ typer.echo("5. Configurar Replication (se multi-cluster)")
663
+
664
+ typer.secho("\n⚠️ IMPORTANTE:", fg=typer.colors.YELLOW, bold=True)
665
+ typer.echo("- Imagens em TST: Máximo 10 ou 30 dias (cleanup automático)")
666
+ typer.echo("- Imagens em PRD: Máximo 20 ou 90 dias (cleanup automático)")
667
+ typer.echo("- Garbage collection roda diariamente às 3h")
668
+ typer.echo("- Vulnerability scan automático em todas as imagens")
669
+ typer.echo("- PRD bloqueia push de imagens com vulnerabilidades CRITICAL")