primitive 0.2.10__py3-none-any.whl → 0.2.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
primitive/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2024-present Dylan Stein <dylan@primitive.tech>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.2.10"
4
+ __version__ = "0.2.12"
@@ -6,7 +6,8 @@ from loguru import logger
6
6
  from primitive.__about__ import __version__
7
7
  from primitive.utils.actions import BaseAction
8
8
 
9
- from ..utils.exceptions import P_CLI_100
9
+ from ..db import sqlite
10
+ from ..db.models import JobRun
10
11
  from .runner import Runner
11
12
  from .uploader import Uploader
12
13
 
@@ -15,154 +16,95 @@ class Agent(BaseAction):
15
16
  def execute(
16
17
  self,
17
18
  ):
18
- logger.enable("primitive")
19
19
  logger.remove()
20
20
  logger.add(
21
21
  sink=sys.stderr,
22
- # catch=True,
22
+ format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <level>{message}</level>",
23
23
  backtrace=True,
24
24
  diagnose=True,
25
+ level="DEBUG" if self.primitive.DEBUG else "INFO",
25
26
  )
26
- logger.info(" [*] primitive")
27
- logger.info(f" [*] Version: {__version__}")
27
+ logger.info("[*] primitive agent")
28
+ logger.info(f"[*] Version: {__version__}")
29
+
30
+ # Initialize the database
31
+ sqlite.init()
28
32
 
29
33
  # Create uploader
30
34
  uploader = Uploader(primitive=self.primitive)
31
35
 
32
- # self.primitive.hardware.update_hardware_system_info()
33
- try:
34
- # hey stupid:
35
- # do not set is_available to True here, it will mess up the reservation logic
36
- # only set is_available after we've checked that no active reservation is present
37
- # setting is_available of the parent also effects the children,
38
- # which may have active reservations as well
39
- self.primitive.hardware.check_in_http(is_online=True)
40
- except Exception as exception:
41
- logger.exception(f"Error checking in hardware: {exception}")
42
- sys.exit(1)
43
-
44
36
  try:
45
- active_reservation_id = None
46
- active_reservation_pk = None
47
-
48
37
  while True:
49
38
  logger.debug("Scanning for files to upload...")
50
39
  uploader.scan()
51
40
 
52
- logger.debug("Syncing children...")
53
- self.primitive.hardware._sync_children()
54
-
55
- hardware = self.primitive.hardware.get_own_hardware_details()
56
-
57
- if hardware["activeReservation"]:
58
- if (
59
- hardware["activeReservation"]["id"] != active_reservation_id
60
- or hardware["activeReservation"]["pk"] != active_reservation_pk
61
- ):
62
- logger.warning("New reservation for this hardware.")
63
- active_reservation_id = hardware["activeReservation"]["id"]
64
- active_reservation_pk = hardware["activeReservation"]["pk"]
65
- logger.debug("Active Reservation:")
66
- logger.debug(f"Node ID: {active_reservation_id}")
67
- logger.debug(f"PK: {active_reservation_pk}")
68
-
69
- logger.debug("Running pre provisioning steps for reservation.")
70
- self.primitive.provisioning.add_reservation_authorized_keys(
71
- reservation_id=active_reservation_id
72
- )
73
- else:
74
- if (
75
- hardware["activeReservation"] is None
76
- and active_reservation_id is not None
77
- # and hardware["isAvailable"] NOTE: this condition was causing the CLI to get into a loop searching for job runs
78
- ):
79
- logger.debug("Previous Reservation is Complete:")
80
- logger.debug(f"Node ID: {active_reservation_id}")
81
- logger.debug(f"PK: {active_reservation_pk}")
82
- logger.debug(
83
- "Running cleanup provisioning steps for reservation."
84
- )
85
- self.primitive.provisioning.remove_reservation_authorized_keys(
86
- reservation_id=active_reservation_id
87
- )
88
- active_reservation_id = None
89
- active_reservation_pk = None
90
-
91
- if not active_reservation_id:
92
- self.primitive.hardware.check_in_http(
93
- is_available=True, is_online=True
94
- )
41
+ db_job_run = JobRun.objects.first()
42
+
43
+ if not db_job_run:
95
44
  sleep_amount = 5
96
45
  logger.debug(
97
- f"No active reservation found... [sleeping {sleep_amount} seconds]"
46
+ f"No pending job runs... [sleeping {sleep_amount} seconds]"
98
47
  )
99
48
  sleep(sleep_amount)
100
49
  continue
101
50
 
102
- job_runs_result = self.primitive.jobs.get_job_runs(
103
- status="pending", first=1, reservation_id=active_reservation_id
51
+ api_job_run_data = self.primitive.jobs.get_job_run(
52
+ id=db_job_run.job_run_id,
104
53
  )
105
54
 
106
- pending_job_runs = [
107
- edge["node"] for edge in job_runs_result.data["jobRuns"]["edges"]
108
- ]
109
-
110
- if not pending_job_runs:
111
- sleep_amount = 5
112
- logger.debug(
113
- f"Waiting for Job Runs... [sleeping {sleep_amount} seconds]"
55
+ if not api_job_run_data or not api_job_run_data.data:
56
+ logger.error(
57
+ f"Job Run {db_job_run.job_run_id} not found in API, deleting from DB"
114
58
  )
115
- sleep(sleep_amount)
59
+ JobRun.objects.filter_by(job_run_id=db_job_run.job_run_id).delete()
116
60
  continue
117
61
 
118
- for job_run in pending_job_runs:
119
- logger.debug("Found pending Job Run")
120
- logger.debug(f"Job Run ID: {job_run['id']}")
121
- logger.debug(f"Job Name: {job_run['job']['name']}")
62
+ api_job_run = api_job_run_data.data["jobRun"]
122
63
 
123
- runner = Runner(
124
- primitive=self.primitive,
125
- job_run=job_run,
126
- max_log_size=500 * 1024,
64
+ logger.debug("Found pending Job Run")
65
+ logger.debug(f"Job Run ID: {api_job_run.get('id')}")
66
+ logger.debug(f"Job Name: {api_job_run.get('name')}")
67
+
68
+ runner = Runner(
69
+ primitive=self.primitive,
70
+ job_run=api_job_run,
71
+ # max_log_size=500 * 1024,
72
+ )
73
+
74
+ try:
75
+ runner.setup()
76
+ except Exception as exception:
77
+ logger.exception(
78
+ f"Exception while initializing runner: {exception}"
127
79
  )
80
+ self.primitive.jobs.job_run_update(
81
+ id=api_job_run.get("id"),
82
+ status="request_completed",
83
+ conclusion="failure",
84
+ )
85
+ JobRun.objects.filter_by(job_run_id=api_job_run.get("id")).delete()
86
+ continue
128
87
 
129
- try:
130
- runner.setup()
131
- except Exception as exception:
132
- logger.exception(
133
- f"Exception while initializing runner: {exception}"
134
- )
135
- self.primitive.jobs.job_run_update(
136
- id=job_run["id"],
137
- status="request_completed",
138
- conclusion="failure",
139
- )
140
- continue
141
-
142
- try:
143
- runner.execute()
144
- except Exception as exception:
145
- logger.exception(f"Exception while executing job: {exception}")
146
- self.primitive.jobs.job_run_update(
147
- id=job_run["id"],
148
- status="request_completed",
149
- conclusion="failure",
150
- )
151
- finally:
152
- runner.cleanup()
153
-
154
- # NOTE: also run scan here to force upload of artifacts
155
- # This should probably eventuall be another daemon?
156
- uploader.scan()
88
+ try:
89
+ runner.execute()
90
+ except Exception as exception:
91
+ logger.exception(f"Exception while executing job: {exception}")
92
+ self.primitive.jobs.job_run_update(
93
+ id=api_job_run.get("id"),
94
+ status="request_completed",
95
+ conclusion="failure",
96
+ )
97
+ finally:
98
+ runner.cleanup()
99
+
100
+ # NOTE: also run scan here to force upload of artifacts
101
+ # This should probably eventually be another daemon?
102
+ uploader.scan()
103
+
104
+ JobRun.objects.filter_by(
105
+ job_run_id=api_job_run.get("id"),
106
+ ).delete()
157
107
 
158
108
  sleep(5)
159
109
  except KeyboardInterrupt:
160
- logger.info(" [*] Stopping primitive...")
161
- try:
162
- self.primitive.hardware.check_in_http(
163
- is_available=False, is_online=False, stopping_agent=True
164
- )
165
- except P_CLI_100 as exception:
166
- logger.error(" [*] Error stopping primitive.")
167
- logger.error(str(exception))
168
- sys.exit()
110
+ logger.info("[*] Stopping primitive agent...")
@@ -1,6 +1,7 @@
1
- import click
2
1
  import typing
3
2
 
3
+ import click
4
+
4
5
  if typing.TYPE_CHECKING:
5
6
  from ..client import Primitive
6
7
 
primitive/agent/runner.py CHANGED
@@ -2,7 +2,6 @@ import asyncio
2
2
  import os
3
3
  import re
4
4
  import shutil
5
- import time
6
5
  import typing
7
6
  from abc import abstractmethod
8
7
  from enum import Enum, IntEnum
@@ -12,6 +11,7 @@ from typing import Dict, List, TypedDict
12
11
  import yaml
13
12
  from loguru import logger
14
13
 
14
+ from ..db.models import JobRun
15
15
  from ..utils.cache import get_artifacts_cache, get_logs_cache, get_sources_cache
16
16
  from ..utils.shell import env_to_dict
17
17
 
@@ -80,8 +80,6 @@ class Runner:
80
80
  self.modified_env = {}
81
81
  self.file_logger = None
82
82
 
83
- logger.enable("primitive")
84
-
85
83
  # If max_log_size set to <= 0, disable file logging
86
84
  if max_log_size > 0:
87
85
  log_name = f"{self.job['slug']}_{self.job_run['jobRunNumber']}_{{time}}.primitive.log"
@@ -158,22 +156,52 @@ class Runner:
158
156
  self.modified_env = {**self.initial_env}
159
157
 
160
158
  task_failed = False
161
- conclusion = "success"
159
+ cancelled = False
160
+
162
161
  for task in self.config["executes"]:
162
+ # the get status check here is to ensure that if cancel is called
163
+ # while one task is running, we do not run any OTHER laebeled tasks
164
+ # THIS is required for MULTI STEP JOBS
165
+ status = self.primitive.jobs.get_job_status(self.job_run["id"])
166
+ status_value = status.data["jobRun"]["status"]
167
+ conclusion_value = status.data["jobRun"]["conclusion"]
168
+
169
+ if status_value == "completed" and conclusion_value == "cancelled":
170
+ cancelled = True
171
+ break
172
+
163
173
  with logger.contextualize(label=task["label"]):
164
174
  with asyncio.Runner() as async_runner:
165
175
  if task_failed := async_runner.run(self.run_task(task)):
166
176
  break
167
177
 
178
+ number_of_files_produced = self.get_number_of_files_produced()
179
+ logger.info(
180
+ f"Produced {number_of_files_produced} files for {self.job['slug']} job"
181
+ )
182
+
183
+ # FOR NONE MULTI STEP JOBS
184
+ # we still have to check that the job was cancelled here as well
185
+ status = self.primitive.jobs.get_job_status(self.job_run["id"])
186
+ status_value = status.data["jobRun"]["status"]
187
+ conclusion_value = status.data["jobRun"]["conclusion"]
188
+ if status_value == "completed" and conclusion_value == "cancelled":
189
+ cancelled = True
190
+
191
+ if cancelled:
192
+ logger.warning("Job cancelled by user")
193
+ self.primitive.jobs.job_run_update(
194
+ self.job_run["id"],
195
+ number_of_files_produced=number_of_files_produced,
196
+ )
197
+ return
198
+
199
+ conclusion = "success"
168
200
  if task_failed:
169
201
  conclusion = "failure"
170
202
  else:
171
203
  logger.success(f"Completed {self.job['slug']} job")
172
204
 
173
- number_of_files_produced = self.get_number_of_files_produced()
174
- logger.info(
175
- f"Produced {number_of_files_produced} files for {self.job['slug']} job"
176
- )
177
205
  self.primitive.jobs.job_run_update(
178
206
  self.job_run["id"],
179
207
  status="request_completed",
@@ -249,24 +277,24 @@ class Runner:
249
277
  stderr=asyncio.subprocess.PIPE,
250
278
  )
251
279
 
252
- loop = asyncio.get_running_loop()
253
- monitor_task = loop.run_in_executor(None, self.monitor_cmd, process)
280
+ JobRun.objects.filter_by(job_run_id=self.job_run["id"]).update(
281
+ {"pid": process.pid}
282
+ )
254
283
 
255
- stdout_failed, stderr_failed, cancelled = await asyncio.gather(
284
+ stdout_failed, stderr_failed = await asyncio.gather(
256
285
  self.log_cmd(
257
286
  process=process, stream=process.stdout, tags=task.get("tags", {})
258
287
  ),
259
288
  self.log_cmd(
260
289
  process=process, stream=process.stderr, tags=task.get("tags", {})
261
290
  ),
262
- monitor_task,
263
291
  )
264
292
 
265
293
  returncode = await process.wait()
266
294
 
267
- if cancelled:
268
- logger.warning("Job cancelled by user")
269
- return True
295
+ JobRun.objects.filter_by(job_run_id=self.job_run["id"]).update(
296
+ {"pid": None}
297
+ )
270
298
 
271
299
  if returncode > 0:
272
300
  logger.error(
@@ -355,25 +383,6 @@ class Runner:
355
383
 
356
384
  return [line for line in lines if len(line) > 0]
357
385
 
358
- def monitor_cmd(self, process) -> bool:
359
- while process.returncode is None:
360
- status = self.primitive.jobs.get_job_status(self.job_run["id"])
361
-
362
- status_value = status.data["jobRun"]["status"]
363
- conclusion_value = status.data["jobRun"]["conclusion"]
364
-
365
- if status_value == "completed" and conclusion_value == "cancelled":
366
- try:
367
- process.terminate()
368
- except ProcessLookupError:
369
- pass
370
-
371
- return True
372
-
373
- time.sleep(10)
374
-
375
- return False
376
-
377
386
  def cleanup(self) -> None:
378
387
  logger.remove(self.file_logger)
379
388
 
@@ -50,8 +50,8 @@ class Uploader:
50
50
  path=file,
51
51
  key_prefix=str(PurePath(file).relative_to(cache.parent).parent),
52
52
  )
53
- except Exception as e:
54
- if "is empty" in str(e):
53
+ except Exception as exception:
54
+ if "is empty" in str(exception):
55
55
  logger.warning(f"{file} is empty, skipping upload")
56
56
  continue
57
57
 
primitive/cli.py CHANGED
@@ -16,6 +16,7 @@ from .jobs.commands import cli as jobs_commands
16
16
  from .organizations.commands import cli as organizations_commands
17
17
  from .projects.commands import cli as projects_commands
18
18
  from .reservations.commands import cli as reservations_commands
19
+ from .monitor.commands import cli as monitor_commands
19
20
 
20
21
 
21
22
  @click.group()
@@ -71,6 +72,7 @@ cli.add_command(organizations_commands, "organizations")
71
72
  cli.add_command(projects_commands, "projects")
72
73
  cli.add_command(reservations_commands, "reservations")
73
74
  cli.add_command(exec_commands, "exec")
75
+ cli.add_command(monitor_commands, "monitor")
74
76
 
75
77
  if __name__ == "__main__":
76
78
  cli(obj={})
primitive/client.py CHANGED
@@ -1,7 +1,8 @@
1
- import sys
2
-
3
1
  from gql import Client
4
2
  from loguru import logger
3
+ from rich.logging import RichHandler
4
+ from rich.traceback import install
5
+ from typing import Optional
5
6
 
6
7
  from .agent.actions import Agent
7
8
  from .auth.actions import Auth
@@ -15,10 +16,9 @@ from .organizations.actions import Organizations
15
16
  from .projects.actions import Projects
16
17
  from .provisioning.actions import Provisioning
17
18
  from .reservations.actions import Reservations
19
+ from .monitor.actions import Monitor
18
20
  from .utils.config import read_config_file
19
21
 
20
- logger.disable("primitive")
21
-
22
22
 
23
23
  class Primitive:
24
24
  def __init__(
@@ -26,24 +26,48 @@ class Primitive:
26
26
  host: str = "api.primitive.tech",
27
27
  DEBUG: bool = False,
28
28
  JSON: bool = False,
29
- token: str = None,
30
- transport: str = None,
29
+ token: Optional[str] = None,
30
+ transport: Optional[str] = None,
31
31
  ) -> None:
32
32
  self.host: str = host
33
- self.session: Client = None
33
+ self.session: Optional[Client] = None
34
34
  self.DEBUG: bool = DEBUG
35
35
  self.JSON: bool = JSON
36
36
 
37
+ # Enable tracebacks with local variables
37
38
  if self.DEBUG:
38
- logger.enable("primitive")
39
- logger.remove()
40
- logger.add(
41
- sink=sys.stderr,
42
- serialize=self.JSON,
43
- catch=True,
44
- backtrace=True,
45
- diagnose=True,
46
- )
39
+ install(show_locals=True)
40
+
41
+ # Configure rich logging handler
42
+ rich_handler = RichHandler(
43
+ rich_tracebacks=self.DEBUG, # Pretty tracebacks
44
+ markup=True, # Allow Rich markup tags
45
+ show_time=self.DEBUG, # Show timestamps
46
+ show_level=self.DEBUG, # Show log levels
47
+ show_path=self.DEBUG, # Hide source path (optional)
48
+ )
49
+
50
+ def formatter(record) -> str:
51
+ match record["level"].name:
52
+ case "ERROR":
53
+ return "[bold red]Error>[/bold red] {name}:{function}:{line} - {message}"
54
+ case "CRITICAL":
55
+ return "[italic bold red]Critical>[/italic bold red] {name}:{function}:{line} - {message}"
56
+ case "WARNING":
57
+ return "[bold yellow]Warning>[/bold yellow] {message}"
58
+ case _:
59
+ return "[#666666]>[/#666666] {message}"
60
+
61
+ logger.remove()
62
+ logger.add(
63
+ sink=rich_handler,
64
+ format="{message}" if self.DEBUG else formatter,
65
+ level="DEBUG" if self.DEBUG else "INFO",
66
+ backtrace=self.DEBUG,
67
+ )
68
+
69
+ # Nothing will print here if DEBUG is false
70
+ logger.debug("Debug mode enabled")
47
71
 
48
72
  # Generate full or partial host config
49
73
  if not token and not transport:
@@ -67,6 +91,7 @@ class Primitive:
67
91
  self.daemons: Daemons = Daemons(self)
68
92
  self.exec: Exec = Exec(self)
69
93
  self.provisioning: Provisioning = Provisioning(self)
94
+ self.monitor: Monitor = Monitor(self)
70
95
 
71
96
  def get_host_config(self):
72
97
  self.full_config = read_config_file()
@@ -1,24 +1,13 @@
1
1
  import platform
2
2
  import typing
3
+ from typing import Dict, Optional, List
3
4
 
4
5
  if typing.TYPE_CHECKING:
5
6
  from ..client import Primitive
6
7
 
7
- from .launch_agents import (
8
- full_launch_agent_install,
9
- full_launch_agent_uninstall,
10
- start_launch_agent,
11
- stop_launch_agent,
12
- view_launch_agent_logs,
13
- )
14
-
15
- from .launch_service import (
16
- full_service_install,
17
- full_service_uninstall,
18
- start_service,
19
- stop_service,
20
- view_service_logs,
21
- )
8
+ from .launch_agents import LaunchAgent
9
+ from .launch_service import LaunchService
10
+ from ..utils.daemons import Daemon
22
11
 
23
12
 
24
13
  class Daemons:
@@ -26,50 +15,47 @@ class Daemons:
26
15
  self.primitive: Primitive = primitive
27
16
  self.os_family = platform.system()
28
17
 
29
- def install(self):
30
- result = True
31
- if self.os_family == "Darwin":
32
- full_launch_agent_install()
33
- elif self.os_family == "Linux":
34
- full_service_install()
35
- elif self.os_family == "Windows":
36
- print("Not Implemented")
37
- return result
38
-
39
- def uninstall(self):
40
- result = True
41
- if self.os_family == "Darwin":
42
- full_launch_agent_uninstall()
43
- elif self.os_family == "Linux":
44
- full_service_uninstall()
45
- elif self.os_family == "Windows":
46
- print("Not Implemented")
47
- return result
48
-
49
- def stop(self) -> bool:
50
- result = True
51
- if self.os_family == "Darwin":
52
- result = stop_launch_agent()
53
- elif self.os_family == "Linux":
54
- stop_service()
55
- elif self.os_family == "Windows":
56
- print("Not Implemented")
57
- return result
58
-
59
- def start(self) -> bool:
60
- result = True
61
- if self.os_family == "Darwin":
62
- result = start_launch_agent()
63
- elif self.os_family == "Linux":
64
- start_service()
65
- elif self.os_family == "Windows":
66
- print("Not Implemented")
67
- return result
68
-
69
- def logs(self):
70
- if self.os_family == "Darwin":
71
- view_launch_agent_logs()
72
- elif self.os_family == "Linux":
73
- view_service_logs()
74
- elif self.os_family == "Windows":
75
- print("Not Implemented")
18
+ match self.os_family:
19
+ case "Darwin":
20
+ self.daemons: Dict[str, Daemon] = {
21
+ "agent": LaunchAgent("tech.primitive.agent"),
22
+ "monitor": LaunchAgent("tech.primitive.monitor"),
23
+ }
24
+ case "Linux":
25
+ self.daemons: Dict[str, Daemon] = {
26
+ "agent": LaunchService("tech.primitive.agent"),
27
+ "monitor": LaunchService("tech.primitive.monitor"),
28
+ }
29
+ case _:
30
+ raise NotImplementedError(f"{self.os_family} is not supported.")
31
+
32
+ def install(self, name: Optional[str]) -> bool:
33
+ if name:
34
+ return self.daemons[name].install()
35
+ else:
36
+ return all([daemon.install() for daemon in self.daemons.values()])
37
+
38
+ def uninstall(self, name: Optional[str]) -> bool:
39
+ if name:
40
+ return self.daemons[name].uninstall()
41
+ else:
42
+ return all([daemon.uninstall() for daemon in self.daemons.values()])
43
+
44
+ def stop(self, name: Optional[str]) -> bool:
45
+ if name:
46
+ return self.daemons[name].stop()
47
+ else:
48
+ return all([daemon.stop() for daemon in self.daemons.values()])
49
+
50
+ def start(self, name: Optional[str]) -> bool:
51
+ if name:
52
+ return self.daemons[name].start()
53
+ else:
54
+ return all([daemon.start() for daemon in self.daemons.values()])
55
+
56
+ def list(self) -> List[Daemon]:
57
+ """List all daemons"""
58
+ return list(self.daemons.values())
59
+
60
+ def logs(self, name: str) -> None:
61
+ self.daemons[name].view_logs()