primitive 0.2.68__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.68"
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
@@ -231,6 +232,11 @@ class Runner:
231
232
  commands = task["cmd"].strip().split("\n")
232
233
 
233
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
234
240
  if cmd == "oobpowercycle":
235
241
  logger.info("Performing out-of-band power cycle")
236
242
  from primitive.network.redfish import RedfishClient
@@ -261,6 +267,22 @@ class Runner:
261
267
  is_rebooting=True,
262
268
  start_rebooting_at=str(datetime.now(timezone.utc)),
263
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,
285
+ )
264
286
  continue
265
287
 
266
288
  if cmd == "pxeboot":
@@ -299,21 +321,29 @@ class Runner:
299
321
  is_rebooting=True,
300
322
  start_rebooting_at=str(datetime.now(timezone.utc)),
301
323
  )
302
- wait_for_ssh(
303
- hostname=self.target_hardware_secret.get("hostname"),
304
- username=self.target_hardware_secret.get("username"),
305
- password=self.target_hardware_secret.get("password"),
306
- port=22,
307
- )
308
- logger.info("PXE boot successful, SSH is now available")
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,
339
+ )
309
340
  continue
310
341
 
311
- args = ["/bin/bash", "-c", cmd]
312
342
  if self.target_hardware_secret:
313
343
  username = self.target_hardware_secret.get("username")
314
344
  password = self.target_hardware_secret.get("password")
315
345
  hostname = self.target_hardware_secret.get("hostname")
316
- args = [
346
+ command_args = [
317
347
  "sshpass",
318
348
  "-p",
319
349
  password,
@@ -326,15 +356,18 @@ class Runner:
326
356
  "IdentitiesOnly=yes",
327
357
  f"{username}@{hostname}",
328
358
  "--",
329
- f'/bin/bash -c "cd {task.get("workdir", "~/")} && {cmd}"',
359
+ f"{cmd}",
330
360
  ]
361
+ print(" ".join(command_args))
362
+ else:
363
+ command_args = ["/bin/bash", "--login", "-c", cmd]
331
364
 
332
365
  logger.info(
333
366
  f"Executing command {i + 1}/{len(commands)}: {cmd} at {self.source_dir / task.get('workdir', '')}"
334
367
  )
335
368
 
336
369
  process = await asyncio.create_subprocess_exec(
337
- *args,
370
+ *command_args,
338
371
  env=self.modified_env,
339
372
  cwd=str(Path(self.source_dir / task.get("workdir", ""))),
340
373
  stdout=asyncio.subprocess.PIPE,
@@ -398,15 +431,16 @@ class Runner:
398
431
 
399
432
  @log_context(label="cleanup")
400
433
  def cleanup(self) -> None:
401
- for glob in self.config.get("stores", []):
402
- # Glob relative to the source directory
403
- matches = self.source_dir.rglob(glob)
404
-
405
- for match in matches:
406
- relative_path = PurePath(match).relative_to(self.source_dir)
407
- dest = Path(get_artifacts_cache(self.job_run["id"]) / relative_path)
408
- dest.parent.mkdir(parents=True, exist_ok=True)
409
- 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)
410
444
 
411
445
  shutil.rmtree(path=self.source_dir)
412
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,
@@ -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