Compare commits

..

16 Commits

Author SHA1 Message Date
911ffe1c92 Add user_init_commands and root_init_commands lists so config files can append init commands successively without overwriting each other. 2022-01-30 05:28:51 -06:00
f43ac055c2 Add --restart feature. This restarts the container (killing any processes running in it) 2021-07-09 12:47:04 -05:00
2e16523810 Add "root_init_command" which is just like user_init_command but run as root. 2021-06-28 15:19:17 -05:00
d7052998c8 Add docker_create_options command, so that config files can specify additional options for docker create command. 2021-06-23 09:00:35 -05:00
488dedb74e Add user_init_command config option, to specify an additional command that should be run when the container is created. 2021-04-03 01:23:49 -05:00
9340dbe720 Parse command line config on startup and apply later, so we can load config files based on container name (and also just because it looks cleaner). Also wrap logging logic into function and expose to config files. 2021-02-28 16:21:07 -06:00
556b9d6b75 use sys.stdout.isatty() to detect if a tty is available 2021-02-28 04:53:49 -06:00
6c48e92378 add flag --notty for disabling tty allocation (-t in docker command) 2021-02-28 04:52:05 -06:00
84aaf451f7 Add ability to load config files, which are processed before command line arguments. 2021-01-10 01:21:38 -06:00
6a266323bf tag generated container with otherworldowner label so we know which containers are ours 2020-10-17 22:54:27 -05:00
0b334ea461 Add --cd=<directory> option to run command in specified directory, or --cd option to run command in the current directory. If the container is being created, this directory is added to the list of directories to mount. Otherwise, if the directory does not exist in the container, an error is raised and the command fails to run. 2020-09-27 19:39:14 -05:00
633ef98fab replace chmod ~ call with a bootstrap script that will chmod all the appropriate directories and also copy from /etc/skel (which won't be done automatically) 2020-08-25 12:08:07 -05:00
42c65b9783 chown ~/.config too, most programs expect it to be writable 2020-08-25 11:53:09 -05:00
b1878aef1b Add --rmi command that removes the generated image. 2020-08-20 02:49:34 -05:00
71fe46e881 Add ability to run multiple "actions" (commit/rm) and even an otherworld command (potentially starting a new container) 2020-08-20 02:44:55 -05:00
d0adb3b0ed Make default image name image_{CONTAINER_NAME}, and use debian:stable as fallback. 2020-08-20 02:37:44 -05:00
2 changed files with 271 additions and 94 deletions

View File

@@ -17,30 +17,40 @@ Otherworld creates a persistent Docker container running your choice of GNU/Linu
## Arguments
These are arguments that go between `otherworld` and the command.
### --container=<container>
Name to assign to container (default: `otherworld_$USER`)
### Creation Options
### --image=<image>
Image to create the container with (default: `debian:stable`)
#### --image=<image>
Image to create the container with (default: `image_$CONTAINER_NAME`, or `debian:stable` if that image does not exist)
### --sudo
Runs command as root, instead of current user.
### --rm
Removes the specified container.
### --commit
Commits the container to an image named `image_$CONTAINER_NAME`.
### --build=<path>
#### --build=<path>
Path to build context to build the image from. This overrides `--image`/`OW_IMAGE`. The resulting image is named `image_$CONTAINER_NAME`
### --volume=<volume>
Volume to map to the container. `~` is expanded automatically. If the directory exists in the user's home directory it will be created to ensure it has the correct ownership and permissions.
#### --volume=<volume>
A volume to mount in the container. `~` is expanded automatically. If the directory exists in the user's home directory it will be created to ensure it has the correct ownership and permissions.
### --env=<key>=<value>
#### --container=<container>
Name to assign to container (default: `otherworld_$USER`)
### Command Options
#### --env=<key>=<value>
Set the environment variable `key` to `value` in the command. **Note that this does not set environment variables in the container itself**.
#### --sudo
Runs command as root, instead of current user.
#### --cd/--cd=<directory>
Runs command in the specified directory, or current directory by default. If the container is being created, specified directory is added to list of volumes to mount. Otherwise, if the specified directory does not exist in the container, an error is raised.
### Container manipulation commands
#### --rm
Removes the otherworld container.
#### --rmi
Removes image generated by the `otherworld` script (`image_$CONTAINER_NAME`)
#### --commit
Commits the container to an image named `image_$CONTAINER_NAME`.
## Environment variables
### OW_IMAGE
Image to create the container with (default: `debian:stable`)
@@ -48,5 +58,8 @@ Image to create the container with (default: `debian:stable`)
### OW_CONTAINER
Name to assign to container (default: `otherworld_$USER`)
## Config Files
Otherworld will look for config files named `.otherworld` in the current directory and all ancestor directories. Config files are Python scripts that can access and redefine the following variables: `command`, `user`, `uid`, `command_user`, `image_name`, `container_name`, `build_path`, `command_workdir`, `user_volumes`, `pull`, `command_env`. Config files are loaded in descending order (e.g. so `~/.otherworld` is loaded before `~/Projects/.otherworld`) and before command line arguments are processed. Additional variables available are `cwd` (the current directory), `config_file` (path to current configuration file) and `config_file_dir` (parent directory of config file)
## License
[GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html) or later

276
otherworld Normal file → Executable file
View File

@@ -7,7 +7,10 @@ import sys
import json
CONTAINER_NAME = "otherworld"
IMAGE_NAME = "debian:stable"
DEFAULT_IMAGE_NAME = "debian:stable"
OTHERWORLD_LABEL = "otherworldowner"
OTHERWORLD_CONFIG = ".otherworld"
def create_x11_mapping ():
home = os.environ["HOME"]
@@ -29,21 +32,37 @@ def create_x11_mapping ():
"--shm-size", "2g",
"--privileged"]
DOCKER_CREATE_OPTIONS = ["-t"] + create_x11_mapping()
DOCKER_CREATE_OPTIONS = ["-t"]
USER_VOLUMES = ["~/otherworld"]
BACKGROUND_COMMAND = None
DEFAULT_COMMAND = ["bash"]
GENERATED_IMAGE_NAME_TEMPLATE = "image_{container_name}"
CREATE_USER_BOOTSTRAP_SCRIPT="""
chown {user} /home/{user}
chown {user} /home/{user}/.config
cd /home/{user}
cp -Rv /etc/skel .
chown -R {user} skel
mv skel/* skel/.* .
rm -rf skel
"""
def get_generated_image_name (container_name):
return GENERATED_IMAGE_NAME_TEMPLATE.format(container_name=container_name)
def docker_inspect (container):
return json.loads(check_output(["docker", "inspect", container]))
def docker_create (image, container, options, command):
docker_command = ["docker", "create", "--name", container] + options + [image]
def docker_list_containers (filter_argument):
return check_output(["docker", "ps", "-a", "--filter", filter_argument, "--format", "{{.Names}}"]).decode().strip().split("\n")
def docker_create (image, container, options, command, labels={}):
docker_command = ["docker", "create", "--name", container] + options
if labels:
docker_command = docker_command + list(chain.from_iterable([["--label", f"{kv[0]}={kv[1]}"] for kv in labels.items()]))
docker_command.append(image)
if command:
docker_command = docker_command + command
call(docker_command)
@@ -56,13 +75,15 @@ def docker_create_user (container, user, uid=None):
if uid is not None:
command = command + ["--uid", str(uid)]
call(command)
call(["docker", "exec", container, "chown", user, f"/home/{user}"])
call(["docker", "exec", container, "sh", "-c", CREATE_USER_BOOTSTRAP_SCRIPT.format(user=user)])
def docker_start (container):
call(["docker", "start", container])
def docker_exec (container, command, user, env={}):
call(["docker", "exec", "--user", user, "-ti"] + \
def docker_exec (container, command, user, env={}, tty=True, work_dir=None):
call(["docker", "exec", "--user", user] + \
(["-ti" if tty else "-i"]) + \
(["--workdir", work_dir] if work_dir else []) + \
list(chain.from_iterable([["--env", f"{kv[0]}={kv[1]}"] for kv in env.items()])) + \
[container] + command)
@@ -72,6 +93,12 @@ def docker_commit (container, image_name):
def docker_rm (container):
call(["docker", "rm", "-f", container])
def docker_restart (container):
call(["docker", "restart", container])
def docker_rmi (container):
call(["docker", "rmi", "-f", container])
def docker_build (build_path, options, image_name):
check_call(["docker", "build"] + options + ["-t", image_name, build_path])
return image_name
@@ -81,7 +108,7 @@ def docker_pull (image_name):
def resolve_build_path (build_path):
if build_path is not None and build_path.startswith("~/"):
build_path = f"{os.environ['HOME']}/{build_path[1:]}"
build_path = f"{os.environ['HOME']}/{build_path[2:]}"
return build_path
@@ -96,78 +123,197 @@ def expand_user_volumes (volumes):
(source, target) = source.split(":")
if source.startswith("~/"):
source = f"{outer_home}/{source[1:]}"
source = f"{outer_home}/{source[2:]}"
if source.startswith(outer_home) and not os.path.exists(source):
os.makedirs(source)
if target.startswith("~/"):
target = f"{inner_home}/{target[1:]}"
target = f"{inner_home}/{target[2:]}"
mappings = mappings + ["-v", f"{source}:{target}"]
return mappings
user = os.environ["USER"]
uid = int(check_output(["id", "-u"]).strip())
image_name = os.environ.get("OW_IMAGE", IMAGE_NAME)
container_name = os.environ.get("OW_CONTAINER", f"{CONTAINER_NAME}_{user}")
build_path = None
def parse_command_line (command, config=None):
if config is None:
config = {
"actions": [],
"user_volumes": [],
"command_env": {}
}
command = sys.argv[1:]
command_user = user
user_volumes = USER_VOLUMES
pull = False
command_env = {}
quiet = False
while len(command) > 0:
arg = command[0]
if arg == "--quiet":
command = command[1:]
quiet = True
config['quiet'] = True
elif arg == "--notty":
command = command[1:]
config['tty'] = False
elif arg == "--rm":
if not quiet:
print(f">> Removing container: {container_name}")
docker_rm(container_name)
sys.exit(0)
config['actions'].append("rm")
command = command[1:]
elif arg == "--restart":
config['actions'].append("restart")
command = command[1:]
elif arg == "--rmi":
config['actions'].append("rmi")
command = command[1:]
elif arg == "--commit":
image_name = get_generated_image_name(container_name)
if not quiet:
print(f">> Committing container: {container_name} to image: {image_name}")
docker_commit(container_name, image_name)
sys.exit(0)
config['actions'].append("commit")
command = command[1:]
elif arg == "--sudo":
command_user = "root"
config['command_user'] = "root"
command = command[1:]
elif arg.startswith("--container="):
container_name = arg[len("--container="):]
command = command[1:]
elif arg.startswith("--image"):
image_name = get_generated_image_name(container_name)
config['container_name'] = arg[len("--container="):]
command = command[1:]
elif arg.startswith("--image="):
image_name = arg[len("--image="):]
config['image_name'] = arg[len("--image="):]
command = command[1:]
elif arg.startswith("--build="):
build_path = arg[len("--build="):]
config['build_path'] = arg[len("--build="):]
command = command[1:]
elif arg.startswith("--volume="):
user_volumes.append(arg[len("--volume="):])
config['user_volumes'].append(arg[len("--volume="):])
command = command[1:]
elif arg.startswith("--env="):
env_pair = arg[len("--env="):]
(key, value) = env_pair.split("=", 1)
command_env[key] = value
config['command_env'][key] = value
command = command[1:]
elif arg == "--cd":
config['command_workdir'] = os.getcwd()
command = command[1:]
elif arg.startswith("--cd="):
config['command_workdir'] = arg[len("--cd="):]
command = command[1:]
elif arg == "--pull":
command = command[1:]
pull = True
config['pull'] = True
else:
break
return (config, command)
if len(command) == 0:
def find_config_files (config_names):
config_files = []
directory = os.path.abspath(os.getcwd())
while True:
for config_name in config_names:
config_file = os.path.join(directory, config_name)
if os.path.exists(config_file):
config_files.append(config_file)
if directory == "/" or (not directory):
break
directory = os.path.dirname(directory)
config_files.reverse()
return config_files
def load_config_files (namespace, config_files):
config_keys = list(namespace.keys())
for config_file in config_files:
with open(config_file) as config_code:
namespace.update({
"config_file": config_file,
"config_file_dir": os.path.dirname(config_file)
})
exec(config_code.read(), {}, namespace)
return dict([item for item in namespace.items() if item[0] in config_keys])
def init_command_as_list (command):
if isinstance(command, str):
command = ["sh", "-c", command]
return command
# parse options and command from command line
(command_line_options, command) = parse_command_line(sys.argv[1:])
actions = command_line_options['actions']
quiet = command_line_options.get("quiet", False)
def log (message, *args, **kwargs):
if quiet:
return
print(">> {}".format(message.format(*args, **kwargs)))
tty = sys.stdout.isatty()
# default config
user = os.environ["USER"]
config = {
"command": command,
"user": user,
"uid": int(check_output(["id", "-u"]).strip()),
"command_user": user,
"image_name": os.environ.get("OW_IMAGE", None),
"container_name": command_line_options.get("container_name", os.environ.get("OW_CONTAINER", f"{CONTAINER_NAME}_{user}")),
"build_path": None,
"command_workdir": None,
"user_volumes": USER_VOLUMES,
"pull": False,
"command_env": {},
"cwd": os.getcwd(),
"tty": sys.stdout.isatty(),
"log": log,
"root_init_command": [],
"root_init_commmands": [],
"user_init_command": [],
"user_init_commands": [],
"docker_create_options": []
}
# load config from files
config_files = find_config_files([
OTHERWORLD_CONFIG,
"{}{}".format(config['container_name'], OTHERWORLD_CONFIG)
])
for config_file in config_files:
log("Loading config file {}", config_file)
config = load_config_files(config, config_files)
# apply config from command line
for key, value in command_line_options.items():
if key == "user_volumes":
config['user_volumes'] = config['user_volumes'] + value
elif key == "command_env":
config['command_env'].update(value)
else:
config[key] = value
# update local variables (FIXME: rewrite to avoid doing this)
locals().update(config)
if command_workdir:
user_volumes.append(command_workdir)
command_workdir = resolve_build_path(command_workdir)
if actions:
if "commit" in actions:
target_image_name = get_generated_image_name(container_name)
log("Committing container: {} to image: {}", container_name, target_image_name)
docker_commit(container_name, target_image_name)
if "restart" in actions:
log("Restarting container: {}", container_name)
docker_restart(container_name)
if "rm" in actions:
log("Removing container: {}", container_name)
docker_rm(container_name)
if "rmi" in actions:
target_image_name = get_generated_image_name(container_name)
log("Removing image: {}", target_image_name)
docker_rmi(target_image_name)
if not command:
sys.exit(0)
if not command:
command = DEFAULT_COMMAND
container = None
@@ -177,32 +323,50 @@ except Exception:
build_path = resolve_build_path(build_path)
if build_path is not None:
image_name = docker_build(build_path, ["--pull"] if pull else [], get_generated_image_name(container_name))
elif pull:
else:
if image_name is None:
image_name = get_generated_image_name(container_name)
try:
docker_inspect(image_name)
log("Using default otherworld image {}", image_name)
except Exception:
log("Default otherworld image {} not found, falling back to {}", image_name, DEFAULT_IMAGE_NAME)
image_name = DEFAULT_IMAGE_NAME
if pull:
docker_pull(image_name)
if not quiet:
print(f">> Creating container: {container_name} (using image: {image_name})")
log("Creating container: {} (using image: {})", container_name, image_name)
docker_create(
image_name,
container_name,
DOCKER_CREATE_OPTIONS + expand_user_volumes(user_volumes),
BACKGROUND_COMMAND
DOCKER_CREATE_OPTIONS + config.get("docker_create_options", []) + create_x11_mapping() + expand_user_volumes(user_volumes),
BACKGROUND_COMMAND, { OTHERWORLD_LABEL: user }
)
container = docker_inspect(container_name)[0]
if not container["State"]["Running"]:
if not quiet:
print(f">> Starting container: {container_name}")
log("Starting container: {}", container_name)
docker_start(container_name)
container = docker_inspect(container_name)[0]
try:
docker_get_user(container_name, user)
except Exception:
if not quiet:
print(f">> Creating container user: {user}")
log("Creating container user: {}", user)
docker_create_user(container_name, user, uid)
if not quiet:
print(f">> Welcome to {container_name} (IP {container['NetworkSettings']['IPAddress']})")
docker_exec(container_name, command, command_user, command_env)
if config['root_init_command']:
config['root_init_commands'].append(config['root_init_command'])
for command in config['root_init_commands']:
docker_exec(container_name, init_command_as_list(command), "root", command_env, tty, "/home/{}".format(command_user))
if config['user_init_command']:
config['user_init_commands'].append(config['user_init_command'])
for command in config['user_init_commands']:
docker_exec(container_name, init_command_as_list(command), command_user, command_env, tty, "/home/{}".format(command_user))
log("Welcome to {} (IP {})", container_name, container['NetworkSettings']['IPAddress'])
docker_exec(container_name, command, command_user, command_env, tty, command_workdir)