raijin-server 0.2.2__py3-none-any.whl → 0.2.4__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,763 @@ 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
+ helm_upgrade_install(
441
+ release="cert-manager",
442
+ chart=CHART_NAME,
443
+ namespace=NAMESPACE,
444
+ ctx=ctx,
445
+ repo="jetstack",
446
+ repo_url=CHART_REPO,
447
+ create_namespace=True,
448
+ extra_args=[
449
+ "--set", "installCRDs=true",
450
+ "--set", "webhook.timeoutSeconds=30",
451
+ "--set", "startupapicheck.timeout=5m",
452
+ "--set", "startupapicheck.enabled=true",
453
+ # Aumenta recursos para ambientes mais lentos
454
+ "--set", "webhook.replicaCount=1",
455
+ "--set", "cainjector.replicaCount=1",
456
+ "--wait", # Espera o Helm considerar o release deployed
457
+ "--timeout", "10m",
458
+ ],
459
+ )
460
+ return True
461
+ except Exception as e:
462
+ typer.secho(f"✗ Erro na instalação do Helm: {e}", fg=typer.colors.RED)
463
+ ctx.errors.append(f"cert-manager: falha na instalação Helm - {e}")
464
+ return False
465
+
466
+
467
+ def _apply_manifest_with_retry(
468
+ manifest: str,
469
+ ctx: ExecutionContext,
470
+ description: str = "manifest",
471
+ max_retries: int = 10,
472
+ retry_interval: int = 30,
473
+ ) -> bool:
474
+ """Aplica um manifest com retry inteligente."""
475
+ if ctx.dry_run:
476
+ typer.echo(f"[dry-run] Aplicando {description}")
477
+ return True
478
+
479
+ manifest_path = MANIFEST_PATH
480
+ manifest_path.write_text(manifest)
481
+
482
+ for attempt in range(1, max_retries + 1):
483
+ typer.echo(f"\n Aplicando {description} (tentativa {attempt}/{max_retries})...")
484
+
485
+ try:
486
+ result = subprocess.run(
487
+ ["kubectl", "apply", "-f", str(manifest_path)],
488
+ capture_output=True,
489
+ text=True,
490
+ timeout=60,
491
+ )
492
+
493
+ if result.returncode == 0:
494
+ typer.secho(f" ✓ {description} aplicado com sucesso!", fg=typer.colors.GREEN)
495
+ return True
496
+
497
+ stderr = result.stderr.lower()
498
+
499
+ # Verifica se é erro de webhook
500
+ if "connection refused" in stderr or "failed to call webhook" in stderr:
501
+ typer.secho(
502
+ f" ⚠ Webhook ainda não está pronto...",
503
+ fg=typer.colors.YELLOW,
504
+ )
505
+ if attempt < max_retries:
506
+ typer.echo(f" Aguardando {retry_interval}s antes de tentar novamente...")
507
+ time.sleep(retry_interval)
508
+ continue
509
+
510
+ # Outro tipo de erro
511
+ typer.secho(f" ✗ Erro: {result.stderr[:200]}", fg=typer.colors.RED)
512
+ if attempt < max_retries:
513
+ time.sleep(retry_interval)
514
+
515
+ except subprocess.TimeoutExpired:
516
+ typer.secho(" ⚠ Timeout ao aplicar manifest", fg=typer.colors.YELLOW)
517
+ if attempt < max_retries:
518
+ time.sleep(retry_interval)
519
+ except Exception as e:
520
+ typer.secho(f" ✗ Erro inesperado: {e}", fg=typer.colors.RED)
521
+ if attempt < max_retries:
522
+ time.sleep(retry_interval)
523
+
524
+ typer.secho(f"✗ Falha ao aplicar {description} após {max_retries} tentativas", fg=typer.colors.RED)
525
+ return False
526
+
527
+
528
+ def _build_issuer_manifests(config: IssuerConfig) -> str:
529
+ """Constrói todos os manifests necessários para o issuer."""
530
+ manifests = []
531
+
532
+ # Secret (se necessário)
533
+ secret = _build_secret(config)
534
+ if secret:
535
+ manifests.append(secret)
536
+
537
+ # ClusterIssuer
538
+ if config.challenge_type == ChallengeType.HTTP01:
539
+ manifests.append(_build_http01_issuer(config))
540
+ elif config.dns_provider == DNSProvider.CLOUDFLARE:
541
+ manifests.append(_build_cloudflare_issuer(config))
542
+ elif config.dns_provider == DNSProvider.ROUTE53:
543
+ manifests.append(_build_route53_issuer(config))
544
+ elif config.dns_provider == DNSProvider.DIGITALOCEAN:
545
+ manifests.append(_build_digitalocean_issuer(config))
546
+ elif config.dns_provider == DNSProvider.AZURE:
547
+ manifests.append(_build_azure_issuer(config))
548
+
549
+ return "---\n".join(manifests)
550
+
551
+
552
+ # =============================================================================
553
+ # Interface Interativa
554
+ # =============================================================================
555
+
556
+ def _collect_issuer_config_interactive() -> Optional[IssuerConfig]:
557
+ """Coleta configuração do issuer interativamente."""
558
+ typer.secho("\n🔧 Configuração do ClusterIssuer", fg=typer.colors.CYAN, bold=True)
559
+
560
+ email = typer.prompt(
561
+ "Email para ACME (Let's Encrypt)",
562
+ default="admin@example.com",
563
+ )
564
+
565
+ if "@" not in email or "example.com" in email:
566
+ typer.secho(
567
+ "⚠ Use um email válido para receber notificações de expiração",
568
+ fg=typer.colors.YELLOW,
569
+ )
570
+ if not typer.confirm("Continuar mesmo assim?", default=False):
571
+ return None
572
+
573
+ solver = typer.prompt(
574
+ "Tipo de challenge (http01/dns01)",
575
+ default="http01",
576
+ ).lower()
577
+
578
+ staging = typer.confirm(
579
+ "Usar servidor de staging? (para testes)",
580
+ default=True,
581
+ )
582
+
583
+ if staging:
584
+ typer.secho(
585
+ "ℹ Staging gera certificados de teste (não válidos para produção)",
586
+ fg=typer.colors.CYAN,
587
+ )
588
+
589
+ config = IssuerConfig(name="", email=email, staging=staging)
590
+
591
+ if solver == "dns01":
592
+ config.challenge_type = ChallengeType.DNS01
593
+
594
+ typer.echo("\nProvedores DNS disponíveis:")
595
+ typer.echo(" 1. Cloudflare")
596
+ typer.echo(" 2. AWS Route53")
597
+ typer.echo(" 3. DigitalOcean")
598
+ typer.echo(" 4. Azure DNS")
599
+
600
+ choice = typer.prompt("Escolha (1-4)", default="1")
601
+
602
+ if choice == "1":
603
+ config.dns_provider = DNSProvider.CLOUDFLARE
604
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-cloudflare")
605
+ config.secret_name = typer.prompt("Nome do Secret", default="cloudflare-api-token")
606
+ config._credentials["api_token"] = typer.prompt("API Token do Cloudflare", hide_input=True)
607
+
608
+ elif choice == "2":
609
+ config.dns_provider = DNSProvider.ROUTE53
610
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-route53")
611
+ config.secret_name = typer.prompt("Nome do Secret", default="route53-credentials")
612
+ config.region = typer.prompt("AWS Region", default="us-east-1")
613
+ config.hosted_zone_id = typer.prompt("Hosted Zone ID (opcional)", default="")
614
+ config._credentials["access_key"] = typer.prompt("AWS Access Key ID", hide_input=True)
615
+ config._credentials["secret_key"] = typer.prompt("AWS Secret Access Key", hide_input=True)
616
+
617
+ elif choice == "3":
618
+ config.dns_provider = DNSProvider.DIGITALOCEAN
619
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-digitalocean")
620
+ config.secret_name = typer.prompt("Nome do Secret", default="digitalocean-token")
621
+ config._credentials["token"] = typer.prompt("DigitalOcean API Token", hide_input=True)
622
+
623
+ elif choice == "4":
624
+ config.dns_provider = DNSProvider.AZURE
625
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-azure")
626
+ config.secret_name = typer.prompt("Nome do Secret", default="azure-dns-credentials")
627
+ config.subscription_id = typer.prompt("Azure Subscription ID")
628
+ config.resource_group = typer.prompt("Resource Group")
629
+ config.tenant_id = typer.prompt("Tenant ID")
630
+ config.client_id = typer.prompt("Client ID (App Registration)")
631
+ config._credentials["client_secret"] = typer.prompt("Client Secret", hide_input=True)
632
+ else:
633
+ typer.secho("Opção inválida", fg=typer.colors.RED)
634
+ return None
635
+ else:
636
+ config.challenge_type = ChallengeType.HTTP01
637
+ config.name = typer.prompt("Nome do ClusterIssuer", default="letsencrypt-http")
638
+ config.ingress_class = typer.prompt("IngressClass", default="traefik")
639
+
640
+ return config
641
+
642
+
643
+ def _print_next_steps(config: IssuerConfig) -> None:
644
+ """Exibe próximos passos após configuração."""
645
+ typer.secho("\n📚 Próximos Passos", fg=typer.colors.CYAN, bold=True)
646
+ typer.echo("─" * 50)
647
+
648
+ typer.echo(f"""
649
+ Para usar certificados TLS em seus Ingresses:
650
+
651
+ 1. Adicione a annotation no Ingress:
652
+ annotations:
653
+ cert-manager.io/cluster-issuer: "{config.name}"
654
+
655
+ 2. Configure o TLS no Ingress:
656
+ tls:
657
+ - hosts:
658
+ - seu-dominio.com
659
+ secretName: seu-dominio-tls
660
+
661
+ 3. Verifique o status do certificado:
662
+ kubectl get certificate -A
663
+ kubectl describe certificate <nome> -n <namespace>
664
+
665
+ 4. Para debug de problemas:
666
+ kubectl describe clusterissuer {config.name}
667
+ kubectl get challenges -A
668
+ kubectl get orders -A
669
+ """)
670
+
671
+ if config.staging:
672
+ typer.secho(
673
+ "⚠ Você está usando o servidor STAGING!\n"
674
+ " Os certificados NÃO são válidos para produção.\n"
675
+ " Após testar, recrie o issuer sem staging.",
676
+ fg=typer.colors.YELLOW,
677
+ )
678
+
679
+ typer.echo("─" * 50)
680
+
681
+
682
+ # =============================================================================
683
+ # Status e Diagnóstico
684
+ # =============================================================================
685
+
686
+ def _get_cert_manager_status(ctx: ExecutionContext) -> dict:
687
+ """Obtém status detalhado do cert-manager."""
688
+ status = {
689
+ "installed": False,
690
+ "version": "",
691
+ "pods": [],
692
+ "webhook_ready": False,
693
+ "crds_installed": False,
694
+ "issuers": [],
695
+ "certificates": [],
696
+ }
697
+
698
+ if ctx.dry_run:
699
+ return status
700
+
701
+ try:
702
+ # Verifica versão via Helm
703
+ result = subprocess.run(
704
+ ["helm", "list", "-n", NAMESPACE, "-o", "json"],
705
+ capture_output=True,
706
+ text=True,
707
+ timeout=15,
708
+ )
709
+ if result.returncode == 0:
710
+ releases = json.loads(result.stdout)
711
+ for r in releases:
712
+ if r.get("name") == "cert-manager":
713
+ status["installed"] = True
714
+ status["version"] = r.get("app_version", "")
715
+ break
716
+
717
+ # Verifica pods
718
+ result = subprocess.run(
719
+ [
720
+ "kubectl", "get", "pods", "-n", NAMESPACE,
721
+ "-o", "jsonpath={range .items[*]}{.metadata.name}:{.status.phase}\\n{end}"
722
+ ],
723
+ capture_output=True,
724
+ text=True,
725
+ timeout=10,
726
+ )
727
+ if result.returncode == 0:
728
+ for line in result.stdout.strip().split("\n"):
729
+ if ":" in line:
730
+ name, phase = line.split(":", 1)
731
+ status["pods"].append({"name": name, "phase": phase})
732
+
733
+ status["webhook_ready"] = _test_webhook_connectivity()
734
+ status["crds_installed"] = _check_crds_installed()
735
+
736
+ # Lista ClusterIssuers
737
+ result = subprocess.run(
738
+ ["kubectl", "get", "clusterissuers", "-o", "jsonpath={.items[*].metadata.name}"],
739
+ capture_output=True,
740
+ text=True,
741
+ timeout=10,
742
+ )
743
+ if result.returncode == 0 and result.stdout.strip():
744
+ status["issuers"] = result.stdout.strip().split()
745
+
746
+ # Lista Certificates
747
+ result = subprocess.run(
748
+ [
749
+ "kubectl", "get", "certificates", "-A",
750
+ "-o", "jsonpath={range .items[*]}{.metadata.namespace}/{.metadata.name}:{.status.conditions[0].status}\\n{end}"
751
+ ],
752
+ capture_output=True,
753
+ text=True,
754
+ timeout=10,
755
+ )
756
+ if result.returncode == 0 and result.stdout.strip():
757
+ for line in result.stdout.strip().split("\n"):
758
+ if ":" in line:
759
+ name, ready = line.split(":", 1)
760
+ status["certificates"].append({"name": name, "ready": ready})
761
+
762
+ except Exception as e:
763
+ logger.error(f"Erro ao obter status do cert-manager: {e}")
764
+
765
+ return status
766
+
767
+
768
+ def _print_status(status: dict) -> None:
769
+ """Exibe status formatado do cert-manager."""
770
+ typer.secho("\n📊 Status do Cert-Manager", fg=typer.colors.CYAN, bold=True)
771
+ typer.echo("─" * 50)
772
+
773
+ if status["installed"]:
774
+ typer.secho(f" ✓ Instalado: versão {status['version']}", fg=typer.colors.GREEN)
775
+ else:
776
+ typer.secho(" ✗ Não instalado", fg=typer.colors.RED)
777
+ return
778
+
779
+ typer.echo("\n Pods:")
780
+ for pod in status["pods"]:
781
+ color = typer.colors.GREEN if pod["phase"] == "Running" else typer.colors.YELLOW
782
+ typer.secho(f" • {pod['name']}: {pod['phase']}", fg=color)
783
+
784
+ if status["webhook_ready"]:
785
+ typer.secho(" ✓ Webhook: Operacional", fg=typer.colors.GREEN)
786
+ else:
787
+ typer.secho(" ✗ Webhook: Não está respondendo", fg=typer.colors.RED)
788
+
789
+ if status["crds_installed"]:
790
+ typer.secho(" ✓ CRDs: Instalados", fg=typer.colors.GREEN)
791
+ else:
792
+ typer.secho(" ✗ CRDs: Não encontrados", fg=typer.colors.RED)
793
+
794
+ if status["issuers"]:
795
+ typer.echo("\n ClusterIssuers:")
796
+ for issuer in status["issuers"]:
797
+ typer.echo(f" • {issuer}")
798
+ else:
799
+ typer.echo("\n ClusterIssuers: Nenhum configurado")
800
+
801
+ if status["certificates"]:
802
+ typer.echo("\n Certificates:")
803
+ for cert in status["certificates"]:
804
+ color = typer.colors.GREEN if cert["ready"] == "True" else typer.colors.YELLOW
805
+ typer.secho(f" • {cert['name']}: {cert['ready']}", fg=color)
806
+
807
+ typer.echo("─" * 50)
808
+
809
+
810
+ def _diagnose_problems(ctx: ExecutionContext) -> None:
811
+ """Diagnóstico detalhado de problemas comuns."""
812
+ typer.secho("\n🔍 Diagnóstico de Problemas", fg=typer.colors.YELLOW, bold=True)
813
+
814
+ if ctx.dry_run:
815
+ typer.echo("[dry-run] Diagnóstico ignorado")
816
+ return
817
+
818
+ problems = []
819
+
820
+ # Verifica conectividade do cluster
821
+ if not _check_cluster_available(ctx):
822
+ problems.append("Cluster Kubernetes não acessível")
823
+
824
+ # Verifica namespace
825
+ result = subprocess.run(
826
+ ["kubectl", "get", "namespace", NAMESPACE],
827
+ capture_output=True,
828
+ timeout=10,
829
+ )
830
+ if result.returncode != 0:
831
+ problems.append(f"Namespace '{NAMESPACE}' não existe")
832
+
833
+ # Verifica eventos recentes com erros
834
+ typer.echo("\n Eventos recentes:")
835
+ try:
836
+ result = subprocess.run(
837
+ [
838
+ "kubectl", "get", "events", "-n", NAMESPACE,
839
+ "--sort-by=.lastTimestamp",
840
+ "--field-selector=type!=Normal",
841
+ ],
842
+ capture_output=True,
843
+ text=True,
844
+ timeout=15,
845
+ )
846
+ if result.returncode == 0 and result.stdout.strip():
847
+ for line in result.stdout.strip().split("\n")[:10]:
848
+ typer.echo(f" {line[:120]}")
849
+ else:
850
+ typer.echo(" Nenhum evento de erro recente")
851
+ except Exception:
852
+ pass
853
+
854
+ # Verifica logs do webhook
855
+ typer.echo("\n Logs recentes do webhook:")
856
+ try:
857
+ result = subprocess.run(
858
+ [
859
+ "kubectl", "logs", "-n", NAMESPACE,
860
+ "-l", "app.kubernetes.io/component=webhook",
861
+ "--tail=15"
862
+ ],
863
+ capture_output=True,
864
+ text=True,
865
+ timeout=15,
866
+ )
867
+ if result.returncode == 0 and result.stdout.strip():
868
+ for line in result.stdout.strip().split("\n")[-10:]:
869
+ typer.echo(f" {line[:120]}")
870
+ else:
871
+ typer.echo(" Nenhum log disponível")
872
+ except Exception:
873
+ pass
874
+
875
+ # Verifica describe dos deployments
876
+ typer.echo("\n Status dos Deployments:")
877
+ for deploy in ["cert-manager", "cert-manager-webhook", "cert-manager-cainjector"]:
878
+ try:
879
+ result = subprocess.run(
880
+ [
881
+ "kubectl", "get", "deployment", deploy, "-n", NAMESPACE,
882
+ "-o", "jsonpath={.status.conditions[?(@.type=='Available')].status}"
883
+ ],
884
+ capture_output=True,
885
+ text=True,
886
+ timeout=10,
887
+ )
888
+ status = result.stdout.strip() if result.returncode == 0 else "Unknown"
889
+ color = typer.colors.GREEN if status == "True" else typer.colors.RED
890
+ typer.secho(f" {deploy}: Available={status}", fg=color)
891
+ except Exception:
892
+ typer.secho(f" {deploy}: Erro ao verificar", fg=typer.colors.RED)
893
+
894
+ if problems:
895
+ typer.secho("\n Problemas detectados:", fg=typer.colors.RED)
896
+ for p in problems:
897
+ typer.echo(f" ✗ {p}")
898
+ else:
899
+ typer.secho("\n Nenhum problema óbvio detectado", fg=typer.colors.GREEN)
900
+
901
+
902
+ # =============================================================================
903
+ # Entry Points
904
+ # =============================================================================
905
+
98
906
  def run(ctx: ExecutionContext) -> None:
907
+ """Execução principal do módulo cert-manager."""
99
908
  require_root(ctx)
100
909
  ensure_tool("helm", ctx, install_hint="Instale helm ou use --dry-run para simular.")
101
910
  ensure_tool("kubectl", ctx, install_hint="Instale kubectl ou use --dry-run para simular.")
102
911
 
103
- # Verifica se cluster esta disponivel antes de instalar
912
+ # Verifica cluster
104
913
  if not _check_cluster_available(ctx):
105
914
  typer.secho(
106
- "✗ Cluster Kubernetes nao esta acessivel. Execute o modulo 'kubernetes' primeiro.",
915
+ "✗ Cluster Kubernetes não acessível. Execute 'kubernetes' primeiro.",
107
916
  fg=typer.colors.RED,
108
917
  )
109
- typer.secho(
110
- " Verifique: kubectl cluster-info",
111
- fg=typer.colors.YELLOW,
112
- )
113
- ctx.errors.append("cert-manager: cluster nao acessivel")
918
+ ctx.errors.append("cert-manager: cluster não acessível")
114
919
  raise typer.Exit(code=1)
115
920
 
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
- )
921
+ # Mostra status atual
922
+ status = _get_cert_manager_status(ctx)
923
+ _print_status(status)
924
+
925
+ # Verifica se já está instalado e funcionando
926
+ if status["installed"] and status["webhook_ready"]:
927
+ typer.echo("\nCert-manager já está instalado e operacional.")
928
+ if not typer.confirm("Deseja adicionar um novo ClusterIssuer?", default=True):
929
+ return
930
+ else:
931
+ # Instala cert-manager
932
+ if not _install_cert_manager_helm(ctx):
933
+ _diagnose_problems(ctx)
934
+ raise typer.Exit(code=1)
935
+
936
+ # Aguarda ficar totalmente operacional
937
+ if not ctx.dry_run:
938
+ if not _wait_for_webhook_ready(ctx):
939
+ typer.secho(
940
+ "\n✗ Cert-manager não ficou pronto no tempo esperado.",
941
+ fg=typer.colors.RED,
942
+ )
943
+ _diagnose_problems(ctx)
944
+ raise typer.Exit(code=1)
945
+
946
+ # Configura ClusterIssuer
947
+ config = _collect_issuer_config_interactive()
948
+ if config is None:
949
+ typer.secho("Configuração cancelada", fg=typer.colors.YELLOW)
950
+ return
951
+
952
+ # Constrói e aplica manifests
953
+ manifests = _build_issuer_manifests(config)
954
+
955
+ if _apply_manifest_with_retry(manifests, ctx, f"ClusterIssuer '{config.name}'"):
956
+ _print_next_steps(config)
957
+ typer.secho("\n✓ Cert-manager configurado com sucesso!", fg=typer.colors.GREEN, bold=True)
958
+ else:
959
+ _diagnose_problems(ctx)
960
+ ctx.errors.append("cert-manager: falha ao criar ClusterIssuer")
130
961
 
