tinybird 0.0.1.dev102__py3-none-any.whl → 0.0.1.dev103__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 tinybird might be problematic. Click here for more details.

@@ -3,28 +3,26 @@ import subprocess
3
3
  import time
4
4
  import uuid
5
5
  from pathlib import Path
6
- from typing import Optional
6
+ from typing import Any, Dict, List, Optional
7
7
 
8
8
  import click
9
- import pyperclip
10
9
  import requests
11
10
  from click import Context
12
11
 
13
12
  from tinybird.client import TinyB
14
- from tinybird.syncasync import async_to_sync
15
13
  from tinybird.tb.modules.cli import CLIException, cli
16
14
  from tinybird.tb.modules.common import coro, echo_safe_humanfriendly_tables_format_smart_table
17
15
  from tinybird.tb.modules.config import CLIConfig
18
16
  from tinybird.tb.modules.feedback_manager import FeedbackManager
19
17
 
20
- from .common import CONTEXT_SETTINGS
18
+ from .common import CONTEXT_SETTINGS, ask_for_organization, get_organizations_by_user
21
19
 
22
20
  K8S_YML = """
23
21
  ---
24
22
  apiVersion: v1
25
23
  kind: Namespace
26
24
  metadata:
27
- name: %(namespace)s
25
+ name: %(kubernetes_namespace)s
28
26
  labels:
29
27
  name: tinybird
30
28
  ---
@@ -32,7 +30,7 @@ apiVersion: v1
32
30
  kind: ServiceAccount
33
31
  metadata:
34
32
  name: tinybird
35
- namespace: %(namespace)s
33
+ namespace: %(kubernetes_namespace)s
36
34
  labels:
37
35
  name: tinybird
38
36
  automountServiceAccountToken: true
@@ -41,7 +39,7 @@ apiVersion: v1
41
39
  kind: Service
42
40
  metadata:
43
41
  name: tinybird
44
- namespace: %(namespace)s
42
+ namespace: %(kubernetes_namespace)s
45
43
  labels:
46
44
  name: tinybird
47
45
  spec:
@@ -57,15 +55,16 @@ spec:
57
55
  apiVersion: networking.k8s.io/v1
58
56
  kind: Ingress
59
57
  metadata:
60
- namespace: %(namespace)s
58
+ namespace: %(kubernetes_namespace)s
61
59
  name: tinybird
62
60
  annotations:
61
+ external-dns.alpha.kubernetes.io/aws-evaluate-target-health: "false"
63
62
  alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
64
63
  alb.ingress.kubernetes.io/scheme: internet-facing
65
64
  alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
66
65
  alb.ingress.kubernetes.io/ssl-redirect: '443'
67
66
  alb.ingress.kubernetes.io/target-type: 'ip'
68
- alb.ingress.kubernetes.io/load-balancer-name: %(namespace)s
67
+ alb.ingress.kubernetes.io/load-balancer-name: %(kubernetes_namespace)s
69
68
  alb.ingress.kubernetes.io/success-codes: '200,301,302'
70
69
  spec:
71
70
  ingressClassName: alb
@@ -87,7 +86,7 @@ apiVersion: apps/v1
87
86
  kind: StatefulSet
88
87
  metadata:
89
88
  name: tinybird
90
- namespace: %(namespace)s
89
+ namespace: %(kubernetes_namespace)s
91
90
  spec:
92
91
  serviceName: "tinybird"
93
92
  replicas: 1
@@ -120,6 +119,8 @@ spec:
120
119
  volumeMounts:
121
120
  - name: clickhouse-data
122
121
  mountPath: /var/lib/clickhouse
122
+ - name: redis-data
123
+ mountPath: /redis-data
123
124
  volumeClaimTemplates:
124
125
  - metadata:
125
126
  name: clickhouse-data
@@ -127,11 +128,17 @@ spec:
127
128
  accessModes: [ "ReadWriteOnce" ]
128
129
  resources:
129
130
  requests:
130
- storage: 100Gi
131
- storageClassName: %(storage_class)s
131
+ storage: 100Gi%(storage_class_line)s
132
+ - metadata:
133
+ name: redis-data
134
+ spec:
135
+ accessModes: [ "ReadWriteOnce" ]
136
+ resources:
137
+ requests:
138
+ storage: 50Gi%(storage_class_line)s
132
139
  """
133
140
 
134
- TERRAFORM_FIRST_TEMPLATE = """
141
+ TERRAFORM_TEMPLATE = """
135
142
  terraform {
136
143
  required_providers {
137
144
  aws = {
@@ -189,7 +196,7 @@ resource "aws_acm_certificate_validation" "cert" {
189
196
  TERRAFORM_SECOND_TEMPLATE = """
190
197
  # Create Route 53 record for the load balancer
191
198
  data "aws_alb" "tinybird" {
192
- name = "%(namespace)s"
199
+ name = "%(kubernetes_namespace)s"
193
200
  }
194
201
 
195
202
  resource "aws_route53_record" "tinybird" {
@@ -210,50 +217,173 @@ output "tinybird_dns" {
210
217
  """
211
218
 
212
219
 
220
+ class Infrastructure:
221
+ """Class to manage Tinybird infrastructure operations."""
222
+
223
+ def __init__(self, client: TinyB):
224
+ self.client = client
225
+
226
+ async def get_organizations_info(self, config: CLIConfig):
227
+ self.orgs = await get_organizations_by_user(config)
228
+
229
+ async def create_infra(
230
+ self, name: str, host: str, organization_id: str
231
+ ) -> Dict[str, Any]:
232
+ """Create a new infrastructure."""
233
+ infra = await self.client.infra_create(
234
+ organization_id=organization_id, name=name, host=host
235
+ )
236
+ return infra
237
+
238
+ async def list_infras(self) -> None:
239
+ """List all self-managed regions for the admin organization."""
240
+
241
+ infras = await self.get_infra_list()
242
+ columns = [
243
+ "name",
244
+ "host",
245
+ "organization",
246
+ ]
247
+ table_human_readable = []
248
+ table_machine_readable = []
249
+
250
+ for infra in infras:
251
+ name = infra["name"]
252
+ host = infra["host"]
253
+ organization = infra["organization"]
254
+
255
+ table_human_readable.append((name, host, organization))
256
+ table_machine_readable.append(
257
+ {"name": name, "host": host, "organization": organization}
258
+ )
259
+
260
+ click.echo(FeedbackManager.info(message="\n** Infras:"))
261
+ echo_safe_humanfriendly_tables_format_smart_table(table_human_readable, column_names=columns)
262
+ click.echo("\n")
263
+
264
+ async def remove_infra(self, name: str):
265
+ try:
266
+ click.echo(FeedbackManager.highlight(message=f"» Deleting infrastructure '{name}' from Tinybird..."))
267
+ infras = await self.get_infra_list()
268
+ infra = next((infra for infra in infras if infra["name"] == name), None)
269
+ if not infra:
270
+ raise CLIException(f"Infrastructure '{name}' not found")
271
+ await self.client.infra_delete(infra["id"], infra["organization_id"])
272
+ click.echo(
273
+ FeedbackManager.success(message=f"\n✓ Infrastructure '{name}' deleted")
274
+ )
275
+ except Exception as e:
276
+ click.echo(FeedbackManager.error(message=f"✗ Error: {e}"))
277
+
278
+ async def update(self, infra_name: str, name: str, host: str):
279
+ infras_list = await self.get_infra_list()
280
+ infra = next(
281
+ (infra for infra in infras_list if infra["name"] == infra_name), None
282
+ )
283
+ if not infra:
284
+ return None
285
+ await self.client.infra_update(
286
+ infra_id=infra["id"],
287
+ organization_id=infra["organization_id"],
288
+ name=name,
289
+ host=host,
290
+ )
291
+ return infra
292
+
293
+ async def get_infra_list(self) -> List[Dict[str, str]]:
294
+ """Get a list of all infrastructures across organizations.
295
+
296
+ Returns:
297
+ List[Dict[str, Any]]: List of infrastructure objects, each containing:
298
+ - id: str
299
+ - name: str
300
+ - host: str
301
+ - organization: str
302
+ """
303
+ try:
304
+ all_infras = []
305
+ for org in self.orgs:
306
+ org_id = org.get("id") or ""
307
+ org_name = org.get("name")
308
+ try:
309
+ infras = await self.client.infra_list(organization_id=org_id)
310
+ for infra in infras:
311
+ infra["organization"] = org_name
312
+ all_infras.append(infra)
313
+ except Exception as e:
314
+ click.echo(
315
+ FeedbackManager.warning(
316
+ message=f"Could not fetch infras for organization {org_name}: {str(e)}"
317
+ )
318
+ )
319
+ continue
320
+ return all_infras
321
+ except Exception as e:
322
+ click.echo(
323
+ FeedbackManager.error(
324
+ message=f"Failed to list infrastructures: {str(e)}"
325
+ )
326
+ )
327
+ return []
328
+
329
+
213
330
  @cli.group(context_settings=CONTEXT_SETTINGS, hidden=True)
214
331
  @click.pass_context
215
- def infra(ctx: Context) -> None:
332
+ @coro
333
+ async def infra(ctx: Context) -> None:
216
334
  """Infra commands."""
335
+ client: TinyB = ctx.ensure_object(dict)["client"]
336
+ config = CLIConfig.get_project_config()
337
+ infra = Infrastructure(client)
338
+ await infra.get_organizations_info(config)
339
+ ctx.ensure_object(dict)["infra"] = infra
217
340
 
218
341
 
219
342
  @infra.command(name="init")
220
- @click.option("--name", type=str, help="Name for identifying the self-managed infrastructure in Tinybird")
221
- @click.option("--provider", type=str, help="Infrastructure provider. Possible values are: aws, gcp, azure)")
222
- @click.option("--region", type=str, help="AWS region, when using aws as the provider")
343
+ @click.option("--name", type=str, help="Name for identifying the self-managed region in Tinybird")
344
+ @click.option("--cloud-provider", type=str, help="Infrastructure provider. Possible values are: aws, gcp, azure)")
345
+ @click.option("--cloud-region", type=str, help="AWS region, when using aws as the provider")
223
346
  @click.option("--dns-zone-name", type=str, help="DNS zone name")
224
- @click.option("--namespace", type=str, help="Kubernetes namespace for the deployment")
225
- @click.option("--dns-record", type=str, help="DNS record name to create, without domain. For example, 'tinybird')")
226
- @click.option("--storage-class", type=str, help="Storage class for the k8s StatefulSet")
347
+ @click.option("--dns-record", type=str, help="DNS record name to create, without domain. For example, 'tinybird'")
348
+ @click.option("--kubernetes-namespace", type=str, help="Kubernetes namespace for the deployment")
349
+ @click.option("--kubernetes-storage-class", type=str, help="Storage class for the k8s StatefulSet")
227
350
  @click.option(
228
351
  "--auto-apply", is_flag=True, help="Automatically apply Terraform and kubectl configuration without prompting"
229
352
  )
230
353
  @click.option("--skip-apply", is_flag=True, help="Skip Terraform and kubectl configuration and application")
354
+ @click.option("--organization-id", type=str, help="Organization ID for the self-managed region")
231
355
  @click.pass_context
232
- def infra_init(
356
+ @coro
357
+ async def infra_init(
233
358
  ctx: Context,
234
359
  name: str,
235
- provider: str,
236
- region: Optional[str] = None,
360
+ cloud_provider: str,
361
+ cloud_region: Optional[str] = None,
237
362
  dns_zone_name: Optional[str] = None,
238
- namespace: Optional[str] = None,
363
+ kubernetes_namespace: Optional[str] = None,
239
364
  dns_record: Optional[str] = None,
240
- storage_class: Optional[str] = None,
365
+ kubernetes_storage_class: Optional[str] = None,
241
366
  auto_apply: bool = False,
242
367
  skip_apply: bool = False,
368
+ organization_id: Optional[str] = None,
243
369
  ) -> None:
244
370
  """Init infra"""
245
371
  # Check if provider is specified
246
- if not provider:
247
- click.echo("Error: --provider option is required. Specify a provider. Possible values are: aws, gcp, azure.")
372
+ if not cloud_provider:
373
+ click.echo(
374
+ FeedbackManager.error(
375
+ message="✗ Error: --cloud-provider option is required. Specify a cloud provider. Possible values are: aws, gcp, azure."
376
+ )
377
+ )
248
378
  return
249
379
 
250
380
  # AWS-specific Terraform template creation
251
- if provider.lower() != "aws":
252
- click.echo("Provider not supported yet.")
381
+ if cloud_provider.lower() != "aws":
382
+ click.echo(FeedbackManager.error(message="✗ Error: Provider not supported yet."))
253
383
  return
254
384
 
255
385
  # Create infra directory if it doesn't exist
256
- infra_dir = Path(f"infra/{provider}")
386
+ infra_dir = Path(f"infra/{cloud_provider}")
257
387
  infra_dir.mkdir(exist_ok=True)
258
388
  yaml_path = infra_dir / "k8s.yaml"
259
389
  tf_path = infra_dir / "main.tf"
@@ -263,145 +393,193 @@ def infra_init(
263
393
  config = {}
264
394
  if config_path.exists():
265
395
  try:
266
- with open(config_path, "r") as f:
396
+ with open(config_path, "r") as f: # noqa: ASYNC230
267
397
  config = json.load(f)
268
- click.echo("Loaded existing configuration from config.json")
398
+ click.echo(FeedbackManager.info(message="** Loaded existing configuration from config.json"))
269
399
  except json.JSONDecodeError:
270
- click.echo("Warning: Could not parse existing config.json. Creating a new file...")
400
+ click.echo(
401
+ FeedbackManager.warning(
402
+ message="** Warning: Could not parse existing config.json. Creating a new file..."
403
+ )
404
+ )
271
405
 
272
406
  # Generate a random ID for default values
273
407
  random_id = str(uuid.uuid4())[:8]
274
408
 
275
409
  # Get or prompt for configuration values
276
- name = name or click.prompt("Enter the name for your self-managed region", type=str)
277
- region = region or config.get("region") or click.prompt("Enter the AWS region", default="us-east-1", type=str)
278
- dns_zone_name = dns_zone_name or config.get("dns_zone_name") or click.prompt("Enter the DNS zone name", type=str)
279
- namespace = (
280
- namespace
281
- or config.get("namespace")
282
- or click.prompt("Enter the Kubernetes namespace", default=f"tinybird-{random_id}", type=str)
410
+ name = name or config.get("name") or click.prompt("** Enter the name for your self-managed region", type=str)
411
+ cloud_region = (
412
+ cloud_region
413
+ or config.get("cloud_region")
414
+ or click.prompt("** Enter the AWS region", default="us-east-1", type=str)
283
415
  )
416
+ dns_zone_name = dns_zone_name or config.get("dns_zone_name") or click.prompt("** Enter the DNS zone name", type=str)
284
417
  dns_record = (
285
418
  dns_record
286
419
  or config.get("dns_record")
287
- or click.prompt("Enter the DNS record name, without domain", default=f"tinybird-{random_id}", type=str)
420
+ or click.prompt("** Enter the DNS record name, without domain", default=f"tinybird-{random_id}", type=str)
288
421
  )
289
- storage_class = config.get("storage_class") or click.prompt(
290
- "Enter the Kubernetes storage class", default="gp3-encrypted", type=str
422
+ kubernetes_namespace = (
423
+ kubernetes_namespace
424
+ or config.get("kubernetes_namespace")
425
+ or click.prompt("** Enter the Kubernetes namespace", default=f"tinybird-{random_id}", type=str)
291
426
  )
292
427
 
428
+ # Special handling for kubernetes_storage_class to handle None values
429
+ if kubernetes_storage_class is not None:
430
+ # Use the provided value
431
+ pass
432
+ elif "kubernetes_storage_class" in config:
433
+ # Use the value from config, even if it's None
434
+ kubernetes_storage_class = config["kubernetes_storage_class"]
435
+ else:
436
+ # Prompt the user
437
+ kubernetes_storage_class = click.prompt(
438
+ "** Enter the Kubernetes storage class (leave empty for None)", default="", show_default=False, type=str
439
+ )
440
+ # Convert empty string to None
441
+ if kubernetes_storage_class == "":
442
+ kubernetes_storage_class = None
443
+
444
+ kubernetes_context = config.get("kubernetes_context")
445
+
293
446
  # Save configuration
294
447
  config = {
295
- "provider": provider,
296
- "region": region,
448
+ "name": name,
449
+ "cloud_provider": cloud_provider,
450
+ "cloud_region": cloud_region,
297
451
  "dns_zone_name": dns_zone_name,
298
- "namespace": namespace,
299
452
  "dns_record": dns_record,
300
- "storage_class": storage_class,
453
+ "kubernetes_namespace": kubernetes_namespace,
454
+ "kubernetes_storage_class": kubernetes_storage_class,
455
+ "kubernetes_context": kubernetes_context,
301
456
  }
302
457
 
303
- with open(config_path, "w") as f:
458
+ with open(config_path, "w") as f: # noqa: ASYNC230
304
459
  json.dump(config, f, indent=2)
305
460
 
306
- click.echo(f"Configuration saved to {config_path}")
461
+ click.echo(FeedbackManager.info(message=f"** Configuration saved to {config_path}"))
307
462
 
308
- client: TinyB = ctx.obj["client"]
463
+ infra: Infrastructure = ctx.ensure_object(dict)["infra"]
309
464
  cli_config = CLIConfig.get_project_config()
310
- user_client = cli_config.get_client(token=cli_config.get_user_token() or "")
311
- user_workspaces = async_to_sync(user_client.user_workspaces_with_organization)()
312
- admin_org_id = user_workspaces.get("organization_id")
313
- admin_org_name = user_workspaces.get("organization_name", "")
314
- infras = async_to_sync(client.infra_list)(organization_id=admin_org_id)
315
- infra = next((infra for infra in infras if infra["name"] == name), None)
316
- if not infra:
465
+
466
+ infras = await infra.get_infra_list()
467
+ infra_name = next((infra for infra in infras if infra["name"] == name), None)
468
+ if not infra_name:
469
+ # Handle organization if not provided
470
+ organization_id, organization_name = await ask_for_organization(infra.orgs, organization_id)
471
+ if not organization_id:
472
+ return
317
473
  click.echo(FeedbackManager.highlight(message=f"\n» Creating infrastructure '{name}' in Tinybird..."))
318
474
  host = f"https://{dns_record}.{dns_zone_name}"
319
- infra = async_to_sync(client.infra_create)(organization_id=admin_org_id, name=name, host=host)
320
-
321
- infra_token = infra["token"]
475
+ infra_obj = await infra.create_infra(name, host, organization_id)
476
+ else:
477
+ click.echo(FeedbackManager.highlight(message=f"» Infrastructure '{name}' already exists."))
478
+ if infra_name["host"] != f"https://{dns_record}.{dns_zone_name}":
479
+ click.echo(
480
+ FeedbackManager.highlight(
481
+ message="» Infrastructure host is different in the current config than the one provisioned at Tinybird"
482
+ )
483
+ )
484
+ if click.confirm("Would you like to update the host in the infra provisioned at Tinybird?"):
485
+ await infra.update(infra_name=name, name=name, host=f"https://{dns_record}.{dns_zone_name}")
486
+ organization_name = infra_name["organization"]
487
+ infra_obj = infra_name
322
488
 
323
489
  # Write the Terraform template
324
- terraform_content = TERRAFORM_FIRST_TEMPLATE % {
325
- "aws_region": region,
490
+ terraform_content = TERRAFORM_TEMPLATE % {
491
+ "aws_region": cloud_region,
326
492
  "dns_zone_name": dns_zone_name,
327
493
  "dns_record": dns_record,
328
494
  }
329
495
 
330
- with open(tf_path, "w") as f:
496
+ with open(tf_path, "w") as f: # noqa: ASYNC230
331
497
  f.write(terraform_content.lstrip())
332
498
 
333
- click.echo(f"Created Terraform configuration in {tf_path}")
499
+ click.echo(FeedbackManager.info(message=f"** Created Terraform configuration in {tf_path}"))
500
+
501
+ # Prepare the storage class line based on whether kubernetes_storage_class is None
502
+ storage_class_line = f"\n storageClassName: {kubernetes_storage_class}" if kubernetes_storage_class else ""
334
503
 
335
504
  new_content = K8S_YML % {
336
- "namespace": namespace,
337
- "storage_class": storage_class,
505
+ "kubernetes_namespace": kubernetes_namespace,
506
+ "storage_class_line": storage_class_line,
338
507
  "full_dns_name": f"{dns_record}.{dns_zone_name}",
339
- "infra_token": infra_token,
508
+ "infra_token": infra_obj["token"],
340
509
  "infra_workspace": cli_config.get("name", ""),
341
- "infra_organization": admin_org_name,
510
+ "infra_organization": organization_name,
342
511
  "infra_user": cli_config.get_user_email() or "",
343
512
  }
344
513
 
345
- with open(yaml_path, "w") as f:
514
+ with open(yaml_path, "w") as f: # noqa: ASYNC230
346
515
  f.write(new_content.lstrip())
347
516
 
348
- click.echo(f"Created Kubernetes configuration in {yaml_path}")
517
+ click.echo(FeedbackManager.info(message=f"** Created Kubernetes configuration in {yaml_path}"))
349
518
 
350
519
  # Apply Terraform configuration if user confirms
351
520
  if not skip_apply:
352
521
  # Initialize Terraform
353
- click.echo("Initializing Terraform...")
354
- init_result = subprocess.run(["terraform", f"-chdir={infra_dir}", "init"], capture_output=True, text=True)
522
+ click.echo(FeedbackManager.info(message="** Initializing Terraform..."))
523
+ command = f"terraform -chdir={infra_dir} init"
524
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
525
+
526
+ init_result = subprocess.run(["terraform", f"-chdir={infra_dir}", "init"], capture_output=True, text=True) # noqa: ASYNC221
355
527
 
356
528
  if init_result.returncode != 0:
357
- click.echo("Terraform initialization failed:")
529
+ click.echo(FeedbackManager.error(message="✗ Error: Terraform initialization failed:"))
358
530
  click.echo(init_result.stderr)
359
531
  return
360
532
 
361
- # Run terraform plan first
362
- click.echo("\nRunning Terraform plan...\n")
363
- plan_result = subprocess.run(["terraform", f"-chdir={infra_dir}", "plan"], capture_output=True, text=True)
533
+ # Run terraform plan
534
+ click.echo(FeedbackManager.info(message="** Running Terraform plan..."))
535
+ command = f"terraform -chdir={infra_dir} plan"
536
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
537
+ plan_result = subprocess.run(command.split(), capture_output=True, text=True) # noqa: ASYNC221
364
538
 
365
539
  if plan_result.returncode != 0:
366
- click.echo("Terraform plan failed:")
367
- click.echo(plan_result.stderr)
540
+ click.echo(FeedbackManager.error(message="✗ Error: Terraform plan failed:"))
541
+ click.echo(format_terraform_error(plan_result.stderr, "TERRAFORM PLAN"))
368
542
  return
369
543
 
370
- click.echo(plan_result.stdout)
544
+ # Display formatted plan output
545
+ click.echo(format_terraform_output(plan_result.stdout, "TERRAFORM PLAN"))
371
546
 
372
547
  # Apply Terraform configuration if user confirms
373
548
  if auto_apply or click.confirm("Would you like to apply the Terraform configuration now?"):
374
- click.echo("\nApplying Terraform configuration...\n")
375
- apply_result = subprocess.run(
376
- ["terraform", f"-chdir={infra_dir}", "apply", "-auto-approve"], capture_output=True, text=True
377
- )
549
+ click.echo(FeedbackManager.info(message="** Applying Terraform configuration..."))
550
+ command = f"terraform -chdir={infra_dir} apply -auto-approve"
551
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
552
+ apply_result = subprocess.run(command.split(), capture_output=True, text=True) # noqa: ASYNC221
378
553
 
379
554
  if apply_result.returncode != 0:
380
- click.echo("Terraform apply failed:")
381
- click.echo(apply_result.stderr)
555
+ click.echo(FeedbackManager.error(message="✗ Error: Terraform apply failed:"))
556
+ click.echo(format_terraform_error(apply_result.stderr, "TERRAFORM APPLY"))
382
557
  return
383
558
 
384
- click.echo(apply_result.stdout)
559
+ # Display formatted apply output
560
+ click.echo(format_terraform_output(apply_result.stdout, "TERRAFORM APPLY"))
385
561
 
386
562
  # Prompt to apply the k8s configuration
387
563
  if not skip_apply and (
388
564
  auto_apply or click.confirm("Would you like to apply the Kubernetes configuration now?")
389
565
  ):
390
566
  # Get current kubectl context
391
- current_context_result = subprocess.run(
392
- ["kubectl", "config", "current-context"], capture_output=True, text=True
393
- )
567
+ command = "kubectl config current-context"
568
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
569
+ current_context_result = subprocess.run(command.split(), capture_output=True, text=True) # noqa: ASYNC221
394
570
 
395
571
  current_context = (
396
572
  current_context_result.stdout.strip() if current_context_result.returncode == 0 else "unknown"
397
573
  )
574
+
398
575
  # Get available contexts
399
- contexts_result = subprocess.run(
400
- ["kubectl", "config", "get-contexts", "-o", "name"], capture_output=True, text=True
401
- )
576
+ command = "kubectl config get-contexts -o name"
577
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
578
+
579
+ contexts_result = subprocess.run(command.split(), capture_output=True, text=True) # noqa: ASYNC221
402
580
 
403
581
  if contexts_result.returncode != 0:
404
- click.echo("Failed to get kubectl contexts:")
582
+ click.echo(FeedbackManager.error(message="✗ Error: Failed to get kubectl contexts:"))
405
583
  click.echo(contexts_result.stderr)
406
584
  return
407
585
 
@@ -412,7 +590,10 @@ def infra_init(
412
590
  return
413
591
 
414
592
  # Prompt user to select a context
415
- if len(available_contexts) == 1:
593
+ if config["kubernetes_context"]:
594
+ selected_context = config["kubernetes_context"]
595
+ click.echo(f"Using the kubectl context specified in the config: {selected_context}")
596
+ elif len(available_contexts) == 1:
416
597
  selected_context = available_contexts[0]
417
598
  click.echo(f"Using the only available kubectl context: {selected_context}")
418
599
  else:
@@ -435,67 +616,83 @@ def infra_init(
435
616
  selected_context = available_contexts[selected_index - 1]
436
617
  click.echo(f"Selected context: {selected_context}")
437
618
 
619
+ # Update the config with the selected context
620
+ config["kubernetes_context"] = selected_context
621
+ with open(config_path, "w") as f: # noqa: ASYNC230
622
+ json.dump(config, f, indent=2)
623
+ click.echo(f"Updated configuration with selected Kubernetes context: {selected_context}")
624
+
438
625
  # Apply the configuration to the selected context
439
626
  click.echo(f"Applying Kubernetes configuration to context '{selected_context}'...")
440
- apply_result = subprocess.run(
441
- ["kubectl", "--context", selected_context, "apply", "-f", str(yaml_path)],
442
- capture_output=True,
443
- text=True,
444
- )
627
+
628
+ # First show a diff of what will be applied
629
+ command = f"kubectl --context {selected_context} diff -f {str(yaml_path)}"
630
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
631
+
632
+ diff_result = subprocess.run(command.split(), capture_output=True, text=True) # noqa: ASYNC221
633
+
634
+ if diff_result.returncode not in [0, 1]: # kubectl diff returns 1 when there are differences
635
+ if (
636
+ "Error from server (NotFound): namespaces" in diff_result.stderr
637
+ or f'namespace "{kubernetes_namespace}" not found' in diff_result.stderr
638
+ ):
639
+ click.echo("\nThis appears to be the first deployment - namespace doesn't exist yet.")
640
+ click.echo("No diff available for the initial deployment.")
641
+ else:
642
+ click.echo("Failed to get diff for Kubernetes configuration:")
643
+ click.echo(diff_result.stderr)
644
+ else:
645
+ if diff_result.stdout:
646
+ click.echo("\nChanges that will be applied:")
647
+ click.echo(diff_result.stdout)
648
+ else:
649
+ click.echo("\nNo changes detected or resources don't exist yet.")
650
+
651
+ # Now apply the configuration
652
+ command = f"kubectl --context {selected_context} apply -f {str(yaml_path)}"
653
+ click.echo(FeedbackManager.highlight(message=f"» Executing: {command}"))
654
+ apply_result = subprocess.run(command.split(), capture_output=True, text=True) # noqa: ASYNC221
445
655
 
446
656
  if apply_result.returncode != 0:
447
- click.echo("Failed to apply Kubernetes configuration:")
657
+ click.echo(FeedbackManager.error(message="✗ Error: Failed to apply Kubernetes configuration:"))
448
658
  click.echo(apply_result.stderr)
449
659
  else:
450
- click.echo("Kubernetes configuration applied successfully:")
660
+ click.echo(FeedbackManager.success(message="Kubernetes configuration applied successfully:"))
451
661
  click.echo(apply_result.stdout)
452
662
 
453
- # Get the namespace from the applied configuration
454
- namespace = None
455
- with open(yaml_path, "r") as f:
456
- for line in f:
457
- if "namespace:" in line and not namespace:
458
- namespace = line.split("namespace:")[1].strip()
459
- break
460
-
461
- if not namespace:
462
- namespace = "tinybird" # Default namespace
463
-
464
- click.echo("\nWaiting for load balancer and DNS to be provisioned...")
663
+ click.echo(FeedbackManager.info(message="Waiting for load balancer and DNS to be provisioned..."))
465
664
 
466
665
  max_attempts = 30 # 30 attempts * 10 seconds = 5 minutes
467
666
  endpoint_url = f"https://{dns_record}.{dns_zone_name}"
468
667
 
469
- with click.progressbar(
470
- range(max_attempts),
471
- label=f"Checking endpoint availability: {endpoint_url}",
472
- length=max_attempts,
473
- show_eta=True,
474
- show_percent=True,
475
- fill_char="█",
476
- empty_char="░",
477
- ) as bar:
478
- for attempt in bar:
479
- try:
480
- response = requests.get(endpoint_url, allow_redirects=False, timeout=5)
481
- if response.status_code < 400: # Consider any non-error response as success
482
- click.echo(click.style("\n✅ HTTPS endpoint is now accessible!", fg="green", bold=True))
483
- break
484
- except requests.RequestException:
485
- pass
486
-
487
- if attempt == max_attempts - 1:
488
- click.echo(
489
- click.style(
490
- "\n⚠️ HTTPS endpoint not accessible after 5 minutes", fg="yellow", bold=True
491
- )
492
- )
668
+ click.echo(f"Checking endpoint availability: {endpoint_url}")
669
+
670
+ for attempt in range(max_attempts):
671
+ progress = "#" * (attempt + 1)
672
+ click.echo(
673
+ f"\rAttempt {attempt + 1}/{max_attempts}: Checking if endpoint is ready... [{progress}]",
674
+ nl=False,
675
+ )
676
+
677
+ try:
678
+ response = requests.get(endpoint_url, allow_redirects=False, timeout=5) # noqa: ASYNC210
679
+ if response.status_code < 400: # Consider any non-error response as success
493
680
  click.echo(
494
- " This might be due to DNS propagation or the Load Balancer provisioning delays"
681
+ "\n" + click.style("✅ HTTPS endpoint is now accessible!", fg="green", bold=True)
495
682
  )
496
- click.echo(f" Please try accessing {endpoint_url} manually in a few minutes")
497
- else:
498
- time.sleep(10)
683
+ break
684
+ except (requests.RequestException, requests.Timeout):
685
+ pass
686
+
687
+ if attempt == max_attempts - 1:
688
+ click.echo(
689
+ "\n"
690
+ + click.style("⚠️ HTTPS endpoint not accessible after 5 minutes", fg="yellow", bold=True)
691
+ )
692
+ click.echo(" This might be due to DNS propagation or the Load Balancer provisioning delays")
693
+ click.echo(f" Try accessing {endpoint_url} manually in a few minutes")
694
+ else:
695
+ time.sleep(10) # noqa: ASYNC251
499
696
 
500
697
  if not skip_apply:
501
698
  # Print a summary with the endpoint URL
@@ -515,110 +712,150 @@ def infra_init(
515
712
  @click.argument("name")
516
713
  @click.pass_context
517
714
  @coro
518
- async def infra_rm(ctx: click.Context, name: str):
715
+ async def infra_remove(ctx: click.Context, name: str):
519
716
  """Delete an infrastructure from Tinybird"""
520
- try:
521
- click.echo(FeedbackManager.highlight(message=f"\n» Deleting infrastructure '{name}' from Tinybird..."))
522
- client: TinyB = ctx.ensure_object(dict)["client"]
523
- user_workspaces = await client.user_workspaces_with_organization()
524
- admin_org_id = user_workspaces.get("organization_id")
525
- if not admin_org_id:
526
- raise CLIException("No organization associated to this workspace")
527
- infras = await client.infra_list(admin_org_id)
528
- infra_id = next((infra["id"] for infra in infras if infra["name"] == name), None)
529
- if not infra_id:
530
- raise CLIException(f"Infrastructure '{name}' not found")
531
- await client.infra_delete(infra_id, admin_org_id)
532
- click.echo(FeedbackManager.success(message=f"\n✓ Infrastructure '{name}' deleted"))
533
- except Exception as e:
534
- click.echo(FeedbackManager.error(message=f"✗ Error: {e}"))
717
+ infra: Infrastructure = ctx.ensure_object(dict)["infra"]
718
+ await infra.remove_infra(name)
535
719
 
536
720
 
537
721
  @infra.command(name="ls")
538
722
  @click.pass_context
539
723
  @coro
540
- async def infra_ls(ctx: click.Context):
724
+ async def infra_list(ctx: click.Context):
541
725
  """List self-managed infrastructures"""
726
+ infra: Infrastructure = ctx.ensure_object(dict)["infra"]
727
+ await infra.list_infras()
542
728
 
543
- client: TinyB = ctx.ensure_object(dict)["client"]
544
- config = CLIConfig.get_project_config()
545
- user_client = config.get_client(token=config.get_user_token() or "")
546
- user_workspaces = await user_client.user_workspaces_with_organization()
547
- admin_org_id = user_workspaces.get("organization_id")
548
- infras = await client.infra_list(organization_id=admin_org_id)
549
- columns = [
550
- "name",
551
- "host",
552
- ]
553
- table_human_readable = []
554
- table_machine_readable = []
555
729
 
556
- for infra in infras:
557
- name = infra["name"]
558
- host = infra["host"]
730
+ @infra.command(name="add")
731
+ @click.option("--name", type=str, help="Name for identifying the self-managed region in Tinybird")
732
+ @click.option("--host", type=str, help="Host for the self-managed region")
733
+ @click.option("--organization-id", type=str, help="Organization ID for the self-managed region")
734
+ @click.pass_context
735
+ @coro
736
+ async def infra_create(ctx: click.Context, name: str, host: str, organization_id: Optional[str] = None):
737
+ """Creates a new self-managed region from an existing infrastructure URL."""
738
+ infra: Infrastructure = ctx.ensure_object(dict)["infra"]
559
739
 
560
- table_human_readable.append((name, host))
561
- table_machine_readable.append({"name": name, "host": host})
740
+ organization_id, organization_name = await ask_for_organization(
741
+ infra.orgs, organization_id
742
+ )
743
+ if not organization_id:
744
+ return
562
745
 
563
- click.echo(FeedbackManager.info(message="\n** Infras:"))
564
- echo_safe_humanfriendly_tables_format_smart_table(table_human_readable, column_names=columns)
565
- click.echo("\n")
746
+ name = name or click.prompt("Enter name", type=str)
747
+ host = host or click.prompt("Enter host", type=str)
566
748
 
749
+ click.echo(FeedbackManager.highlight(message=f"» Adding self-managed region '{name}' in Tinybird..."))
750
+ new_infra = await infra.create_infra(name, host, organization_id)
751
+ infra_token = new_infra["token"]
752
+ click.echo(FeedbackManager.success(message=f"\n✓ Self-managed region '{name}' added in '{organization_name}' Organization"))
567
753
 
568
- @infra.command(name="create")
569
- @click.option("--name", type=str, help="Name for identifying the self-managed infrastructure in Tinybird")
570
- @click.option("--host", type=str, help="Host for the infrastructure")
571
- @click.pass_context
572
- @coro
573
- async def infra_create(ctx: click.Context, name: str, host: str):
574
- """Create a self-managed infrastructure"""
575
- try:
576
- client: TinyB = ctx.ensure_object(dict)["client"]
577
- user_workspaces = await client.user_workspaces_with_organization()
578
- admin_org_id = user_workspaces.get("organization_id")
579
- if not admin_org_id:
580
- raise CLIException("No organization associated to this workspace")
581
- name = name or click.prompt("Enter name", type=str)
582
- host = host or click.prompt("Enter host", type=str)
583
- click.echo(FeedbackManager.highlight(message=f"\n» Creating infrastructure '{name}' in Tinybird..."))
584
- infra = await client.infra_create(organization_id=admin_org_id, name=name, host=host)
585
- click.echo(FeedbackManager.success(message=f"\n✓ Infrastructure '{name}' created"))
586
- pyperclip.copy(infra["token"])
587
- click.echo(FeedbackManager.info(message="Access token has been copied to your clipboard."))
588
- click.echo(
589
- FeedbackManager.info(message="Pass it as an environment variable in your deployment as TB_INFRA_TOKEN")
590
- )
591
- except Exception as e:
592
- click.echo(FeedbackManager.error(message=f"✗ Error: {str(e)}"))
754
+ # Get CLI config to access workspace and user information
755
+ cli_config = CLIConfig.get_project_config()
756
+
757
+ # Print environment variables needed for self-managed infrastructure
758
+ click.echo(FeedbackManager.highlight(message="» Required environment variables:"))
759
+ click.echo(f"TB_INFRA_TOKEN={infra_token}")
760
+ click.echo(f"TB_INFRA_WORKSPACE={cli_config.get('name', '')}")
761
+ click.echo(f"TB_INFRA_ORGANIZATION={organization_name}")
762
+ click.echo(f"TB_INFRA_USER={cli_config.get_user_email() or ''}")
593
763
 
594
764
 
595
765
  @infra.command(name="update")
596
766
  @click.argument("infra_name")
597
- @click.option("--name", type=str, help="Name for identifying the self-managed infrastructure in Tinybird")
598
- @click.option("--host", type=str, help="Host for the infrastructure")
767
+ @click.option("--name", type=str, help="Name for identifying the self-managed region in Tinybird")
768
+ @click.option("--host", type=str, help="Host for the self-managed region")
599
769
  @click.pass_context
600
770
  @coro
601
771
  async def infra_update(ctx: click.Context, infra_name: str, name: str, host: str):
602
- """Update a self-managed infrastructure"""
603
- try:
604
- client: TinyB = ctx.ensure_object(dict)["client"]
605
- user_workspaces = await client.user_workspaces_with_organization()
606
- admin_org_id = user_workspaces.get("organization_id")
607
- if not admin_org_id:
608
- raise CLIException("No organization associated to this workspace")
609
-
610
- if not name and not host:
772
+ """Updates the URL of an existing self-managed region."""
773
+ infra: Infrastructure = ctx.ensure_object(dict)["infra"]
774
+ if not name and not host:
775
+ click.echo(FeedbackManager.warning(message="No name or host provided. Provide either a name or a host."))
776
+ return
777
+
778
+ if name or host:
779
+ try:
611
780
  click.echo(
612
- FeedbackManager.warning(message="No name or host provided. Please provide either a name or a host.")
781
+ FeedbackManager.highlight(message=f"» Updating self-managed region'{infra_name}' in Tinybird...")
613
782
  )
614
- return
615
-
616
- if name or host:
617
- infras = await client.infra_list(organization_id=admin_org_id)
618
- infra_id = next((infra["id"] for infra in infras if infra["name"] == infra_name), None)
783
+ infra_id = await infra.update(infra_name, name, host)
619
784
  if not infra_id:
620
- raise CLIException(f"Infrastructure '{infra_name}' not found")
621
- click.echo(FeedbackManager.highlight(message=f"\n» Updating infrastructure '{infra_name}' in Tinybird..."))
622
- await client.infra_update(infra_id=infra_id, organization_id=admin_org_id, name=name, host=host)
623
- except Exception as e:
624
- click.echo(FeedbackManager.error(message=f"✗ Error: {str(e)}"))
785
+ raise CLIException(f"Self-managed region '{infra_name}' not found")
786
+ except Exception as e:
787
+ click.echo(FeedbackManager.error(message=f"✗ Error: {str(e)}"))
788
+
789
+
790
+ def format_terraform_output(output, command_name="Terraform"):
791
+ """Format Terraform command output with colors and styling.
792
+
793
+ Args:
794
+ output: The stdout string from the Terraform command
795
+ command_name: The name of the command (e.g., "PLAN", "APPLY")
796
+
797
+ Returns:
798
+ Formatted output ready to be displayed
799
+ """
800
+ if not output:
801
+ return FeedbackManager.info(message=f"No output from {command_name} command.")
802
+
803
+ # Add a header
804
+ formatted_lines = [
805
+ "\n" + "=" * 80,
806
+ FeedbackManager.highlight(message=f"{command_name.upper()} OUTPUT"),
807
+ "=" * 80 + "\n",
808
+ ]
809
+
810
+ # Process and format the output
811
+ for line in output.splitlines():
812
+ # Highlight resource changes
813
+ if line.strip().startswith("+ "):
814
+ formatted_lines.append(click.style(line, fg="green"))
815
+ elif line.strip().startswith("- "):
816
+ formatted_lines.append(click.style(line, fg="red"))
817
+ elif line.strip().startswith("~ "):
818
+ formatted_lines.append(click.style(line, fg="yellow"))
819
+ # Highlight plan/apply summary
820
+ elif any(keyword in line for keyword in ["Plan:", "Apply complete", "Destroy complete"]):
821
+ formatted_lines.append("\n" + click.style(line, bold=True, fg="cyan"))
822
+ else:
823
+ formatted_lines.append(line)
824
+
825
+ # Add a footer
826
+ formatted_lines.append("\n" + "=" * 80)
827
+
828
+ return "\n".join(formatted_lines)
829
+
830
+
831
+ def format_terraform_error(error_output, command_name="Terraform"):
832
+ """Format Terraform command error output with colors and styling.
833
+
834
+ Args:
835
+ error_output: The stderr string from the Terraform command
836
+ command_name: The name of the command (e.g., "PLAN", "APPLY")
837
+
838
+ Returns:
839
+ Formatted error output ready to be displayed
840
+ """
841
+ if not error_output:
842
+ return FeedbackManager.error(message=f"Unknown error in {command_name} command.")
843
+
844
+ # Add a header
845
+ formatted_lines = ["\n" + "=" * 80, FeedbackManager.error(message=f"{command_name.upper()} ERROR"), "=" * 80 + "\n"]
846
+
847
+ # Process and format the error output
848
+ for line in error_output.splitlines():
849
+ # Highlight error messages
850
+ if any(keyword in line.lower() for keyword in ["error", "failed", "fatal"]):
851
+ formatted_lines.append(click.style(line, fg="red", bold=True))
852
+ # Highlight warnings
853
+ elif any(keyword in line.lower() for keyword in ["warning", "deprecated"]):
854
+ formatted_lines.append(click.style(line, fg="yellow"))
855
+ else:
856
+ formatted_lines.append(line)
857
+
858
+ # Add a footer
859
+ formatted_lines.append("\n" + "=" * 80)
860
+
861
+ return "\n".join(formatted_lines)