more podman stuff. not done yet.

This commit is contained in:
Michael Woods
2025-02-12 21:13:14 -05:00
parent e229e21849
commit efa7e4e77f
6 changed files with 193 additions and 10 deletions

View File

@@ -0,0 +1 @@
VERSION="0.1-alpha"

View File

@@ -78,6 +78,20 @@ def bytes_to_tar_bytes(name: str, data: bytes) -> bytes:
temp.seek(0) temp.seek(0)
return temp.read() return temp.read()
def multi_bytes_to_tar_bytes(objects: dict) -> bytes:
"""Creates a tar archive with a single file of name <name> with <data> bytes as the contents"""
with tempfile.TemporaryFile() as temp:
tar_obj = tarfile.TarFile(fileobj=temp, mode="w")
for name in objects:
data = bytes(objects[name])
bio = BytesIO(data)
tar_info = tarfile.TarInfo(name=name)
tar_info.size = len(data)
tar_obj.addfile(tar_info, bio)
tar_obj.close()
temp.seek(0)
return temp.read()
def extract_tar_bytes(tarfile_bytes: bytes) -> Tuple[str, bytes]: def extract_tar_bytes(tarfile_bytes: bytes) -> Tuple[str, bytes]:
"""Takes the bytes of a tarfile, and returns the name and bytes of the first file in the archive.""" """Takes the bytes of a tarfile, and returns the name and bytes of the first file in the archive."""
out_bytes = b'' out_bytes = b''

View File

@@ -4,6 +4,51 @@ from enum import Enum
import datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from threading import Lock from threading import Lock
import os.path
from packetserver.runner.constants import job_setup_script, job_end_script, container_setup_script
from packetserver.common.util import multi_bytes_to_tar_bytes
def scripts_tar() -> bytes:
return multi_bytes_to_tar_bytes({
'job_setup_script.sh': job_setup_script,
'job_end_script.sh': job_end_script,
'container_setup_script.sh': container_setup_script
})
class RunnerFile:
def __init__(self, destination_path: str, source_path: str = None, data: bytes = b'', read_only: bool = False):
self._data = data
self._source_path = ""
if source_path is not None:
if source_path.strip() != "":
if not os.path.isfile(source_path.strip()):
raise ValueError("Source Path must point to a file.")
self._source_path = source_path.strip()
self.destination_path = destination_path.strip()
if self.destination_path == "":
raise ValueError("Destination path cannot be empty.")
if not os.path.isabs(self.destination_path):
raise ValueError("Destination path must be an absolute path.")
self.is_read_only = read_only
@property
def basename(self) -> str:
return os.path.basename(self.destination_path)
@property
def dirname(self) -> str:
return os.path.dirname(self.destination_path)
@property
def data(self) -> bytes:
if self._source_path == "":
return self._data
else:
return open(self._source_path, "rb").read()
class RunnerStatus(Enum): class RunnerStatus(Enum):
CREATED = 1 CREATED = 1
@@ -18,7 +63,12 @@ class RunnerStatus(Enum):
class Runner: class Runner:
"""Abstract class to take arguments and run a job and track the status and results.""" """Abstract class to take arguments and run a job and track the status and results."""
def __init__(self, username: str, args: Iterable[str], job_id: int, environment: Optional[dict] = None, def __init__(self, username: str, args: Iterable[str], job_id: int, environment: Optional[dict] = None,
timeout_secs: str = 300, refresh_db: bool = True, labels: Optional[list] = None): timeout_secs: str = 300, refresh_db: bool = True, labels: Optional[list] = None,
files: list[RunnerFile] = None):
self.files = []
if files is not None:
for f in files:
self.files.append(f)
self.status = RunnerStatus.CREATED self.status = RunnerStatus.CREATED
self.username = username.strip().lower() self.username = username.strip().lower()
self.args = args self.args = args
@@ -35,11 +85,16 @@ class Runner:
self.refresh_db = refresh_db self.refresh_db = refresh_db
self.created_at = datetime.datetime.now(datetime.UTC) self.created_at = datetime.datetime.now(datetime.UTC)
def is_finished(self): def is_finished(self) -> bool:
if self.status in [RunnerStatus.TIMED_OUT, RunnerStatus.SUCCESSFUL, RunnerStatus.FAILED]: if self.status in [RunnerStatus.TIMED_OUT, RunnerStatus.SUCCESSFUL, RunnerStatus.FAILED]:
return True return True
return False return False
def is_in_process(self) -> bool:
if self.status in [RunnerStatus.QUEUED, RunnerStatus.RUNNING, RunnerStatus.STARTING, RunnerStatus.STOPPING]:
return True
return False
def start(self): def start(self):
self.started = datetime.datetime.now() self.started = datetime.datetime.now()
@@ -97,7 +152,8 @@ class Orchestrator:
pass pass
def new_runner(self, username: str, args: Iterable[str], job_id: int, environment: Optional[dict] = None, def new_runner(self, username: str, args: Iterable[str], job_id: int, environment: Optional[dict] = None,
timeout_secs: str = 300, refresh_db: bool = True, labels: Optional[list] = None) -> Runner: timeout_secs: str = 300, refresh_db: bool = True, labels: Optional[list] = None,
files: list[RunnerFile] = None) -> Runner:
pass pass
def manage_lifecycle(self): def manage_lifecycle(self):

