hecaton 0.1.0__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.
- hecaton-0.1.0/PKG-INFO +17 -0
- hecaton-0.1.0/README.md +2 -0
- hecaton-0.1.0/hecaton/__init__.py +0 -0
- hecaton-0.1.0/hecaton/client/__init__.py +0 -0
- hecaton-0.1.0/hecaton/client/argparser.py +17 -0
- hecaton-0.1.0/hecaton/client/cli.py +257 -0
- hecaton-0.1.0/hecaton/client/managers/__init__.py +11 -0
- hecaton-0.1.0/hecaton/client/managers/api.py +131 -0
- hecaton-0.1.0/hecaton/client/managers/db.py +63 -0
- hecaton-0.1.0/hecaton/client/managers/image.py +129 -0
- hecaton-0.1.0/hecaton/client/managers/job.py +107 -0
- hecaton-0.1.0/hecaton/client/managers/server.py +150 -0
- hecaton-0.1.0/hecaton/gpu/argparser.py +9 -0
- hecaton-0.1.0/hecaton/gpu/docker_manager.py +129 -0
- hecaton-0.1.0/hecaton/gpu/main.py +37 -0
- hecaton-0.1.0/hecaton/gpu/utils.py +50 -0
- hecaton-0.1.0/hecaton/gpu/web_client.py +105 -0
- hecaton-0.1.0/hecaton/gpu/worker.py +55 -0
- hecaton-0.1.0/hecaton/server/argparser.py +12 -0
- hecaton-0.1.0/hecaton/server/dto.py +37 -0
- hecaton-0.1.0/hecaton/server/main.py +129 -0
- hecaton-0.1.0/hecaton/server/worker.py +221 -0
- hecaton-0.1.0/hecaton/serverless.py +42 -0
- hecaton-0.1.0/hecaton.egg-info/PKG-INFO +17 -0
- hecaton-0.1.0/hecaton.egg-info/SOURCES.txt +29 -0
- hecaton-0.1.0/hecaton.egg-info/dependency_links.txt +1 -0
- hecaton-0.1.0/hecaton.egg-info/entry_points.txt +4 -0
- hecaton-0.1.0/hecaton.egg-info/requires.txt +7 -0
- hecaton-0.1.0/hecaton.egg-info/top_level.txt +1 -0
- hecaton-0.1.0/pyproject.toml +33 -0
- hecaton-0.1.0/setup.cfg +4 -0
hecaton-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hecaton
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hecaton distributed compute framework
|
|
5
|
+
Author-email: Just1duc <aworld068@gmail.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: uvicorn
|
|
10
|
+
Requires-Dist: platformdirs
|
|
11
|
+
Requires-Dist: typer
|
|
12
|
+
Requires-Dist: prompt_toolkit
|
|
13
|
+
Requires-Dist: click
|
|
14
|
+
Requires-Dist: docker
|
|
15
|
+
|
|
16
|
+
# hecaton
|
|
17
|
+
A solution for self-hosted GPU access from a source that isn't deployed
|
hecaton-0.1.0/README.md
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
parser = argparse.ArgumentParser(
|
|
4
|
+
prog="Hecaton Client",
|
|
5
|
+
description="A simple cli to use the Hecaton Server"
|
|
6
|
+
)
|
|
7
|
+
# The login is done in CLI
|
|
8
|
+
# Saved in .cache/hecaton
|
|
9
|
+
# The logout should also be available
|
|
10
|
+
# The usage of a server is done in the cli
|
|
11
|
+
|
|
12
|
+
# CLI
|
|
13
|
+
# connect [SERVER]
|
|
14
|
+
# new_job, new [filepath] -> job id
|
|
15
|
+
# status_job, status [ID] -> (STATUS, results|None)
|
|
16
|
+
# get_jobs, get -> Return all running jobs (from the server)
|
|
17
|
+
# delete_job, del -> Put a job in failed and free a GPU
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import shlex, typer, os
|
|
2
|
+
from prompt_toolkit import PromptSession
|
|
3
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
4
|
+
from hecaton.client.managers import ServerManager, ImageManager, JobManager, apps
|
|
5
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
6
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
7
|
+
from typer.main import get_command
|
|
8
|
+
from typing import Iterable, List, Tuple, Optional
|
|
9
|
+
import click
|
|
10
|
+
import importlib
|
|
11
|
+
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
|
|
14
|
+
_original_echo = typer.echo
|
|
15
|
+
|
|
16
|
+
def _indented_echo(message="", indent=4, **kwargs):
|
|
17
|
+
prefix = " " * indent
|
|
18
|
+
msg = "\n".join(prefix + line for line in str(message).splitlines())
|
|
19
|
+
_original_echo(msg, **kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
shared_context = {
|
|
23
|
+
"server_mgr" : ServerManager(),
|
|
24
|
+
"job_mgr" : JobManager(),
|
|
25
|
+
"image_mgr" : ImageManager()
|
|
26
|
+
}
|
|
27
|
+
def _split_args(text: str):
|
|
28
|
+
try:
|
|
29
|
+
return click.parser.split_arg_string(text)
|
|
30
|
+
except Exception:
|
|
31
|
+
return text.split()
|
|
32
|
+
|
|
33
|
+
def _resolve_chain(root_cmd, root_ctx, args):
|
|
34
|
+
cmd, ctx, rest = root_cmd, root_ctx, list(args)
|
|
35
|
+
# print("start rest", rest)
|
|
36
|
+
while isinstance(cmd, click.MultiCommand) and rest:
|
|
37
|
+
name, sub_cmd, rest2 = cmd.resolve_command(ctx, rest)
|
|
38
|
+
if not sub_cmd:
|
|
39
|
+
break
|
|
40
|
+
ctx = sub_cmd.make_context(
|
|
41
|
+
info_name=name,
|
|
42
|
+
args=rest2,
|
|
43
|
+
parent=ctx,
|
|
44
|
+
resilient_parsing=True,
|
|
45
|
+
obj=ctx.obj,
|
|
46
|
+
)
|
|
47
|
+
cmd, rest = sub_cmd, rest[1:]
|
|
48
|
+
# print("rest end", rest)
|
|
49
|
+
return cmd, ctx, rest
|
|
50
|
+
|
|
51
|
+
def _match_option(token: str, options: List[click.Option]) -> Optional[click.Option]:
|
|
52
|
+
for opt in options:
|
|
53
|
+
if token in opt.opts or token in opt.secondary_opts:
|
|
54
|
+
return opt
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _consume_tokens_for_option(opt: click.Option, tokens: List[str], i: int) -> int:
|
|
58
|
+
|
|
59
|
+
t = tokens[i]
|
|
60
|
+
|
|
61
|
+
if "=" in t and t.startswith("-"):
|
|
62
|
+
return i + 1
|
|
63
|
+
|
|
64
|
+
if opt.nargs == 0:
|
|
65
|
+
return i + 1
|
|
66
|
+
|
|
67
|
+
return min(i + 1 + opt.nargs, len(tokens))
|
|
68
|
+
|
|
69
|
+
def determine_active_param(
|
|
70
|
+
leaf_cmd: click.Command,
|
|
71
|
+
args_after_leaf: List[str],
|
|
72
|
+
completing_option_name: bool,
|
|
73
|
+
) -> Tuple[str, Optional[click.Parameter], int]:
|
|
74
|
+
"""
|
|
75
|
+
Returns (kind, param, pos_index)
|
|
76
|
+
kind ∈ {"option_name", "option_value", "positional", "none"}
|
|
77
|
+
param: the active click.Option or click.Argument (or None)
|
|
78
|
+
pos_index: index of active positional (or -1)
|
|
79
|
+
"""
|
|
80
|
+
params = leaf_cmd.params
|
|
81
|
+
opt_params = [p for p in params if isinstance(p, click.Option)]
|
|
82
|
+
pos_params = [p for p in params if isinstance(p, click.Argument)]
|
|
83
|
+
|
|
84
|
+
if completing_option_name:
|
|
85
|
+
return ("option_name", None, -1)
|
|
86
|
+
|
|
87
|
+
i = 0
|
|
88
|
+
pos_i = 0
|
|
89
|
+
while i < len(args_after_leaf):
|
|
90
|
+
tok = args_after_leaf[i]
|
|
91
|
+
# Option with attached value: --opt=value → treat as fully consumed
|
|
92
|
+
if tok.startswith("--") and "=" in tok:
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
# Option name?
|
|
96
|
+
opt = _match_option(tok, opt_params) if tok.startswith("-") else None
|
|
97
|
+
if opt is not None:
|
|
98
|
+
i = _consume_tokens_for_option(opt, args_after_leaf, i)
|
|
99
|
+
continue
|
|
100
|
+
# Otherwise this token belongs to current positional
|
|
101
|
+
if pos_i < len(pos_params):
|
|
102
|
+
arg = pos_params[pos_i]
|
|
103
|
+
# default nargs is 1; Typer uses 1 unless you set it
|
|
104
|
+
nargs = getattr(arg, "nargs", 1)
|
|
105
|
+
if nargs in (1, None):
|
|
106
|
+
pos_i += 1
|
|
107
|
+
i += 1
|
|
108
|
+
elif nargs == -1: # variadic: all remaining go here
|
|
109
|
+
# Since we’re *before* the incomplete token, all remaining are consumed
|
|
110
|
+
# and we remain on this positional
|
|
111
|
+
i = len(args_after_leaf)
|
|
112
|
+
else: # fixed >1
|
|
113
|
+
# consume up to nargs tokens into this positional
|
|
114
|
+
take = min(nargs, len(args_after_leaf) - i)
|
|
115
|
+
pos_i += 1
|
|
116
|
+
i += take
|
|
117
|
+
else:
|
|
118
|
+
# extra tokens; move on
|
|
119
|
+
i += 1
|
|
120
|
+
|
|
121
|
+
if args_after_leaf:
|
|
122
|
+
last = args_after_leaf[-1]
|
|
123
|
+
last_opt = _match_option(last, opt_params)
|
|
124
|
+
if last_opt is not None and last_opt.nargs != 0:
|
|
125
|
+
return ("option_value", last_opt, -1)
|
|
126
|
+
|
|
127
|
+
if pos_i < len(pos_params):
|
|
128
|
+
return ("positional", pos_params[pos_i], pos_i)
|
|
129
|
+
|
|
130
|
+
return ("none", None, -1)
|
|
131
|
+
|
|
132
|
+
class TyperCompleter(Completer):
|
|
133
|
+
def __init__(self, typer_app, make_root_ctx):
|
|
134
|
+
self.root_cmd = get_command(typer_app)
|
|
135
|
+
self.make_root_ctx = make_root_ctx
|
|
136
|
+
|
|
137
|
+
def get_completions(self, document, complete_event):
|
|
138
|
+
text = document.text_before_cursor
|
|
139
|
+
args = _split_args(text)
|
|
140
|
+
incomplete = "" if text.endswith((" ", "\t")) else (args.pop() if args else "")
|
|
141
|
+
# print(args)
|
|
142
|
+
|
|
143
|
+
root_ctx = self.make_root_ctx()
|
|
144
|
+
leaf_cmd, leaf_ctx, rest = _resolve_chain(self.root_cmd, root_ctx, args)
|
|
145
|
+
|
|
146
|
+
args_after_leaf = rest
|
|
147
|
+
# IMPORTANT: call on the LEAF command with (ctx, incomplete)
|
|
148
|
+
# print("Command name", leaf_cmd.name)
|
|
149
|
+
items = list(leaf_cmd.shell_complete(leaf_ctx, incomplete))
|
|
150
|
+
|
|
151
|
+
if not items:
|
|
152
|
+
# Decide what we’re completing
|
|
153
|
+
kind, param, pos_index = determine_active_param(
|
|
154
|
+
leaf_cmd,
|
|
155
|
+
args_after_leaf=args_after_leaf,
|
|
156
|
+
completing_option_name=incomplete.startswith("-"),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if kind == "option_name":
|
|
160
|
+
# suggest option switches
|
|
161
|
+
for p in (pp for pp in leaf_cmd.params if isinstance(pp, click.Option)):
|
|
162
|
+
for sw in (*p.opts, *p.secondary_opts):
|
|
163
|
+
if sw.startswith(incomplete):
|
|
164
|
+
items.append(click.shell_completion.CompletionItem(sw))
|
|
165
|
+
|
|
166
|
+
elif kind == "option_value" and param is not None:
|
|
167
|
+
# OPTION value completion → param or its type
|
|
168
|
+
if getattr(param, "shell_complete", None):
|
|
169
|
+
items = list(param.shell_complete(leaf_ctx, incomplete))
|
|
170
|
+
elif getattr(param.type, "shell_complete", None):
|
|
171
|
+
items = list(param.type.shell_complete(leaf_ctx, param, incomplete))
|
|
172
|
+
|
|
173
|
+
elif kind == "positional" and param is not None:
|
|
174
|
+
# POSITIONAL completion → param or its type
|
|
175
|
+
if getattr(param, "shell_complete", None):
|
|
176
|
+
items = list(param.shell_complete(leaf_ctx, incomplete))
|
|
177
|
+
elif getattr(param.type, "shell_complete", None):
|
|
178
|
+
items = list(param.type.shell_complete(leaf_ctx, param, incomplete))
|
|
179
|
+
|
|
180
|
+
# print("DBG", {
|
|
181
|
+
# "args_after_leaf": args_after_leaf,
|
|
182
|
+
# "kind": kind,
|
|
183
|
+
# "param": getattr(param, "name", None),
|
|
184
|
+
# "pos_index": pos_index,
|
|
185
|
+
# "incomplete": incomplete,
|
|
186
|
+
# })
|
|
187
|
+
|
|
188
|
+
# yield items
|
|
189
|
+
start = -len(incomplete)
|
|
190
|
+
for it in items:
|
|
191
|
+
yield Completion(it.value, start_position=start,
|
|
192
|
+
display=it.value, display_meta=(it.help or ""))
|
|
193
|
+
|
|
194
|
+
@app.callback()
|
|
195
|
+
def main(ctx: typer.Context):
|
|
196
|
+
ctx.obj = shared_context
|
|
197
|
+
|
|
198
|
+
app.add_typer(apps.job_app, name="job")
|
|
199
|
+
app.add_typer(apps.image_app, name="image")
|
|
200
|
+
app.add_typer(apps.server_app, name="server")
|
|
201
|
+
|
|
202
|
+
@app.command("ls")
|
|
203
|
+
def list_files():
|
|
204
|
+
print(*[" " + f for f in os.listdir(".")], sep="\n")
|
|
205
|
+
|
|
206
|
+
@app.command("cd")
|
|
207
|
+
def change_dir(dir):
|
|
208
|
+
os.chdir(dir)
|
|
209
|
+
|
|
210
|
+
@app.command("help")
|
|
211
|
+
def greet():
|
|
212
|
+
with importlib.resources.open_text("hecaton", "../help.txt") as f:
|
|
213
|
+
typer.echo(f.read())
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def unknown():
|
|
217
|
+
typer.echo(f"Unknown command")
|
|
218
|
+
|
|
219
|
+
def run_shell():
|
|
220
|
+
logo = importlib.resources.open_text("hecaton", "../logo_hecaton.txt").read()
|
|
221
|
+
# logo = open("logo_hecaton.txt", encoding="utf-8").read()
|
|
222
|
+
typer.echo(logo)
|
|
223
|
+
|
|
224
|
+
root_click_cmd = get_command(app)
|
|
225
|
+
def make_root_ctx():
|
|
226
|
+
return root_click_cmd.make_context(
|
|
227
|
+
info_name="", args=[], resilient_parsing=True, obj=shared_context
|
|
228
|
+
)
|
|
229
|
+
completer = TyperCompleter(app, make_root_ctx)
|
|
230
|
+
|
|
231
|
+
typer.echo = _indented_echo
|
|
232
|
+
session = PromptSession(
|
|
233
|
+
completer=completer,
|
|
234
|
+
complete_while_typing=True
|
|
235
|
+
)
|
|
236
|
+
while True:
|
|
237
|
+
try:
|
|
238
|
+
line = session.prompt(ANSI(f" \x1b[36;1mhecaton\x1b[0m ({shared_context['server_mgr'].selected_server or 'Not connected'}) \x1b[35m›\x1b[0m "))
|
|
239
|
+
except (EOFError, KeyboardInterrupt):
|
|
240
|
+
break
|
|
241
|
+
if not line.strip():
|
|
242
|
+
continue
|
|
243
|
+
if line.strip() in {"quit", "exit"}:
|
|
244
|
+
break
|
|
245
|
+
try:
|
|
246
|
+
# Run Typer/Click without sys.exit()
|
|
247
|
+
app(standalone_mode=False, args=shlex.split(line))
|
|
248
|
+
except SystemExit:
|
|
249
|
+
pass
|
|
250
|
+
except Exception as e:
|
|
251
|
+
typer.echo(f"error: {e}")
|
|
252
|
+
|
|
253
|
+
def main():
|
|
254
|
+
run_shell()
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from hecaton.client.managers.image import ImageManager, image_app
|
|
2
|
+
from hecaton.client.managers.server import ServerManager, server_app
|
|
3
|
+
from hecaton.client.managers.job import JobManager, job_app
|
|
4
|
+
|
|
5
|
+
class Apps:
|
|
6
|
+
|
|
7
|
+
image_app = image_app
|
|
8
|
+
server_app = server_app
|
|
9
|
+
job_app = job_app
|
|
10
|
+
|
|
11
|
+
apps = Apps()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# File that contain the class that communicates with the hecaton's server
|
|
2
|
+
import requests
|
|
3
|
+
from typing import Optional, List, Tuple
|
|
4
|
+
|
|
5
|
+
class HecatonServer:
|
|
6
|
+
|
|
7
|
+
str_to_method = {
|
|
8
|
+
"GET" : requests.get,
|
|
9
|
+
"POST": requests.post,
|
|
10
|
+
"PUT" : requests.put,
|
|
11
|
+
"DELETE" : requests.delete
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def call_endpoint(
|
|
15
|
+
ip : str,
|
|
16
|
+
secret : str,
|
|
17
|
+
method : str,
|
|
18
|
+
endpoint : str,
|
|
19
|
+
payload : dict | None = None
|
|
20
|
+
):
|
|
21
|
+
ip = ip if ip.startswith('http') else f'http://{ip}'
|
|
22
|
+
result = HecatonServer.str_to_method[method](f"{ip}{endpoint}",
|
|
23
|
+
headers = { "Authorization" : secret },
|
|
24
|
+
**({"json" : payload} if method != "GET" else {})
|
|
25
|
+
)
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
def list_jobs(
|
|
29
|
+
ip : str,
|
|
30
|
+
secret : str
|
|
31
|
+
):
|
|
32
|
+
results = HecatonServer.call_endpoint(
|
|
33
|
+
ip=ip,
|
|
34
|
+
secret=secret,
|
|
35
|
+
method="GET",
|
|
36
|
+
endpoint="/jobs"
|
|
37
|
+
)
|
|
38
|
+
if results.ok:
|
|
39
|
+
return results.json()
|
|
40
|
+
return results.json()["detail"]
|
|
41
|
+
|
|
42
|
+
def list_images(
|
|
43
|
+
ip : str,
|
|
44
|
+
secret : str
|
|
45
|
+
):
|
|
46
|
+
results = HecatonServer.call_endpoint(
|
|
47
|
+
ip=ip,
|
|
48
|
+
secret=secret,
|
|
49
|
+
method="GET",
|
|
50
|
+
endpoint="/images"
|
|
51
|
+
)
|
|
52
|
+
if results.ok:
|
|
53
|
+
return results.json()
|
|
54
|
+
return results.json()["detail"]
|
|
55
|
+
|
|
56
|
+
def new_job(
|
|
57
|
+
ip : str,
|
|
58
|
+
secret : str,
|
|
59
|
+
file_path : str,
|
|
60
|
+
image : str
|
|
61
|
+
):
|
|
62
|
+
file_content = open(file_path, "r").read()
|
|
63
|
+
|
|
64
|
+
results = HecatonServer.call_endpoint(
|
|
65
|
+
ip=ip,
|
|
66
|
+
secret=secret,
|
|
67
|
+
method="POST",
|
|
68
|
+
endpoint="/jobs/new",
|
|
69
|
+
payload={
|
|
70
|
+
"payload" : file_content,
|
|
71
|
+
"image" : image
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
if results.ok:
|
|
75
|
+
return results.json()["job_id"]
|
|
76
|
+
return results.json()["detail"]
|
|
77
|
+
|
|
78
|
+
def update_image(
|
|
79
|
+
ip,
|
|
80
|
+
secret,
|
|
81
|
+
image : str,
|
|
82
|
+
env : List[Tuple[str, str]] | None = None,
|
|
83
|
+
description : Optional[str] = None
|
|
84
|
+
):
|
|
85
|
+
results = HecatonServer.call_endpoint(
|
|
86
|
+
ip=ip,
|
|
87
|
+
secret=secret,
|
|
88
|
+
method="POST",
|
|
89
|
+
endpoint="/images/update",
|
|
90
|
+
payload={
|
|
91
|
+
"image_name" : image,
|
|
92
|
+
**({"env" : [{"key" : var[0], "value" : var[1]} for var in env]} if env else {}),
|
|
93
|
+
**({"description" : description} if description else {})
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
if results.ok:
|
|
97
|
+
return results.json()["message"]
|
|
98
|
+
return results.json()["detail"]
|
|
99
|
+
|
|
100
|
+
def new_image(
|
|
101
|
+
ip : str,
|
|
102
|
+
secret : str,
|
|
103
|
+
image : str
|
|
104
|
+
):
|
|
105
|
+
results = HecatonServer.call_endpoint(
|
|
106
|
+
ip=ip,
|
|
107
|
+
secret=secret,
|
|
108
|
+
method="POST",
|
|
109
|
+
endpoint="/images/new",
|
|
110
|
+
payload={
|
|
111
|
+
"image_name" : image,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
if results.ok:
|
|
115
|
+
return results.json()["message"]
|
|
116
|
+
return results.json()["detail"]
|
|
117
|
+
|
|
118
|
+
def get_job(
|
|
119
|
+
ip : str,
|
|
120
|
+
secret : str,
|
|
121
|
+
jid : str
|
|
122
|
+
):
|
|
123
|
+
results = HecatonServer.call_endpoint(
|
|
124
|
+
ip=ip,
|
|
125
|
+
secret=secret,
|
|
126
|
+
method="GET",
|
|
127
|
+
endpoint=f"/jobs/{jid}"
|
|
128
|
+
)
|
|
129
|
+
if results.ok:
|
|
130
|
+
return results.json()
|
|
131
|
+
return results.json()["detail"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from filelock import FileLock
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from platformdirs import user_data_path
|
|
11
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
|
|
15
|
+
class ServerInfo(BaseModel):
|
|
16
|
+
ip: str
|
|
17
|
+
name: str
|
|
18
|
+
secret: str
|
|
19
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
20
|
+
|
|
21
|
+
class Database(BaseModel):
|
|
22
|
+
version: int = SCHEMA_VERSION
|
|
23
|
+
servers: List[ServerInfo] = Field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
APP_NAME = "hecaton"
|
|
26
|
+
APP_AUTHOR = "Just1truc"
|
|
27
|
+
|
|
28
|
+
def data_dir() -> Path:
|
|
29
|
+
d = user_data_path(appname=APP_NAME, appauthor=APP_AUTHOR, roaming=False)
|
|
30
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return d
|
|
32
|
+
|
|
33
|
+
def db_path() -> Path:
|
|
34
|
+
return data_dir() / "db.json"
|
|
35
|
+
|
|
36
|
+
def lock_path() -> Path:
|
|
37
|
+
return data_dir() / "db.lock"
|
|
38
|
+
|
|
39
|
+
def load_db() -> Database:
|
|
40
|
+
p = db_path()
|
|
41
|
+
if not p.exists():
|
|
42
|
+
return Database()
|
|
43
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
44
|
+
return Database.model_validate(raw)
|
|
45
|
+
|
|
46
|
+
def _atomic_write(path: Path, data: str) -> None:
|
|
47
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
48
|
+
tmp.write_text(data, encoding="utf-8")
|
|
49
|
+
tmp.replace(path)
|
|
50
|
+
|
|
51
|
+
def save_db(db: Database) -> None:
|
|
52
|
+
text = db.model_dump_json(indent=2, by_alias=True)
|
|
53
|
+
_atomic_write(db_path(), text)
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def with_locked_db(mutate: bool = False):
|
|
57
|
+
"""Read DB under a lock; optionally write back on exit if mutate=True."""
|
|
58
|
+
lock = FileLock(str(lock_path()))
|
|
59
|
+
with lock:
|
|
60
|
+
db = load_db()
|
|
61
|
+
yield db
|
|
62
|
+
if mutate:
|
|
63
|
+
save_db(db)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import os
|
|
3
|
+
import click
|
|
4
|
+
from typing import Optional, List, Tuple
|
|
5
|
+
from hecaton.client.managers.api import HecatonServer
|
|
6
|
+
from hecaton.client.managers.server import ServerManager, ServerInfo
|
|
7
|
+
|
|
8
|
+
image_app = typer.Typer()
|
|
9
|
+
|
|
10
|
+
class ImageManager:
|
|
11
|
+
|
|
12
|
+
def list_images(
|
|
13
|
+
self,
|
|
14
|
+
ip,
|
|
15
|
+
secret
|
|
16
|
+
):
|
|
17
|
+
return HecatonServer.list_images(ip, secret)
|
|
18
|
+
|
|
19
|
+
def new_image(
|
|
20
|
+
self,
|
|
21
|
+
ip,
|
|
22
|
+
secret,
|
|
23
|
+
image
|
|
24
|
+
):
|
|
25
|
+
return HecatonServer.new_image(ip, secret, image)
|
|
26
|
+
|
|
27
|
+
def update_image(
|
|
28
|
+
self,
|
|
29
|
+
ip,
|
|
30
|
+
secret,
|
|
31
|
+
image : str,
|
|
32
|
+
env : List[Tuple[str, str]] | None = None,
|
|
33
|
+
description : Optional[str] = None
|
|
34
|
+
):
|
|
35
|
+
return HecatonServer.update_image(ip, secret, image, env, description)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@image_app.command("list")
|
|
39
|
+
def list_image(
|
|
40
|
+
ctx : typer.Context
|
|
41
|
+
):
|
|
42
|
+
mgr : ImageManager = ctx.obj["image_mgr"]
|
|
43
|
+
server_mgr : ServerManager = ctx.obj["server_mgr"]
|
|
44
|
+
server_info : ServerInfo = server_mgr.connected_server()
|
|
45
|
+
|
|
46
|
+
images = mgr.list_images(server_info.ip, server_info.secret)
|
|
47
|
+
for image in images:
|
|
48
|
+
typer.echo(image[1])
|
|
49
|
+
|
|
50
|
+
@image_app.command("new")
|
|
51
|
+
def new_image(
|
|
52
|
+
ctx : typer.Context,
|
|
53
|
+
image : str
|
|
54
|
+
):
|
|
55
|
+
mgr : ImageManager = ctx.obj["image_mgr"]
|
|
56
|
+
server_mgr : ServerManager = ctx.obj["server_mgr"]
|
|
57
|
+
server_info : ServerInfo = server_mgr.connected_server()
|
|
58
|
+
|
|
59
|
+
typer.echo(mgr.new_image(server_info.ip, server_info.secret, image))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def complete_image_name(ctx : typer.Context, param: click.Parameter, incomplete : str) -> List[str]:
|
|
63
|
+
|
|
64
|
+
mgr : ImageManager = ctx.obj["image_mgr"]
|
|
65
|
+
server_mgr : ServerManager = ctx.obj["server_mgr"]
|
|
66
|
+
server_info : ServerInfo = server_mgr.connected_server()
|
|
67
|
+
|
|
68
|
+
images = mgr.list_images(server_info.ip, server_info.secret)
|
|
69
|
+
return [image[1] for image in images if image[1].startswith(incomplete)]
|
|
70
|
+
|
|
71
|
+
def prompt_optional(label: str, *, hide: bool = False):
|
|
72
|
+
def _cb(ctx: click.Context, param: click.Parameter, value: Optional[str]):
|
|
73
|
+
# Don't prompt during shell completion / help rendering
|
|
74
|
+
if ctx.resilient_parsing:
|
|
75
|
+
return value
|
|
76
|
+
if value is not None:
|
|
77
|
+
return value
|
|
78
|
+
# Prompt once; empty -> None
|
|
79
|
+
ans = typer.prompt(label, default="", show_default=False, hide_input=hide)
|
|
80
|
+
return ans if ans.strip() else None
|
|
81
|
+
return _cb
|
|
82
|
+
|
|
83
|
+
@image_app.command("update")
|
|
84
|
+
def update_image(
|
|
85
|
+
ctx : typer.Context,
|
|
86
|
+
image : str = typer.Argument(..., shell_complete=complete_image_name),
|
|
87
|
+
env_file_path : Optional[str] = typer.Option(
|
|
88
|
+
None,
|
|
89
|
+
"--fp",
|
|
90
|
+
callback=prompt_optional(" New_env_path (Press Enter to keep current variables)"),
|
|
91
|
+
help="Env Path",
|
|
92
|
+
show_default=False,
|
|
93
|
+
),
|
|
94
|
+
description : Optional[str] = typer.Option(
|
|
95
|
+
None,
|
|
96
|
+
"--desc",
|
|
97
|
+
callback=prompt_optional(" Description (Press Enter to keep current value)"),
|
|
98
|
+
help="Description",
|
|
99
|
+
show_default=False,
|
|
100
|
+
)
|
|
101
|
+
):
|
|
102
|
+
mgr : ImageManager = ctx.obj["image_mgr"]
|
|
103
|
+
server_mgr : ServerManager = ctx.obj["server_mgr"]
|
|
104
|
+
server_info : ServerInfo = server_mgr.connected_server()
|
|
105
|
+
|
|
106
|
+
env = None
|
|
107
|
+
if env_file_path and not os.path.isfile(env_file_path):
|
|
108
|
+
typer.echo(f"error: Invalid filepath {env_file_path}")
|
|
109
|
+
return
|
|
110
|
+
elif os.path.isfile(env_file_path):
|
|
111
|
+
env = [line.split("=") for line in open(env_file_path, "r").read().split() if "=" in line]
|
|
112
|
+
|
|
113
|
+
typer.echo(mgr.update_image(server_info.ip, server_info.secret, image, env, description))
|
|
114
|
+
|
|
115
|
+
@image_app.command("show")
|
|
116
|
+
def image_info(
|
|
117
|
+
ctx : typer.Context,
|
|
118
|
+
image : str = typer.Argument(..., shell_complete=complete_image_name)
|
|
119
|
+
):
|
|
120
|
+
mgr : ImageManager = ctx.obj["image_mgr"]
|
|
121
|
+
server_mgr : ServerManager = ctx.obj["server_mgr"]
|
|
122
|
+
server_info : ServerInfo = server_mgr.connected_server()
|
|
123
|
+
|
|
124
|
+
images = mgr.list_images(server_info.ip, server_info.secret)
|
|
125
|
+
for im in images:
|
|
126
|
+
if im[1] == image:
|
|
127
|
+
typer.echo(f'name: \t\t {im[1]}')
|
|
128
|
+
typer.echo(f'description: \t\t {im[2] or "No Description yet..."}')
|
|
129
|
+
typer.echo(f'env: \t\t {im[3]}')
|