131
- issuer_docs = []
132
962
 
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)
963
+ def status(ctx: ExecutionContext) -> None:
964
+ """Exibe status detalhado do cert-manager."""
965
+ ensure_tool("kubectl", ctx)
966
+ status_data = _get_cert_manager_status(ctx)
967
+ _print_status(status_data)
968
+
969
+
970
+ def diagnose(ctx: ExecutionContext) -> None:
971
+ """Executa diagnóstico completo do cert-manager."""
972
+ ensure_tool("kubectl", ctx)
973
+ status_data = _get_cert_manager_status(ctx)
974
+ _print_status(status_data)
975
+ _diagnose_problems(ctx)
139
976
 
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
977
 
148
- issuer_docs.append(_build_http01_issuer(issuer_name, email, ingress_class, staging))
978
+ def install_only(ctx: ExecutionContext) -> bool:
979
+ """Instala apenas o cert-manager sem configurar issuer (para uso em full_install)."""
980
+ require_root(ctx)
981
+ ensure_tool("helm", ctx)
982
+ ensure_tool("kubectl", ctx)
983
+
984
+ if not _check_cluster_available(ctx):
985
+ ctx.errors.append("cert-manager: cluster não acessível")
986
+ return False
987
+
988
+ # Verifica se já está instalado
989
+ status = _get_cert_manager_status(ctx)
990
+ if status["installed"] and status["webhook_ready"]:
991
+ typer.secho("✓ Cert-manager já está instalado e operacional", fg=typer.colors.GREEN)
992
+ return True
993
+
994
+ # Instala
995
+ if not _install_cert_manager_helm(ctx):
996
+ return False
997
+
998
+ # Aguarda
999
+ if not ctx.dry_run:
1000
+ return _wait_for_webhook_ready(ctx)
1001
+
1002
+ return True
149
1003
 
