gravi-cli 0.2.2__tar.gz → 0.2.4__tar.gz

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,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "0.2.2"
2
+ current_version = "0.2.4"
3
3
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
4
4
  serialize = ["{major}.{minor}.{patch}"]
5
5
  search = "{current_version}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gravi-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: CLI tool for Gravitate infrastructure management
5
5
  Project-URL: Homepage, https://github.com/gravitate/mom
6
6
  Project-URL: Documentation, https://github.com/gravitate/mom/tree/main/cli
@@ -6,4 +6,4 @@ allowing developers to authenticate, manage tokens, and access instance
6
6
  configurations and credentials.
7
7
  """
8
8
 
9
- __version__ = "0.2.2"
9
+ __version__ = "0.2.4"
@@ -32,6 +32,7 @@ from .exceptions import (
32
32
  APIError,
33
33
  RateLimitError,
34
34
  ConfigError,
35
+ PermissionDeniedError,
35
36
  )
36
37
 
37
38
 
@@ -393,6 +394,39 @@ def config(instance_key, format):
393
394
  sys.exit(1)
394
395
 
395
396
 
397
+ @main.command()
398
+ @click.argument("instance_key")
399
+ @click.option(
400
+ "--json",
401
+ "output_json",
402
+ is_flag=True,
403
+ help="Output as JSON for scripting",
404
+ )
405
+ def token(instance_key, output_json):
406
+ """Get access and refresh tokens for an instance."""
407
+ try:
408
+ token_data = get_instance_token(instance_key)
409
+
410
+ if output_json:
411
+ click.echo(json.dumps({
412
+ "access_token": token_data.get("access_token", ""),
413
+ "refresh_token": token_data.get("refresh_token", ""),
414
+ }))
415
+ else:
416
+ click.echo(f"AccessToken: {token_data.get('access_token', 'N/A')}")
417
+ click.echo(f"RefreshToken: {token_data.get('refresh_token', 'N/A')}")
418
+
419
+ except NotAuthenticatedError as e:
420
+ click.echo(f"Error: {e}", err=True)
421
+ sys.exit(1)
422
+ except PermissionDeniedError as e:
423
+ click.echo(f"Error: {e}", err=True)
424
+ sys.exit(1)
425
+ except Exception as e:
426
+ click.echo(f"Error: {e}", err=True)
427
+ sys.exit(1)
428
+
429
+
396
430
  # Add missing import for timedelta
397
431
  from datetime import timedelta
398
432
 
@@ -448,5 +482,365 @@ def dbtun(burner_id):
448
482
  sys.exit(1)
449
483
 
450
484
 
485
+ # ============================================================================
486
+ # Burner Commands
487
+ # ============================================================================
488
+
489
+ @main.group()
490
+ def burner():
491
+ """Manage ephemeral burner instances."""
492
+ pass
493
+
494
+
495
+ def poll_operation_until_complete(
496
+ client: MomClient,
497
+ token: str,
498
+ operation_id: str,
499
+ show_progress: bool = True,
500
+ poll_interval: float = 2.0,
501
+ timeout: float = 600.0,
502
+ ) -> dict:
503
+ """
504
+ Poll an operation until it completes or fails.
505
+
506
+ Args:
507
+ client: MomClient instance
508
+ token: Access token
509
+ operation_id: Operation ID to poll
510
+ show_progress: Whether to print progress
511
+ poll_interval: Seconds between polls
512
+ timeout: Maximum time to wait
513
+
514
+ Returns:
515
+ Final operation status dict
516
+ """
517
+ start_time = time.time()
518
+ last_step = None
519
+
520
+ while True:
521
+ if time.time() - start_time > timeout:
522
+ return {"status": "failed", "error_message": "Timeout waiting for operation"}
523
+
524
+ status = client.get_operation_status(operation_id, token)
525
+
526
+ if show_progress:
527
+ current_step = status.get("current_step")
528
+ progress = status.get("progress_percent", 0)
529
+ if current_step and current_step != last_step:
530
+ click.echo(f"\n {current_step}...", nl=False)
531
+ last_step = current_step
532
+ else:
533
+ click.echo(".", nl=False)
534
+
535
+ if status["status"] in ("completed", "failed"):
536
+ if show_progress:
537
+ click.echo()
538
+ return status
539
+
540
+ time.sleep(poll_interval)
541
+
542
+
543
+ @burner.command("start")
544
+ @click.option("--ttl", "ttl_hours", default=2, type=int, help="Time-to-live in hours (1-168)")
545
+ @click.option("--sheet-id", default=None, help="Google Sheet ID for full seeding")
546
+ @click.option("--source-env", default=None, help="Source environment to copy from")
547
+ @click.option("--simple-demand", is_flag=True, help="Use even demand distribution")
548
+ @click.option("--sync", is_flag=True, help="Wait for burner to be ready")
549
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
550
+ def burner_start(ttl_hours, sheet_id, source_env, simple_demand, sync, output_json):
551
+ """Create a new burner instance.
552
+
553
+ Examples:
554
+
555
+ gravi burner start # Quick barebone burner
556
+
557
+ gravi burner start --ttl 8 # 8 hour TTL
558
+
559
+ gravi burner start --sync # Wait until ready
560
+
561
+ gravi burner start --sheet-id ABC123 # Full seed from sheet
562
+ """
563
+ try:
564
+ mom_token = get_mom_token()
565
+ mom_url = get_mom_url()
566
+ client = MomClient(mom_url)
567
+
568
+ # Determine seed level based on options
569
+ seed_level = "barebone"
570
+ if sheet_id:
571
+ seed_level = "full"
572
+ elif source_env:
573
+ seed_level = "copy"
574
+
575
+ # Initiate creation
576
+ response = client.create_burner(
577
+ access_token=mom_token,
578
+ ttl_hours=ttl_hours,
579
+ sheet_id=sheet_id,
580
+ source_env=source_env,
581
+ seed_level=seed_level,
582
+ simple_demand=simple_demand,
583
+ )
584
+
585
+ burner_id = response["burner_id"]
586
+ operation_id = response["operation_id"]
587
+
588
+ if not sync:
589
+ # Async mode: just output the operation info and exit
590
+ if output_json:
591
+ click.echo(json.dumps(response))
592
+ else:
593
+ click.echo(f"Burner '{burner_id}' creation started")
594
+ click.echo(f" Operation ID: {operation_id}")
595
+ click.echo(f"\nUse 'gravi burner status {burner_id}' to check status")
596
+ return
597
+
598
+ # Sync mode: poll until complete
599
+ if not output_json:
600
+ click.echo(f"Creating burner '{burner_id}'", nl=False)
601
+
602
+ final_result = poll_operation_until_complete(
603
+ client, mom_token, operation_id,
604
+ show_progress=(not output_json)
605
+ )
606
+
607
+ if final_result["status"] == "completed":
608
+ # Fetch the burner details
609
+ burner_info = client.get_burner(burner_id, mom_token)
610
+
611
+ if output_json:
612
+ click.echo(json.dumps(burner_info))
613
+ else:
614
+ click.echo(f"\nBurner ready:")
615
+ click.echo(f" ID: {burner_info['burner_id']}")
616
+ click.echo(f" URL: https://{burner_info['url']}")
617
+ click.echo(f" Expires: {burner_info['expires_at']}")
618
+ else:
619
+ error_msg = final_result.get("error_message", "Unknown error")
620
+ if output_json:
621
+ click.echo(json.dumps({"error": error_msg, "burner_id": burner_id}))
622
+ else:
623
+ click.echo(f"\nError: {error_msg}", err=True)
624
+ sys.exit(1)
625
+
626
+ except NotAuthenticatedError:
627
+ click.echo("Error: Please run 'gravi login' first", err=True)
628
+ sys.exit(1)
629
+ except RateLimitError as e:
630
+ click.echo(f"Error: {e}", err=True)
631
+ sys.exit(1)
632
+ except APIError as e:
633
+ click.echo(f"Error: {e}", err=True)
634
+ sys.exit(1)
635
+
636
+
637
+ @burner.command("delete")
638
+ @click.argument("burner_id")
639
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
640
+ def burner_delete(burner_id, output_json):
641
+ """Delete a burner instance.
642
+
643
+ Examples:
644
+
645
+ gravi burner delete tank
646
+
647
+ gravi burner delete pump --json
648
+ """
649
+ try:
650
+ mom_token = get_mom_token()
651
+ mom_url = get_mom_url()
652
+ client = MomClient(mom_url)
653
+
654
+ response = client.delete_burner(burner_id, mom_token)
655
+
656
+ if output_json:
657
+ click.echo(json.dumps(response))
658
+ else:
659
+ click.echo(f"Burner '{burner_id}' deletion initiated")
660
+ click.echo(f" Operation ID: {response['operation_id']}")
661
+
662
+ except NotAuthenticatedError:
663
+ click.echo("Error: Please run 'gravi login' first", err=True)
664
+ sys.exit(1)
665
+ except APIError as e:
666
+ click.echo(f"Error: {e}", err=True)
667
+ sys.exit(1)
668
+
669
+
670
+ @burner.command("status")
671
+ @click.argument("burner_id")
672
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
673
+ def burner_status(burner_id, output_json):
674
+ """Get detailed status of a burner.
675
+
676
+ Examples:
677
+
678
+ gravi burner status tank
679
+
680
+ gravi burner status pump --json
681
+ """
682
+ try:
683
+ mom_token = get_mom_token()
684
+ mom_url = get_mom_url()
685
+ client = MomClient(mom_url)
686
+
687
+ burner_info = client.get_burner(burner_id, mom_token)
688
+
689
+ if output_json:
690
+ click.echo(json.dumps(burner_info))
691
+ else:
692
+ click.echo(f"Burner: {burner_info['burner_id']}")
693
+ click.echo("=" * 40)
694
+ click.echo(f"Status: {burner_info['status']}")
695
+ click.echo(f"URL: https://{burner_info['url']}")
696
+ click.echo(f"Created by: {burner_info['created_by']}")
697
+ click.echo(f"Created: {burner_info['created_at']}")
698
+ click.echo(f"Expires: {burner_info['expires_at']}")
699
+ click.echo(f"TTL: {burner_info['ttl_hours']} hours")
700
+ click.echo(f"Build: {burner_info['build']}")
701
+ click.echo(f"Seed level: {burner_info['seed_level']}")
702
+ if burner_info.get('custom_name'):
703
+ click.echo(f"Name: {burner_info['custom_name']}")
704
+ if burner_info.get('sheet_id'):
705
+ click.echo(f"Sheet ID: {burner_info['sheet_id']}")
706
+
707
+ except NotAuthenticatedError:
708
+ click.echo("Error: Please run 'gravi login' first", err=True)
709
+ sys.exit(1)
710
+ except APIError as e:
711
+ click.echo(f"Error: {e}", err=True)
712
+ sys.exit(1)
713
+
714
+
715
+ @burner.command("pods")
716
+ @click.argument("burner_id")
717
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
718
+ def burner_pods(burner_id, output_json):
719
+ """List pods in a burner instance.
720
+
721
+ Shows all pods grouped by deployment with their status.
722
+
723
+ Examples:
724
+
725
+ gravi burner pods tank
726
+
727
+ gravi burner pods pump --json
728
+ """
729
+ try:
730
+ mom_token = get_mom_token()
731
+ mom_url = get_mom_url()
732
+ client = MomClient(mom_url)
733
+
734
+ data = client.list_burner_pods(burner_id, mom_token)
735
+
736
+ if output_json:
737
+ click.echo(json.dumps(data))
738
+ else:
739
+ click.echo(f"Pods in burner '{burner_id}' ({data['namespace']})")
740
+ click.echo("=" * 60)
741
+
742
+ for deployment in data.get("deployments", []):
743
+ ready = deployment["ready_replicas"]
744
+ desired = deployment["desired_replicas"]
745
+ status_icon = "✓" if ready == desired else "○"
746
+ click.echo(f"\n{status_icon} {deployment['name']} ({ready}/{desired} ready)")
747
+
748
+ for pod in deployment.get("pods", []):
749
+ pod_status = pod.get("status", "Unknown")
750
+ restarts = pod.get("restart_count", 0)
751
+ restart_str = f" (restarts: {restarts})" if restarts > 0 else ""
752
+ status_color = "green" if pod_status == "Running" else "yellow"
753
+ click.echo(f" {pod['name']}: {pod_status}{restart_str}")
754
+
755
+ except NotAuthenticatedError:
756
+ click.echo("Error: Please run 'gravi login' first", err=True)
757
+ sys.exit(1)
758
+ except APIError as e:
759
+ click.echo(f"Error: {e}", err=True)
760
+ sys.exit(1)
761
+
762
+
763
+ @burner.command("logs")
764
+ @click.argument("burner_id")
765
+ @click.argument("pod_name")
766
+ @click.option("--tail", "-n", "tail_lines", default=100, type=int, help="Number of lines (default: 100)")
767
+ @click.option("--container", "-c", default=None, help="Container name (optional)")
768
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
769
+ def burner_logs(burner_id, pod_name, tail_lines, container, output_json):
770
+ """Fetch logs from a pod in a burner.
771
+
772
+ Examples:
773
+
774
+ gravi burner logs tank backend-xyz
775
+
776
+ gravi burner logs tank backend-xyz --tail 500
777
+
778
+ gravi burner logs tank backend-xyz -c backend
779
+ """
780
+ try:
781
+ mom_token = get_mom_token()
782
+ mom_url = get_mom_url()
783
+ client = MomClient(mom_url)
784
+
785
+ data = client.get_pod_logs(
786
+ burner_id=burner_id,
787
+ pod_name=pod_name,
788
+ access_token=mom_token,
789
+ container=container,
790
+ tail_lines=tail_lines,
791
+ )
792
+
793
+ if output_json:
794
+ click.echo(json.dumps(data))
795
+ else:
796
+ if data.get("truncated"):
797
+ click.echo(f"[Showing last {tail_lines} lines, logs truncated]", err=True)
798
+ # API returns 'lines' as array or 'logs' as string
799
+ logs = data.get("logs") or "\n".join(data.get("lines", []))
800
+ click.echo(logs)
801
+
802
+ except NotAuthenticatedError:
803
+ click.echo("Error: Please run 'gravi login' first", err=True)
804
+ sys.exit(1)
805
+ except APIError as e:
806
+ click.echo(f"Error: {e}", err=True)
807
+ sys.exit(1)
808
+
809
+
810
+ @burner.command("restart")
811
+ @click.argument("burner_id")
812
+ @click.argument("deployment_name")
813
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
814
+ def burner_restart(burner_id, deployment_name, output_json):
815
+ """Restart a deployment in a burner.
816
+
817
+ Triggers a rolling restart of all pods in the deployment.
818
+
819
+ Examples:
820
+
821
+ gravi burner restart tank backend
822
+
823
+ gravi burner restart pump forecast
824
+ """
825
+ try:
826
+ mom_token = get_mom_token()
827
+ mom_url = get_mom_url()
828
+ client = MomClient(mom_url)
829
+
830
+ response = client.restart_deployment(burner_id, deployment_name, mom_token)
831
+
832
+ if output_json:
833
+ click.echo(json.dumps(response))
834
+ else:
835
+ click.echo(f"Restart initiated for '{deployment_name}' in burner '{burner_id}'")
836
+
837
+ except NotAuthenticatedError:
838
+ click.echo("Error: Please run 'gravi login' first", err=True)
839
+ sys.exit(1)
840
+ except APIError as e:
841
+ click.echo(f"Error: {e}", err=True)
842
+ sys.exit(1)
843
+
844
+
451
845
  if __name__ == "__main__":
452
846
  main()
@@ -359,3 +359,206 @@ class MomClient:
359
359
  json_data=json_data,
360
360
  auth_token=access_token
361
361
  )
362
+
363
+ def create_burner(
364
+ self,
365
+ access_token: str,
366
+ ttl_hours: int = 2,
367
+ sheet_id: str | None = None,
368
+ source_env: str | None = None,
369
+ seed_level: str = "barebone",
370
+ custom_name: str | None = None,
371
+ build: str = "dev",
372
+ simple_demand: bool = False,
373
+ ) -> dict:
374
+ """
375
+ Create a new burner instance.
376
+
377
+ Args:
378
+ access_token: Mom access token
379
+ ttl_hours: Time-to-live in hours (1-168)
380
+ sheet_id: Google Sheet ID for full seeding
381
+ source_env: Source environment to copy from
382
+ seed_level: Seeding level (none, barebone, full, copy)
383
+ custom_name: Custom name for the burner
384
+ build: Build to deploy (dev, test, rc, or SHA)
385
+ simple_demand: Use even demand distribution
386
+
387
+ Returns:
388
+ {
389
+ "operation_id": "...",
390
+ "burner_id": "tank",
391
+ "stream_url": "/burners/operations/stream?operation_id=...",
392
+ "message": "Burner creation initiated"
393
+ }
394
+ """
395
+ json_data = {
396
+ "ttl_hours": ttl_hours,
397
+ "seed_level": seed_level,
398
+ "build": build,
399
+ "simple_demand": simple_demand,
400
+ }
401
+ if sheet_id:
402
+ json_data["sheet_id"] = sheet_id
403
+ if source_env:
404
+ json_data["source_env"] = source_env
405
+ if custom_name:
406
+ json_data["custom_name"] = custom_name
407
+
408
+ return self._make_request(
409
+ "POST",
410
+ "burners/create",
411
+ json_data=json_data,
412
+ auth_token=access_token,
413
+ )
414
+
415
+ def delete_burner(self, burner_id: str, access_token: str) -> dict:
416
+ """
417
+ Delete a burner instance.
418
+
419
+ Args:
420
+ burner_id: Burner ID to delete (e.g., "tank")
421
+ access_token: Mom access token
422
+
423
+ Returns:
424
+ {
425
+ "operation_id": "...",
426
+ "burner_id": "tank",
427
+ "stream_url": "/burners/operations/stream?operation_id=...",
428
+ "message": "Burner deletion initiated"
429
+ }
430
+ """
431
+ return self._make_request(
432
+ "POST",
433
+ "burners/delete",
434
+ json_data={"burner_id": burner_id},
435
+ auth_token=access_token,
436
+ )
437
+
438
+ def get_burner(self, burner_id: str, access_token: str) -> dict:
439
+ """
440
+ Get details of a specific burner.
441
+
442
+ Args:
443
+ burner_id: Burner ID (e.g., "tank")
444
+ access_token: Mom access token
445
+
446
+ Returns:
447
+ Burner details including status, url, created_by, expires_at, etc.
448
+ """
449
+ return self._make_request(
450
+ "POST",
451
+ "burners/get",
452
+ json_data={"burner_id": burner_id},
453
+ auth_token=access_token,
454
+ )
455
+
456
+ def get_operation_status(self, operation_id: str, access_token: str) -> dict:
457
+ """
458
+ Get status of a burner operation.
459
+
460
+ Args:
461
+ operation_id: Operation ID
462
+ access_token: Mom access token
463
+
464
+ Returns:
465
+ {
466
+ "operation_id": "...",
467
+ "status": "pending|running|completed|failed",
468
+ "progress_percent": 0-100,
469
+ "current_step": "...",
470
+ "initiated_at": "...",
471
+ "completed_at": "...",
472
+ "error_message": "..."
473
+ }
474
+ """
475
+ return self._make_request(
476
+ "POST",
477
+ "burners/operations/get",
478
+ json_data={"operation_id": operation_id},
479
+ auth_token=access_token,
480
+ )
481
+
482
+ def list_burner_pods(self, burner_id: str, access_token: str) -> dict:
483
+ """
484
+ List all pods in a burner namespace, grouped by deployment.
485
+
486
+ Args:
487
+ burner_id: Burner ID (e.g., "tank")
488
+ access_token: Mom access token
489
+
490
+ Returns:
491
+ {
492
+ "pods": [...],
493
+ "deployments": [
494
+ {"name": "backend", "desired_replicas": 1, "ready_replicas": 1, "pods": [...]},
495
+ ...
496
+ ],
497
+ "namespace": "burner-tank"
498
+ }
499
+ """
500
+ return self._make_request(
501
+ "GET",
502
+ f"burners/{burner_id}/pods",
503
+ auth_token=access_token,
504
+ )
505
+
506
+ def get_pod_logs(
507
+ self,
508
+ burner_id: str,
509
+ pod_name: str,
510
+ access_token: str,
511
+ container: str | None = None,
512
+ tail_lines: int = 100,
513
+ since_seconds: int | None = None,
514
+ ) -> dict:
515
+ """
516
+ Fetch logs from a pod.
517
+
518
+ Args:
519
+ burner_id: Burner ID (e.g., "tank")
520
+ pod_name: Name of the pod
521
+ access_token: Mom access token
522
+ container: Container name (optional)
523
+ tail_lines: Number of lines from end (default 100)
524
+ since_seconds: Only fetch logs newer than this many seconds
525
+
526
+ Returns:
527
+ {
528
+ "pod_name": "...",
529
+ "container": "...",
530
+ "logs": "...",
531
+ "truncated": false
532
+ }
533
+ """
534
+ # Build query params
535
+ params = [f"tail_lines={tail_lines}"]
536
+ if container:
537
+ params.append(f"container={container}")
538
+ if since_seconds:
539
+ params.append(f"since_seconds={since_seconds}")
540
+
541
+ query_string = "&".join(params)
542
+ return self._make_request(
543
+ "GET",
544
+ f"burners/{burner_id}/pods/{pod_name}/logs?{query_string}",
545
+ auth_token=access_token,
546
+ )
547
+
548
+ def restart_deployment(self, burner_id: str, deployment_name: str, access_token: str) -> dict:
549
+ """
550
+ Restart all pods in a deployment.
551
+
552
+ Args:
553
+ burner_id: Burner ID (e.g., "tank")
554
+ deployment_name: Name of the deployment to restart
555
+ access_token: Mom access token
556
+
557
+ Returns:
558
+ {"message": "Deployment backend restart initiated"}
559
+ """
560
+ return self._make_request(
561
+ "POST",
562
+ f"burners/{burner_id}/deployments/{deployment_name}/restart",
563
+ auth_token=access_token,
564
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gravi-cli"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "CLI tool for Gravitate infrastructure management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes