primitive 0.1.89__py3-none-any.whl → 0.1.91__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.
primitive/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2024-present Dylan Stein <dylan@primitive.tech>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.1.89"
4
+ __version__ = "0.1.91"
@@ -28,7 +28,12 @@ class Agent(BaseAction):
28
28
 
29
29
  # self.primitive.hardware.update_hardware_system_info()
30
30
  try:
31
- self.primitive.hardware.check_in_http(is_available=True, is_online=True)
31
+ # hey stupid:
32
+ # do not set is_available to True here, it will mess up the reservation logic
33
+ # only set is_available after we've checked that no active reservation is present
34
+ # setting is_available of the parent also effects the children,
35
+ # which may have active reservations as well
36
+ self.primitive.hardware.check_in_http(is_online=True)
32
37
  except Exception as ex:
33
38
  logger.error(f"Error checking in hardware: {ex}")
34
39
  sys.exit(1)
primitive/agent/runner.py CHANGED
@@ -193,7 +193,9 @@ class AgentRunner:
193
193
 
194
194
  returncode = proc.wait()
195
195
 
196
- logger.debug(f"Process {step['name']} finished with return code {returncode}")
196
+ logger.debug(
197
+ f"Process {step['name']} finished with return code {returncode}"
198
+ )
197
199
 
198
200
  if proc.errors > 0 and self.failure_level >= FailureLevel.ERROR:
199
201
  fail_level_detected = True
@@ -212,7 +214,9 @@ class AgentRunner:
212
214
  self.primitive.jobs.job_run_update(
213
215
  self.job_id, status="request_completed", conclusion="failure"
214
216
  )
215
- logger.error(f"Step {step['name']} failed with return code {returncode}")
217
+ logger.error(
218
+ f"Step {step['name']} failed with return code {returncode}"
219
+ )
216
220
  return
217
221
 
218
222
  if fail_level_detected and self.parse_logs:
primitive/exec/actions.py CHANGED
@@ -6,6 +6,7 @@ if typing.TYPE_CHECKING:
6
6
  pass
7
7
 
8
8
 
9
+ from loguru import logger
9
10
  from paramiko import SSHClient
10
11
 
11
12
  from primitive.utils.actions import BaseAction
@@ -16,6 +17,15 @@ class Exec(BaseAction):
16
17
  super().__init__(*args, **kwargs)
17
18
 
18
19
  def execute_command(self, hardware_identifier: str, command: str) -> None:
20
+ child_hardware = None
21
+ if "." in hardware_identifier:
22
+ hardware_slugs = hardware_identifier.split(".")
23
+ hardware_identifier = hardware_slugs[0]
24
+ child_hardware_identifier = hardware_slugs[-1]
25
+ child_hardware = self.primitive.hardware.get_hardware_from_slug_or_id(
26
+ hardware_identifier=child_hardware_identifier
27
+ )
28
+
19
29
  hardware = self.primitive.hardware.get_hardware_from_slug_or_id(
20
30
  hardware_identifier=hardware_identifier
21
31
  )
@@ -37,14 +47,23 @@ class Exec(BaseAction):
37
47
  reservation_result = self.primitive.reservations.create_reservation(
38
48
  requested_hardware_ids=[hardware["id"]],
39
49
  reason="Executing command from Primitive CLI",
50
+ wait=False,
40
51
  )
41
52
  reservation = reservation_result.data["reservationCreate"]
42
53
  created_reservation_on_behalf_of_user = True
43
54
 
44
- reservation = self.primitive.reservations.wait_for_reservation_status(
55
+ reservation_result = self.primitive.reservations.wait_for_reservation_status(
45
56
  reservation_id=reservation["id"], desired_status="in_progress"
46
57
  )
47
58
 
59
+ reservation = reservation_result.data["reservation"]
60
+ if reservation.get("status") != "in_progress":
61
+ logger.enable("primitive")
62
+ logger.info(
63
+ f"Reservation {reservation.get('id')} is in status {reservation.get('status')}, cannot execute command at this time."
64
+ )
65
+ return
66
+
48
67
  ssh_hostname = hardware["hostname"]
49
68
  ssh_username = hardware["sshUsername"]
50
69
 
@@ -57,11 +76,29 @@ class Exec(BaseAction):
57
76
  )
58
77
 
59
78
  if command:
60
- formatted_command = " ".join(command)
79
+ if child_hardware:
80
+ # if the child hardware has ssh credentials, format the proxy command
81
+ if child_hardware.get("systemInfo").get("os_family"):
82
+ formatted_command = (
83
+ f"adb -s {child_hardware.get('slug')} shell {command}"
84
+ )
85
+ else:
86
+ # happy path!
87
+ formatted_command = " ".join(command)
88
+
61
89
  stdin, stdout, stderr = ssh_client.exec_command(formatted_command)
62
- print(stdout.read())
90
+
91
+ stdout_string = stdout.read().decode("utf-8").rstrip("\n")
92
+ stderr_string = stderr.read().decode("utf-8").rstrip("\n")
93
+ if stdout_string != b"":
94
+ print(stdout_string)
95
+ if stderr.read() != b"":
96
+ print(stderr_string)
97
+
63
98
  ssh_client.close()
64
99
  else:
100
+ # if the child hardware has ssh credentials, format the proxy jump
101
+
65
102
  channel = ssh_client.get_transport().open_session()
66
103
  channel.get_pty()
67
104
  channel.invoke_shell()
primitive/git/actions.py CHANGED
@@ -38,9 +38,7 @@ class Git(BaseAction):
38
38
  source_dir = Path(destination).joinpath(git_repo_full_name.split("/")[-1])
39
39
 
40
40
  try:
41
- run(
42
- ["git", "clone", url, source_dir, "--no-checkout"], check=True
43
- )
41
+ run(["git", "clone", url, source_dir, "--no-checkout"], check=True)
44
42
  except CalledProcessError:
45
43
  raise Exception("Failed to download repository")
46
44
 
@@ -20,15 +20,23 @@ from ..utils.config import update_config_file
20
20
  from .graphql.mutations import (
21
21
  hardware_checkin_mutation,
22
22
  hardware_update_mutation,
23
+ register_child_hardware_mutation,
23
24
  register_hardware_mutation,
25
+ unregister_hardware_mutation,
26
+ )
27
+ from .graphql.queries import (
28
+ hardware_details,
29
+ hardware_list,
30
+ nested_children_hardware_list,
24
31
  )
25
- from .graphql.queries import hardware_list
26
32
 
27
33
  if typing.TYPE_CHECKING:
28
34
  pass
29
35
 
30
36
 
37
+ from primitive.hardware.android import AndroidDevice, list_devices
31
38
  from primitive.utils.actions import BaseAction
39
+ from primitive.utils.shell import does_executable_exist
32
40
 
33
41
 
34
42
  class Hardware(BaseAction):
@@ -40,6 +48,7 @@ class Hardware(BaseAction):
40
48
  "isAvailable": False,
41
49
  "isOnline": False,
42
50
  }
51
+ self.children = []
43
52
 
44
53
  def _get_darwin_system_profiler_values(self) -> Dict[str, str]:
45
54
  system_profiler_hardware_data_type = subprocess.check_output(
@@ -276,10 +285,12 @@ class Hardware(BaseAction):
276
285
  return system_info
277
286
 
278
287
  @guard
279
- def register(self):
288
+ def register(self, organization_id: Optional[str] = None):
280
289
  system_info = self.get_system_info()
281
290
  mutation = gql(register_hardware_mutation)
282
291
  input = {"systemInfo": system_info}
292
+ if organization_id:
293
+ input["organizationId"] = organization_id
283
294
  variables = {"input": input}
284
295
  result = self.primitive.session.execute(
285
296
  mutation, variable_values=variables, get_execution_result=True
@@ -303,6 +314,30 @@ class Hardware(BaseAction):
303
314
  # and headers are set correctly
304
315
  self.primitive.get_host_config()
305
316
  self.check_in_http(is_healthy=True)
317
+ for child in self._list_local_children():
318
+ self.register_child(child=child)
319
+ return result
320
+
321
+ @guard
322
+ def unregister(self, organization_id: Optional[str] = None):
323
+ mutation = gql(unregister_hardware_mutation)
324
+ input = {
325
+ "fingerprint": self.primitive.host_config.get("fingerprint"),
326
+ }
327
+ variables = {"input": input}
328
+ result = self.primitive.session.execute(
329
+ mutation, variable_values=variables, get_execution_result=True
330
+ )
331
+
332
+ if messages := result.data.get("unregisterHardware").get("messages"):
333
+ for message in messages:
334
+ logger.enable("primitive")
335
+ if message.get("kind") == "ERROR":
336
+ logger.error(message.get("message"))
337
+ else:
338
+ logger.debug(message.get("message"))
339
+ return False
340
+
306
341
  return result
307
342
 
308
343
  @guard
@@ -355,6 +390,7 @@ class Hardware(BaseAction):
355
390
  is_online: bool = True,
356
391
  ):
357
392
  fingerprint = self.primitive.host_config.get("fingerprint", None)
393
+
358
394
  if not fingerprint:
359
395
  message = (
360
396
  "No fingerprint found. Please register: primitive hardware register"
@@ -417,15 +453,50 @@ class Hardware(BaseAction):
417
453
 
418
454
  @guard
419
455
  def get_hardware_list(
420
- self, fingerprint: Optional[str] = None, slug: Optional[str] = None
456
+ self,
457
+ fingerprint: Optional[str] = None,
458
+ id: Optional[str] = None,
459
+ slug: Optional[str] = None,
460
+ nested_children: Optional[bool] = False,
421
461
  ):
422
- query = gql(hardware_list)
462
+ query = gql(nested_children_hardware_list if nested_children else hardware_list)
463
+
464
+ filters = {
465
+ "isRegistered": {"exact": True},
466
+ }
467
+ if fingerprint is not None:
468
+ filters["fingerprint"] = {"exact": fingerprint}
469
+ if slug is not None:
470
+ filters["slug"] = {"exact": slug}
471
+ if id is not None:
472
+ filters["id"] = {"exact": id}
473
+ if nested_children is True:
474
+ filters["hasParent"] = {"exact": False}
475
+
476
+ variables = {
477
+ "filters": filters,
478
+ }
479
+ result = self.primitive.session.execute(
480
+ query, variable_values=variables, get_execution_result=True
481
+ )
482
+ return result
483
+
484
+ @guard
485
+ def get_hardware_details(
486
+ self,
487
+ fingerprint: Optional[str] = None,
488
+ id: Optional[str] = None,
489
+ slug: Optional[str] = None,
490
+ ):
491
+ query = gql(hardware_details)
423
492
 
424
493
  filters = {}
425
- if fingerprint:
494
+ if fingerprint is not None:
426
495
  filters["fingerprint"] = {"exact": fingerprint}
427
- if slug:
496
+ if slug is not None:
428
497
  filters["slug"] = {"exact": slug}
498
+ if id is not None:
499
+ filters["id"] = {"exact": id}
429
500
 
430
501
  variables = {
431
502
  "first": 1,
@@ -434,7 +505,10 @@ class Hardware(BaseAction):
434
505
  result = self.primitive.session.execute(
435
506
  query, variable_values=variables, get_execution_result=True
436
507
  )
437
- return result
508
+ if edges := result.data["hardwareList"]["edges"]:
509
+ return edges[0]["node"]
510
+ else:
511
+ raise Exception(f"No hardware found with {filters}")
438
512
 
439
513
  def get_own_hardware_details(self):
440
514
  hardware_list_result = self.get_hardware_list(
@@ -448,6 +522,7 @@ class Hardware(BaseAction):
448
522
  def get_hardware_from_slug_or_id(self, hardware_identifier: str):
449
523
  is_id = False
450
524
  is_slug = False
525
+ id = None
451
526
  # first check if the hardware_identifier is a slug or ID
452
527
  try:
453
528
  type_name, id = from_base64(hardware_identifier)
@@ -466,16 +541,45 @@ class Hardware(BaseAction):
466
541
  hardware = None
467
542
 
468
543
  if is_id and is_hardware_type:
469
- hardware_list_result = self.get_hardware_list(slug=hardware_identifier)
470
- if edges := hardware_list_result.data["hardwareList"]["edges"]:
471
- hardware = edges[0]["node"]
472
- else:
473
- raise Exception(f"No hardware found with slug {hardware_identifier}")
544
+ hardware = self.get_hardware_details(id=id)
474
545
  elif is_slug:
475
- hardware_list_result = self.get_hardware_list(slug=hardware_identifier)
476
- if edges := hardware_list_result.data["hardwareList"]["edges"]:
477
- hardware = edges[0]["node"]
478
- else:
479
- raise Exception(f"No hardware found with slug {hardware_identifier}")
546
+ hardware = self.get_hardware_details(slug=hardware_identifier)
480
547
 
481
548
  return hardware
549
+
550
+ @guard
551
+ def register_child(self, child: AndroidDevice):
552
+ system_info = child.system_info
553
+ mutation = gql(register_child_hardware_mutation)
554
+ input = {"childSystemInfo": system_info}
555
+ variables = {"input": input}
556
+ result = self.primitive.session.execute(
557
+ mutation, variable_values=variables, get_execution_result=True
558
+ )
559
+
560
+ if messages := result.data.get("registerChildHardware").get("messages"):
561
+ for message in messages:
562
+ logger.enable("primitive")
563
+ if message.get("kind") == "ERROR":
564
+ logger.error(message.get("message"))
565
+ else:
566
+ logger.debug(message.get("message"))
567
+ return False
568
+ return result
569
+
570
+ def _list_local_children(self) -> List[AndroidDevice]:
571
+ if does_executable_exist("adb"):
572
+ self.children: List[AndroidDevice] = list_devices()
573
+ return self.children
574
+
575
+ @guard
576
+ def _remove_child(self):
577
+ pass
578
+
579
+ @guard
580
+ def _sync_children(self):
581
+ # get the existing children if any from the hardware details
582
+ # get the latest children from the node
583
+ # compare the two and update the node with the latest children
584
+ # remove any children from remote that are not in the latest children
585
+ pass
@@ -0,0 +1,75 @@
1
+ from dataclasses import dataclass, field
2
+ from subprocess import PIPE, Popen
3
+ from typing import Dict
4
+
5
+
6
+ @dataclass
7
+ class AndroidDevice:
8
+ serial: str
9
+ usb: str
10
+ product: str
11
+ model: str
12
+ device: str
13
+ transport_id: str
14
+ system_info: Dict = field(default_factory=lambda: {})
15
+
16
+ def _get_android_values(self):
17
+ """Get the values of the Android device."""
18
+
19
+ # example line:
20
+ # System name | Network (domain) name | Kernel Release number | Kernel Version | Machine (hardware) name
21
+ # Linux localhost 6.1.75-android14-11-g48b922851ac5-ab12039954 #1 SMP PREEMPT Tue Jul 2 09:33:34 UTC 2024 aarch64 Toybox
22
+ uname_output = execute_command(serial=self.serial, command="uname -a")
23
+ self.system_info["name"] = self.serial
24
+ self.system_info["os_family"] = "Android"
25
+ self.system_info["os_release"] = uname_output[2]
26
+ self.system_info["os_version"] = uname_output[3]
27
+ self.system_info["platform"] = ""
28
+ self.system_info["processor"] = ""
29
+ self.system_info["machine"] = uname_output[13]
30
+ self.system_info["architecture"] = "64bit"
31
+ return self.system_info
32
+
33
+
34
+ def list_devices():
35
+ """List all connected Android devices."""
36
+ devices = []
37
+
38
+ with Popen(["adb", "devices", "-l"], stdout=PIPE) as process:
39
+ for line in process.stdout.read().decode("utf-8").split("\n"):
40
+ if line == "":
41
+ continue
42
+
43
+ if "List of devices attached" in line:
44
+ continue
45
+
46
+ device_details_array = [
47
+ detail for detail in line.split(" ") if detail != ""
48
+ ]
49
+
50
+ android_device = AndroidDevice(
51
+ serial=device_details_array[0],
52
+ usb=device_details_array[1],
53
+ product=device_details_array[2],
54
+ model=device_details_array[3],
55
+ device=device_details_array[4],
56
+ transport_id=device_details_array[5],
57
+ )
58
+ android_device._get_android_values()
59
+ devices.append(android_device)
60
+
61
+ return devices
62
+
63
+
64
+ def execute_command(serial: str, command: str):
65
+ """Execute a command on an Android device."""
66
+ with Popen(
67
+ ["adb", "-s", serial, "shell", *command.split(" ")], stdin=PIPE, stdout=PIPE
68
+ ) as process:
69
+ return process.stdout.read().decode("utf-8")
70
+
71
+
72
+ def create_interactive_shell(serial: str, command: str):
73
+ """Create an interactive shell to an Android device."""
74
+ with Popen(["adb", "-s", serial, "shell"], stdin=PIPE, stdout=PIPE) as process:
75
+ return process.stdout.read().decode("utf-8")
@@ -7,6 +7,9 @@ from ..utils.printer import print_result
7
7
  if typing.TYPE_CHECKING:
8
8
  from ..client import Primitive
9
9
 
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
10
13
 
11
14
  @click.group()
12
15
  @click.pass_context
@@ -40,6 +43,21 @@ def register_command(context):
40
43
  print_result(message=message, context=context, fg=color)
41
44
 
42
45
 
46
+ @cli.command("unregister")
47
+ @click.pass_context
48
+ def unregister_command(context):
49
+ """Unregister Hardware with Primitive"""
50
+ primitive: Primitive = context.obj.get("PRIMITIVE")
51
+ result = primitive.hardware.unregister()
52
+ color = "green" if result else "red"
53
+ if not result:
54
+ message = "There was an error unregistering this device. Please review the above logs."
55
+ return
56
+ elif result.data.get("unregisterHardware"):
57
+ message = "Hardware unregistered successfully"
58
+ print_result(message=message, context=context, fg=color)
59
+
60
+
43
61
  @cli.command("checkin")
44
62
  @click.pass_context
45
63
  def checkin_command(context):
@@ -53,11 +71,85 @@ def checkin_command(context):
53
71
  print_result(message=message, context=context, fg="green")
54
72
 
55
73
 
74
+ def hardware_status_string(hardware):
75
+ if activeReservation := hardware.get("activeReservation"):
76
+ if activeReservation.get("status", None) == "in_progress":
77
+ return "Reserved"
78
+ if hardware.get("isQuarantined"):
79
+ return "Quarantined"
80
+ if not hardware.get("isOnline"):
81
+ return "Offline"
82
+ if not hardware.get("isHealthy"):
83
+ return "Not healthy"
84
+ if not hardware.get("isAvailable"):
85
+ return "Not available"
86
+ else:
87
+ return "Available"
88
+
89
+
56
90
  @cli.command("list")
57
91
  @click.pass_context
58
92
  def list_command(context):
59
93
  """List Hardware"""
60
94
  primitive: Primitive = context.obj.get("PRIMITIVE")
61
- get_hardware_list_result = primitive.hardware.get_hardware_list()
95
+ get_hardware_list_result = primitive.hardware.get_hardware_list(
96
+ nested_children=True
97
+ )
62
98
  message = get_hardware_list_result.data
63
- print_result(message=message, context=context)
99
+
100
+ hardware_list = [
101
+ hardware.get("node")
102
+ for hardware in get_hardware_list_result.data.get("hardwareList").get("edges")
103
+ ]
104
+
105
+ message = hardware_list
106
+ if context.obj["JSON"]:
107
+ print_result(message=message, context=context)
108
+ return
109
+
110
+ console = Console()
111
+
112
+ table = Table(show_header=True, header_style="bold magenta")
113
+ table.add_column("Organization")
114
+ table.add_column("Name | Slug")
115
+ table.add_column("Status")
116
+ table.add_column("Reservation")
117
+
118
+ for hardware in hardware_list:
119
+ name = hardware.get("name")
120
+ slug = hardware.get("slug")
121
+ print_name = name
122
+ if name != slug:
123
+ print_name = f"{name} | {slug}"
124
+ child_table = Table(show_header=False, header_style="bold magenta")
125
+ child_table.add_column("Organization")
126
+ child_table.add_column("Name | Slug")
127
+ child_table.add_column("Status")
128
+ child_table.add_column("Reservation", justify="right")
129
+
130
+ table.add_row(
131
+ hardware.get("organization").get("name"),
132
+ print_name,
133
+ hardware_status_string(hardware),
134
+ f"{hardware.get('activeReservation').get('createdBy').get('username')} | {hardware.get('activeReservation').get('status')}"
135
+ if hardware.get("activeReservation", None)
136
+ else "",
137
+ )
138
+
139
+ if len(hardware.get("children", [])) > 0:
140
+ for child in hardware.get("children"):
141
+ name = child.get("name")
142
+ slug = child.get("slug")
143
+ print_name = name
144
+ if name != slug:
145
+ print_name = f"└── {name} | {slug}"
146
+ table.add_row(
147
+ hardware.get("organization").get("name"),
148
+ print_name,
149
+ hardware_status_string(hardware),
150
+ f"{hardware.get('activeReservation').get('createdBy').get('username')} | {hardware.get('activeReservation').get('status')}"
151
+ if hardware.get("activeReservation", None)
152
+ else "",
153
+ )
154
+
155
+ console.print(table)
@@ -10,15 +10,22 @@ fragment HardwareFragment on Hardware {
10
10
  isOnline
11
11
  isQuarantined
12
12
  isHealthy
13
+ systemInfo
13
14
  hostname
14
15
  sshUsername
15
- capabilities {
16
+ organization {
16
17
  id
17
18
  pk
19
+ name
20
+ slug
18
21
  }
19
22
  activeReservation {
20
23
  id
21
24
  pk
25
+ status
26
+ createdBy {
27
+ username
28
+ }
22
29
  }
23
30
  }
24
31
  """
@@ -14,6 +14,35 @@ mutation registerHardware($input: RegisterHardwareInput!) {
14
14
  """
15
15
  )
16
16
 
17
+ register_child_hardware_mutation = (
18
+ operation_info_fragment
19
+ + """
20
+ mutation registerChildHardware($input: RegisterChildHardwareInput!) {
21
+ registerChildHardware(input: $input) {
22
+ ... on Hardware {
23
+ fingerprint
24
+ }
25
+ ...OperationInfoFragment
26
+ }
27
+ }
28
+ """
29
+ )
30
+
31
+ unregister_hardware_mutation = (
32
+ operation_info_fragment
33
+ + """
34
+ mutation unregisterHardware($input: UnregisterHardwareInput!) {
35
+ unregisterHardware(input: $input) {
36
+ ... on Hardware {
37
+ fingerprint
38
+ }
39
+ ...OperationInfoFragment
40
+ }
41
+ }
42
+ """
43
+ )
44
+
45
+
17
46
  hardware_update_mutation = (
18
47
  operation_info_fragment
19
48
  + """
@@ -29,3 +29,69 @@ query hardwareList(
29
29
  }
30
30
  """
31
31
  )
32
+
33
+ nested_children_hardware_list = (
34
+ hardware_fragment
35
+ + """
36
+
37
+ query hardwareList(
38
+ $before: String
39
+ $after: String
40
+ $first: Int
41
+ $last: Int
42
+ $filters: HardwareFilters
43
+ ) {
44
+ hardwareList(
45
+ before: $before
46
+ after: $after
47
+ first: $first
48
+ last: $last
49
+ filters: $filters
50
+ ) {
51
+ totalCount
52
+ edges {
53
+ cursor
54
+ node {
55
+ ...HardwareFragment
56
+ children {
57
+ ...HardwareFragment
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ """
64
+ )
65
+
66
+ hardware_details = (
67
+ hardware_fragment
68
+ + """
69
+
70
+ query hardwareDetails(
71
+ $before: String
72
+ $after: String
73
+ $first: Int
74
+ $last: Int
75
+ $filters: HardwareFilters
76
+ ) {
77
+ hardwareList(
78
+ before: $before
79
+ after: $after
80
+ first: $first
81
+ last: $last
82
+ filters: $filters
83
+ ) {
84
+ totalCount
85
+ edges {
86
+ cursor
87
+ node {
88
+ ...HardwareFragment
89
+ children {
90
+ ...HardwareFragment
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ """
97
+ )
@@ -14,7 +14,7 @@ class Organizations(BaseAction):
14
14
  self,
15
15
  organization_id: Optional[str] = None,
16
16
  slug: Optional[str] = None,
17
- first: Optional[int] = 1,
17
+ first: Optional[int] = None,
18
18
  last: Optional[int] = None,
19
19
  ):
20
20
  query = gql(organizations_query)
@@ -1,14 +1,16 @@
1
1
  import typing
2
- from time import sleep
3
2
 
4
3
  from primitive.graphql.relay import from_base64
5
4
 
6
5
  if typing.TYPE_CHECKING:
7
6
  pass
8
7
 
8
+ from datetime import datetime, timedelta
9
+ from time import sleep
9
10
  from typing import List, Optional
10
11
 
11
12
  from gql import gql
13
+ from loguru import logger
12
14
 
13
15
  from primitive.utils.actions import BaseAction
14
16
 
@@ -57,6 +59,7 @@ class Reservations(BaseAction):
57
59
  def create_reservation(
58
60
  self,
59
61
  reason: str,
62
+ wait: bool = True,
60
63
  requested_hardware_ids: Optional[List[str]] = None,
61
64
  organization_id: Optional[str] = None,
62
65
  hardware_identifier: Optional[str] = None,
@@ -84,6 +87,21 @@ class Reservations(BaseAction):
84
87
  result = self.primitive.session.execute(
85
88
  mutation, variable_values=variables, get_execution_result=True
86
89
  )
90
+ if messages := result.data.get("reservationCreate").get("messages"):
91
+ for message in messages:
92
+ logger.enable("primitive")
93
+ if message.get("kind") == "ERROR":
94
+ logger.error(message.get("message"))
95
+ else:
96
+ logger.debug(message.get("message"))
97
+ return False
98
+
99
+ if wait:
100
+ reservation = result.data["reservationCreate"]
101
+ result = self.wait_for_reservation_status(
102
+ reservation_id=reservation["id"], desired_status="in_progress"
103
+ )
104
+
87
105
  return result
88
106
 
89
107
  @guard
@@ -116,19 +134,47 @@ class Reservations(BaseAction):
116
134
  return result
117
135
 
118
136
  @guard
119
- def wait_for_reservation_status(self, reservation_id: str, desired_status: str):
137
+ def wait_for_reservation_status(
138
+ self, reservation_id: str, desired_status: str, total_sleep_time: int = 30
139
+ ):
120
140
  reservation_result = self.get_reservation(reservation_id=reservation_id)
121
141
  reservation = reservation_result.data["reservation"]
122
142
  current_status = reservation["status"]
123
143
 
124
- sleep_amount = 1
144
+ logger.enable("primitive")
145
+ logger.debug(
146
+ f"Waiting {total_sleep_time}s for reservation {reservation_id} to be in_progress."
147
+ )
148
+
149
+ now = datetime.now()
150
+ future_time = now + timedelta(seconds=total_sleep_time)
151
+
125
152
  while current_status != desired_status:
153
+ now = datetime.now()
154
+ logger.debug(
155
+ f"[{(future_time - now).seconds}s remaining] Waiting for reservation {reservation_id} to be {desired_status}. Current status: {current_status}"
156
+ )
157
+ if now > future_time:
158
+ logger.info(
159
+ f"Reservation {reservation_id} did not reach {desired_status} status in {total_sleep_time}s."
160
+ )
161
+ break
162
+ if current_status == "completed":
163
+ logger.error(
164
+ f"Reservation {reservation_id} concluded with {reservation['conclusionMessage']}. Reason: {reservation['conclusionMessage']}"
165
+ )
166
+ break
167
+
126
168
  reservation_result = self.get_reservation(reservation_id=reservation_id)
127
169
  reservation = reservation_result.data["reservation"]
128
170
  current_status = reservation["status"]
129
171
  if current_status == desired_status:
130
172
  break
131
- sleep(sleep_amount)
132
- sleep_amount += 1
173
+ sleep(1)
174
+
175
+ if current_status == "waiting_for_hardware":
176
+ logger.info(
177
+ f"Reservation {reservation_id} is waiting for hardware to come online."
178
+ )
133
179
 
134
- return reservation
180
+ return reservation_result
@@ -44,11 +44,14 @@ def get(context, reservation_id: str):
44
44
  @click.pass_context
45
45
  @click.argument("hardware_identifier", type=str)
46
46
  @click.argument("reason", type=str)
47
- def create_reservation(context, hardware_identifier: str, reason: str):
47
+ @click.option(
48
+ "--wait", default=True, type=bool, help="Wait for reservation to be in progress."
49
+ )
50
+ def create_reservation(context, hardware_identifier: str, reason: str, wait: bool):
48
51
  """Crate a reservation by a Hardware's ID or Slug"""
49
52
  primitive: Primitive = context.obj.get("PRIMITIVE")
50
53
  create_reservation_result = primitive.reservations.create_reservation(
51
- hardware_identifier=hardware_identifier, reason=reason
54
+ hardware_identifier=hardware_identifier, reason=reason, wait=wait
52
55
  )
53
56
  message = create_reservation_result.data
54
57
  print_result(message=message, context=context)
primitive/utils/shell.py CHANGED
@@ -1,5 +1,6 @@
1
- from pathlib import Path
2
1
  import subprocess
2
+ from pathlib import Path
3
+ from shutil import which
3
4
  from typing import Dict
4
5
 
5
6
 
@@ -56,3 +57,8 @@ def env_string_to_dict(env_str: str) -> Dict:
56
57
  env_dict[current_key] = "\n".join(current_value)
57
58
 
58
59
  return env_dict
60
+
61
+
62
+ def does_executable_exist(executable_name: str) -> bool:
63
+ """Check if an executable is available on the path."""
64
+ return which(executable_name) is not None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primitive
3
- Version: 0.1.89
3
+ Version: 0.1.91
4
4
  Project-URL: Documentation, https://github.com//primitivecorp/primitive-cli#readme
5
5
  Project-URL: Issues, https://github.com//primitivecorp/primitive-cli/issues
6
6
  Project-URL: Source, https://github.com//primitivecorp/primitive-cli
@@ -24,6 +24,7 @@ Requires-Dist: loguru
24
24
  Requires-Dist: paramiko[invoke]
25
25
  Requires-Dist: primitive-pal==0.1.4
26
26
  Requires-Dist: pyyaml
27
+ Requires-Dist: rich>=13.9.4
27
28
  Requires-Dist: speedtest-cli
28
29
  Description-Content-Type: text/markdown
29
30
 
@@ -1,13 +1,13 @@
1
- primitive/__about__.py,sha256=y9oBO8HmXAlJTY3fF293ERSrPhDBBLaBmiz1zVeWKa8,130
1
+ primitive/__about__.py,sha256=Pcjvhbmmtb_HD3hZl5K0wZmVNyhcTeRZL0NrkJqP0yg,130
2
2
  primitive/__init__.py,sha256=bwKdgggKNVssJFVPfKSxqFMz4IxSr54WWbmiZqTMPNI,106
3
3
  primitive/cli.py,sha256=CiI60bG3UZyNFuLTpchr0KeJRG5SALj455Ob11CegGE,2412
4
4
  primitive/client.py,sha256=PPyIQRvKKSqCF9RRF5mJJ4Vqqolpzy1YXqffNLKIvAA,2390
5
5
  primitive/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- primitive/agent/actions.py,sha256=dkqSWsPY8-ipcMO3_rMKaC4WtQiQfUCDVToAnPUCE-Y,6493
6
+ primitive/agent/actions.py,sha256=Ay76qUwMYNERndA0lAvwK8zjVOrR8nMmSi-NeSwIlA8,6817
7
7
  primitive/agent/commands.py,sha256=-dVDilELfkGfbZB7qfEPs77Dm1oT62qJj4tsIk4KoxI,254
8
8
  primitive/agent/process.py,sha256=32eoj0W1-LG-9xxeHia-jk9jTah1cnmjCYnvczgXYGU,3538
9
9
  primitive/agent/provision.py,sha256=rmwnro1K5F8mwtd45XAq7RVQmpDWnbBCQ8X_qgWhm3M,1546
10
- primitive/agent/runner.py,sha256=hzf_4KRdrInc351Fa5DleoKB1sT9pNYnnFvicDS062c,9281
10
+ primitive/agent/runner.py,sha256=B_M0BOeCy7gO7Mp6nfxv-FJ-9yBbujGVNoZziAMcDjc,9357
11
11
  primitive/agent/uploader.py,sha256=OkgwXhWKoECOJnW_ZmpzmUS_cpb-orC_uebNcmf5byw,2948
12
12
  primitive/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  primitive/auth/actions.py,sha256=MPsG9LcKcOPwA7gZ9Ewk0PZJhTQvIrGfODdz4GxSzgA,999
@@ -20,7 +20,7 @@ primitive/daemons/commands.py,sha256=-Muh-6ib4uAVtPn_67AcMrDwuCwYlCnRQozCi2Xurmk
20
20
  primitive/daemons/launch_agents.py,sha256=qovt32gwpjGDd82z_SY5EGCUjaUyNA49pZFajZsw3eE,4796
21
21
  primitive/daemons/launch_service.py,sha256=FPB9qKEjhllRfEpct0ng2L9lpIaGJbQwn1JdFT8uBA8,5600
22
22
  primitive/exec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- primitive/exec/actions.py,sha256=onBOsMwANCKjCf8aSmDhep9RTX7LhtS8nlcjZM6av1w,2632
23
+ primitive/exec/actions.py,sha256=xw9Qyl3dJsMQQH3P_NUefx77PUFgWQXLDRtJmaSMZAY,4163
24
24
  primitive/exec/commands.py,sha256=66LO2kkJC-ynNZQpUCXv4Ol15QoacdSZAHblePDcmLo,510
25
25
  primitive/exec/interactive.py,sha256=TscY6s2ZysijidKPheq6y-fCErUVLS0zcdTW8XyFWGI,2435
26
26
  primitive/files/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -31,7 +31,7 @@ primitive/files/graphql/fragments.py,sha256=II6WHZjzSqX4IELwdiWokqHTKvDq6mMHF5gp
31
31
  primitive/files/graphql/mutations.py,sha256=Da_e6WSp-fsCYVE9A6SGkIQy9WDzjeQycNyHEn7vJqE,935
32
32
  primitive/files/graphql/queries.py,sha256=_ky-IRz928sKeSJuqaggTPxV4CGgmho3OyaAFu1z7nw,397
33
33
  primitive/git/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- primitive/git/actions.py,sha256=kXOJIfD9qDL2yNDIsJ7yZ91GnQiz0z95EeJvWl5Vnhc,1666
34
+ primitive/git/actions.py,sha256=UKzPAK5zw9jKumY0LBcG6vcS1YCCX6tiL6hkSMqnxe0,1636
35
35
  primitive/git/commands.py,sha256=sCeSjkRgSEjCEsB5seXgB_h6xfk0KpvMvzMKoRfUbRA,1177
36
36
  primitive/git/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
37
  primitive/git/graphql/queries.py,sha256=I1HGlqBb1lHIAWVSsC8tVY9JdsQ8DJVqs4nqSTcL30M,98
@@ -40,12 +40,13 @@ primitive/graphql/relay.py,sha256=bmij2AjdpURQ6GGVCxwWhauF-r_SxuAU2oJ4sDbLxpI,72
40
40
  primitive/graphql/sdk.py,sha256=DBFH8vw8FAGvRy8_FZc9WcjnwaQDlXmI8fiYmhCg-b0,1458
41
41
  primitive/graphql/utility_fragments.py,sha256=uIjwILC4QtWNyO5vu77VjQf_p0jvP3A9q_6zRq91zqs,303
42
42
  primitive/hardware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
- primitive/hardware/actions.py,sha256=jtthNgRyeRD8txt4WqEZskPtsDWU2Yg2gJZLSrEx1io,18603
44
- primitive/hardware/commands.py,sha256=_HaWOdRQSkhnA1xZZHZWgadSQ9Gijxtnzg2vc_IDSMA,1854
43
+ primitive/hardware/actions.py,sha256=6dnPUN_hjXy4DIVoKWneiqgdF6UWfwDN82GczAqRykw,22018
44
+ primitive/hardware/android.py,sha256=NcvbDb9ZLl5PCeHII84yLxZo0V48Vn5K75AzkIsg9is,2623
45
+ primitive/hardware/commands.py,sha256=sU9xES9UK3JO1RxZMeC0412pcWIwXWXwr8rmMzz1VGQ,5088
45
46
  primitive/hardware/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
- primitive/hardware/graphql/fragments.py,sha256=PuqhW42fKUvRqOli5W7nOs2RfJ8FruSQC1gKBp3psBQ,271
47
- primitive/hardware/graphql/mutations.py,sha256=Zd6HxnIgTJ9mJQAfKJkdeDfstcPAal6Bj38pnKb_RuI,904
48
- primitive/hardware/graphql/queries.py,sha256=dhihQwr4O7zxDNRjeNWhkAXaSDOBsK-uqIczEGy1XLI,430
47
+ primitive/hardware/graphql/fragments.py,sha256=kI6qnTNjaEaUr-C6eD55COphtueVYbYOWZwN5EW_3qw,350
48
+ primitive/hardware/graphql/mutations.py,sha256=_4Hkbfik9Ron4T-meulu6T-9FR_BZjyPNwn745MPksU,1484
49
+ primitive/hardware/graphql/queries.py,sha256=2k-ZMaSDDO8YsbYNV54pw0yHnsPco6Y9ZfKBOEnQCsw,1348
49
50
  primitive/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
51
  primitive/jobs/actions.py,sha256=CtyO-Z9614TgIoXJJX1QGsoll0fgpBIjG9PJH5JwCQs,4901
51
52
  primitive/jobs/commands.py,sha256=MxPCkBEYW_eLNqgCRYeyj7ZcLOFAWfpVZlqDR2Y_S0o,830
@@ -54,7 +55,7 @@ primitive/jobs/graphql/fragments.py,sha256=GZ_rVc_pc3MfC8EhCY_X9rjeNUdiwEytdqrkn
54
55
  primitive/jobs/graphql/mutations.py,sha256=8ASvCmwQh7cMeeiykOdYaYVryG8FRIuVF6v_J8JJZuw,219
55
56
  primitive/jobs/graphql/queries.py,sha256=BrU_GnLjK0bTAmWsLSmGEUea7EM8MqTKxN1Qp6sSjwc,1597
56
57
  primitive/organizations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- primitive/organizations/actions.py,sha256=o7WnTONtH-WI0kzn71Uq-kF0CRjDS8Xb9YA7DIjYnwY,1085
58
+ primitive/organizations/actions.py,sha256=Tgp_rox0jcvfhQ-LmcWc9vkPdeJu5Bk6U1rNuT9oDnw,1088
58
59
  primitive/organizations/commands.py,sha256=_dwgVEJCqMa5VgB_7P1wLPFc0AuT1p9dtyR9JRr4kpw,487
59
60
  primitive/organizations/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
61
  primitive/organizations/graphql/fragments.py,sha256=a1qKq4FZB5qze0XTo1fOUeGAscIasjn_Ig4gA2_vStY,142
@@ -72,8 +73,8 @@ primitive/provisioning/actions.py,sha256=IYZYAbtomtZtlkqDaBxx4e7PFKGkRNqek_tABH6
72
73
  primitive/provisioning/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
74
  primitive/provisioning/graphql/queries.py,sha256=cBtuKa6shoatYZfKSnQoPJP6B8g8y3QhFqJ_pkvMcG0,134
74
75
  primitive/reservations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
- primitive/reservations/actions.py,sha256=XjjF0UJAgKryuSJqakLMAWshZIbuM-DkTmdU95cANs4,4434
76
- primitive/reservations/commands.py,sha256=OwWWE9DrvtrVBcBki0bKTOqCzCQk090c0rPIAt89JLY,2243
76
+ primitive/reservations/actions.py,sha256=ELQRl9GEVqE_1c7lBxAFS3C9XJuhUEILgDVQkHv8Evc,6257
77
+ primitive/reservations/commands.py,sha256=LFRoV59QGgWIjBdrGjJdffHugg8TLe0Fwlcyu_JaTkk,2369
77
78
  primitive/reservations/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
78
79
  primitive/reservations/graphql/fragments.py,sha256=_TQfJeHky-Hh3WCHWobQ6-A1lpSvU-YkS0V9cqj2nOU,476
79
80
  primitive/reservations/graphql/mutations.py,sha256=IqzwQL7OclN7RpIcidrTQo9cGYofY7wqoBOdnY0pwN8,651
@@ -88,10 +89,10 @@ primitive/utils/files.py,sha256=QUa7c4t2PNvKOtyndLAxQMGvDM4cBftSeFh28xprVbM,752
88
89
  primitive/utils/git.py,sha256=1qNOu8X-33CavmrD580BmrFhD_WVO9PGWHUUboXJR_g,663
89
90
  primitive/utils/memory_size.py,sha256=4xfha21kW82nFvOTtDFx9Jk2ZQoEhkfXii-PGNTpIUk,3058
90
91
  primitive/utils/printer.py,sha256=f1XUpqi5dkTL3GWvYRUGlSwtj2IxU1q745T4Fxo7Tn4,370
91
- primitive/utils/shell.py,sha256=j7E1YwgNWw57dFHVfEbqRNVcPHX0xDefX2vFSNgeI_8,1648
92
+ primitive/utils/shell.py,sha256=vpjr2Y7UQGYOvPGa6_RYXPPjqScfa9k7kT3tugF9h4Y,1837
92
93
  primitive/utils/verible.py,sha256=Zb5NUISvcaIgEvgCDBWr-GCoceMa79Tcwvr5Wl9lfnA,2252
93
- primitive-0.1.89.dist-info/METADATA,sha256=i5JNWnpMH7cIgrZX2mo9EheW1yJD92nspT2x-jD6Br0,3642
94
- primitive-0.1.89.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
95
- primitive-0.1.89.dist-info/entry_points.txt,sha256=p1K8DMCWka5FqLlqP1sPek5Uovy9jq8u51gUsP-z334,48
96
- primitive-0.1.89.dist-info/licenses/LICENSE.txt,sha256=B8kmQMJ2sxYygjCLBk770uacaMci4mPSoJJ8WoDBY_c,1098
97
- primitive-0.1.89.dist-info/RECORD,,
94
+ primitive-0.1.91.dist-info/METADATA,sha256=K3hzrLYG6ODSBpx-Vxz3gpKzzHSBa-VaoCYppm5NpQY,3670
95
+ primitive-0.1.91.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
96
+ primitive-0.1.91.dist-info/entry_points.txt,sha256=p1K8DMCWka5FqLlqP1sPek5Uovy9jq8u51gUsP-z334,48
97
+ primitive-0.1.91.dist-info/licenses/LICENSE.txt,sha256=B8kmQMJ2sxYygjCLBk770uacaMci4mPSoJJ8WoDBY_c,1098
98
+ primitive-0.1.91.dist-info/RECORD,,