primitive 0.2.58__tar.gz → 0.2.59__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {primitive-0.2.58 → primitive-0.2.59}/.vscode/settings.json +3 -0
  2. {primitive-0.2.58 → primitive-0.2.59}/PKG-INFO +2 -1
  3. {primitive-0.2.58 → primitive-0.2.59}/pyproject.toml +1 -0
  4. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/__about__.py +1 -1
  5. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/client.py +4 -0
  6. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/graphql/sdk.py +1 -1
  7. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/actions.py +96 -0
  8. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/commands.py +56 -19
  9. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/graphql/mutations.py +18 -0
  10. primitive-0.2.59/src/primitive/messaging/provider.py +127 -0
  11. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/monitor/actions.py +2 -0
  12. primitive-0.2.59/src/primitive/utils/__init__.py +0 -0
  13. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/actions.py +2 -2
  14. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/printer.py +1 -1
  15. primitive-0.2.59/src/primitive/utils/x509.py +140 -0
  16. {primitive-0.2.58 → primitive-0.2.59}/uv.lock +11 -0
  17. {primitive-0.2.58 → primitive-0.2.59}/.git-hooks/pre-commit +0 -0
  18. {primitive-0.2.58 → primitive-0.2.59}/.gitattributes +0 -0
  19. {primitive-0.2.58 → primitive-0.2.59}/.github/workflows/lint.yml +0 -0
  20. {primitive-0.2.58 → primitive-0.2.59}/.github/workflows/publish.yml +0 -0
  21. {primitive-0.2.58 → primitive-0.2.59}/.github/workflows/pyright.yml +0 -0
  22. {primitive-0.2.58 → primitive-0.2.59}/.gitignore +0 -0
  23. {primitive-0.2.58 → primitive-0.2.59}/.vscode/extensions.json +0 -0
  24. {primitive-0.2.58 → primitive-0.2.59}/LICENSE.txt +0 -0
  25. {primitive-0.2.58 → primitive-0.2.59}/Makefile +0 -0
  26. {primitive-0.2.58 → primitive-0.2.59}/README.md +0 -0
  27. {primitive-0.2.58 → primitive-0.2.59}/linux setup.md +0 -0
  28. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/__init__.py +0 -0
  29. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/agent/__init__.py +0 -0
  30. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/agent/actions.py +0 -0
  31. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/agent/commands.py +0 -0
  32. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/agent/runner.py +0 -0
  33. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/agent/uploader.py +0 -0
  34. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/auth/__init__.py +0 -0
  35. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/auth/actions.py +0 -0
  36. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/auth/commands.py +0 -0
  37. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/auth/graphql/__init__.py +0 -0
  38. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/auth/graphql/queries.py +0 -0
  39. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/cli.py +0 -0
  40. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/daemons/__init__.py +0 -0
  41. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/daemons/actions.py +0 -0
  42. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/daemons/commands.py +0 -0
  43. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/daemons/launch_agents.py +0 -0
  44. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/daemons/launch_service.py +0 -0
  45. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/daemons/ui.py +0 -0
  46. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/exec/__init__.py +0 -0
  47. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/exec/actions.py +0 -0
  48. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/exec/commands.py +0 -0
  49. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/exec/interactive.py +0 -0
  50. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/__init__.py +0 -0
  51. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/actions.py +0 -0
  52. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/commands.py +0 -0
  53. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/graphql/__init__.py +0 -0
  54. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/graphql/fragments.py +0 -0
  55. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/graphql/mutations.py +0 -0
  56. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/files/graphql/queries.py +0 -0
  57. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/git/__init__.py +0 -0
  58. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/git/actions.py +0 -0
  59. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/git/commands.py +0 -0
  60. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/git/graphql/__init__.py +0 -0
  61. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/git/graphql/queries.py +0 -0
  62. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/graphql/__init__.py +0 -0
  63. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/graphql/relay.py +0 -0
  64. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/graphql/utility_fragments.py +0 -0
  65. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/__init__.py +0 -0
  66. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/android.py +0 -0
  67. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/graphql/__init__.py +0 -0
  68. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/graphql/fragments.py +0 -0
  69. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/graphql/queries.py +0 -0
  70. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/hardware/ui.py +0 -0
  71. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/__init__.py +0 -0
  72. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/actions.py +0 -0
  73. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/commands.py +0 -0
  74. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/graphql/__init__.py +0 -0
  75. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/graphql/fragments.py +0 -0
  76. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/graphql/mutations.py +0 -0
  77. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/jobs/graphql/queries.py +0 -0
  78. {primitive-0.2.58/src/primitive/organizations → primitive-0.2.59/src/primitive/messaging}/__init__.py +0 -0
  79. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/monitor/commands.py +0 -0
  80. {primitive-0.2.58/src/primitive/organizations/graphql → primitive-0.2.59/src/primitive/organizations}/__init__.py +0 -0
  81. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/organizations/actions.py +0 -0
  82. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/organizations/commands.py +0 -0
  83. {primitive-0.2.58/src/primitive/projects → primitive-0.2.59/src/primitive/organizations/graphql}/__init__.py +0 -0
  84. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/organizations/graphql/fragments.py +0 -0
  85. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/organizations/graphql/mutations.py +0 -0
  86. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/organizations/graphql/queries.py +0 -0
  87. {primitive-0.2.58/src/primitive/projects/graphql → primitive-0.2.59/src/primitive/projects}/__init__.py +0 -0
  88. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/projects/actions.py +0 -0
  89. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/projects/commands.py +0 -0
  90. {primitive-0.2.58/src/primitive/provisioning → primitive-0.2.59/src/primitive/projects/graphql}/__init__.py +0 -0
  91. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/projects/graphql/fragments.py +0 -0
  92. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/projects/graphql/mutations.py +0 -0
  93. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/projects/graphql/queries.py +0 -0
  94. {primitive-0.2.58/src/primitive/provisioning/graphql → primitive-0.2.59/src/primitive/provisioning}/__init__.py +0 -0
  95. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/provisioning/actions.py +0 -0
  96. {primitive-0.2.58/src/primitive/reservations → primitive-0.2.59/src/primitive/provisioning/graphql}/__init__.py +0 -0
  97. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/provisioning/graphql/queries.py +0 -0
  98. {primitive-0.2.58/src/primitive/reservations/graphql → primitive-0.2.59/src/primitive/reservations}/__init__.py +0 -0
  99. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/reservations/actions.py +0 -0
  100. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/reservations/commands.py +0 -0
  101. {primitive-0.2.58/src/primitive/utils → primitive-0.2.59/src/primitive/reservations/graphql}/__init__.py +0 -0
  102. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/reservations/graphql/fragments.py +0 -0
  103. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/reservations/graphql/mutations.py +0 -0
  104. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/reservations/graphql/queries.py +0 -0
  105. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/auth.py +0 -0
  106. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/cache.py +0 -0
  107. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/chunk_size.py +0 -0
  108. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/config.py +0 -0
  109. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/daemons.py +0 -0
  110. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/exceptions.py +0 -0
  111. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/logging.py +0 -0
  112. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/memory_size.py +0 -0
  113. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/psutil.py +0 -0
  114. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/shell.py +0 -0
  115. {primitive-0.2.58 → primitive-0.2.59}/src/primitive/utils/text.py +0 -0
  116. {primitive-0.2.58 → primitive-0.2.59}/tests/__init__.py +0 -0
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "cSpell.words": [
3
3
  "ajob",
4
+ "cafile",
4
5
  "Checkin",
5
6
  "loguru",
6
7
  "machdep",
7
8
  "Mbit",
8
9
  "mbps",
10
+ "percpu",
9
11
  "pkey",
10
12
  "plutil",
11
13
  "procs",
14
+ "RABBITMQ",
12
15
  "sessionmaker",
13
16
  "testsuites",
14
17
  "untar",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primitive
3
- Version: 0.2.58
3
+ Version: 0.2.59
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
@@ -35,6 +35,7 @@ dependencies = [
35
35
  "speedtest-cli",
36
36
  "rich>=13.9.4",
37
37
  "psutil>=7.0.0",
38
+ "pika>=1.3.2",
38
39
  ]
39
40
 
40
41
  [tool.uv]
@@ -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.59"
@@ -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)
@@ -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,28 @@ 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
+ self.primitive.messaging.send_message(
744
+ message_type=MESSAGE_TYPES.METRICS,
745
+ message=self.get_metrics(),
746
+ )
747
+
748
+ def get_metrics(self):
749
+ cpu_percent = psutil.cpu_percent(interval=1, percpu=False)
750
+ virtual_memory = psutil.virtual_memory()
751
+ disk_usage = psutil.disk_usage("/")
752
+
753
+ metrics = {
754
+ "cpu_percent": cpu_percent,
755
+ "memory_percent": virtual_memory.percent,
756
+ "memory_total": virtual_memory.total,
757
+ "memory_available": virtual_memory.available,
758
+ "memory_used": virtual_memory.used,
759
+ "memory_free": virtual_memory.free,
760
+ "disk_percent": disk_usage.percent,
761
+ "disk_total": disk_usage.total,
762
+ "disk_used": disk_usage.used,
763
+ "disk_free": disk_usage.free,
764
+ }
765
+ 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
@@ -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"]:
File without changes
@@ -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
+ )
@@ -801,6 +801,15 @@ wheels = [
801
801
  { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
802
802
  ]
803
803
 
804
+ [[package]]
805
+ name = "pika"
806
+ version = "1.3.2"
807
+ source = { registry = "https://pypi.org/simple" }
808
+ sdist = { url = "https://files.pythonhosted.org/packages/db/db/d4102f356af18f316c67f2cead8ece307f731dd63140e2c71f170ddacf9b/pika-1.3.2.tar.gz", hash = "sha256:b2a327ddddf8570b4965b3576ac77091b850262d34ce8c1d8cb4e4146aa4145f", size = 145029, upload-time = "2023-05-05T14:25:43.368Z" }
809
+ wheels = [
810
+ { url = "https://files.pythonhosted.org/packages/f9/f3/f412836ec714d36f0f4ab581b84c491e3f42c6b5b97a6c6ed1817f3c16d0/pika-1.3.2-py3-none-any.whl", hash = "sha256:0779a7c1fafd805672796085560d290213a465e4f6f76a6fb19e378d8041a14f", size = 155415, upload-time = "2023-05-05T14:25:41.484Z" },
811
+ ]
812
+
804
813
  [[package]]
805
814
  name = "primitive"
806
815
  source = { editable = "." }
@@ -809,6 +818,7 @@ dependencies = [
809
818
  { name = "gql", extra = ["all"] },
810
819
  { name = "loguru" },
811
820
  { name = "paramiko", extra = ["invoke"] },
821
+ { name = "pika" },
812
822
  { name = "psutil" },
813
823
  { name = "rich" },
814
824
  { name = "speedtest-cli" },
@@ -828,6 +838,7 @@ requires-dist = [
828
838
  { name = "gql", extras = ["all"] },
829
839
  { name = "loguru" },
830
840
  { name = "paramiko", extras = ["invoke"] },
841
+ { name = "pika", specifier = ">=1.3.2" },
831
842
  { name = "psutil", specifier = ">=7.0.0" },
832
843
  { name = "rich", specifier = ">=13.9.4" },
833
844
  { name = "speedtest-cli" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes