primitive 0.2.66__py3-none-any.whl → 0.2.70__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 primitive might be problematic. Click here for more details.

primitive/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Dylan Stein <dylan@primitive.tech>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.2.66"
4
+ __version__ = "0.2.70"
@@ -96,11 +96,14 @@ class Agent(BaseAction):
96
96
  job_run_status = job_run_data.get("status", None)
97
97
 
98
98
  hardware_id = hardware.get("id", None) if hardware else None
99
- execution_hardware_id = (
100
- job_run_data.get("executionHardware", {}).get("id", None)
101
- if job_run_data
102
- else None
103
- )
99
+ execution_hardware_id = None
100
+ if job_run_data:
101
+ execution_hardware = job_run_data.get("executionHardware", None)
102
+ execution_hardware_id = (
103
+ execution_hardware.get("id", None)
104
+ if execution_hardware
105
+ else None
106
+ )
104
107
  target_hardware_id = None
105
108
 
106
109
  if (
primitive/agent/runner.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import os
3
3
  import shutil
4
+ import time
4
5
  import typing
5
6
  from enum import Enum
6
7
  from pathlib import Path, PurePath
@@ -9,6 +10,7 @@ from datetime import datetime, timezone
9
10
 
10
11
  from loguru import logger
11
12
 
13
+ from primitive.network.ssh import wait_for_ssh
12
14
  from primitive.utils.cache import get_artifacts_cache, get_logs_cache, get_sources_cache
13
15
  from primitive.utils.logging import fmt, log_context
14
16
  from primitive.utils.psutil import kill_process_and_children
@@ -230,7 +232,13 @@ class Runner:
230
232
  commands = task["cmd"].strip().split("\n")
231
233
 
232
234
  for i, cmd in enumerate(commands):
235
+ if cmd.strip() == "":
236
+ continue
237
+ if cmd.strip().startswith("#"):
238
+ logger.debug(f"Skipping comment line: {cmd.strip()}")
239
+ continue
233
240
  if cmd == "oobpowercycle":
241
+ logger.info("Performing out-of-band power cycle")
234
242
  from primitive.network.redfish import RedfishClient
235
243
 
236
244
  bmc_host = self.target_hardware_secret.get("bmcHostname", None)
@@ -253,15 +261,32 @@ class Runner:
253
261
  )
254
262
  redfish.compute_system_reset(system_id="1", reset_type="ForceRestart")
255
263
  if self.target_hardware_id:
256
- self.primitive.hardware.update_hardware(
264
+ await self.primitive.hardware.aupdate_hardware(
257
265
  hardware_id=self.target_hardware_id,
258
266
  is_online=False,
259
267
  is_rebooting=True,
260
- start_rebooting_at=datetime.now(timezone.utc),
268
+ start_rebooting_at=str(datetime.now(timezone.utc)),
269
+ )
270
+ logger.info(
271
+ "Box rebooting, waiting 30 seconds before beginning SSH connection."
272
+ )
273
+ time.sleep(30)
274
+ wait_for_ssh(
275
+ hostname=self.target_hardware_secret.get("hostname"),
276
+ username=self.target_hardware_secret.get("username"),
277
+ password=self.target_hardware_secret.get("password"),
278
+ port=22,
279
+ )
280
+ logger.info("Reboot successful, SSH is now available")
281
+ await self.primitive.hardware.aupdate_hardware(
282
+ hardware_id=self.target_hardware_id,
283
+ is_online=True,
284
+ is_rebooting=False,
261
285
  )
262
286
  continue
263
287
 
264
288
  if cmd == "pxeboot":
289
+ logger.info("Setting next boot to PXE and rebooting")
265
290
  from primitive.network.redfish import RedfishClient
266
291
 
267
292
  bmc_host = self.target_hardware_secret.get("bmcHostname", None)
@@ -290,20 +315,35 @@ class Runner:
290
315
  )
291
316
  redfish.compute_system_reset(system_id="1", reset_type="ForceRestart")
292
317
  if self.target_hardware_id:
293
- self.primitive.hardware.update_hardware(
318
+ await self.primitive.hardware.aupdate_hardware(
294
319
  hardware_id=self.target_hardware_id,
295
320
  is_online=False,
296
321
  is_rebooting=True,
297
- start_rebooting_at=datetime.now(timezone.utc),
322
+ start_rebooting_at=str(datetime.now(timezone.utc)),
323
+ )
324
+ logger.info(
325
+ "Box rebooting, waiting 30 seconds before beginning SSH connection."
326
+ )
327
+ time.sleep(30)
328
+ wait_for_ssh(
329
+ hostname=self.target_hardware_secret.get("hostname"),
330
+ username=self.target_hardware_secret.get("username"),
331
+ password=self.target_hardware_secret.get("password"),
332
+ port=22,
333
+ )
334
+ logger.info("PXE boot successful, SSH is now available")
335
+ await self.primitive.hardware.aupdate_hardware(
336
+ hardware_id=self.target_hardware_id,
337
+ is_online=True,
338
+ is_rebooting=False,
298
339
  )
299
340
  continue
300
341
 
301
- args = ["/bin/bash", "-c", cmd]
302
342
  if self.target_hardware_secret:
303
343
  username = self.target_hardware_secret.get("username")
304
344
  password = self.target_hardware_secret.get("password")
305
345
  hostname = self.target_hardware_secret.get("hostname")
306
- args = [
346
+ command_args = [
307
347
  "sshpass",
308
348
  "-p",
309
349
  password,
@@ -316,15 +356,18 @@ class Runner:
316
356
  "IdentitiesOnly=yes",
317
357
  f"{username}@{hostname}",
318
358
  "--",
319
- f'/bin/bash -c "cd {task.get("workdir", "~/")} && {cmd}"',
359
+ f"{cmd}",
320
360
  ]
361
+ print(" ".join(command_args))
362
+ else:
363
+ command_args = ["/bin/bash", "--login", "-c", cmd]
321
364
 
322
365
  logger.info(
323
366
  f"Executing command {i + 1}/{len(commands)}: {cmd} at {self.source_dir / task.get('workdir', '')}"
324
367
  )
325
368
 
326
369
  process = await asyncio.create_subprocess_exec(
327
- *args,
370
+ *command_args,
328
371
  env=self.modified_env,
329
372
  cwd=str(Path(self.source_dir / task.get("workdir", ""))),
330
373
  stdout=asyncio.subprocess.PIPE,
@@ -388,15 +431,16 @@ class Runner:
388
431
 
389
432
  @log_context(label="cleanup")
390
433
  def cleanup(self) -> None:
391
- for glob in self.config.get("stores", []):
392
- # Glob relative to the source directory
393
- matches = self.source_dir.rglob(glob)
394
-
395
- for match in matches:
396
- relative_path = PurePath(match).relative_to(self.source_dir)
397
- dest = Path(get_artifacts_cache(self.job_run["id"]) / relative_path)
398
- dest.parent.mkdir(parents=True, exist_ok=True)
399
- Path(match).replace(dest)
434
+ if stores := self.config.get("stores"):
435
+ for glob in stores:
436
+ # Glob relative to the source directory
437
+ matches = self.source_dir.rglob(glob)
438
+
439
+ for match in matches:
440
+ relative_path = PurePath(match).relative_to(self.source_dir)
441
+ dest = Path(get_artifacts_cache(self.job_run["id"]) / relative_path)
442
+ dest.parent.mkdir(parents=True, exist_ok=True)
443
+ Path(match).replace(dest)
400
444
 
401
445
  shutil.rmtree(path=self.source_dir)
402
446
 
primitive/cli.py CHANGED
@@ -18,6 +18,7 @@ from .projects.commands import cli as projects_commands
18
18
  from .reservations.commands import cli as reservations_commands
19
19
  from .monitor.commands import cli as monitor_commands
20
20
  from .network.commands import cli as network_commands
21
+ from .operating_systems.commands import cli as operating_system_commands
21
22
 
22
23
 
23
24
  @click.group()
@@ -75,6 +76,7 @@ cli.add_command(reservations_commands, "reservations")
75
76
  cli.add_command(exec_commands, "exec")
76
77
  cli.add_command(monitor_commands, "monitor")
77
78
  cli.add_command(network_commands, "network")
79
+ cli.add_command(operating_system_commands, "operating-systems")
78
80
 
79
81
  if __name__ == "__main__":
80
82
  cli(obj={})
primitive/client.py CHANGED
@@ -17,6 +17,7 @@ from .hardware.actions import Hardware
17
17
  from .jobs.actions import Jobs
18
18
  from .monitor.actions import Monitor
19
19
  from .network.actions import Network
20
+ from .operating_systems.actions import OperatingSystems
20
21
  from .organizations.actions import Organizations
21
22
  from .projects.actions import Projects
22
23
  from .provisioning.actions import Provisioning
@@ -99,6 +100,7 @@ class Primitive:
99
100
  self.provisioning: Provisioning = Provisioning(self)
100
101
  self.monitor: Monitor = Monitor(self)
101
102
  self.network: Network = Network(self)
103
+ self.operating_systems: OperatingSystems = OperatingSystems(self)
102
104
 
103
105
  def get_host_config(self):
104
106
  self.full_config = read_config_file()
@@ -62,6 +62,7 @@ class Files(BaseAction):
62
62
  chunk_size: int,
63
63
  number_of_parts: int,
64
64
  is_public: bool = False,
65
+ organization_id: Optional[str] = None,
65
66
  ):
66
67
  mutation = gql(pending_file_create_mutation)
67
68
  input = {
@@ -74,6 +75,8 @@ class Files(BaseAction):
74
75
  "chunkSize": chunk_size,
75
76
  "numberOfParts": number_of_parts,
76
77
  }
78
+ if organization_id:
79
+ input["organizationId"] = organization_id
77
80
  variables = {"input": input}
78
81
  result = self.primitive.session.execute(
79
82
  mutation, variable_values=variables, get_execution_result=True
@@ -223,6 +226,7 @@ class Files(BaseAction):
223
226
  is_public: bool = False,
224
227
  key_prefix: str = "",
225
228
  file_id: Optional[str] = None,
229
+ organization_id: Optional[str] = None,
226
230
  ):
227
231
  if path.exists() is False:
228
232
  raise Exception(f"File {path} does not exist.")
@@ -262,6 +266,7 @@ class Files(BaseAction):
262
266
  is_public=is_public,
263
267
  chunk_size=chunk_size,
264
268
  number_of_parts=number_of_parts,
269
+ organization_id=organization_id,
265
270
  )
266
271
  file_id = pending_file_create.get("id")
267
272
  parts_details = pending_file_create.get("partsDetails")
@@ -332,6 +337,7 @@ class Files(BaseAction):
332
337
  path: Path,
333
338
  is_public: bool = False,
334
339
  key_prefix: str = "",
340
+ organization_id: Optional[str] = None,
335
341
  ):
336
342
  """
337
343
  This method uploads a file via the Primitive API.
@@ -347,6 +353,11 @@ class Files(BaseAction):
347
353
  + file_path
348
354
  + """\", "keyPrefix": \""""
349
355
  + key_prefix
356
+ + (
357
+ f'", "organizationId": "{organization_id}'
358
+ if organization_id
359
+ else ""
360
+ )
350
361
  + """\" } } }"""
351
362
  ) # noqa
352
363
 
@@ -356,6 +367,11 @@ class Files(BaseAction):
356
367
  + file_path
357
368
  + """\", "keyPrefix": \""""
358
369
  + key_prefix
370
+ + (
371
+ f'", "organizationId": "{organization_id}'
372
+ if organization_id
373
+ else ""
374
+ )
359
375
  + """\" } } }"""
360
376
  ) # noqa
361
377
  body = {
@@ -385,6 +401,7 @@ class Files(BaseAction):
385
401
  output_path: Path = Path().cwd(),
386
402
  ) -> Path:
387
403
  file_pk = None
404
+ file_size = None
388
405
 
389
406
  files_result = self.primitive.files.files(
390
407
  file_id=file_id,
@@ -396,6 +413,7 @@ class Files(BaseAction):
396
413
  file = files_data["files"]["edges"][0]["node"]
397
414
  file_pk = file["pk"]
398
415
  file_name = file["fileName"]
416
+ file_size = int(file["fileSize"])
399
417
 
400
418
  if not file_pk:
401
419
  raise Exception(
@@ -404,7 +422,11 @@ class Files(BaseAction):
404
422
 
405
423
  session = create_requests_session(host_config=self.primitive.host_config)
406
424
  transport = self.primitive.host_config.get("transport")
407
- url = f"{transport}://{self.primitive.host}/files/{file_pk}/stream/"
425
+
426
+ if file_size and file_size < 5 * 1024 * 1024:
427
+ url = f"{transport}://{self.primitive.host}/files/{file_pk}/stream/"
428
+ else:
429
+ url = f"{transport}://{self.primitive.host}/files/{file_pk}/presigned-url/"
408
430
 
409
431
  downloaded_file = output_path / file_name
410
432
 
@@ -283,7 +283,177 @@ class Hardware(BaseAction):
283
283
  return {"linux_machine_id": machine_id}
284
284
  return {}
285
285
 
286
- def get_system_info(self):
286
+ def _get_homebrew_installed(self):
287
+ try:
288
+ raw = json.loads(
289
+ subprocess.check_output(["brew", "info", "--json=v2", "--installed"])
290
+ )
291
+
292
+ if isinstance(raw, Dict):
293
+ raw_formulae = raw.get("formulae", [])
294
+ raw_casks = raw.get("casks", [])
295
+
296
+ formulae = []
297
+ casks = []
298
+
299
+ if isinstance(raw_formulae, List):
300
+ for raw_formula in raw_formulae:
301
+ try:
302
+ formulae.append(
303
+ {
304
+ "full_name": raw_formula["full_name"],
305
+ "tap": raw_formula["tap"],
306
+ "version": raw_formula["versions"]["stable"],
307
+ }
308
+ )
309
+ except Exception:
310
+ pass
311
+
312
+ if isinstance(raw_casks, List):
313
+ for raw_cask in raw_casks:
314
+ try:
315
+ casks.append(
316
+ {
317
+ "full_token": raw_cask["full_token"],
318
+ "name": raw_cask["name"][0],
319
+ "version": raw_cask["installed"],
320
+ }
321
+ )
322
+ except Exception:
323
+ pass
324
+
325
+ return {
326
+ "formulae": formulae,
327
+ "casks": casks,
328
+ }
329
+ except Exception:
330
+ pass
331
+
332
+ def _get_darwin_md_application(self, application_path: str):
333
+ lines = (
334
+ subprocess.check_output(
335
+ [
336
+ "mdls",
337
+ "-name",
338
+ "kMDItemDisplayName",
339
+ "-name",
340
+ "kMDItemVersion",
341
+ "-name",
342
+ "kMDItemCFBundleIdentifier",
343
+ application_path,
344
+ ]
345
+ )
346
+ .strip()
347
+ .decode("utf-8")
348
+ .split("\n")
349
+ )
350
+
351
+ item = {
352
+ "path": application_path,
353
+ }
354
+
355
+ for line in lines:
356
+ raw_key, raw_value = line.split(" = ")
357
+
358
+ key = raw_key.strip()
359
+
360
+ if raw_value == "(null)":
361
+ item[key] = None
362
+ elif raw_value.startswith('"') and raw_value.endswith('"'):
363
+ item[key] = raw_value[1:-1]
364
+
365
+ return item
366
+
367
+ def _get_darwin_md_applications(self):
368
+ import concurrent.futures
369
+
370
+ try:
371
+ application_paths = (
372
+ subprocess.check_output(["mdfind", "kMDItemKind == 'Application'"])
373
+ .strip()
374
+ .decode("utf-8")
375
+ .split("\n")
376
+ )
377
+
378
+ with concurrent.futures.ThreadPoolExecutor() as executor:
379
+ return list(
380
+ executor.map(self._get_darwin_md_application, application_paths)
381
+ )
382
+
383
+ except Exception:
384
+ pass
385
+
386
+ def _get_fedora_installed_packages(self):
387
+ try:
388
+ lines = (
389
+ subprocess.check_output(
390
+ [
391
+ "dnf",
392
+ "repoquery",
393
+ "--installed",
394
+ "--qf",
395
+ "%{name} , %{version}\n",
396
+ ]
397
+ )
398
+ .decode("utf-8")
399
+ .strip()
400
+ .split("\n")
401
+ )
402
+
403
+ items = []
404
+
405
+ for line in lines:
406
+ try:
407
+ name, version = line.split(" , ")
408
+
409
+ items.append(
410
+ {
411
+ "name": name,
412
+ "version": version,
413
+ }
414
+ )
415
+ except Exception:
416
+ pass
417
+ return items
418
+ except Exception:
419
+ pass
420
+
421
+ def _get_ubuntu_installed_packages(self):
422
+ try:
423
+ lines = (
424
+ subprocess.check_output(["apt", "list", "--installed"])
425
+ .decode("utf-8")
426
+ .strip()
427
+ .split("\n")
428
+ )
429
+
430
+ lines.pop()
431
+
432
+ items = []
433
+
434
+ for line in lines:
435
+ try:
436
+ columns = line.split()
437
+
438
+ if len(columns) < 2:
439
+ continue
440
+
441
+ name, version, *_ = columns
442
+ items.append(
443
+ {
444
+ "name": name.split("/")[0],
445
+ "version": version,
446
+ }
447
+ )
448
+ except Exception:
449
+ pass
450
+
451
+ return items
452
+
453
+ except Exception:
454
+ pass
455
+
456
+ def get_system_info(self, with_installed_applications: bool = False):
287
457
  os_family = platform.system()
288
458
  system_info = {}
289
459
  if os_family == "Darwin":
@@ -294,6 +464,13 @@ class Hardware(BaseAction):
294
464
  .decode("utf-8")
295
465
  )
296
466
  system_info["apple_mac_os_version"] = platform.mac_ver()[0]
467
+ system_info["homebrew_installed"] = self._get_homebrew_installed()
468
+
469
+ if with_installed_applications:
470
+ system_info["darwin_md_applications"] = (
471
+ self._get_darwin_md_applications()
472
+ )
473
+
297
474
  elif os_family == "Linux":
298
475
  # Support for Linux-based VMs in Windows
299
476
  if "WSL2" in platform.platform():
@@ -304,6 +481,18 @@ class Hardware(BaseAction):
304
481
  }
305
482
  else:
306
483
  system_info = {**system_info, **self._get_ubuntu_values()}
484
+ system_info["linux_release"] = platform.freedesktop_os_release()
485
+
486
+ match system_info["linux_release"]["ID"]:
487
+ case "ubuntu":
488
+ system_info["ubuntu_installed_packages"] = (
489
+ self._get_ubuntu_installed_packages()
490
+ )
491
+ case "fedora":
492
+ system_info["fedora_installed_packages"] = (
493
+ self._get_fedora_installed_packages()
494
+ )
495
+
307
496
  elif os_family == "Windows":
308
497
  system_info = {
309
498
  **system_info,
@@ -981,3 +1170,39 @@ class Hardware(BaseAction):
981
1170
  logger.info(message)
982
1171
 
983
1172
  return result
1173
+
1174
+ @guard
1175
+ async def aupdate_hardware(
1176
+ self,
1177
+ hardware_id: str,
1178
+ is_online: Optional[bool] = None,
1179
+ is_rebooting: Optional[bool] = None,
1180
+ start_rebooting_at: Optional[str] = None,
1181
+ ):
1182
+ new_state: dict = {
1183
+ "id": hardware_id,
1184
+ }
1185
+ if is_online is not None:
1186
+ new_state["isOnline"] = is_online
1187
+ if is_rebooting is not None:
1188
+ new_state["isRebooting"] = is_rebooting
1189
+ if start_rebooting_at is not None:
1190
+ new_state["startRebootingAt"] = start_rebooting_at
1191
+
1192
+ mutation = gql(hardware_update_mutation)
1193
+
1194
+ input = new_state
1195
+ variables = {"input": input}
1196
+ try:
1197
+ result = await self.primitive.session.execute_async(
1198
+ mutation, variable_values=variables, get_execution_result=True
1199
+ )
1200
+ except client_exceptions.ClientConnectorError as exception:
1201
+ message = "Failed to update hardware! "
1202
+ logger.exception(message)
1203
+ raise exception
1204
+
1205
+ message = "Updated hardware successfully! "
1206
+ logger.info(message)
1207
+
1208
+ return result
@@ -22,11 +22,22 @@ def cli(context):
22
22
 
23
23
 
24
24
  @cli.command("systeminfo")
25
+ @click.option(
26
+ "--with-installed-applications",
27
+ is_flag=True,
28
+ default=False,
29
+ help="Only for MacOS, list installed applications using metadata commands",
30
+ )
25
31
  @click.pass_context
26
- def systeminfo_command(context):
32
+ def systeminfo_command(
33
+ context,
34
+ with_installed_applications: bool = False,
35
+ ):
27
36
  """Get System Info"""
28
37
  primitive: Primitive = context.obj.get("PRIMITIVE")
29
- message = primitive.hardware.get_system_info()
38
+ message = primitive.hardware.get_system_info(
39
+ with_installed_applications=with_installed_applications
40
+ )
30
41
  print_result(message=message, context=context)
31
42
 
32
43
 
@@ -191,11 +191,16 @@ class Monitor(BaseAction):
191
191
  job_run_id = None
192
192
  else:
193
193
  hardware_id = hardware.get("id", None) if hardware else None
194
- execution_hardware_id = (
195
- job_run_data.get("executionHardware", {}).get("id", None)
196
- if job_run_data
197
- else None
198
- )
194
+ execution_hardware_id = None
195
+ if job_run_data:
196
+ execution_hardware = job_run_data.get(
197
+ "executionHardware", None
198
+ )
199
+ execution_hardware_id = (
200
+ execution_hardware.get("id", None)
201
+ if execution_hardware
202
+ else None
203
+ )
199
204
 
200
205
  if (
201
206
  hardware_id is not None