150
- manifest = "---\n".join(issuer_docs)
151
- write_file(MANIFEST_PATH, manifest, ctx)
152
- kubectl_apply(str(MANIFEST_PATH), ctx)
153
1004
 
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.")
1005
+ def create_issuer(
1006
+ ctx: ExecutionContext,
1007
+ name: str,
1008
+ email: str,
1009
+ challenge_type: str = "http01",
1010
+ staging: bool = True,
1011
+ ingress_class: str = "traefik",
1012
+ dns_provider: Optional[str] = None,
1013
+ secret_name: str = "dns-api-credentials",
1014
+ credentials: Optional[dict] = None,
1015
+ ) -> bool:
1016
+ """Cria um ClusterIssuer programaticamente (para uso em automação)."""
1017
+ ensure_tool("kubectl", ctx)
1018
+
1019
+ config = IssuerConfig(
1020
+ name=name,
1021
+ email=email,
1022
+ staging=staging,
1023
+ challenge_type=ChallengeType(challenge_type),
1024
+ ingress_class=ingress_class,
1025
+ dns_provider=DNSProvider(dns_provider) if dns_provider else None,
1026
+ secret_name=secret_name,
1027
+ )
1028
+
1029
+ if credentials:
1030
+ config._credentials = credentials
1031
+
1032
+ manifests = _build_issuer_manifests(config)
1033
+ return _apply_manifest_with_retry(manifests, ctx, f"ClusterIssuer '{name}'")
156
1034