ahaz_cli 0.0.4__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.
- ahaz_cli-0.0.4/PKG-INFO +9 -0
- ahaz_cli-0.0.4/ahaz_cli/__init__.py +0 -0
- ahaz_cli-0.0.4/ahaz_cli/__main__.py +4 -0
- ahaz_cli-0.0.4/ahaz_cli/ahaz.py +128 -0
- ahaz_cli-0.0.4/ahaz_cli/assets/hello-world/Dockerfile +3 -0
- ahaz_cli-0.0.4/ahaz_cli/assets/task.yaml +36 -0
- ahaz_cli-0.0.4/ahaz_cli/cli.py +28 -0
- ahaz_cli-0.0.4/ahaz_cli/lib/docker.py +215 -0
- ahaz_cli-0.0.4/ahaz_cli/lib/file.py +21 -0
- ahaz_cli-0.0.4/ahaz_cli/lib/task.py +82 -0
- ahaz_cli-0.0.4/ahaz_cli/templates.py +24 -0
- ahaz_cli-0.0.4/ahaz_cli.egg-info/PKG-INFO +9 -0
- ahaz_cli-0.0.4/ahaz_cli.egg-info/SOURCES.txt +17 -0
- ahaz_cli-0.0.4/ahaz_cli.egg-info/dependency_links.txt +1 -0
- ahaz_cli-0.0.4/ahaz_cli.egg-info/entry_points.txt +2 -0
- ahaz_cli-0.0.4/ahaz_cli.egg-info/requires.txt +5 -0
- ahaz_cli-0.0.4/ahaz_cli.egg-info/top_level.txt +1 -0
- ahaz_cli-0.0.4/pyproject.toml +26 -0
- ahaz_cli-0.0.4/setup.cfg +4 -0
ahaz_cli-0.0.4/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import colorsys
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import docker
|
|
8
|
+
import docker.errors
|
|
9
|
+
import typer
|
|
10
|
+
from rich.status import Status
|
|
11
|
+
|
|
12
|
+
from .lib.docker import cleanup_env, create_env, log_docker_logs, try_build_image
|
|
13
|
+
from .lib.file import test_for_file
|
|
14
|
+
from .lib.task import deserialise_task, normalise_task_name
|
|
15
|
+
from .templates import copy_example_images, write_task_yaml
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
CWD = Path.cwd()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test(
|
|
22
|
+
build: Annotated[bool, typer.Option("--build", "-b", help="Always build Docker images.")] = False,
|
|
23
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging.")] = False,
|
|
24
|
+
up: Annotated[bool, typer.Option("--up", "-u", help="Start the task environment after testing.")] = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Test the task configuration by validating the config file and attempting to build task images.
|
|
28
|
+
|
|
29
|
+
Optionally starts the task environment after testing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
if verbose:
|
|
33
|
+
log.setLevel(logging.DEBUG)
|
|
34
|
+
|
|
35
|
+
config = "task.yaml"
|
|
36
|
+
if not test_for_file(config):
|
|
37
|
+
config = "task.yml"
|
|
38
|
+
if not test_for_file(config):
|
|
39
|
+
log.error("No task configuration file found (task.yaml or task.yml)")
|
|
40
|
+
log.error("Are you sure you are in the task directory?")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
task = deserialise_task(Path(config).read_text())
|
|
44
|
+
log.info(f"Loaded task: {task.name}")
|
|
45
|
+
|
|
46
|
+
log.info("Testing Docker images for all pods...")
|
|
47
|
+
client = docker.from_env()
|
|
48
|
+
for pod in task.pods:
|
|
49
|
+
with Status(f"Checking image for pod '{pod.name}'...", spinner="dots") as status:
|
|
50
|
+
image_tag = f"{pod.image.image_name}:{task.version}"
|
|
51
|
+
if not build:
|
|
52
|
+
# See if we can find the image locally
|
|
53
|
+
try:
|
|
54
|
+
client.images.get(image_tag)
|
|
55
|
+
status.update(f"Image '{image_tag}' found locally.")
|
|
56
|
+
log.info(f"Image '{image_tag}' found locally for pod '{pod.name}'.")
|
|
57
|
+
continue
|
|
58
|
+
except docker.errors.ImageNotFound:
|
|
59
|
+
log.info(f"Image '{image_tag}' not found locally for pod '{pod.name}', building...")
|
|
60
|
+
# Build the image
|
|
61
|
+
status.update(f"Building image '{image_tag}'...")
|
|
62
|
+
log.info(f"Building image '{image_tag}' for pod '{pod.name}'...")
|
|
63
|
+
build_args = {arg.name: arg.value for arg in (pod.image.build_args or [])}
|
|
64
|
+
try:
|
|
65
|
+
try_build_image(image_tag, pod.image.build_context, build_args, verbose)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
log.error(f"Failed to build image '{image_tag}' for pod '{pod.name}': {e}")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# Attempt to set up the entire task environment
|
|
71
|
+
if up:
|
|
72
|
+
log.info("Setting up the task environment...")
|
|
73
|
+
containers = create_env(task)
|
|
74
|
+
log_docker_logs(
|
|
75
|
+
containers,
|
|
76
|
+
lambda: cleanup_env(
|
|
77
|
+
task.name, [pod.name for pod in task.pods], [net.name for net in task.networks]
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
log.info("Task test completed.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def init(
|
|
84
|
+
name: Annotated[str, typer.Argument(help="Name of the task.")],
|
|
85
|
+
path: Annotated[Path, typer.Argument(help="Path to the task.", show_default=False)] = CWD,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Initialize a new task directory with a template configuration and an example image.
|
|
89
|
+
|
|
90
|
+
Creates the following structure:\n
|
|
91
|
+
<out_dir>/\n
|
|
92
|
+
├─task.yaml\n
|
|
93
|
+
└─hello-world/\n
|
|
94
|
+
....└──Dockerfile\n
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
log.info(f"Initializing new task '{name}' at '{path}'...")
|
|
98
|
+
out_dir = path / normalise_task_name(name)
|
|
99
|
+
if out_dir.exists():
|
|
100
|
+
log.error(f"Directory '{out_dir}' already exists.")
|
|
101
|
+
return
|
|
102
|
+
out_dir.mkdir(parents=True)
|
|
103
|
+
log.info(f"Writing task template to '{out_dir}'...")
|
|
104
|
+
write_task_yaml(out_dir, name)
|
|
105
|
+
log.info(f"Copying example images to '{out_dir}'...")
|
|
106
|
+
copy_example_images(out_dir)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def useless_gradient_function(step: int) -> str:
|
|
110
|
+
# Calculate hue based on step
|
|
111
|
+
hue = (step * 3 % 360) / 360.0
|
|
112
|
+
# Convert hue to RGB
|
|
113
|
+
red, green, blue = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
|
|
114
|
+
# Output as ANSI
|
|
115
|
+
return f"\x1b[38;2;{int(red * 255)};{int(green * 255)};{int(blue * 255)}m"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Load bearing function, do not remove :3
|
|
119
|
+
def epic() -> None:
|
|
120
|
+
"""
|
|
121
|
+
A very important function.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
step = 0
|
|
125
|
+
while True:
|
|
126
|
+
print(f"\r{useless_gradient_function(step)}Epic function executed successfully.\x1b[0m", end="")
|
|
127
|
+
step += 1
|
|
128
|
+
time.sleep(0.1)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: "My Very Cool Task" # Name of the task; shown to the user
|
|
2
|
+
version: "1.0.0" # Version of the task; used for image tagging, increment when making changes to already-pushed tasks
|
|
3
|
+
description: "Very cool description UwU" # Description of the task; shown to the user
|
|
4
|
+
score: 150 # Maximum score for the task
|
|
5
|
+
scoring_type: "dynamic" # Scoring type; can be "static" or "dynamic"
|
|
6
|
+
|
|
7
|
+
# Definition of the pods that will be created for the task
|
|
8
|
+
pods:
|
|
9
|
+
- name: "hello-world" # Name of the pod; used for referencing in networks and env_vars
|
|
10
|
+
visible_to_user: true # Whether the user can see this pod
|
|
11
|
+
# Definition of the container image for the pod
|
|
12
|
+
image:
|
|
13
|
+
image_name: "hello-world" # Name of the image; used for pulling/building the image
|
|
14
|
+
build_context: "./hello-world" # Context path for building the image, needs to at least contain a Dockerfile
|
|
15
|
+
limits_ram: "128Mi" # RAM limit for the pod
|
|
16
|
+
limits_cpu: 1 # CPU limit for the pod
|
|
17
|
+
# Testing configuration for the pod; used for `ahaz test` command
|
|
18
|
+
testing:
|
|
19
|
+
# Defines ports that will be exposed on the host machine via Docker
|
|
20
|
+
exposed_ports:
|
|
21
|
+
- 1337:80
|
|
22
|
+
|
|
23
|
+
# Network definitions for the task
|
|
24
|
+
networks:
|
|
25
|
+
# Teamnet will always contain the VPN pod - use for user-accessible services
|
|
26
|
+
- name: "teamnet" # Name of the network
|
|
27
|
+
devices: [ "hello-world" ] # Pods connected to this network
|
|
28
|
+
# Other networks are inaccessible to the players
|
|
29
|
+
- name: "super-secret-internal-network"
|
|
30
|
+
devices: [ "hello-world" ]
|
|
31
|
+
|
|
32
|
+
# Environment variables to be set in the pods
|
|
33
|
+
env_vars:
|
|
34
|
+
- pod_name: "hello-world" # Pod to which the environment variable will be set
|
|
35
|
+
name: "COOL_ENV_VAR" # Name of the environment variable
|
|
36
|
+
value: "hello-world" # Value of the environment variable
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import rich.logging
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .ahaz import epic, init, test
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger()
|
|
10
|
+
log.addHandler(rich.logging.RichHandler(markup=True))
|
|
11
|
+
log.setLevel(logging.INFO)
|
|
12
|
+
|
|
13
|
+
SCRIPTS_ROOT = Path(__file__).parent.parent.resolve()
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
help="""\
|
|
18
|
+
CLI for interacting with the Ahaz CTF task manager.
|
|
19
|
+
""",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app.command()(test)
|
|
23
|
+
app.command()(epic)
|
|
24
|
+
app.command()(init)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
app()
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
import docker
|
|
7
|
+
import rich
|
|
8
|
+
import rich.ansi
|
|
9
|
+
import rich.style
|
|
10
|
+
from ahaz_common.task import Pod, Task
|
|
11
|
+
from docker.errors import BuildError, NotFound
|
|
12
|
+
from docker.models.containers import Container
|
|
13
|
+
from rich.status import Status
|
|
14
|
+
|
|
15
|
+
from .task import normalise_task_name
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def calculate_string_colour(s: str) -> int:
|
|
21
|
+
"""Calculate a consistent colour code for a given string."""
|
|
22
|
+
hash_value = 0
|
|
23
|
+
for char in s:
|
|
24
|
+
hash_value = (hash_value * 31 + ord(char)) & 0xFFFFFFFF
|
|
25
|
+
return 16 + (hash_value % 216) # Use colours from 16 to 231
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def number_to_hex_colour(n: int) -> str:
|
|
29
|
+
"""Convert a number to a hex colour code."""
|
|
30
|
+
r = (n >> 16) & 0xFF
|
|
31
|
+
g = (n >> 8) & 0xFF
|
|
32
|
+
b = n & 0xFF
|
|
33
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ContainerLoggerAdapter(logging.LoggerAdapter):
|
|
37
|
+
def process(self, msg, kwargs):
|
|
38
|
+
if not self.extra:
|
|
39
|
+
self.extra = {}
|
|
40
|
+
cname: str = self.extra.get("container", "<unknown>") # type: ignore
|
|
41
|
+
colour = calculate_string_colour(cname)
|
|
42
|
+
return f"[bold {number_to_hex_colour(colour)}]{cname}[/] [dim]|[/] {msg}", kwargs
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_container_name(task_name: str, pod_k8s_name: str) -> str:
|
|
46
|
+
return f"ahaz-{normalise_task_name(task_name)}-{pod_k8s_name}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_network_name(task_name: str, network_netname: str) -> str:
|
|
50
|
+
return f"ahaz-{normalise_task_name(task_name)}-{network_netname}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def try_build_image(image_tag: str, build_context: str, build_args: dict[str, str], verbose: bool) -> None:
|
|
54
|
+
client = docker.from_env()
|
|
55
|
+
log.info(f"Building image '{image_tag}'...")
|
|
56
|
+
try:
|
|
57
|
+
build_logs = client.api.build(
|
|
58
|
+
path=build_context,
|
|
59
|
+
tag=image_tag,
|
|
60
|
+
buildargs=build_args,
|
|
61
|
+
decode=True,
|
|
62
|
+
)
|
|
63
|
+
print("\x1b[2m")
|
|
64
|
+
for chunk in build_logs:
|
|
65
|
+
if "stream" in chunk and verbose:
|
|
66
|
+
# dim the build output
|
|
67
|
+
print(f"{chunk['stream']}", end="")
|
|
68
|
+
print("\x1b[0m")
|
|
69
|
+
except BuildError as e:
|
|
70
|
+
raise e
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def config_units_to_docker_units(config_value: str) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Convert resource limit strings from config format to Docker-compatible format.
|
|
76
|
+
E.g., "512Mi" -> "512m", "2Gi" -> "2048m"
|
|
77
|
+
"""
|
|
78
|
+
if config_value.endswith("Mi"):
|
|
79
|
+
return config_value[:-2] + "m"
|
|
80
|
+
elif config_value.endswith("Gi"):
|
|
81
|
+
gi_value = int(config_value[:-2])
|
|
82
|
+
return str(gi_value * 1024) + "m"
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError(f"Unsupported resource unit in value: {config_value}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_env(task: Task) -> list[tuple[Pod, Container]]:
|
|
88
|
+
client = docker.from_env()
|
|
89
|
+
try:
|
|
90
|
+
with Status("Setting up task environment...", spinner="dots") as status:
|
|
91
|
+
# Set up networks
|
|
92
|
+
network_guide = {}
|
|
93
|
+
for net in task.networks:
|
|
94
|
+
log.info(f"Setting up network '{net.name}'...")
|
|
95
|
+
status.update(f"Setting up network '{net.name}'...")
|
|
96
|
+
network = client.networks.create(
|
|
97
|
+
name=f"{get_network_name(task.name, net.name)}", check_duplicate=True
|
|
98
|
+
)
|
|
99
|
+
for device in net.devices:
|
|
100
|
+
network_guide.setdefault(device, []).append(network)
|
|
101
|
+
|
|
102
|
+
status.update("Setting up containers...")
|
|
103
|
+
containers = []
|
|
104
|
+
for pod in task.pods:
|
|
105
|
+
status.update(f"Creating container for pod '{pod.name}'...")
|
|
106
|
+
|
|
107
|
+
image_tag = f"{pod.image.image_name}:{task.version}"
|
|
108
|
+
env_vars = {env.name: env.value for env in task.env_vars or [] if env.pod_name == pod.name}
|
|
109
|
+
testing = getattr(pod, "testing", None)
|
|
110
|
+
exposed_ports = {
|
|
111
|
+
port.split(":")[1]: int(port.split(":")[0])
|
|
112
|
+
for port in (testing.exposed_ports if testing and testing.exposed_ports else [])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
container = client.containers.run(
|
|
116
|
+
image=image_tag,
|
|
117
|
+
name=f"{get_container_name(task.name, pod.name)}",
|
|
118
|
+
detach=True,
|
|
119
|
+
tty=True,
|
|
120
|
+
stdin_open=True,
|
|
121
|
+
environment=env_vars,
|
|
122
|
+
mem_limit=config_units_to_docker_units(pod.limits_ram),
|
|
123
|
+
cpu_count=pod.limits_cpu,
|
|
124
|
+
ports=exposed_ports,
|
|
125
|
+
hostname=pod.name,
|
|
126
|
+
stream=True,
|
|
127
|
+
)
|
|
128
|
+
containers.append((pod, container))
|
|
129
|
+
|
|
130
|
+
# Connect to networks
|
|
131
|
+
for network in network_guide.get(pod.name, []):
|
|
132
|
+
status.update(f"Connecting pod '{pod.name}' to network '{network.name}'...")
|
|
133
|
+
network.connect(container)
|
|
134
|
+
|
|
135
|
+
status.update("Task environment set up successfully.")
|
|
136
|
+
log.info("Task environment set up successfully.")
|
|
137
|
+
except Exception as e:
|
|
138
|
+
# Clean up any created containers and networks
|
|
139
|
+
log.error(f"Error setting up task environment: {e}")
|
|
140
|
+
cleanup_env(task.name, [pod.name for pod in task.pods], [net.name for net in task.networks])
|
|
141
|
+
raise e
|
|
142
|
+
return containers
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cleanup_env(task_name: str, pod_names: list[str], network_names: list[str]):
|
|
146
|
+
client = docker.from_env()
|
|
147
|
+
log.info("Cleaning up created containers and networks...")
|
|
148
|
+
for pod_name in pod_names:
|
|
149
|
+
container_name = get_container_name(task_name, pod_name)
|
|
150
|
+
log.info(f"Cleaning up container '{container_name}'...")
|
|
151
|
+
try:
|
|
152
|
+
container = client.containers.get(container_name)
|
|
153
|
+
container.stop()
|
|
154
|
+
container.remove()
|
|
155
|
+
except NotFound:
|
|
156
|
+
log.warning(f"Container '{container_name}' not found during cleanup.")
|
|
157
|
+
pass # Container does not exist, nothing to clean up
|
|
158
|
+
for network_name in network_names:
|
|
159
|
+
network_name_full = get_network_name(task_name, network_name)
|
|
160
|
+
log.info(f"Cleaning up network '{network_name_full}'...")
|
|
161
|
+
try:
|
|
162
|
+
network = client.networks.get(network_name_full)
|
|
163
|
+
network.remove()
|
|
164
|
+
except NotFound:
|
|
165
|
+
log.warning(f"Network '{network_name_full}' not found during cleanup.")
|
|
166
|
+
pass # Network does not exist, nothing to clean up
|
|
167
|
+
log.info("Docker test environment cleaned up successfully!")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def stream_logs(pod: Pod, container: Container, logger: _ContainerLoggerAdapter) -> None:
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
buffer = ""
|
|
174
|
+
for chunk in container.logs(stream=True, tail=0, follow=True):
|
|
175
|
+
# chunk can be bytes, str, or a (stdout, stderr) tuple
|
|
176
|
+
if isinstance(chunk, tuple):
|
|
177
|
+
chunk = chunk[0] or chunk[1]
|
|
178
|
+
if isinstance(chunk, bytes):
|
|
179
|
+
buffer += chunk.decode("utf-8", errors="replace")
|
|
180
|
+
else:
|
|
181
|
+
buffer += str(chunk)
|
|
182
|
+
while "\n" in buffer:
|
|
183
|
+
line, buffer = buffer.split("\n", 1)
|
|
184
|
+
if line.strip():
|
|
185
|
+
ansi_line = rich.ansi.AnsiDecoder().decode(line)
|
|
186
|
+
for segment in ansi_line:
|
|
187
|
+
logger.info(segment.plain)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
log.debug(f"Log stream for pod '{pod.name}' ended: {e}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def log_docker_logs(containers: list[tuple[Pod, Container]], exit_callback: Callable) -> None:
|
|
193
|
+
# Hang until the user interrupts
|
|
194
|
+
log.info("Task environment is running. Press [bold]Ctrl+C[/bold] to stop.")
|
|
195
|
+
print()
|
|
196
|
+
# Create loggers for each container
|
|
197
|
+
loggers: dict[str, _ContainerLoggerAdapter] = {}
|
|
198
|
+
for pod, _ in containers:
|
|
199
|
+
logger = logging.getLogger(pod.name)
|
|
200
|
+
loggers[pod.name] = _ContainerLoggerAdapter(logger, {"container": pod.name})
|
|
201
|
+
try:
|
|
202
|
+
# Fetch logs from containers
|
|
203
|
+
# TODO: I am fairly certain this allows the threads to log also the container shutdown logs,
|
|
204
|
+
# but I have not tested this
|
|
205
|
+
threads = []
|
|
206
|
+
for pod, container in containers:
|
|
207
|
+
t = threading.Thread(target=stream_logs, args=(pod, container, loggers[pod.name]), daemon=True)
|
|
208
|
+
t.start()
|
|
209
|
+
threads.append(t)
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
time.sleep(1)
|
|
213
|
+
except KeyboardInterrupt:
|
|
214
|
+
print()
|
|
215
|
+
exit_callback()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def read_file(file_path: str) -> str:
|
|
5
|
+
try:
|
|
6
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
7
|
+
return f.read()
|
|
8
|
+
except FileNotFoundError as e:
|
|
9
|
+
raise FileNotFoundError(f"File not found: {file_path}") from e
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_file(file_path: str, content: str) -> None:
|
|
13
|
+
try:
|
|
14
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
15
|
+
f.write(content)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
raise IOError(f"Error writing to file: {file_path}") from e
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_for_file(file_path: str) -> bool:
|
|
21
|
+
return Path(file_path).is_file()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from ahaz_common import Task
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def deserialise_task(task_config: str) -> Task:
|
|
11
|
+
try:
|
|
12
|
+
config_dict = yaml.safe_load(task_config)
|
|
13
|
+
except yaml.YAMLError as e:
|
|
14
|
+
log.error(f"Error parsing YAML: {e}")
|
|
15
|
+
raise ValueError("Invalid task configuration") from e
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
return Task(**config_dict)
|
|
19
|
+
except TypeError as e:
|
|
20
|
+
log.error(f"Error constructing Task object: {e}")
|
|
21
|
+
raise ValueError("Invalid task configuration structure") from e
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# TODO: Figure out a better way to handle serialisation of nested objects
|
|
25
|
+
# TODO: Is this even necessary?
|
|
26
|
+
def serialise_task(task: Task) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Serialise a Task into YAML with a fixed field order matching the example:
|
|
29
|
+
name, version, description, score, scoring_type, pods, networks, env_vars
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# helper getters to be defensive about missing attributes
|
|
33
|
+
def _image_dict(pod):
|
|
34
|
+
img = getattr(pod, "image", None) or {}
|
|
35
|
+
return {
|
|
36
|
+
"image_name": getattr(img, "image_name", None),
|
|
37
|
+
"build_context": getattr(img, "build_context", None),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def _testing_dict(pod):
|
|
41
|
+
testing = getattr(pod, "testing", None)
|
|
42
|
+
return {"exposed_ports": testing.exposed_ports if testing is not None else []}
|
|
43
|
+
|
|
44
|
+
# Build top-level mapping in the desired order (insertion order is preserved)
|
|
45
|
+
config_dict = {
|
|
46
|
+
"name": task.name,
|
|
47
|
+
"version": getattr(task, "version", "1.0.0"),
|
|
48
|
+
"description": task.description,
|
|
49
|
+
"score": task.score,
|
|
50
|
+
"scoring_type": task.scoring_type,
|
|
51
|
+
"pods": [
|
|
52
|
+
{
|
|
53
|
+
"name": pod.name,
|
|
54
|
+
"image": _image_dict(pod),
|
|
55
|
+
"limits_ram": getattr(pod, "limits_ram", None),
|
|
56
|
+
"limits_cpu": getattr(pod, "limits_cpu", None),
|
|
57
|
+
"visible_to_user": getattr(pod, "visible_to_user", None),
|
|
58
|
+
# include build for convenience (matches example where build == build_context)
|
|
59
|
+
"build": getattr(getattr(pod, "image", None), "build_context", None),
|
|
60
|
+
"testing": _testing_dict(pod),
|
|
61
|
+
}
|
|
62
|
+
for pod in task.pods or []
|
|
63
|
+
],
|
|
64
|
+
"networks": [{"name": net.name, "devices": list(net.devices)} for net in task.networks or []],
|
|
65
|
+
"env_vars": [
|
|
66
|
+
{
|
|
67
|
+
# map original fields to the example-style names
|
|
68
|
+
"pod_name": env.pod_name,
|
|
69
|
+
"name": env.name,
|
|
70
|
+
"value": env.value,
|
|
71
|
+
}
|
|
72
|
+
for env in task.env_vars or []
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Preserve insertion order in output
|
|
77
|
+
return yaml.safe_dump(config_dict, sort_keys=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalise_task_name(name: str) -> str:
|
|
81
|
+
# Make the task lowercase, replace spaces with hyphens, and remove special characters
|
|
82
|
+
return name.lower().replace(" ", "-").replace(r"([^a-z0-9-])", "")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .lib.task import deserialise_task, serialise_task
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def write_task_yaml(file_path: Path, task_name: str) -> None:
|
|
7
|
+
template_path = Path(__file__).parent / "assets" / "task.yaml"
|
|
8
|
+
task = deserialise_task(template_path.read_text())
|
|
9
|
+
|
|
10
|
+
task.name = task_name
|
|
11
|
+
|
|
12
|
+
task_dest = file_path / "task.yaml"
|
|
13
|
+
task_dest.write_text(serialise_task(task))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def copy_example_images(dest_path: Path) -> None:
|
|
17
|
+
example_image_src = Path(__file__).parent / "assets" / "hello-world"
|
|
18
|
+
example_image_dest = dest_path / "hello-world"
|
|
19
|
+
example_image_dest.mkdir(exist_ok=True)
|
|
20
|
+
|
|
21
|
+
for item in example_image_src.iterdir():
|
|
22
|
+
if item.is_file():
|
|
23
|
+
dest_file = example_image_dest / item.name
|
|
24
|
+
dest_file.write_text(item.read_text())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
ahaz_cli/__init__.py
|
|
3
|
+
ahaz_cli/__main__.py
|
|
4
|
+
ahaz_cli/ahaz.py
|
|
5
|
+
ahaz_cli/cli.py
|
|
6
|
+
ahaz_cli/templates.py
|
|
7
|
+
ahaz_cli.egg-info/PKG-INFO
|
|
8
|
+
ahaz_cli.egg-info/SOURCES.txt
|
|
9
|
+
ahaz_cli.egg-info/dependency_links.txt
|
|
10
|
+
ahaz_cli.egg-info/entry_points.txt
|
|
11
|
+
ahaz_cli.egg-info/requires.txt
|
|
12
|
+
ahaz_cli.egg-info/top_level.txt
|
|
13
|
+
ahaz_cli/assets/task.yaml
|
|
14
|
+
ahaz_cli/assets/hello-world/Dockerfile
|
|
15
|
+
ahaz_cli/lib/docker.py
|
|
16
|
+
ahaz_cli/lib/file.py
|
|
17
|
+
ahaz_cli/lib/task.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ahaz_cli
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ahaz_cli"
|
|
3
|
+
version = "0.0.4"
|
|
4
|
+
description = "Command-line management interface for Ahaz"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"docker>=7.1.0",
|
|
7
|
+
"rich>=14.2.0",
|
|
8
|
+
"typer>=0.20.0",
|
|
9
|
+
"ahaz_common",
|
|
10
|
+
"pyyaml>=6.0.3",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.uv.sources]
|
|
14
|
+
ahaz_common = { workspace = true }
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
ahaz = "ahaz_cli.cli:app"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
include-package-data = true
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-data]
|
|
23
|
+
"ahaz_cli" = ["assets/**"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
include = ["ahaz_cli*"]
|
ahaz_cli-0.0.4/setup.cfg
ADDED