View File

@@ -0,0 +1,25 @@
from packetserver.common.util import multi_bytes_to_tar_bytes
container_setup_script = """#!/bin/bash
useradd -m -s /bin/bash "$PACKETSERVER_USER" -u 1000
mkdir -p "/home/${PACKETSERVER_USER}/.packetserver/artifacts"
mkdir -p /artifact_output
chown -R $PACKETSERVER_USER "/home/$PACKETSERVER_USER"
"""
job_setup_script = """#!/bin/bash
chmod 444 /user-db.json.gz
chown -R $PACKETSERVER_USER "/home/$PACKETSERVER_USER"
mkdir -p "/home/$PACKETSERVER_USER/.packetserver/artifacts/$PACKETSERVER_JOBID"
"""
job_end_script = """#!/bin/bash
PACKETSERVER_ARTIFACT_DIR="/home/$PACKETSERVER_USER/.packetserver/artifacts/$PACKETSERVER_JOBID"
PACKETSERVER_ARTIFACT_TAR="/artifact_output/${PACKETSERVER_JOBID}.tar"
cwd=$(pwd)
if [ $(find "$PACKETSERVER_ARTIFACT_DIR" | wc -l) -gt "1" ]; then
cd $PACKETSERVER_ARTIFACT_DIR
tar -cf ${PACKETSERVER_ARTIFACT_TAR} .
fi
rm -rf ${PACKETSERVER_ARTIFACT_DIR}
"""

View File

