diff --git a/shipcat.py b/shipcat.py new file mode 100755 index 0000000000000000000000000000000000000000..80e32be5a79a979278cb13dec823a5c51a19df90 --- /dev/null +++ b/shipcat.py @@ -0,0 +1,227 @@ +#!/usr/bin/python3 + +import argparse +from dataclasses import dataclass +import os +from pathlib import Path +from pwd import getpwnam +import socket +import subprocess +import sys +from typing import NoReturn, List, Optional + +from shipcat.config import GlobalConfig, ContainerConfig, ConfigError + + +def die(msg: str) -> NoReturn: + print(msg, file=sys.stderr) + sys.exit(1) + + +def progress(msg: str) -> None: + print(msg, file=sys.stderr, end="", flush=True) + + +def load_container_config(name: str) -> ContainerConfig: + try: + cc = ContainerConfig.load(config, name) + except ConfigError as e: + die(str(e)) + + return cc + + +def is_root() -> bool: + return os.geteuid() == 0 + + +def assert_root() -> None: + if not is_root(): + die('This operation must be performed by root') + + +@dataclass(frozen=True, order=True) +class SubIdRange: + first: int + count: int + name: str + + +class SubIds: + ranges: List[SubIdRange] + + def __init__(self, file: str) -> None: + ranges = [] + with open(file) as f: + for line in f: + name, first_str, count_str = line.rstrip().split(':') + ranges.append(SubIdRange(int(first_str), int(count_str), name)) + + ranges.sort() + for i in range(1, len(ranges)): + r, pr = ranges[i], ranges[i-1] + if r.first > pr.first + pr.count: + die(f'Inconsistent {name}: entries {r} and {pr} overlap') + + self.ranges = ranges + + def find_range(self, uid: int, user: str, size: int) -> Optional[SubIdRange]: + uid_str = str(uid) + for r in self.ranges: + if (r.name == user or r.name == uid_str) and r.count >= size: + return r + return None + + def alloc_range(self, user: str, size: int) -> SubIdRange: + # Call at most once (does not update the list of ranges) + i = 100000 + for r in self.ranges: + if r.first >= i + size: + return SubIdRange(i, size, user) + else: + i = r.first + r.count + return SubIdRange(i, size, user) + + +def do_init(args: argparse.Namespace) -> None: + name = args.name + cc = load_container_config(name) + assert_root() + + progress(f'User {cc.user_name}: ') + try: + pwd = getpwnam(cc.user_name) + progress('already exists\n') + except KeyError: + progress('creating\n') + subprocess.run( + ['adduser', '--system', '--group', '--gecos', f'Container {name}', '--disabled-password', cc.user_name], + check=True, + ) + pwd = getpwnam(cc.user_name) + uid = pwd.pw_uid + + progress('Subuid range: ') + subuids = SubIds('/etc/subuid') + sur = subuids.find_range(uid, cc.user_name, 65536) + if sur is not None: + progress('already exists\n') + else: + progress('allocating\n') + sur = subuids.alloc_range(cc.user_name, 65536) + subprocess.run( + ['usermod', '--add-subuids', f'{sur.first}-{sur.first + sur.count - 1}', cc.user_name], + check=True, + ) + + progress('Subgid range: ') + subgids = SubIds('/etc/subgid') + sgr = subgids.find_range(uid, cc.user_name, 65536) + if sgr is not None: + progress('already exists\n') + else: + progress('allocating\n') + sgr = subgids.alloc_range(cc.user_name, 65536) + subprocess.run( + ['usermod', '--add-subgids', f'{sgr.first}-{sgr.first + sgr.count - 1}', cc.user_name], + check=True, + ) + + progress(f'Using user {cc.user_name}, uid {uid}, subuids {sur.first}+{sur.count}, subgids {sgr.first}+{sgr.count}\n') + + root_path = Path(cc.root_dir) + progress(f'Container directory {root_path}: ') + if root_path.is_dir(): + progress('already exists\n') + else: + root_path.mkdir(mode=0o750, exist_ok=True) + os.chown(root_path, 0, sgr.first) + progress('created\n') + + data_path = root_path / 'data' + progress(f'Data directory {data_path}: ') + if data_path.is_dir(): + progress('already exists\n') + else: + data_path.mkdir(mode=0o755, exist_ok=True) + os.chown(data_path, sur.first, sgr.first) + progress('created\n') + + progress('IP address: ') + try: + ip = socket.gethostbyname(name) + except OSError as e: + die(str(e)) + progress(f'{ip}\n') + + +def do_create(args: argparse.Namespace) -> None: + name = args.name + cc = load_container_config(name) + assert_root() + + data_dir = Path(cc.root_dir) / 'data' + ip = socket.gethostbyname(name) + + subprocess.run( + ['podman', 'pull', cc.image], + check=True, + ) + + # FIXME: If the container was running, stop it + + subprocess.run( + ['podman', 'rm', '-if', name], + check=True, + ) + + subprocess.run( + [ + 'podman', 'create', + '--name', name, + '--conmon-pidfile', cc.pid_file, + '--log-driver', 'journald', + '--hostname', name, + '--volume', f'{data_dir}:/data', + '--network', 'static', + '--ip', ip, + '--subuidname', cc.user_name, + '--subgidname', cc.user_name, + cc.image, + ], + check=True, + ) + + +def do_start(args: argparse.Namespace) -> None: + pass + + +parser = argparse.ArgumentParser( + description='FIXME', +) +subparsers = parser.add_subparsers(help='action to perform', dest='action', required=True, metavar='ACTION') + +init_parser = subparsers.add_parser('init', help='initialize a new container', description='Initialize a new container. Should be called by root.') +init_parser.add_argument('name', help='Name of the container') + +create_parser = subparsers.add_parser('create', help='create a container from an image', description='Create a container from an image.') +create_parser.add_argument('name', help='Name of the container') + +start_parser = subparsers.add_parser('start', help='start a container', description='Start a container') +start_parser.add_argument('name', help='Name of the container') + +args = parser.parse_args() + +try: + config = GlobalConfig.load('shipcat.toml') +except ConfigError as e: + print(e, file=sys.stderr) + sys.exit(1) + +if args.action == 'init': + do_init(args) +elif args.action == 'create': + do_create(args) +elif args.action == 'start': + do_start(args) diff --git a/shipcat/config.py b/shipcat/config.py index 6c82a1adf35787bc4feaa2ef316d4a9a0fa16405..2d34571cfe376e7e42f7ab1e796900b2918e8bcc 100644 --- a/shipcat/config.py +++ b/shipcat/config.py @@ -16,7 +16,7 @@ class ConfigError(RuntimeError): class GlobalConfig: - container_dir: Path + container_config_dir: Path verbosity: int @classmethod @@ -39,7 +39,7 @@ class GlobalConfig: def parse(self, walker: Walker) -> None: with walker.enter_object() as w: - self.container_dir = Path(w['container_dir'].as_str()) + self.container_config_dir = Path(w['container_config_dir'].as_str()) self.verbosity = w['verbosity'].as_int(0) @@ -50,12 +50,16 @@ class ContainerConfig: allowed_users: Set[int] allowed_groups: Set[int] + # Automatically generated + pid_file: str + user_name: str + @classmethod def load(cls, global_config: GlobalConfig, name: str) -> 'ContainerConfig': if not re.fullmatch(r'[0-9A-Za-z_-]+', name): raise ConfigError(f'Invalid container name {name}') - path = global_config.container_dir / (name + '.toml') + path = global_config.container_config_dir / (name + '.toml') try: with open(path, 'rb') as f: root = tomllib.load(f) @@ -95,3 +99,6 @@ class ContainerConfig: except KeyError: wu.raise_error(f'Unknown group {name}') self.allowed_groups.add(grp.gr_gid) + + self.pid_file = f'/run/{self.container}.pid' + self.user_name = self.container