raijin-server 0.2.3__py3-none-any.whl → 0.2.5__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.
@@ -1,6 +1,22 @@
1
- """Instala e configura cert-manager com emissores ACME (HTTP-01 ou DNS-01)."""
1
+ """Instala e configura cert-manager com emissores ACME (HTTP-01 ou DNS-01).
2
2
 
3
+ Este módulo oferece:
4
+ - Instalação do cert-manager via Helm com verificação completa de readiness
5
+ - Múltiplos provedores DNS (Cloudflare, Route53, DigitalOcean, Azure)
6
+ - Verificação REAL do webhook (testa com dry-run antes de aplicar)
7
+ - Modo não-interativo para automação completa
8
+ - Diagnóstico detalhado de problemas
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
3
18
  from pathlib import Path
19
+ from typing import Callable, Optional, List
4
20
 
5
21
  import typer
6
22
 
@@ -8,9 +24,10 @@ from raijin_server.utils import (
8
24
  ExecutionContext,
9
25
  ensure_tool,
10
26
  helm_upgrade_install,
11
- kubectl_apply,
12
27
  require_root,
28
+ run_cmd,
13
29
  write_file,
30
+ logger,
14
31
  )
15
32
 
16
33
  CHART_REPO = "https://charts.jetstack.io"
@@ -18,73 +35,233 @@ CHART_NAME = "cert-manager"
18
35
  NAMESPACE = "cert-manager"
19
36
  MANIFEST_PATH = Path("/tmp/raijin-cert-manager-issuer.yaml")
20
37
 
38
+ # Timeouts mais generosos para ambientes lentos
39
+ WEBHOOK_READY_TIMEOUT = 600 # 10 minutos
40
+ POD_READY_TIMEOUT = 300 # 5 minutos
41
+ CRD_READY_TIMEOUT = 180 # 3 minutos
21
42
 
22
- def _build_http01_issuer(name: str, email: str, ingress_class: str, staging: bool) -> str:
23
- server = (
24
- "https://acme-staging-v02.api.letsencrypt.org/directory"
25
- if staging
26
- else "https://acme-v02.api.letsencrypt.org/directory"
27
- )
43
+
44
+ class DNSProvider(str, Enum):
45
+ """Provedores DNS suportados para challenge DNS-01."""
46
+ CLOUDFLARE = "cloudflare"
47
+ ROUTE53 = "route53"
48
+ DIGITALOCEAN = "digitalocean"
49
+ AZURE = "azure"
50
+
51
+
52
+ class ChallengeType(str, Enum):
53
+ """Tipos de challenge ACME suportados."""
54
+ HTTP01 = "http01"
55
+ DNS01 = "dns01"
56
+
57
+
58
+ @dataclass
59
+ class IssuerConfig:
60
+ """Configuração para criação de ClusterIssuer."""
61
+ name: str
62
+ email: str
63
+ staging: bool = False
64
+ challenge_type: ChallengeType = ChallengeType.HTTP01
65
+ ingress_class: str = "traefik"
66
+ dns_provider: Optional[DNSProvider] = None
67
+ secret_name: str = "dns-api-credentials"
68
+ region: str = "us-east-1"
69
+ hosted_zone_id: str = ""
70
+ resource_group: str = ""
71
+ subscription_id: str = ""
72
+ tenant_id: str = ""
73
+ client_id: str = ""
74
+ # Credentials (transient, not persisted)
75
+ _credentials: dict = field(default_factory=dict)
76
+
77
+
78
+ def _get_acme_server(staging: bool) -> str:
79
+ """Retorna URL do servidor ACME."""
80
+ if staging:
81
+ return "https://acme-staging-v02.api.letsencrypt.org/directory"
82
+ return "https://acme-v02.api.letsencrypt.org/directory"
83
+
84
+
85
+ # =============================================================================
86
+ # Builders de Manifests YAML
87
+ # =============================================================================
88
+
89
+ def _build_http01_issuer(config: IssuerConfig) -> str:
28
90
  return f"""apiVersion: cert-manager.io/v1
29
91
  kind: ClusterIssuer
30
92
  metadata:
31
- name: {name}
93
+ name: {config.name}
32
94
  spec:
33
95
  acme:
34
- email: {email}
35
- server: {server}
96
+ email: {config.email}
97
+ server: {_get_acme_server(config.staging)}
36
98
  privateKeySecretRef:
37
- name: {name}
99
+ name: {config.name}-account-key
38
100
  solvers:
39
101
  - http01:
40
102
  ingress:
41
- class: {ingress_class}
103
+ class: {config.ingress_class}
42
104
  """
43
105
 
44
106
 
45
- def _build_cloudflare_dns01(name: str, email: str, secret_name: str, staging: bool) -> str:
46
- server = (
47
- "https://acme-staging-v02.api.letsencrypt.org/directory"
48
- if staging
49
- else "https://acme-v02.api.letsencrypt.org/directory"
50
- )
107
+ def _build_cloudflare_issuer(config: IssuerConfig) -> str:
51
108
  return f"""apiVersion: cert-manager.io/v1
52
109
  kind: ClusterIssuer
53
110
  metadata:
54
- name: {name}
111
+ name: {config.name}
55
112
  spec:
56
113
  acme:
57
- email: {email}
58
- server: {server}
114
+ email: {config.email}
115
+ server: {_get_acme_server(config.staging)}
59
116
  privateKeySecretRef:
60
- name: {name}
117
+ name: {config.name}-account-key
61
118
  solvers:
62
119
  - dns01:
63
120
  cloudflare:
64
121
  apiTokenSecretRef:
65
- name: {secret_name}
122
+ name: {config.secret_name}
66
123
  key: api-token
67
124
  """
68
125
 
69
126
 
70
- def _build_cloudflare_secret(secret_name: str, api_token: str) -> str:
71
- return f"""apiVersion: v1
127
+ def _build_route53_issuer(config: IssuerConfig) -> str:
128
+ hosted_zone = ""
129
+ if config.hosted_zone_id:
130
+ hosted_zone = f"\n hostedZoneID: {config.hosted_zone_id}"
131
+
132
+ return f"""apiVersion: cert-manager.io/v1
133
+ kind: ClusterIssuer
134
+ metadata:
135
+ name: {config.name}
136
+ spec:
137
+ acme:
138
+ email: {config.email}
139
+ server: {_get_acme_server(config.staging)}
140
+ privateKeySecretRef:
141
+ name: {config.name}-account-key
142
+ solvers:
143
+ - dns01:
144
+ route53:
145
+ region: {config.region}{hosted_zone}
146
+ accessKeyIDSecretRef:
147
+ name: {config.secret_name}
148
+ key: access-key-id
149
+ secretAccessKeySecretRef:
150
+ name: {config.secret_name}
151
+ key: secret-access-key
152
+ """
153
+
154
+
155
+ def _build_digitalocean_issuer(config: IssuerConfig) -> str:
156
+ return f"""apiVersion: cert-manager.io/v1
157
+ kind: ClusterIssuer
158
+ metadata:
159
+ name: {config.name}
160
+ spec:
161
+ acme:
162
+ email: {config.email}
163
+ server: {_get_acme_server(config.staging)}
164
+ privateKeySecretRef:
165
+ name: {config.name}-account-key
166
+ solvers:
167
+ - dns01:
168
+ digitalocean:
169
+ tokenSecretRef:
170
+ name: {config.secret_name}
171
+ key: access-token
172
+ """
173
+
174
+
175
+ def _build_azure_issuer(config: IssuerConfig) -> str:
176
+ return f"""apiVersion: cert-manager.io/v1
177
+ kind: ClusterIssuer
178
+ metadata:
179
+ name: {config.name}
180
+ spec:
181
+ acme:
182
+ email: {config.email}
183
+ server: {_get_acme_server(config.staging)}
184
+ privateKeySecretRef:
185
+ name: {config.name}-account-key
186
+ solvers:
187
+ - dns01:
188
+ azureDNS:
189
+ subscriptionID: {config.subscription_id}
190
+ resourceGroupName: {config.resource_group}
191
+ hostedZoneName: {config.name}
192
+ environment: AzurePublicCloud
193
+ clientID: {config.client_id}
194
+ tenantID: {config.tenant_id}
195
+ clientSecretSecretRef:
196
+ name: {config.secret_name}
197
+ key: client-secret
198
+ """
199
+
200
+
201
+ def _build_secret(config: IssuerConfig) -> Optional[str]:
202
+ """Gera Secret baseado no provider DNS."""
203
+ if config.challenge_type != ChallengeType.DNS01:
204
+ return None
205
+
206
+ creds = config._credentials
207
+
208
+ if config.dns_provider == DNSProvider.CLOUDFLARE:
209
+ return f"""apiVersion: v1
72
210
  kind: Secret
73
211
  metadata:
74
- name: {secret_name}
212
+ name: {config.secret_name}
75
213
  namespace: {NAMESPACE}
76
214
  type: Opaque
77
215
  stringData:
78
- api-token: {api_token}
216
+ api-token: {creds.get('api_token', '')}
79
217
  """
218
+
219
+ elif config.dns_provider == DNSProvider.ROUTE53:
220
+ return f"""apiVersion: v1
221
+ kind: Secret
222
+ metadata:
223
+ name: {config.secret_name}
224
+ namespace: {NAMESPACE}
225
+ type: Opaque
226
+ stringData:
227
+ access-key-id: {creds.get('access_key', '')}
228
+ secret-access-key: {creds.get('secret_key', '')}
229
+ """
230
+
231
+ elif config.dns_provider == DNSProvider.DIGITALOCEAN:
232
+ return f"""apiVersion: v1
233
+ kind: Secret
234
+ metadata:
235
+ name: {config.secret_name}
236
+ namespace: {NAMESPACE}
237
+ type: Opaque
238
+ stringData:
239
+ access-token: {creds.get('token', '')}
240
+ """
241
+
242
+ elif config.dns_provider == DNSProvider.AZURE:
243
+ return f"""apiVersion: v1
244
+ kind: Secret
245
+ metadata:
246
+ name: {config.secret_name}
247
+ namespace: {NAMESPACE}
248
+ type: Opaque
249
+ stringData:
250
+ client-secret: {creds.get('client_secret', '')}
251
+ """
252
+
253
+ return None
254
+
80
255
 
256
+ # =============================================================================
257
+ # Verificações de Estado
258
+ # =============================================================================
81
259
 
82
260
  def _check_cluster_available(ctx: ExecutionContext) -> bool:
83
- """Verifica se o cluster Kubernetes esta acessivel."""
261
+ """Verifica se o cluster Kubernetes está acessível."""
84
262
  if ctx.dry_run:
85
263
  return True
86
264
  try:
87
- import subprocess
88
265
  result = subprocess.run(
89
266
  ["kubectl", "cluster-info"],
90
267
  capture_output=True,
@@ -95,62 +272,764 @@ def _check_cluster_available(ctx: ExecutionContext) -> bool:
95
272
  return False
96
273
 
97
274
 
275
+ def _check_crds_installed() -> bool:
276
+ """Verifica se os CRDs do cert-manager estão instalados."""
277
+ required_crds = [
278
+ "certificates.cert-manager.io",
279
+ "clusterissuers.cert-manager.io",
280
+ "issuers.cert-manager.io",
281
+ ]
282
+ try:
283
+ for crd in required_crds:
284
+ result = subprocess.run(
285
+ ["kubectl", "get", "crd", crd],
286
+ capture_output=True,
287
+ timeout=10,
288
+ )
289
+ if result.returncode != 0:
290
+ return False
291
+ return True
292
+ except Exception:
293
+ return False
294
+
295
+
296
+ def _check_deployment_ready(name: str, namespace: str) -> bool:
297
+ """Verifica se um deployment está com todas as replicas ready."""
298
+ try:
299
+ result = subprocess.run(
300
+ [
301
+ "kubectl", "get", "deployment", name,
302
+ "-n", namespace,
303
+ "-o", "jsonpath={.status.readyReplicas}/{.spec.replicas}"
304
+ ],
305
+ capture_output=True,
306
+ text=True,
307
+ timeout=10,
308
+ )
309
+ if result.returncode != 0:
310
+ return False
311
+
312
+ parts = result.stdout.strip().split("/")
313
+ if len(parts) != 2:
314
+ return False
315
+
316
+ ready = int(parts[0]) if parts[0] else 0
317
+ desired = int(parts[1]) if parts[1] else 1
318
+ return ready >= desired
319
+ except Exception:
320
+ return False
321
+
322
+
323
+ def _check_webhook_endpoint_ready() -> bool:
324
+ """Verifica se o endpoint do webhook tem IPs atribuídos."""
325
+ try:
326
+ result = subprocess.run(
327
+ [
328
+ "kubectl", "get", "endpoints", "cert-manager-webhook",
329
+ "-n", NAMESPACE,
330
+ "-o", "jsonpath={.subsets[0].addresses[0].ip}"
331
+ ],
332
+ capture_output=True,
333
+ text=True,
334
+ timeout=10,
335
+ )
336
+ return bool(result.stdout.strip())
337
+ except Exception:
338
+ return False
339
+
340
+
341
+ def _test_webhook_connectivity() -> bool:
342
+ """Testa se o webhook está realmente respondendo usando dry-run."""
343
+ try:
344
+ # Cria um ClusterIssuer de teste para validar o webhook
345
+ test_manifest = """apiVersion: cert-manager.io/v1
346
+ kind: ClusterIssuer
347
+ metadata:
348
+ name: test-webhook-connectivity
349
+ spec:
350
+ acme:
351
+ email: test@test.com
352
+ server: https://acme-staging-v02.api.letsencrypt.org/directory
353
+ privateKeySecretRef:
354
+ name: test-key
355
+ solvers:
356
+ - http01:
357
+ ingress:
358
+ class: nginx
359
+ """
360
+ # Usa --dry-run=server para testar se o webhook responde
361
+ result = subprocess.run(
362
+ ["kubectl", "apply", "--dry-run=server", "-f", "-"],
363
+ input=test_manifest,
364
+ capture_output=True,
365
+ text=True,
366
+ timeout=30,
367
+ )
368
+
369
+ # Se passou no dry-run, o webhook está funcionando
370
+ if result.returncode == 0:
371
+ return True
372
+
373
+ # Verifica se o erro é de conexão com webhook
374
+ stderr = result.stderr.lower()
375
+ if "connection refused" in stderr or "no endpoints" in stderr:
376
+ return False
377
+
378
+ # Outros erros (validação, etc) significam que o webhook respondeu
379
+ return True
380
+ except subprocess.TimeoutExpired:
381
+ return False
382
+ except Exception:
383
+ return False
384
+
385
+
386
+ def _wait_for_webhook_ready(ctx: ExecutionContext, timeout: int = WEBHOOK_READY_TIMEOUT) -> bool:
387
+ """Aguarda até que o webhook do cert-manager esteja totalmente operacional."""
388
+ if ctx.dry_run:
389
+ typer.echo("[dry-run] Aguardando webhook...")
390
+ return True
391
+
392
+ typer.secho("\n⏳ Aguardando cert-manager ficar pronto...", fg=typer.colors.CYAN)
393
+
394
+ start_time = time.time()
395
+ interval = 15
396
+
397
+ stages = [
398
+ ("CRDs instalados", _check_crds_installed),
399
+ ("Deployment cert-manager ready", lambda: _check_deployment_ready("cert-manager", NAMESPACE)),
400
+ ("Deployment cert-manager-webhook ready", lambda: _check_deployment_ready("cert-manager-webhook", NAMESPACE)),
401
+ ("Deployment cert-manager-cainjector ready", lambda: _check_deployment_ready("cert-manager-cainjector", NAMESPACE)),
402
+ ("Endpoint webhook disponível", _check_webhook_endpoint_ready),
403
+ ("Webhook respondendo (teste de conectividade)", _test_webhook_connectivity),
404
+ ]
405
+
406
+ for stage_name, check_fn in stages:
407
+ typer.echo(f"\n → Verificando: {stage_name}")
408
+
409
+ while True:
410
+ elapsed = int(time.time() - start_time)
411
+ remaining = timeout - elapsed
412
+
413
+ if remaining <= 0:
414
+ typer.secho(f" ✗ TIMEOUT após {timeout}s", fg=typer.colors.RED)
415
+ return False
416
+
417
+ try:
418
+ if check_fn():
419
+ typer.secho(f" ✓ {stage_name}", fg=typer.colors.GREEN)
420
+ break
421
+ except Exception as e:
422
+ logger.debug(f"Verificação falhou: {e}")
423
+
424
+ typer.echo(f" ... aguardando ({elapsed}s / {timeout}s)")
425
+ time.sleep(interval)
426
+
427
+ typer.secho("\n✓ Cert-manager totalmente operacional!", fg=typer.colors.GREEN, bold=True)
428
+ return True
429
+
430
+
431
+ # =============================================================================
432
+ # Instalação e Configuração
433
+ # =============================================================================
434
+
435
+ def _install_cert_manager_helm(ctx: ExecutionContext) -> bool:
436
+ """Instala cert-manager via Helm."""
437
+ typer.secho("\n📦 Instalando cert-manager via Helm...", fg=typer.colors.CYAN, bold=True)
438
+
439
+ try:
440
+ # O helm_upgrade_install agora limpa releases pendentes automaticamente
441
+ helm_upgrade_install(
442
+ release="cert-manager",
443
+ chart=CHART_NAME,
444
+ namespace=NAMESPACE,
445
+ ctx=ctx,
446
+ repo="jetstack",
447
+ repo_url=CHART_REPO,
448
+ create_namespace=True,
449
+ extra_args=[
450
+ "--set", "installCRDs=true",
451
+ "--set", "webhook.timeoutSeconds=30",
452
+ "--set", "startupapicheck.timeout=5m",
453
+ "--set", "startupapicheck.enabled=true",
454
+ # Aumenta recursos para ambientes mais lentos
455
+ "--set", "webhook.replicaCount=1",
456
+ "--set", "cainjector.replicaCount=1",
457
+ "--wait", # Espera o Helm considerar o release deployed
458
+ "--timeout", "10m",
459
+ ],
460
+ )
461
+ return True
462
+ except Exception as e:
463
+ typer.secho(f"✗ Erro na instalação do Helm: {e}", fg=typer.colors.RED)
464
+ ctx.errors.append(f"cert-manager: falha na instalação Helm - {e}")
465
+ return False
466
+
467
+
468
+ def _apply_manifest_with_retry(
469
+ manifest: str,
470
+ ctx: ExecutionContext,
471
+ description: str = "manifest",
472
+ max_retries: int = 10,
473
+ retry_interval: int = 30,
474
+ ) -> bool:
475
+ """Aplica um manifest com retry inteligente."""
476
+ if ctx.dry_run:
477
+ typer.echo(f"[dry-run] Aplicando {description}")
478
+ return True
479
+
480
+ manifest_path = MANIFEST_PATH
481
+ manifest_path.write_text(manifest)
482
+
483
+ for attempt in range(1, max_retries + 1):
484
+ typer.echo(f"\n Aplicando {description} (tentativa {attempt}/{max_retries})...")
485
+
486
+ try:
487
+ result = subprocess.run(
488
+ ["kubectl", "apply", "-f", str(manifest_path)],
489
+ capture_output=True,
490
+ text=True,
491
+ timeout=60,
492
+ )
493
+
494
+ if result.returncode == 0:
495
+ typer.secho(f" ✓ {description} aplicado com sucesso!", fg=typer.colors.GREEN)
496
+ return True
497
+
498
+ stderr = result.stderr.lower()
499
+
500
+ # Verifica se é erro de webhook
501
+ if "connection refused" in stderr or "failed to call webhook" in stderr:
502
+ typer.secho(
503
+ f" ⚠ Webhook ainda não está pronto...",
504
+ fg=typer.colors.YELLOW,
505
+ )
506
+ if attempt < max_retries:
507
+ typer.echo(f" Aguardando {retry_interval}s antes de tentar novamente...")
508
+ time.sleep(retry_interval)
509
+ continue
510
+
511
+ # Outro tipo de erro
512
+ typer.secho(f" ✗ Erro: {result.stderr[:200]}", fg=typer.colors.RED)
513
+ if attempt < max_retries:
514
+ time.sleep(retry_interval)
515
+
516
+ except subprocess.TimeoutExpired:
517
+ typer.secho(" ⚠ Timeout ao aplicar manifest", fg=typer.colors.YELLOW)
518
+ if attempt < max_retries:
519
+ time.sleep(retry_interval)
520
+ except Exception as e:
521
+ typer.secho(f" ✗ Erro inesperado: {e}", fg=typer.colors.RED)
522
+ if attempt < max_retries:
523
+ time.sleep(retry_interval)
524
+
525
+ typer.secho(f"✗ Falha ao aplicar {description} após {max_retries} tentativas", fg=typer.colors.RED)
526
+ return False
527
+
528
+
529
+ def _build_issuer_manifests(config: IssuerConfig) -> str:
530
+ """Constrói todos os manifests necessários para o issuer."""
531
+ manifests = []
532
+
533
+ # Secret (se necessário)
534
+ secret = _build_secret(config)
535
+ if secret:
536
+ manifests.append(secret)
537
+
538
+ # ClusterIssuer
539
+ if config.challenge_type == ChallengeType.HTTP01:
540
+ manifests.append(_build_http01_issuer(config))
541
+ elif config.dns_provider == DNSProvider.CLOUDFLARE:
542
+ manifests.append(_build_cloudflare_issuer(config))
543
+ elif config.dns_provider == DNSProvider.ROUTE53:
544
+ manifests.append(_build_route53_issuer(config))
545
+ elif config.dns_provider == DNSProvider.DIGITALOCEAN:
546
+ manifests.append(_build_digitalocean_issuer(config))
547
+ elif config.dns_provider == DNSProvider.AZURE:
548
+ manifests.append(_build_azure_issuer(config))
549
+
550
+ return "---\n".join(manifests)
551
+
552
+
553
+ # =============================================================================
554
+ # Interface Interativa
555
+ # =============================================================================
556
+
557
+ def _collect_issuer_config_interactive() -> Optional[IssuerConfig]:
558
+ """Coleta configuração do issuer interativamente."""
559
+ typer.secho("\n🔧 Configuração do ClusterIssuer", fg=typer.colors.CYAN, bold=True)
560
+
561
+ email = typer.prompt(
562
+ "Email para ACME (Let's Encrypt)",
563
+ default="admin@example.com",
564
+ )
565
+
566
+ if "@" not in email or "example.com" in email:
567
+ typer.secho(
568
+ "⚠ Use um email válido para receber notificações de expiração",
569
+ fg=typer.colors.YELLOW,
570
+ )
571
+ if not typer.confirm("Continuar mesmo assim?", default=False):
572
+ return None
573
+
574
+ solver = typer.prompt(
575
+ "Tipo de challenge (http01/dns01)",
576
+ default="http01",
577
+ ).lower()
578
+
579
+ staging = typer.confirm(
580
+ "Usar servidor de staging? (para testes)",
581
+ default=True,
582
+ )
583
+
584
+ if staging:
585
+ typer.secho(
586
+ "ℹ Staging gera certificados de teste (não válidos para produção)",
587
+ fg=typer.colors.CYAN,
588
+ )
589
+
590
+ config = IssuerConfig(name="", email=email, staging=staging)
591
+
592
+ if solver == "dns01":
593
+ config.challenge_type = ChallengeType.DNS01
594
+
595
+ typer.echo("\nProvedores DNS disponíveis:")
596
+ typer.echo(" 1. Cloudflare")
597
+ typer.echo(" 2. AWS Route53")
598
+ typer.echo(" 3. DigitalOcean")
599
+ typer.echo(" 4. Azure DNS")
600
+
601
+ choice = typer.prompt("Escolha (1-4)", default="1")
602
+
603
+ if choice == "1":
604
+ config.dns_provider = DNSProvider.CLOUDFLARE
605
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-cloudflare")
606
+ config.secret_name = typer.prompt("Nome do Secret", default="cloudflare-api-token")
607
+ config._credentials["api_token"] = typer.prompt("API Token do Cloudflare", hide_input=True)
608
+
609
+ elif choice == "2":
610
+ config.dns_provider = DNSProvider.ROUTE53
611
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-route53")
612
+ config.secret_name = typer.prompt("Nome do Secret", default="route53-credentials")
613
+ config.region = typer.prompt("AWS Region", default="us-east-1")
614
+ config.hosted_zone_id = typer.prompt("Hosted Zone ID (opcional)", default="")
615
+ config._credentials["access_key"] = typer.prompt("AWS Access Key ID", hide_input=True)
616
+ config._credentials["secret_key"] = typer.prompt("AWS Secret Access Key", hide_input=True)
617
+
618
+ elif choice == "3":
619
+ config.dns_provider = DNSProvider.DIGITALOCEAN
620
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-digitalocean")
621
+ config.secret_name = typer.prompt("Nome do Secret", default="digitalocean-token")
622
+ config._credentials["token"] = typer.prompt("DigitalOcean API Token", hide_input=True)
623
+
624
+ elif choice == "4":
625
+ config.dns_provider = DNSProvider.AZURE
626
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-azure")
627
+ config.secret_name = typer.prompt("Nome do Secret", default="azure-dns-credentials")
628
+ config.subscription_id = typer.prompt("Azure Subscription ID")
629
+ config.resource_group = typer.prompt("Resource Group")
630
+ config.tenant_id = typer.prompt("Tenant ID")
631
+ config.client_id = typer.prompt("Client ID (App Registration)")
632
+ config._credentials["client_secret"] = typer.prompt("Client Secret", hide_input=True)
633
+ else:
634
+ typer.secho("Opção inválida", fg=typer.colors.RED)
635
+ return None
636
+ else:
637
+ config.challenge_type = ChallengeType.HTTP01
638
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-http")
639
+ config.ingress_class = typer.prompt("IngressClass", default="traefik")
640
+
641
+ return config
642
+
643
+
644
+ def _print_next_steps(config: IssuerConfig) -> None:
645
+ """Exibe próximos passos após configuração."""
646
+ typer.secho("\n📚 Próximos Passos", fg=typer.colors.CYAN, bold=True)
647
+ typer.echo("─" * 50)
648
+
649
+ typer.echo(f"""
650
+ Para usar certificados TLS em seus Ingresses:
651
+
652
+ 1. Adicione a annotation no Ingress:
653
+ annotations:
654
+ cert-manager.io/cluster-issuer: "{config.name}"
655
+
656
+ 2. Configure o TLS no Ingress:
657
+ tls:
658
+ - hosts:
659
+ - seu-dominio.com
660
+ secretName: seu-dominio-tls
661
+
662
+ 3. Verifique o status do certificado:
663
+ kubectl get certificate -A
664
+ kubectl describe certificate <nome> -n <namespace>
665
+
666
+ 4. Para debug de problemas:
667
+ kubectl describe clusterissuer {config.name}
668
+ kubectl get challenges -A
669
+ kubectl get orders -A
670
+ """)
671
+
672
+ if config.staging:
673
+ typer.secho(
674
+ "⚠ Você está usando o servidor STAGING!\n"
675
+ " Os certificados NÃO são válidos para produção.\n"
676
+ " Após testar, recrie o issuer sem staging.",
677
+ fg=typer.colors.YELLOW,
678
+ )
679
+
680
+ typer.echo("─" * 50)
681
+
682
+
683
+ # =============================================================================
684
+ # Status e Diagnóstico
685
+ # =============================================================================
686
+
687
+ def _get_cert_manager_status(ctx: ExecutionContext) -> dict:
688
+ """Obtém status detalhado do cert-manager."""
689
+ status = {
690
+ "installed": False,
691
+ "version": "",
692
+ "pods": [],
693
+ "webhook_ready": False,
694
+ "crds_installed": False,
695
+ "issuers": [],
696
+ "certificates": [],
697
+ }
698
+
699
+ if ctx.dry_run:
700
+ return status
701
+
702
+ try:
703
+ # Verifica versão via Helm
704
+ result = subprocess.run(
705
+ ["helm", "list", "-n", NAMESPACE, "-o", "json"],
706
+ capture_output=True,
707
+ text=True,
708
+ timeout=15,
709
+ )
710
+ if result.returncode == 0:
711
+ releases = json.loads(result.stdout)
712
+ for r in releases:
713
+ if r.get("name") == "cert-manager":
714
+ status["installed"] = True
715
+ status["version"] = r.get("app_version", "")
716
+ break
717
+
718
+ # Verifica pods
719
+ result = subprocess.run(
720
+ [
721
+ "kubectl", "get", "pods", "-n", NAMESPACE,
722
+ "-o", "jsonpath={range .items[*]}{.metadata.name}:{.status.phase}\\n{end}"
723
+ ],
724
+ capture_output=True,
725
+ text=True,
726
+ timeout=10,
727
+ )
728
+ if result.returncode == 0:
729
+ for line in result.stdout.strip().split("\n"):
730
+ if ":" in line:
731
+ name, phase = line.split(":", 1)
732
+ status["pods"].append({"name": name, "phase": phase})
733
+
734
+ status["webhook_ready"] = _test_webhook_connectivity()
735
+ status["crds_installed"] = _check_crds_installed()
736
+
737
+ # Lista ClusterIssuers
738
+ result = subprocess.run(
739
+ ["kubectl", "get", "clusterissuers", "-o", "jsonpath={.items[*].metadata.name}"],
740
+ capture_output=True,
741
+ text=True,
742
+ timeout=10,
743
+ )
744
+ if result.returncode == 0 and result.stdout.strip():
745
+ status["issuers"] = result.stdout.strip().split()
746
+
747
+ # Lista Certificates
748
+ result = subprocess.run(
749
+ [
750
+ "kubectl", "get", "certificates", "-A",
751
+ "-o", "jsonpath={range .items[*]}{.metadata.namespace}/{.metadata.name}:{.status.conditions[0].status}\\n{end}"
752
+ ],
753
+ capture_output=True,
754
+ text=True,
755
+ timeout=10,
756
+ )
757
+ if result.returncode == 0 and result.stdout.strip():
758
+ for line in result.stdout.strip().split("\n"):
759
+ if ":" in line:
760
+ name, ready = line.split(":", 1)
761
+ status["certificates"].append({"name": name, "ready": ready})
762
+
763
+ except Exception as e:
764
+ logger.error(f"Erro ao obter status do cert-manager: {e}")
765
+
766
+ return status
767
+
768
+
769
+ def _print_status(status: dict) -> None:
770
+ """Exibe status formatado do cert-manager."""
771
+ typer.secho("\n📊 Status do Cert-Manager", fg=typer.colors.CYAN, bold=True)
772
+ typer.echo("─" * 50)
773
+
774
+ if status["installed"]:
775
+ typer.secho(f" ✓ Instalado: versão {status['version']}", fg=typer.colors.GREEN)
776
+ else:
777
+ typer.secho(" ✗ Não instalado", fg=typer.colors.RED)
778
+ return
779
+
780
+ typer.echo("\n Pods:")
781
+ for pod in status["pods"]:
782
+ color = typer.colors.GREEN if pod["phase"] == "Running" else typer.colors.YELLOW
783
+ typer.secho(f" • {pod['name']}: {pod['phase']}", fg=color)
784
+
785
+ if status["webhook_ready"]:
786
+ typer.secho(" ✓ Webhook: Operacional", fg=typer.colors.GREEN)
787
+ else:
788
+ typer.secho(" ✗ Webhook: Não está respondendo", fg=typer.colors.RED)
789
+
790
+ if status["crds_installed"]:
791
+ typer.secho(" ✓ CRDs: Instalados", fg=typer.colors.GREEN)
792
+ else:
793
+ typer.secho(" ✗ CRDs: Não encontrados", fg=typer.colors.RED)
794
+
795
+ if status["issuers"]:
796
+ typer.echo("\n ClusterIssuers:")
797
+ for issuer in status["issuers"]:
798
+ typer.echo(f" • {issuer}")
799
+ else:
800
+ typer.echo("\n ClusterIssuers: Nenhum configurado")
801
+
802
+ if status["certificates"]:
803
+ typer.echo("\n Certificates:")
804
+ for cert in status["certificates"]:
805
+ color = typer.colors.GREEN if cert["ready"] == "True" else typer.colors.YELLOW
806
+ typer.secho(f" • {cert['name']}: {cert['ready']}", fg=color)
807
+
808
+ typer.echo("─" * 50)
809
+
810
+
811
+ def _diagnose_problems(ctx: ExecutionContext) -> None:
812
+ """Diagnóstico detalhado de problemas comuns."""
813
+ typer.secho("\n🔍 Diagnóstico de Problemas", fg=typer.colors.YELLOW, bold=True)
814
+
815
+ if ctx.dry_run:
816
+ typer.echo("[dry-run] Diagnóstico ignorado")
817
+ return
818
+
819
+ problems = []
820
+
821
+ # Verifica conectividade do cluster
822
+ if not _check_cluster_available(ctx):
823
+ problems.append("Cluster Kubernetes não acessível")
824
+
825
+ # Verifica namespace
826
+ result = subprocess.run(
827
+ ["kubectl", "get", "namespace", NAMESPACE],
828
+ capture_output=True,
829
+ timeout=10,
830
+ )
831
+ if result.returncode != 0:
832
+ problems.append(f"Namespace '{NAMESPACE}' não existe")
833
+
834
+ # Verifica eventos recentes com erros
835
+ typer.echo("\n Eventos recentes:")
836
+ try:
837
+ result = subprocess.run(
838
+ [
839
+ "kubectl", "get", "events", "-n", NAMESPACE,
840
+ "--sort-by=.lastTimestamp",
841
+ "--field-selector=type!=Normal",
842
+ ],
843
+ capture_output=True,
844
+ text=True,
845
+ timeout=15,
846
+ )
847
+ if result.returncode == 0 and result.stdout.strip():
848
+ for line in result.stdout.strip().split("\n")[:10]:
849
+ typer.echo(f" {line[:120]}")
850
+ else:
851
+ typer.echo(" Nenhum evento de erro recente")
852
+ except Exception:
853
+ pass
854
+
855
+ # Verifica logs do webhook
856
+ typer.echo("\n Logs recentes do webhook:")
857
+ try:
858
+ result = subprocess.run(
859
+ [
860
+ "kubectl", "logs", "-n", NAMESPACE,
861
+ "-l", "app.kubernetes.io/component=webhook",
862
+ "--tail=15"
863
+ ],
864
+ capture_output=True,
865
+ text=True,
866
+ timeout=15,
867
+ )
868
+ if result.returncode == 0 and result.stdout.strip():
869
+ for line in result.stdout.strip().split("\n")[-10:]:
870
+ typer.echo(f" {line[:120]}")
871
+ else:
872
+ typer.echo(" Nenhum log disponível")
873
+ except Exception:
874
+ pass
875
+
876
+ # Verifica describe dos deployments
877
+ typer.echo("\n Status dos Deployments:")
878
+ for deploy in ["cert-manager", "cert-manager-webhook", "cert-manager-cainjector"]:
879
+ try:
880
+ result = subprocess.run(
881
+ [
882
+ "kubectl", "get", "deployment", deploy, "-n", NAMESPACE,
883
+ "-o", "jsonpath={.status.conditions[?(@.type=='Available')].status}"
884
+ ],
885
+ capture_output=True,
886
+ text=True,
887
+ timeout=10,
888
+ )
889
+ status = result.stdout.strip() if result.returncode == 0 else "Unknown"
890
+ color = typer.colors.GREEN if status == "True" else typer.colors.RED
891
+ typer.secho(f" {deploy}: Available={status}", fg=color)
892
+ except Exception:
893
+ typer.secho(f" {deploy}: Erro ao verificar", fg=typer.colors.RED)
894
+
895
+ if problems:
896
+ typer.secho("\n Problemas detectados:", fg=typer.colors.RED)
897
+ for p in problems:
898
+ typer.echo(f" ✗ {p}")
899
+ else:
900
+ typer.secho("\n Nenhum problema óbvio detectado", fg=typer.colors.GREEN)
901
+
902
+
903
+ # =============================================================================
904
+ # Entry Points
905
+ # =============================================================================
906
+
98
907
  def run(ctx: ExecutionContext) -> None:
908
+ """Execução principal do módulo cert-manager."""
99
909
  require_root(ctx)
100
910
  ensure_tool("helm", ctx, install_hint="Instale helm ou use --dry-run para simular.")
101
911
  ensure_tool("kubectl", ctx, install_hint="Instale kubectl ou use --dry-run para simular.")
102
912
 
103
- # Verifica se cluster esta disponivel antes de instalar
913
+ # Verifica cluster
104
914
  if not _check_cluster_available(ctx):
105
915
  typer.secho(
106
- "✗ Cluster Kubernetes nao esta acessivel. Execute o modulo 'kubernetes' primeiro.",
916
+ "✗ Cluster Kubernetes não acessível. Execute 'kubernetes' primeiro.",
107
917
  fg=typer.colors.RED,
108
918
  )
109
- typer.secho(
110
- " Verifique: kubectl cluster-info",
111
- fg=typer.colors.YELLOW,
112
- )
113
- ctx.errors.append("cert-manager: cluster nao acessivel")
919
+ ctx.errors.append("cert-manager: cluster não acessível")
114
920
  raise typer.Exit(code=1)
115
921
 
116
- typer.echo("Instalando cert-manager via Helm...")
117
- email = typer.prompt("Email para ACME (Let's Encrypt)", default="admin@example.com")
118
- solver = typer.prompt("Tipo de desafio (http01/dns01)", default="http01")
119
-
120
- helm_upgrade_install(
121
- release="cert-manager",
122
- chart=CHART_NAME,
123
- namespace=NAMESPACE,
124
- ctx=ctx,
125
- repo="jetstack",
126
- repo_url=CHART_REPO,
127
- create_namespace=True,
128
- extra_args=["--set", "installCRDs=true"],
129
- )
922
+ # Mostra status atual
923
+ status = _get_cert_manager_status(ctx)
924
+ _print_status(status)
925
+
926
+ # Verifica se já está instalado e funcionando
927
+ if status["installed"] and status["webhook_ready"]:
928
+ typer.echo("\nCert-manager já está instalado e operacional.")
929
+ if not typer.confirm("Deseja adicionar um novo ClusterIssuer?", default=True):
930
+ return
931
+ else:
932
+ # Instala cert-manager
933
+ if not _install_cert_manager_helm(ctx):
934
+ _diagnose_problems(ctx)
935
+ raise typer.Exit(code=1)
936
+
937
+ # Aguarda ficar totalmente operacional
938
+ if not ctx.dry_run:
939
+ if not _wait_for_webhook_ready(ctx):
940
+ typer.secho(
941
+ "\n✗ Cert-manager não ficou pronto no tempo esperado.",
942
+ fg=typer.colors.RED,
943
+ )
944
+ _diagnose_problems(ctx)
945
+ raise typer.Exit(code=1)
946
+
947
+ # Configura ClusterIssuer
948
+ config = _collect_issuer_config_interactive()
949
+ if config is None:
950
+ typer.secho("Configuração cancelada", fg=typer.colors.YELLOW)
951
+ return
952
+
953
+ # Constrói e aplica manifests
954
+ manifests = _build_issuer_manifests(config)
955
+
956
+ if _apply_manifest_with_retry(manifests, ctx, f"ClusterIssuer '{config.name}'"):
957
+ _print_next_steps(config)
958
+ typer.secho("\n✓ Cert-manager configurado com sucesso!", fg=typer.colors.GREEN, bold=True)
959
+ else:
960
+ _diagnose_problems(ctx)
961
+ ctx.errors.append("cert-manager: falha ao criar ClusterIssuer")
130
962
 
131
- issuer_docs = []
132
963
 
133
- if solver.lower() == "dns01":
134
- typer.secho("DNS-01 selecionado (Cloudflare)", fg=typer.colors.CYAN)
135
- issuer_name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-dns")
136
- staging = typer.confirm("Usar endpoint de staging? (para testes)", default=False)
137
- secret_name = typer.prompt("Secret com API token do Cloudflare", default="cloudflare-api-token")
138
- api_token = typer.prompt("Informe o API token do Cloudflare", hide_input=True)
964
+ def status(ctx: ExecutionContext) -> None:
965
+ """Exibe status detalhado do cert-manager."""
966
+ ensure_tool("kubectl", ctx)
967
+ status_data = _get_cert_manager_status(ctx)
968
+ _print_status(status_data)
969
+
970
+
971
+ def diagnose(ctx: ExecutionContext) -> None:
972
+ """Executa diagnóstico completo do cert-manager."""
973
+ ensure_tool("kubectl", ctx)
974
+ status_data = _get_cert_manager_status(ctx)
975
+ _print_status(status_data)
976
+ _diagnose_problems(ctx)
139
977
 
140
- issuer_docs.append(_build_cloudflare_secret(secret_name, api_token))
141
- issuer_docs.append(_build_cloudflare_dns01(issuer_name, email, secret_name, staging))
142
- else:
143
- typer.secho("HTTP-01 selecionado (Ingress)", fg=typer.colors.CYAN)
144
- issuer_name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-http")
145
- staging = typer.confirm("Usar endpoint de staging? (para testes)", default=False)
146
- ingress_class = typer.prompt("IngressClass para resolver HTTP-01", default="traefik")
147
978
 
148
- issuer_docs.append(_build_http01_issuer(issuer_name, email, ingress_class, staging))
979
+ def install_only(ctx: ExecutionContext) -> bool:
980
+ """Instala apenas o cert-manager sem configurar issuer (para uso em full_install)."""
981
+ require_root(ctx)
982
+ ensure_tool("helm", ctx)
983
+ ensure_tool("kubectl", ctx)
984
+
985
+ if not _check_cluster_available(ctx):
986
+ ctx.errors.append("cert-manager: cluster não acessível")
987
+ return False
988
+
989
+ # Verifica se já está instalado
990
+ status = _get_cert_manager_status(ctx)
991
+ if status["installed"] and status["webhook_ready"]:
992
+ typer.secho("✓ Cert-manager já está instalado e operacional", fg=typer.colors.GREEN)
993
+ return True
994
+
995
+ # Instala
996
+ if not _install_cert_manager_helm(ctx):
997
+ return False
998
+
999
+ # Aguarda
1000
+ if not ctx.dry_run:
1001
+ return _wait_for_webhook_ready(ctx)
1002
+
1003
+ return True
149
1004
 
150
- manifest = "---\n".join(issuer_docs)
151
- write_file(MANIFEST_PATH, manifest, ctx)
152
- kubectl_apply(str(MANIFEST_PATH), ctx)
153
1005
 
154
- typer.secho("cert-manager instalado e issuer aplicado.", fg=typer.colors.GREEN)
155
- typer.echo("Execute um Certificate/Ingress apontando para o ClusterIssuer para emitir certificados.")
1006
+ def create_issuer(
1007
+ ctx: ExecutionContext,
1008
+ name: str,
1009
+ email: str,
1010
+ challenge_type: str = "http01",
1011
+ staging: bool = True,
1012
+ ingress_class: str = "traefik",
1013
+ dns_provider: Optional[str] = None,
1014
+ secret_name: str = "dns-api-credentials",
1015
+ credentials: Optional[dict] = None,
1016
+ ) -> bool:
1017
+ """Cria um ClusterIssuer programaticamente (para uso em automação)."""
1018
+ ensure_tool("kubectl", ctx)
1019
+
1020
+ config = IssuerConfig(
1021
+ name=name,
1022
+ email=email,
1023
+ staging=staging,
1024
+ challenge_type=ChallengeType(challenge_type),
1025
+ ingress_class=ingress_class,
1026
+ dns_provider=DNSProvider(dns_provider) if dns_provider else None,
1027
+ secret_name=secret_name,
1028
+ )
1029
+
1030
+ if credentials:
1031
+ config._credentials = credentials
1032
+
1033
+ manifests = _build_issuer_manifests(config)
1034
+ return _apply_manifest_with_retry(manifests, ctx, f"ClusterIssuer '{name}'")
156
1035