@@ -1,7 +1,7 @@
"""Uses podman to run jobs in containers.""" """Uses podman to run jobs in containers."""
import time import time
from . import Runner, Orchestrator, RunnerStatus from . import Runner, Orchestrator, RunnerStatus, RunnerFile, scripts_tar
from collections import namedtuple from collections import namedtuple
from typing import Optional, Iterable from typing import Optional, Iterable
import subprocess import subprocess
@@ -14,6 +14,11 @@ import ZODB
import datetime import datetime
from os.path import basename, dirname from os.path import basename, dirname
from packetserver.common.util import bytes_to_tar_bytes, random_string from packetserver.common.util import bytes_to_tar_bytes, random_string
from packetserver import VERSION as packetserver_version
import re
from threading import Thread
env_splitter_rex = '''([a-zA-Z0-9]+)=([a-zA-Z0-9]*)'''
PodmanOptions = namedtuple("PodmanOptions", ["default_timeout", "max_timeout", "image_name", PodmanOptions = namedtuple("PodmanOptions", ["default_timeout", "max_timeout", "image_name",
"max_active_jobs", "container_keepalive", "name_prefix"]) "max_active_jobs", "container_keepalive", "name_prefix"])
@@ -33,6 +38,7 @@ class PodmanOrchestrator(Orchestrator):
super().__init__() super().__init__()
self.started = False self.started = False
self.user_containers = {} self.user_containers = {}
self.manager_thread = None
if uri: if uri:
self.uri = uri self.uri = uri
else: else:
@@ -60,7 +66,41 @@ class PodmanOrchestrator(Orchestrator):
def get_file_from_user_container(self, username: str, path: str) -> bytes : def get_file_from_user_container(self, username: str, path: str) -> bytes :
pass pass
def podman_container_env(self, container_name: str) -> dict:
cli = self.client
logging.debug(f"Attempting to remove container named {container_name}")
try:
con = cli.containers.get(container_name)
splitter = re.compile(env_splitter_rex)
env = {}
for i in con.inspect()['Config']['Env']:
m = splitter.match(i)
if m:
env[m.groups()[0]] = m.groups()[1]
return env
except podman.errors.exceptions.NotFound as e:
return
def podman_container_version(self, container_name: str) -> str:
try:
env = self.podman_container_env(container_name)
except:
env = {}
return env.get("PACKETSERVER_VERSION", "0.0.0")
def podman_user_container_env(self, username: str) -> dict:
container_name = self.get_container_name(username)
return self.podman_container_env(container_name)
def podman_user_container_version(self, username: str) -> str:
container_name = self.get_container_name(username)
return self.podman_container_version(container_name)
def podman_start_user_container(self, username: str): def podman_start_user_container(self, username: str):
container_env = {
"PACKETSERVER_VERSION": packetserver_version,
"PACKETSERVER_USER": username.strip().lower()
}
con = self.client.containers.create(self.opts.image_name, name=self.get_container_name(username), con = self.client.containers.create(self.opts.image_name, name=self.get_container_name(username),
command=["tail", "-f", "/dev/null"]) command=["tail", "-f", "/dev/null"])
con.start() con.start()
@@ -77,6 +117,7 @@ class PodmanOrchestrator(Orchestrator):
con.stop() con.stop()
con.remove() con.remove()
raise RuntimeError(f"Couldn't start container for user {username}") raise RuntimeError(f"Couldn't start container for user {username}")
self.touch_user_container(username)
def podman_remove_container_name(self, container_name: str): def podman_remove_container_name(self, container_name: str):
cli = self.client cli = self.client
@@ -110,6 +151,15 @@ class PodmanOrchestrator(Orchestrator):
except podman.errors.exceptions.NotFound: except podman.errors.exceptions.NotFound:
return False return False
def podman_run_command_simple(self, username: str, command: Iterable[str], as_root: bool = True) -> int:
"""Runs command defined by arguments iterable in container. As root by default. Returns exit code."""
container_name = self.get_container_name(username)
un = username.lower().strip()
con = self.client.containers.get(container_name)
if as_root:
un = 'root'
return con.exec_run(list(command), user=un)[0]
def clean_orphaned_containers(self): def clean_orphaned_containers(self):
cli = self.client cli = self.client
for i in cli.containers.list(all=True): for i in cli.containers.list(all=True):
@@ -123,7 +173,7 @@ class PodmanOrchestrator(Orchestrator):
self.user_containers[self.get_container_name(username)] = datetime.datetime.now() self.user_containers[self.get_container_name(username)] = datetime.datetime.now()
def start_user_container(self, username: str): def start_user_container(self, username: str):
if not self.podman_container_exists(self.get_container_name(username)): if not self.podman_user_container_exists(username):
self.podman_start_user_container(username) self.podman_start_user_container(username)
self.touch_user_container(username) self.touch_user_container(username)
@@ -133,6 +183,23 @@ class PodmanOrchestrator(Orchestrator):
if (datetime.datetime.now() - self.user_containers[c]) > self.opts.container_keepalive: if (datetime.datetime.now() - self.user_containers[c]) > self.opts.container_keepalive:
self.podman_remove_container_name(c) self.podman_remove_container_name(c)
del self.user_containers[c] del self.user_containers[c]
else:
if packetserver_version < self.podman_user_container_version(c):
def user_runners_in_process(self, username: str) -> int:
un = username.strip().lower()
count = 0
for r in self.runners:
if r.is_in_process:
if r.username == un:
count = count + 1
return count
def user_running(self, username: str) -> bool:
if self.user_runners_in_process(username) > 0:
return True
else:
return False
def runners_in_process(self) -> int: def runners_in_process(self) -> int:
count = 0 count = 0
@@ -151,7 +218,8 @@ class PodmanOrchestrator(Orchestrator):
return False return False
def new_runner(self, username: str, args: Iterable[str], job_id: int, environment: Optional[dict] = None, def new_runner(self, username: str, args: Iterable[str], job_id: int, environment: Optional[dict] = None,
timeout_secs: str = 300, refresh_db: bool = True, labels: Optional[list] = None) -> Optional[Runner]: timeout_secs: str = 300, refresh_db: bool = True, labels: Optional[list] = None,
files: list[RunnerFile] = None) -> Optional[Runner]:
if not self.started: if not self.started:
return None return None
with self.runner_lock: with self.runner_lock:
@@ -160,6 +228,8 @@ class PodmanOrchestrator(Orchestrator):
pass pass
def manage_lifecycle(self): def manage_lifecycle(self):
if not self.started:
return
with self.runner_lock: with self.runner_lock:
for r in self.runners: for r in self.runners:
if r.status is RunnerStatus.RUNNING: if r.status is RunnerStatus.RUNNING:
@@ -169,8 +239,22 @@ class PodmanOrchestrator(Orchestrator):
self.clean_containers() self.clean_containers()
self.clean_orphaned_containers() self.clean_orphaned_containers()
def manager(self):
logging.debug("Starting podman orchestrator thread.")
while self.started:
self.manage_lifecycle()
time.sleep(.5)
logging.debug("Stopping podman orchestrator thread.")
def start(self): def start(self):
if not self.started:
self.clean_orphaned_containers()
self.started = True self.started = True
self.manager_thread = Thread(target=self.manager)
self.manager_thread.start()
def stop(self): def stop(self):
self.started = False self.started = False
if self.manager_thread is not None:
self.manager_thread.join(timeout=15)
self.manager_thread = None

