primitive 0.2.58__py3-none-any.whl → 0.2.60__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: 2025-present Dylan Stein <dylan@primitive.tech>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.2.58"
4
+ __version__ = "0.2.60"
primitive/client.py CHANGED
@@ -5,6 +5,8 @@ from loguru import logger
5
5
  from rich.logging import RichHandler
6
6
  from rich.traceback import install
7
7
 
8
+ from primitive.messaging.provider import MessagingProvider
9
+
8
10
  from .agent.actions import Agent
9
11
  from .auth.actions import Auth
10
12
  from .daemons.actions import Daemons
@@ -80,6 +82,8 @@ class Primitive:
80
82
  else:
81
83
  self.host_config = {"username": "", "token": token, "transport": transport}
82
84
 
85
+ self.messaging: MessagingProvider = MessagingProvider(self)
86
+
83
87
  self.auth: Auth = Auth(self)
84
88
  self.organizations: Organizations = Organizations(self)
85
89
  self.projects: Projects = Projects(self)
@@ -124,15 +124,39 @@ class Files(BaseAction):
124
124
  return result
125
125
 
126
126
  @guard
127
- def get_file(self, file_id: Optional[str] = None):
127
+ def files(
128
+ self,
129
+ file_id: Optional[str] = None,
130
+ file_name: Optional[str] = None,
131
+ organization_id: Optional[str] = None,
132
+ organization_slug: Optional[str] = None,
133
+ ):
128
134
  query = gql(files_list)
129
135
 
130
136
  filters = {}
137
+ if not organization_id and not organization_slug:
138
+ whoami_result = self.primitive.auth.whoami()
139
+ default_organization = whoami_result.data["whoami"]["defaultOrganization"]
140
+ organization_id = default_organization["id"]
141
+ logger.info(
142
+ f"Using default organization ID: {default_organization.get('slug')} ({organization_id})"
143
+ )
144
+ if organization_slug and not organization_id:
145
+ organization = self.primitive.organizations.get_organization(
146
+ slug=organization_slug
147
+ )
148
+ organization_id = organization.get("id")
149
+
150
+ if organization_id:
151
+ filters["organization"] = {"id": organization_id}
152
+
131
153
  if file_id:
132
- filters["id"] = {"exact": file_id}
154
+ filters["id"] = file_id
155
+ if file_name:
156
+ filters["fileName"] = {"exact": file_name}
133
157
 
134
158
  variables = {
135
- "first": 1,
159
+ "first": 25,
136
160
  "filters": filters,
137
161
  }
138
162
  result = self.primitive.session.execute(
@@ -242,9 +266,9 @@ class Files(BaseAction):
242
266
  file_id = pending_file_create.get("id")
243
267
  parts_details = pending_file_create.get("partsDetails")
244
268
  else:
245
- get_file_result = self.get_file(file_id)
269
+ file_result = self.files(file_id=file_id)
246
270
  parts_details = (
247
- get_file_result.data.get("files")
271
+ file_result.data.get("files")
248
272
  .get("edges")[0]
249
273
  .get("node")
250
274
  .get("partsDetails")
@@ -340,14 +364,56 @@ class Files(BaseAction):
340
364
  "fileObject": (path.name, open(path, "rb")),
341
365
  }
342
366
 
343
- session = create_requests_session(self.primitive.host_config)
367
+ session = create_requests_session(host_config=self.primitive.host_config)
344
368
  transport = self.primitive.host_config.get("transport")
345
369
  url = f"{transport}://{self.primitive.host}/"
346
370
  response = session.post(url, files=body)
347
371
  return response
348
372
 
349
- def get_presigned_url(self, file_pk: str):
373
+ def get_presigned_url(self, file_pk: str) -> str:
350
374
  transport = self.primitive.host_config.get("transport")
351
375
  host = self.primitive.host_config.get("host")
352
376
  file_access_url = f"{transport}://{host}/files/{file_pk}/presigned-url/"
353
377
  return file_access_url
378
+
379
+ def download_file(
380
+ self,
381
+ file_name: str = "",
382
+ file_id: str = "",
383
+ organization_id: str = "",
384
+ organization_slug: str = "",
385
+ output_path: Path = Path().cwd(),
386
+ ) -> Path:
387
+ file_pk = None
388
+
389
+ files_result = self.primitive.files.files(
390
+ file_id=file_id,
391
+ file_name=file_name,
392
+ organization_id=organization_id,
393
+ organization_slug=organization_slug,
394
+ )
395
+ if files_data := files_result.data:
396
+ file = files_data["files"]["edges"][0]["node"]
397
+ file_pk = file["pk"]
398
+ file_name = file["fileName"]
399
+
400
+ if not file_pk:
401
+ raise Exception(
402
+ "File not found on remote server. Please check file name or file id"
403
+ )
404
+
405
+ session = create_requests_session(host_config=self.primitive.host_config)
406
+ transport = self.primitive.host_config.get("transport")
407
+ url = f"{transport}://{self.primitive.host}/files/{file_pk}/stream/"
408
+
409
+ downloaded_file = output_path / file_name
410
+
411
+ with session.get(url, stream=True) as response:
412
+ response.raise_for_status()
413
+ with open(downloaded_file, "wb") as partial_downloaded_file:
414
+ for chunk in response.iter_content(chunk_size=8192):
415
+ if chunk:
416
+ partial_downloaded_file.write(chunk)
417
+ partial_downloaded_file.flush()
418
+
419
+ return downloaded_file
@@ -4,6 +4,8 @@ from pathlib import Path
4
4
 
5
5
  import click
6
6
 
7
+ from primitive.files.ui import render_files_table
8
+
7
9
  from ..utils.printer import print_result
8
10
 
9
11
  if typing.TYPE_CHECKING:
@@ -22,7 +24,7 @@ def cli(context):
22
24
  @click.argument("path", type=click.Path(exists=True))
23
25
  @click.option("--public", "-p", help="Is this a Public file", is_flag=True)
24
26
  @click.option("--key-prefix", "-k", help="Key Prefix", default="")
25
- @click.option("--direct", "-k", help="direct", is_flag=True)
27
+ @click.option("--direct", help="direct", is_flag=True)
26
28
  def file_upload_command(context, path, public, key_prefix, direct):
27
29
  """File Upload"""
28
30
  primitive: Primitive = context.obj.get("PRIMITIVE")
@@ -36,8 +38,63 @@ def file_upload_command(context, path, public, key_prefix, direct):
36
38
  path, is_public=public, key_prefix=key_prefix
37
39
  )