View File

@@ -179,8 +179,6 @@ class Server:
if not self.started: if not self.started:
return return
# Add things to do here: # Add things to do here:
if self.orchestrator is not None:
self.orchestrator.manage_lifecycle()
def run_worker(self): def run_worker(self):
"""Intended to be running as a thread.""" """Intended to be running as a thread."""
@@ -215,7 +213,10 @@ class Server:
self.app.start(self.pe_server, self.pe_port) self.app.start(self.pe_server, self.pe_port)
self.app.register_callsigns(self.callsign) self.app.register_callsigns(self.callsign)
self.started = True self.started = True
if self.orchestrator is not None:
self.orchestrator.start()
self.worker_thread = Thread(target=self.run_worker) self.worker_thread = Thread(target=self.run_worker)
self.worker_thread.start()
def exit_gracefully(self, signum, frame): def exit_gracefully(self, signum, frame):
self.stop() self.stop()
@@ -232,6 +233,8 @@ class Server:
cm = self.app._engine._active_handler._handlers[1]._connection_map cm = self.app._engine._active_handler._handlers[1]._connection_map
for key in cm._connections.keys(): for key in cm._connections.keys():
cm._connections[key].close() cm._connections[key].close()
if self.orchestrator is not None:
self.orchestrator.stop()
self.app.stop() self.app.stop()
self.stop_db() self.stop_db()