38
40
  try:
39
- message = json.dumps(result.data)
41
+ message = json.dumps(result.json())
40
42
  except AttributeError:
41
43
  message = "File Upload Failed"
42
44
 
43
45
  print_result(message=message, context=context)
46
+
47
+
48
+ @cli.command("download")
49
+ @click.pass_context
50
+ @click.option("--file-id", help="File ID", required=False)
51
+ @click.option("--file-name", help="File Name", required=False)
52
+ @click.option("--output", help="Output Path", required=False, type=click.Path())
53
+ @click.option("--organization-id", help="Organization ID", required=False)
54
+ @click.option("--organization", help="Organization Slug", required=False)
55
+ def file_download_command(
56
+ context,
57
+ file_id=None,
58
+ file_name=None,
59
+ output=None,
60
+ organization_id=None,
61
+ organization=None,
62
+ ):
63
+ """File Download"""
64
+ primitive: Primitive = context.obj.get("PRIMITIVE")
65
+
66
+ if not file_id and not file_name:
67
+ raise click.UsageError("Either --id or --file-name must be provided.")
68
+
69
+ if not output:
70
+ output = Path().cwd()
71
+ else:
72
+ output = Path(output)
73
+
74
+ downloaded_file = primitive.files.download_file(
75
+ output_path=output,
76
+ file_id=file_id,
77
+ file_name=file_name,
78
+ organization_id=organization_id,
79
+ organization_slug=organization,
80
+ )
81
+ print_result(message=f"File downloaded to {downloaded_file}", context=context)
82
+
83
+
84
+ @cli.command("list")
85
+ @click.pass_context
86
+ @click.option("--organization-id", help="Organization ID", required=False)
87
+ @click.option("--organization", help="Organization Slug", required=False)
88
+ def list_command(context, organization_id=None, organization=None):
89
+ """List Files"""
90
+ primitive: Primitive = context.obj.get("PRIMITIVE")
91
+ files_result = primitive.files.files(
92
+ organization_id=organization_id, organization_slug=organization
93
+ )
94
+
95
+ files = [file.get("node") for file in files_result.data.get("files").get("edges")]
96
+
97
+ if context.obj["JSON"]:
98
+ print_result(message=files, context=context)
99
+ else:
100
+ render_files_table(files)
@@ -4,7 +4,11 @@ fragment FileFragment on File {
4
4
  pk
5
5
  createdAt
6
6
  updatedAt
7
- createdBy
7
+ createdBy {
8
+ id
9
+ pk
10
+ username
11
+ }
8
12
  location
9
13
  fileName
10
14
  fileSize
@@ -13,6 +17,5 @@ fragment FileFragment on File {
13
17
  isComplete
14
18
  partsDetails
15
19
  humanReadableMemorySize
16
- contents
17
20
  }
18
21
  """
primitive/files/ui.py ADDED
@@ -0,0 +1,37 @@
1
+ from rich.console import Console
2
+ from rich.table import Table
3
+
4
+
5
+ def render_files_table(file_list) -> None:
6
+ console = Console()
7
+
8
+ table = Table(show_header=True, header_style="bold #FFA800")
9
+ table.add_column("File Name")
10
+ table.add_column("File ID")
11
+ table.add_column("File Size (bytes)", justify="right")
12
+
13
+ for file in file_list:
14
+ file_name = file.get("fileName")
15
+ file_id = file.get("id")
16
+ file_size = file.get("fileSize")
17
+
18
+ table.add_row(
19
+ file_name,
20
+ file_id,
21
+ file_size,
22
+ )
23
+
24
+ console.print(table)
25
+
26
+
27
+ def file_status_string(file) -> str:
28
+ if file.get("isQuarantined"):
29
+ return "Quarantined"
30
+ if not file.get("isOnline"):
31
+ return "Offline"
32
+ if not file.get("isHealthy"):
33
+ return "Not healthy"
34
+ if not file.get("isAvailable"):
35
+ return "Not available"
36
+ else:
37
+ return "Available"
primitive/graphql/sdk.py CHANGED
@@ -26,7 +26,7 @@ def create_session(
26
26
  if fingerprint:
27
27
  headers["x-primitive-fingerprint"] = fingerprint
28
28
 
29
- transport = AIOHTTPTransport(url=url, headers=headers)
29
+ transport = AIOHTTPTransport(url=url, headers=headers, ssl=True)
30
30
  session = Client(
31
31
  transport=transport,
32
32
  fetch_schema_from_transport=fetch_schema_from_transport,
@@ -4,15 +4,18 @@ import json
4
4
  import platform
5
5
  import subprocess
6
6
  import typing
7
+ from dataclasses import dataclass
7
8
  from shutil import which
8
9
  from typing import Dict, List, Optional
9
10
 
10
11
  import click
12
+ import psutil
11
13
  from aiohttp import client_exceptions
12
14
  from gql import gql
13
15
  from loguru import logger
14
16
 
15
17
  from primitive.graphql.relay import from_base64
18
+ from primitive.messaging.provider import MESSAGE_TYPES
16
19
  from primitive.utils.memory_size import MemorySize
17
20
 
18
21
  from ..utils.auth import guard
@@ -24,6 +27,7 @@ from .graphql.mutations import (
24
27
  register_child_hardware_mutation,
25
28
  register_hardware_mutation,
26
29
  unregister_hardware_mutation,
30
+ hardware_certificate_create_mutation,
27
31
  )
28
32
  from .graphql.queries import (
29
33
  hardware_details,
@@ -31,6 +35,7 @@ from .graphql.queries import (
31
35
  nested_children_hardware_list,
32
36
  )
33
37
 
38
+
34
39
  if typing.TYPE_CHECKING:
35
40
  pass
36
41
 
@@ -40,6 +45,12 @@ from primitive.utils.actions import BaseAction
40
45
  from primitive.utils.shell import does_executable_exist
41
46
 
42
47
 
48
+ @dataclass
49
+ class CertificateCreateResult:
50
+ certificate_id: str
51
+ certificate_pem: str
52
+
53
+
43
54
  class Hardware(BaseAction):
44
55
  def __init__(self, *args, **kwargs) -> None:
45
56
  super().__init__(*args, **kwargs)
@@ -280,6 +291,48 @@ class Hardware(BaseAction):
280
291
  system_info["gpu_config"] = self._get_gpu_config()
281
292
  return system_info
282
293
 
294
+ @guard
295
+ def certificate_create(
296
+ self, hardware_id: str, csr_pem: str
297
+ ) -> CertificateCreateResult:
298
+ mutation = gql(hardware_certificate_create_mutation)
299
+ variables = {
300
+ "input": {
301
+ "hardwareId": hardware_id,
302
+ "csrPem": csr_pem,
303
+ }
304
+ }
305
+
306
+ if not self.primitive.session:
307
+ raise Exception("No active session available for certificate creation")
308
+
309
+ result = self.primitive.session.execute(
310
+ mutation,
311
+ variable_values=variables,
312
+ get_execution_result=True,
313
+ )
314
+
315
+ if result.errors:
316
+ message = " ".join([error.message for error in result.errors])
317
+ raise Exception(message)
318
+
319
+ if not result.data:
320
+ raise Exception(
321
+ "No data received from hardware certificate creation request"
322
+ )
323
+
324
+ hardware_certificate_create = result.data["hardwareCertificateCreate"]
325
+
326
+ if hardware_certificate_create["__typename"] == "OperationInfo":
327
+ messages = hardware_certificate_create["messages"]
328
+ message = " ".join([error["message"] for error in messages])
329
+ raise Exception(message)
330
+
331
+ return CertificateCreateResult(
332
+ certificate_id=hardware_certificate_create["id"],
333
+ certificate_pem=hardware_certificate_create["certificatePem"],
334
+ )
335
+
283
336
  @guard
284
337
  def register(self, organization_id: Optional[str] = None):
285
338
  system_info = self.get_system_info()
@@ -375,6 +428,24 @@ class Hardware(BaseAction):
375
428
 
376
429
  return result
377
430
 
431
+ def check_in(
432
+ self,
433
+ is_healthy: bool = True,
434
+ is_quarantined: bool = False,
435
+ is_available: bool = False,
436
+ is_online: bool = True,
437
+ stopping_agent: Optional[bool] = False,
438
+ ):
439
+ message = {
440
+ "is_healthy": is_healthy,
441
+ "is_quarantined": is_quarantined,
442
+ "is_available": is_available,
443
+ "is_online": is_online,
444
+ }
445
+ self.primitive.messaging.send_message(
446
+ message_type=MESSAGE_TYPES.CHECK_IN, message=message
447
+ )
448
+
378
449
  @guard
379
450
  def check_in_http(
380
451
  self,
@@ -667,3 +738,29 @@ class Hardware(BaseAction):
667
738
 
668
739
  except Exception as exception:
669
740
  logger.exception(f"Error checking in children: {exception}")
741
+
742
+ def push_metrics(self):
743
+ if self.primitive.messaging.ready:
744
+ self.primitive.messaging.send_message(
745
+ message_type=MESSAGE_TYPES.METRICS,
746
+ message=self.get_metrics(),
747
+ )
748
+
749
+ def get_metrics(self):
750
+ cpu_percent = psutil.cpu_percent(interval=1, percpu=False)
751
+ virtual_memory = psutil.virtual_memory()
752
+ disk_usage = psutil.disk_usage("/")
753
+
754
+ metrics = {
755
+ "cpu_percent": cpu_percent,
756
+ "memory_percent": virtual_memory.percent,
757
+ "memory_total": virtual_memory.total,
758
+ "memory_available": virtual_memory.available,
759
+ "memory_used": virtual_memory.used,
760
+ "memory_free": virtual_memory.free,
761
+ "disk_percent": disk_usage.percent,
762
+ "disk_total": disk_usage.total,
763
+ "disk_used": disk_usage.used,
764
+ "disk_free": disk_usage.free,
765
+ }
766
+ return metrics
@@ -1,15 +1,18 @@
1
+ from time import sleep
1
2
  from typing import TYPE_CHECKING, Optional
2
3
 
3
4
  import click
4
-
5
- from ..utils.printer import print_result
6
- from .ui import render_hardware_table
5
+ from loguru import logger
6
+ from primitive.utils.printer import print_result
7
+ from primitive.hardware.ui import render_hardware_table
8
+ from primitive.utils.x509 import (
9
+ generate_csr_pem,
10
+ write_certificate_pem,
11
+ )
7
12
 
8
13
  if TYPE_CHECKING:
9
14
  from ..client import Primitive
10
15
 
11
- from loguru import logger
12
-
13
16
 
14
17
  @click.group()
15
18
  @click.pass_context
@@ -33,8 +36,17 @@ def systeminfo_command(context):
33
36
  type=str,
34
37
  help="Organization [slug] to register hardware with",
35
38
  )
39
+ @click.option(
40
+ "--issue-certificate",
41
+ is_flag=True,
42
+ show_default=True,
43
+ default=False,
44
+ help="Issue certificate.",
45
+ )
36
46
  @click.pass_context
37
- def register_command(context, organization: Optional[str] = None):
47
+ def register_command(
48
+ context, organization: Optional[str] = None, issue_certificate: bool = False
49
+ ):
38
50
  """Register Hardware with Primitive"""
39
51
  primitive: Primitive = context.obj.get("PRIMITIVE")
40
52
 
@@ -47,14 +59,30 @@ def register_command(context, organization: Optional[str] = None):
47
59
  logger.info("Registering hardware with the default organization.")
48
60
 
49
61
  result = primitive.hardware.register(organization_id=organization_id)
50
- color = "green" if result else "red"
51
- if result.data.get("registerHardware"):
52
- message = "Hardware registered successfully"
53
- else:
54
- message = (
55
- "There was an error registering this device. Please review the above logs."
62
+ hardware = result.data.get("registerHardware")
63
+
64
+ if not hardware:
65
+ print_result(
66
+ fg="red",
67
+ context=context,
68
+ message="There was an error registering this device. Please review the above logs.",
56
69
  )
57
- print_result(message=message, context=context, fg=color)
70
+ return
71
+
72
+ if issue_certificate:
73
+ certificate = primitive.hardware.certificate_create(
74
+ hardware_id=hardware["id"],
75
+ csr_pem=generate_csr_pem(
76
+ hardware_id=hardware["pk"],
77
+ ),
78
+ )
79
+ write_certificate_pem(certificate.certificate_pem)
80
+
81
+ print_result(
82
+ fg="green",
83
+ context=context,
84
+ message="Hardware registered successfully.",
85
+ )
58
86
 
59
87
 
60
88
  @cli.command("unregister")
@@ -77,12 +105,7 @@ def unregister_command(context):
77
105
  def checkin_command(context):
78
106
  """Checkin Hardware with Primitive"""
79
107
  primitive: Primitive = context.obj.get("PRIMITIVE")
80
- check_in_http_result = primitive.hardware.check_in_http()
81
- if messages := check_in_http_result.data.get("checkIn").get("messages"):
82
- print_result(message=messages, context=context, fg="yellow")
83
- else:
84
- message = "Hardware checked in successfully"
85
- print_result(message=message, context=context, fg="green")
108
+ primitive.hardware.check_in()
86
109
 
87
110
 
88
111
  @cli.command("list")
@@ -125,3 +148,17 @@ def get_command(context, hardware_identifier: str) -> None:
125
148
  return
126
149
  else:
127
150
  render_hardware_table([hardware])
151
+
152
+
153
+ @cli.command("metrics")
154
+ @click.pass_context
155
+ @click.option("--watch", is_flag=True, help="Watch hardware metrics")
156
+ def metrics_command(context, watch: bool = False) -> None:
157
+ """Get Hardware Metrics"""
158
+ primitive: Primitive = context.obj.get("PRIMITIVE")
159
+ if watch:
160
+ while True:
161
+ print_result(message=primitive.hardware.get_metrics(), context=context)
162
+ sleep(1)
163
+ else:
164
+ print_result(message=primitive.hardware.get_metrics(), context=context)
@@ -1,11 +1,29 @@
1
1
  from primitive.graphql.utility_fragments import operation_info_fragment
2
2
 
3
+ hardware_certificate_create_mutation = (
4
+ operation_info_fragment
5
+ + """
6
+ mutation hardwareCertificateCreate($input: HardwareCertificateCreateInput!) {
7
+ hardwareCertificateCreate(input: $input) {
8
+ __typename
9
+ ... on HardwareCertificate {
10
+ id
11
+ certificatePem
12
+ }
13
+ ...OperationInfoFragment
14
+ }
15
+ }
16
+ """
17
+ )
18
+
3
19
  register_hardware_mutation = (
4
20
  operation_info_fragment
5
21
  + """
6
22
  mutation registerHardware($input: RegisterHardwareInput!) {
7
23
  registerHardware(input: $input) {
8
24
  ... on Hardware {
25
+ id
26
+ pk
9
27
  fingerprint
10
28
  }
11
29
  ...OperationInfoFragment
primitive/jobs/actions.py CHANGED
@@ -54,6 +54,7 @@ class Jobs(BaseAction):
54
54
  jobs = [edge["node"] for edge in result.data["jobs"]["edges"]]
55
55
  return jobs
56
56
 
57
+ @guard
57
58
  def get_job_runs(
58
59
  self,
59
60
  organization_id: Optional[str] = None,
File without changes
@@ -0,0 +1,127 @@
1
+ import enum
2
+ import json
3
+ from datetime import datetime, timezone
4
+ from typing import TYPE_CHECKING
5
+ from uuid import uuid4
6
+ import pika
7
+ from pika import credentials
8
+ from ..utils.x509 import (
9
+ are_certificate_files_present,
10
+ create_ssl_context,
11
+ read_certificate_common_name,
12
+ )
13
+ from loguru import logger
14
+
15
+ from primitive.utils.actions import BaseAction
16
+
17
+ if TYPE_CHECKING:
18
+ import primitive.client
19
+
20
+ EXCHANGE = "hardware"
21
+ ROUTING_KEY = "hardware"
22
+ VIRTUAL_HOST = "primitive"
23
+ CELERY_TASK_NAME = "hardware.tasks.task_receive_hardware_message"
24
+
25
+
26
+ class MESSAGE_TYPES(enum.Enum):
27
+ CHECK_IN = "CHECK_IN"
28
+ METRICS = "METRICS"
29
+
30
+
31
+ class MessagingProvider(BaseAction):
32
+ def __init__(self, primitive: "primitive.client.Primitive") -> None:
33
+ super().__init__(primitive=primitive)
34
+ self.ready = False
35
+
36
+ self.fingerprint = self.primitive.host_config.get("fingerprint", None)
37
+ if not self.fingerprint:
38
+ return
39
+ self.token = self.primitive.host_config.get("token", None)
40
+ if not self.token:
41
+ return
42
+
43
+ rabbitmq_host = "rabbitmq-cluster.primitive.tech"
44
+ RABBITMQ_PORT = 443
45
+
46
+ if primitive.host == "api.dev.primitive.tech":
47
+ rabbitmq_host = "rabbitmq-cluster.dev.primitive.tech"
48
+ elif primitive.host == "api.primitive.tech":
49
+ rabbitmq_host = "rabbitmq-cluster.primitive.tech"
50
+ elif primitive.host == "api.staging.primitive.tech":
51
+ rabbitmq_host = "rabbitmq-cluster.staging.primitive.tech"
52
+ elif primitive.host == "api.test.primitive.tech":
53
+ rabbitmq_host = "rabbitmq-cluster.test.primitive.tech"
54
+ elif primitive.host == "localhost:8000":
55
+ rabbitmq_host = "localhost"
56
+ RABBITMQ_PORT = 5671
57
+
58
+ if not are_certificate_files_present():
59
+ logger.warning(
60
+ "Certificate files not present or incomplete. MessagingProvider not initialized."
61
+ )
62
+ return
63
+
64
+ ssl_context = create_ssl_context()
65
+ ssl_options = pika.SSLOptions(ssl_context)
66
+ self.common_name = read_certificate_common_name()
67
+
68
+ self.parameters = pika.ConnectionParameters(
69
+ host=rabbitmq_host,
70
+ port=RABBITMQ_PORT,
71
+ virtual_host=VIRTUAL_HOST,
72
+ ssl_options=ssl_options,
73
+ credentials=credentials.ExternalCredentials(),
74
+ )
75
+
76
+ self.ready = True
77
+
78
+ def send_message(self, message_type: MESSAGE_TYPES, message: dict[str, any]): # type: ignore
79
+ if not self.ready:
80
+ logger.warning(
81
+ "send_message: cannot send message. MessagingProvider not initialized."
82
+ )
83
+ return
84
+
85
+ body = {
86
+ "timestamp": datetime.now(timezone.utc).isoformat(),
87
+ "message_type": message_type.value,
88
+ "message": message,
89
+ }
90
+
91
+ full_body_for_celery = [
92
+ [],
93
+ body,
94
+ {"callbacks": None, "errbacks": None, "chain": None, "chord": None},
95
+ ]
96
+ with pika.BlockingConnection(parameters=self.parameters) as conn:
97
+ message_uuid = str(uuid4())
98
+ channel = conn.channel()
99
+
100
+ headers = {
101
+ "fingerprint": self.fingerprint,
102
+ "token": self.token,
103
+ "argsrepr": "()",
104
+ "id": message_uuid,
105
+ "ignore_result": False,
106
+ "kwargsrepr": str(body),
107
+ "lang": "py",
108
+ "replaced_task_nesting": 0,
109
+ "retries": 0,
110
+ "root_id": message_uuid,
111
+ "task": CELERY_TASK_NAME,
112
+ }
113
+
114
+ channel.basic_publish(
115
+ exchange=EXCHANGE,
116
+ routing_key=ROUTING_KEY,
117
+ body=json.dumps(full_body_for_celery),
118
+ properties=pika.BasicProperties(
119
+ user_id=self.common_name,
120
+ correlation_id=message_uuid,
121
+ priority=0,
122
+ delivery_mode=2,
123
+ headers=headers,
124
+ content_encoding="utf-8",
125
+ content_type="application/json",
126
+ ),
127
+ )
@@ -67,6 +67,8 @@ class Monitor(BaseAction):
67
67
  # handles cleanup of old reservations
68
68
  # obtains an active JobRun's ID
69
69
  if not RUNNING_IN_CONTAINER:
70
+ # self.primitive.hardware.push_metrics()
71
+
70
72
  hardware = self.primitive.hardware.get_own_hardware_details()
71
73
  # fetch the latest hardware and activeReservation details
72
74
  if active_reservation_data := hardware["activeReservation"]:
@@ -1,6 +1,6 @@
1
- import typing
1
+ from typing import TYPE_CHECKING
2
2
 
3
- if typing.TYPE_CHECKING:
3
+ if TYPE_CHECKING:
4
4
  import primitive.client
5
5
 
6
6
 
@@ -4,7 +4,7 @@ import click
4
4
  from loguru import logger
5
5
 
6
6
 
7
- def print_result(message: str, context: click.Context, fg: str = None):
7
+ def print_result(message: str | dict, context: click.Context, fg: str = None):
8
8
  """Print message to stdout or stderr"""
9
9
  if context.obj["DEBUG"]:
10
10
  logger.info(json.dumps(message))
@@ -0,0 +1,140 @@
1
+ import ssl
2
+ from pathlib import Path
3
+ from loguru import logger
4
+
5
+ from cryptography.hazmat.primitives import serialization, hashes
6
+ from cryptography.hazmat.primitives.asymmetric import ec
7
+ from cryptography import x509
8
+ from cryptography.x509.oid import NameOID
9
+
10
+ PrivateKey = ec.EllipticCurvePrivateKey
11
+
12
+ HOME_DIRECTORY = Path.home()
13
+
14
+ PRIVATE_KEY_PATH = Path(HOME_DIRECTORY / ".config" / "primitive" / "private-key.pem")
15
+ CERTIFICATE_PATH = Path(HOME_DIRECTORY / ".config" / "primitive" / "certificate.pem")
16
+
17
+ # NOTE Only used with self-signed server certificate
18
+ # THIS IS FOR LOCAL TESTING
19
+ SELF_SIGNED_SERVER_CA_PATH = Path(
20
+ HOME_DIRECTORY / ".config" / "primitive" / "server-ca.crt.pem"
21
+ )
22
+
23
+
24
+ def are_certificate_files_present() -> bool:
25
+ return PRIVATE_KEY_PATH.exists() and CERTIFICATE_PATH.exists()
26
+
27
+
28
+ def generate_private_key() -> PrivateKey:
29
+ return ec.generate_private_key(
30
+ curve=ec.SECP521R1(),
31
+ )
32
+
33
+
34
+ def read_certificate() -> x509.Certificate:
35
+ cert = x509.load_pem_x509_certificate(CERTIFICATE_PATH.read_bytes())
36
+ return cert
37
+
38
+
39
+ def read_certificate_common_name() -> str:
40
+ cert = read_certificate()
41
+
42
+ names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
43
+
44
+ return str(names[0].value)
45
+
46
+
47
+ def create_ssl_context() -> ssl.SSLContext:
48
+ context = ssl.create_default_context(
49
+ cafile=SELF_SIGNED_SERVER_CA_PATH
50
+ if SELF_SIGNED_SERVER_CA_PATH.exists()
51
+ else None,
52
+ purpose=ssl.Purpose.SERVER_AUTH,
53
+ )
54
+ context.load_cert_chain(
55
+ certfile=CERTIFICATE_PATH,
56
+ keyfile=PRIVATE_KEY_PATH,
57
+ )
58
+
59
+ return context
60
+
61
+
62
+ def read_private_key() -> PrivateKey:
63
+ private_key = serialization.load_pem_private_key(
64
+ data=PRIVATE_KEY_PATH.read_bytes(),
65
+ password=None,
66
+ )
67
+
68
+ if not isinstance(private_key, PrivateKey):
69
+ raise Exception(
70
+ f"Expected private key type {PrivateKey.__name__}, got {type(private_key).__name__}"
71
+ )
72
+
73
+ return private_key
74
+
75
+
76
+ def ensure_private_key() -> PrivateKey:
77
+ private_key_path = PRIVATE_KEY_PATH
78
+
79
+ if private_key_path.exists():
80
+ private_key = read_private_key()
81
+ else:
82
+ logger.info("Generating private key.")
83
+
84
+ private_key = generate_private_key()
85
+
86
+ with private_key_path.open("wb") as f:
87
+ f.write(
88
+ private_key.private_bytes(
89
+ encoding=serialization.Encoding.PEM,
90
+ format=serialization.PrivateFormat.PKCS8,
91
+ encryption_algorithm=serialization.NoEncryption(),
92
+ )
93
+ )
94
+
95
+ return private_key
96
+
97
+
98
+ def write_certificate_pem(certificate_pem: str):
99
+ CERTIFICATE_PATH.write_text(
100
+ data=certificate_pem,
101
+ encoding="utf-8",
102
+ )
103
+
104
+
105
+ def check_certificate():
106
+ try:
107
+ if CERTIFICATE_PATH.exists():
108
+ with CERTIFICATE_PATH.open("rb") as file:
109
+ private_key = read_private_key()
110
+ crt = x509.load_pem_x509_certificate(file.read())
111
+
112
+ # NOTE: Make sure certificate match private key
113
+ return crt.public_key() == private_key.public_key()
114
+ except Exception:
115
+ return False
116
+
117
+ return False
118
+
119
+
120
+ def generate_csr_pem(hardware_id: str) -> str:
121
+ builder = x509.CertificateSigningRequestBuilder()
122
+ builder = builder.subject_name(
123
+ x509.Name(
124
+ [
125
+ x509.NameAttribute(NameOID.COMMON_NAME, hardware_id),
126
+ ]
127
+ )
128
+ )
129
+ builder = builder.add_extension(
130
+ x509.BasicConstraints(ca=False, path_length=None),
131
+ critical=True,
132
+ )
133
+ csr = builder.sign(
134
+ algorithm=hashes.SHA512(),
135
+ private_key=ensure_private_key(),
136
+ )
137
+
138
+ return csr.public_bytes(encoding=serialization.Encoding.PEM).decode(
139
+ encoding="utf-8", errors="strict"
140
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primitive
3
- Version: 0.2.58
3
+ Version: 0.2.60
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
@@ -22,6 +22,7 @@ Requires-Dist: click
22
22
  Requires-Dist: gql[all]
23
23
  Requires-Dist: loguru
24
24
  Requires-Dist: paramiko[invoke]
25
+ Requires-Dist: pika>=1.3.2
25
26
  Requires-Dist: psutil>=7.0.0
26
27
  Requires-Dist: rich>=13.9.4
27
28
  Requires-Dist: speedtest-cli
@@ -1,7 +1,7 @@
1
- primitive/__about__.py,sha256=sJwLEcIrjInX1UruXeXYv5PNiQHV2nDo_D6fBYLaMyc,130
1
+ primitive/__about__.py,sha256=PQj7AICmnMMGLJ9kvGOHRMY1k1xLtWYcSN-xiyaE_qY,130
2
2
  primitive/__init__.py,sha256=bwKdgggKNVssJFVPfKSxqFMz4IxSr54WWbmiZqTMPNI,106
3
3
  primitive/cli.py,sha256=g7EtHI9MATAB0qQu5w-WzbXtxz_8zu8z5E7sETmMkKU,2509
4
- primitive/client.py,sha256=RMF46F89oK82gfZH6Bf0WZrhXPUu01pbieSO_Vcuoc4,3624
4
+ primitive/client.py,sha256=gyZIj61qMtOi_s8y0WG3gDehGT-Ms3BtbDP2J_2lajU,3753
5
5
  primitive/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  primitive/agent/actions.py,sha256=f1sSDdFHOflfGQCwNJ5P51YWUTKs8EK5YXbMAvZ-KMM,8018
7
7
  primitive/agent/commands.py,sha256=o847pK7v7EWQGG67tky6a33qtwoutX6LZrP2FIS_NOk,388
@@ -23,10 +23,11 @@ primitive/exec/actions.py,sha256=4d_TCjNDcVFoZ9Zw7ZuBa6hKMv2Xzm7_UX_8wcX1aSk,412
23
23
  primitive/exec/commands.py,sha256=66LO2kkJC-ynNZQpUCXv4Ol15QoacdSZAHblePDcmLo,510
24
24
  primitive/exec/interactive.py,sha256=TscY6s2ZysijidKPheq6y-fCErUVLS0zcdTW8XyFWGI,2435
25
25
  primitive/files/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
- primitive/files/actions.py,sha256=jvsBivYBmPeqb6Ge7gECm_x20AFUL7UYPGJJFmoCeOM,12409
27
- primitive/files/commands.py,sha256=ZNW4y8JZF1li7P5ej1r-Xcqu0iGpRRlMYvthuZOLLbQ,1163
26
+ primitive/files/actions.py,sha256=tuf5PXgNrcxegfzC-CZ6r3iWBkQV9gxZpALU-hjyGpY,14865
27
+ primitive/files/commands.py,sha256=-U0ArpZXDWltmkELG2SYIOLbaiMC6Zv24X9zZ29aqCE,3021
28
+ primitive/files/ui.py,sha256=lYnfu6gnZ5f-C28wps_egVDz8tdjL5P4361uxpSyfvY,919
28
29
  primitive/files/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
- primitive/files/graphql/fragments.py,sha256=II6WHZjzSqX4IELwdiWokqHTKvDq6mMHF5gp3rLnj3U,231
30
+ primitive/files/graphql/fragments.py,sha256=h_Gfi1a3o1_tTJfvW_HUvWmeo3VSSMcj38ObcUv7X7c,253
30
31
  primitive/files/graphql/mutations.py,sha256=Da_e6WSp-fsCYVE9A6SGkIQy9WDzjeQycNyHEn7vJqE,935
31
32
  primitive/files/graphql/queries.py,sha256=_ky-IRz928sKeSJuqaggTPxV4CGgmho3OyaAFu1z7nw,397
32
33
  primitive/git/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -36,25 +37,27 @@ primitive/git/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
36
37
  primitive/git/graphql/queries.py,sha256=I1HGlqBb1lHIAWVSsC8tVY9JdsQ8DJVqs4nqSTcL30M,98
37
38
  primitive/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
39
  primitive/graphql/relay.py,sha256=bmij2AjdpURQ6GGVCxwWhauF-r_SxuAU2oJ4sDbLxpI,726
39
- primitive/graphql/sdk.py,sha256=KhVWDZms_eMBgt6ftSJitRALguagy-nmrj4IC2taeXY,1535
40
+ primitive/graphql/sdk.py,sha256=dE4TD8KiTKw3Y0uiw5XrIcuZGqexE47eSlPaPD6jDGo,1545
40
41
  primitive/graphql/utility_fragments.py,sha256=uIjwILC4QtWNyO5vu77VjQf_p0jvP3A9q_6zRq91zqs,303
41
42
  primitive/hardware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
- primitive/hardware/actions.py,sha256=qBMcCcgSf3XRe-ua1unf5M64J-Y8EelUQKysa8vje0s,26060
43
+ primitive/hardware/actions.py,sha256=4xswcLaHk4IhEwRK5Pw_rpEgn6XciT5K2apnHi8_cI4,29204
43
44
  primitive/hardware/android.py,sha256=tu7pBPxWFrIwb_mm5CEdFFf1_veNDOKjOCQg13i_Lh4,2758
44
- primitive/hardware/commands.py,sha256=NMliVHBZDl4UAvhmNEjrvN9KWPuqn87-d7eVb0ZqEYA,3752
45
+ primitive/hardware/commands.py,sha256=lwO-cm3doGtuGvR30fib-nALJyNCScdCTXOVVbYVpkY,4604
45
46
  primitive/hardware/ui.py,sha256=12rucuZ2s-w5R4bKyxON5dEbrdDnVf5sbj3K_nbdo44,2473
46
47
  primitive/hardware/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
48
  primitive/hardware/graphql/fragments.py,sha256=H315uv-ujOsZEbQR5tmstbRU_9OCa5SErZMwIckSiLc,383
48
- primitive/hardware/graphql/mutations.py,sha256=_4Hkbfik9Ron4T-meulu6T-9FR_BZjyPNwn745MPksU,1484
49
+ primitive/hardware/graphql/mutations.py,sha256=wOKNJN-1x_DGxvdHt2Bm3AUqyW8P1lNPkxLIQThbXUg,1874
49
50
  primitive/hardware/graphql/queries.py,sha256=I86uLuOSjHSph11Y5MVCYko5Js7hoiEZ-cEoPTc4J-k,1392
50
51
  primitive/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
- primitive/jobs/actions.py,sha256=Fej-rpdfIiHjs0SEFY2ZcMn64AWHn6kD90RC5wtRM3Q,6531
52
+ primitive/jobs/actions.py,sha256=-IRNO7KM63cPAbGRNQycyFsugW9hAyg-TDuK0g5AiSw,6542
52
53
  primitive/jobs/commands.py,sha256=MxPCkBEYW_eLNqgCRYeyj7ZcLOFAWfpVZlqDR2Y_S0o,830
53
54
  primitive/jobs/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
55
  primitive/jobs/graphql/fragments.py,sha256=rWZWxZs9ZWjWal0eiY_XPWUS7i7f3fwSM_w8ZvDs2JQ,614
55
56
  primitive/jobs/graphql/mutations.py,sha256=8ASvCmwQh7cMeeiykOdYaYVryG8FRIuVF6v_J8JJZuw,219
56
57
  primitive/jobs/graphql/queries.py,sha256=ZxNmm-WovytbggNuKRnwa0kc26T34_0yhqkoqx-2uj0,1736
57
- primitive/monitor/actions.py,sha256=coGEQkKvkTRO0Zgii4hQOTSk1wa9HkVFDn4OiEJ20r4,9581
58
+ primitive/messaging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
+ primitive/messaging/provider.py,sha256=NCgl1KiV4Ow49Laz2grNGsP7Ij4vUUeorUjkhAKF1H4,4182
60
+ primitive/monitor/actions.py,sha256=AZOoNzASorvye5309h6BJbsup_jR8YunQ19EiG5SAQg,9643
58
61
  primitive/monitor/commands.py,sha256=VDlEL_Qpm_ysHxug7VpI0cVAZ0ny6AS91Y58D7F1zkU,409
59
62
  primitive/organizations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
63
  primitive/organizations/actions.py,sha256=kVHOhG1oS2sI5p8uldSo5L-RUZsnG36eaulVuKLyZ-M,1863
@@ -82,7 +85,7 @@ primitive/reservations/graphql/fragments.py,sha256=h3edd0Es38SNldhHiRU3hAJNYwrUI
82
85
  primitive/reservations/graphql/mutations.py,sha256=IqzwQL7OclN7RpIcidrTQo9cGYofY7wqoBOdnY0pwN8,651
83
86
  primitive/reservations/graphql/queries.py,sha256=x31wTRelskX2fc0fx2qrY7XT1q74nvzLv_Xef3o9weg,746
84
87
  primitive/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
85
- primitive/utils/actions.py,sha256=HOFrmM3-0A_A3NS84MqrZ6JmQEiiPSoDqEeuu6b_qfQ,196
88
+ primitive/utils/actions.py,sha256=Gm84YgXFSpilpYqjG5nxcQTdRSp3waN7ZorzIzMBLtc,208
86
89
  primitive/utils/auth.py,sha256=uBIZNPF2CpbaPV2UMi6eWVUKghV6WIm-pG3-UM29bNs,1465
87
90
  primitive/utils/cache.py,sha256=FHGmVWYLJFQOazpXXcEwI0YJEZbdkgG39nOLdOv6VNk,1575
88
91
  primitive/utils/chunk_size.py,sha256=PAuVuirUTA9oRXyjo1c6MWxo31WVBRkWMuWw-AS58Bw,2914
@@ -91,12 +94,13 @@ primitive/utils/daemons.py,sha256=mSoSHitiGfS4KYAEK9sKsiv_YcACHKgY3qISnDpUUIE,10
91
94
  primitive/utils/exceptions.py,sha256=KSAR2zqWEjQhqb6OAtaMUvYejKPETdBVRRRIKzy90A8,554
92
95
  primitive/utils/logging.py,sha256=vpwu-hByZC1BgJfUi6iSfAxzCobP_zg9-99EUf80KtY,1132
93
96
  primitive/utils/memory_size.py,sha256=4xfha21kW82nFvOTtDFx9Jk2ZQoEhkfXii-PGNTpIUk,3058
94
- primitive/utils/printer.py,sha256=f1XUpqi5dkTL3GWvYRUGlSwtj2IxU1q745T4Fxo7Tn4,370
97
+ primitive/utils/printer.py,sha256=Iy9RkTyDZ6CcoTZfiNTPMAAT2rM8U0Reku4RjQf_DqM,377
95
98
  primitive/utils/psutil.py,sha256=xa7ef435UL37jyjmUPbEqCO2ayQMpCs0HCrxVEvLcuM,763
96
99
  primitive/utils/shell.py,sha256=Z4zxmOaSyGCrS0D6I436iQci-ewHLt4UxVg1CD9Serc,2171
97
100
  primitive/utils/text.py,sha256=XiESMnlhjQ534xE2hMNf08WehE1SKaYFRNih0MmnK0k,829
98
- primitive-0.2.58.dist-info/METADATA,sha256=LZUy9sTcKO-Qm4NchZ02_oHQLhfgfRHVHM9WFCu2ctQ,3513
99
- primitive-0.2.58.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
100
- primitive-0.2.58.dist-info/entry_points.txt,sha256=p1K8DMCWka5FqLlqP1sPek5Uovy9jq8u51gUsP-z334,48
101
- primitive-0.2.58.dist-info/licenses/LICENSE.txt,sha256=B8kmQMJ2sxYygjCLBk770uacaMci4mPSoJJ8WoDBY_c,1098
102
- primitive-0.2.58.dist-info/RECORD,,
101
+ primitive/utils/x509.py,sha256=HwHRPqakTHWd40ny-9O_yNknSL1Cxo50O0UCjXHFq04,3796
102
+ primitive-0.2.60.dist-info/METADATA,sha256=8_ftdlVATEzZHXucRmr6BnzxWhjjCPNhGju0EnVFX9c,3540
103
+ primitive-0.2.60.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
104
+ primitive-0.2.60.dist-info/entry_points.txt,sha256=p1K8DMCWka5FqLlqP1sPek5Uovy9jq8u51gUsP-z334,48
105
+ primitive-0.2.60.dist-info/licenses/LICENSE.txt,sha256=B8kmQMJ2sxYygjCLBk770uacaMci4mPSoJJ8WoDBY_c,1098
106
+ primitive-0.2.60.dist-info/RECORD,,