#!/usr/bin/env perl # This is an unoptimized prototype with lots of subshells in preparation # for port to Go eventually. In other words, this just works for now. # See documentation after __END__ at the bottom. (I didn't feel like # writing it in Perl POD format, sorry.) use v5.14; use File::Path qw(make_path); no strict; for (qw( yq docker id )) { not `which $_` and say "Missing dependency: $_" and exit 1; } # attemp to filter out that piece of shit Python yq `yq --help` =~ /eval-all.*shell-completion/s or say "Missing GOOD yq." and exit 1; my $wsdir = $ENV{'WORKSPACES'} || "$ENV{'HOME'}/Workspaces"; my $image = image() || $ENV{'WORKSPACE_IMAGE'} || 'rwxrob/workspace'; my $editor = $ENV{'EDITOR'} || 'vi'; not -d $wsdir and mkdir($wsdir); sub end { say shift; exit; } sub get_ws_list { opendir( my $dh, $wsdir ) || end "Failed to open workspace directory: $!"; return grep !/^(\.\.?|README)/, readdir($dh); } sub image { not -e "$wsdir/.config.yml" and return ""; my $image = `yq e '.image' "$wsdir/.config.yml"`; chomp $image; return $image; } sub image_exists { my $name = shift; `docker image inspect $name >/dev/null 2>&1`; return $? == 0; } sub x_image { my $name = shift; my $conf = "$wsdir/.config.yml"; not $name and say "$image" and return; not image_exists($name) and say "Image '$name' not found. Pull." and return; not -e $conf and `yq e -n '.image="$name"' >| "$conf"` and return; `yq e -i '.image="$name"' "$conf"` and return; } sub x_pull { my $arg = shift; $arg and exec qq{docker pull $arg}; exec qq{docker pull $image}; } sub x_config { my $name = shift || x_pick(); say "$wsdir/$name/.config/ws/config.yml"; } sub x_edit { my $name = shift || x_pick(); my $conf = "$wsdir/$name/.config/ws/config.yml"; exec $editor, $conf; } sub expand { my $r = shift; return unless @$r; map { !/:/; $_ = "$_:$_" } @$r; } sub x_open { my @names = get_ws_list(); not @names and return x_create(@_); my $name = shift || x_pick(); my $dir = "$wsdir/$name"; not -d $dir and return x_create($name); chdir $dir; my $confpath=`ws config $name`; chomp $confpath; not $confpath and end "No config file found."; my $username = `yq e .user.name "$confpath"`; chomp $username; not $username and end "No user name found."; my @cmd = (qw(docker run -it --rm --privileged --name), $name, '-h', $name ); my @ports = qx(yq e '.ports[]' `ws config $name`); chomp @ports; if ($ports[0] eq 'all') { @cmd = ( @cmd, '--network', 'host' ); } else { expand \@ports; map { @cmd = ( @cmd, '-p', $_ ) } @ports; } my @mounts = qx(yq e '.mounts[]' `ws config $name`); chomp @mounts; unshift @mounts, "/var/run/docker.sock"; expand \@mounts; unshift @mounts, "$dir:/home/$username"; map { @cmd = ( @cmd, '-v', $_ ) } @mounts; #say "Executing: " . join( " ", ( @cmd, $image ) ); exec( @cmd, $image ); } sub x_list { map { say $_} get_ws_list; } *x_ls = *x_list; sub x_remove { my $name = shift; $name or $name = x_pick(); my $path = "$wsdir/$name"; ! -d $path and say "Path not found: $path" and return; print "Sure you want to remove '$path'? (n) "; =~ /^y/i or return; -d $path and `rm -rf $path`; say "Removed $wsdir/$name directory."; } *x_rm = *x_remove; sub x_pick { my $n = 1; my @names = get_ws_list(); not @names and end "No workspaces found."; my @list = get_ws_list(); scalar(@list) == 1 and return $list[0]; map { say "$n. $_"; $n++ } @list; my $pick; until ( 0 < $pick && $pick < $n ) { print "#? "; $pick = ; } my $name = @names[ $pick - 1 ]; return $name; } sub x_usage { my @path = split( /\//, $0 ); my $exe = pop @path; say "usage: $exe [COMMAND]"; } sub x_create { my $name, $dir, $username, $userid, $groupname, $groupid, $ports, $mounts, $getlatest, $resp, $confd, $conf; $name = shift; $dir = "$wsdir/$name"; until ($name) { print "Workspace Name: "; $name = ; chomp $name; not $name and next; $dir = "$wsdir/$name"; if ( -d $dir ) { say "Workspace ($name) already exists."; $name = ""; next; } } $username = `id -un`; chomp $username; print "User Name ($username): "; $resp = ; chomp $resp; $resp and $username = $resp; $userid = `id -u`; chomp $userid; say "User ID will be $userid."; $shell = $ENV{'SHELL'}; print "User Shell ($shell): "; $resp = ; chomp $resp; $resp and $shell = $resp; $groupname = `id -gn`; chomp $groupname; print "Group Name ($groupname): "; $resp = ; chomp $resp; $resp and $groupname = $resp; $groupid = `id -g`; chomp $groupid; say "Group ID will be $groupid."; $ports = ""; printf "Ports: "; $resp = ; chomp $resp; $ports = $resp; $mounts = ''; printf "Extra Mounts: "; $resp = ; chomp $resp; $mounts = $resp; $confd = "$dir/.config/ws/"; $conf = "$confd/config.yml"; -d $dir and end 'Workspace directory already exists: ' . $dir; make_path($dir) || end 'Failed to create workspace directory: ' . $dir; make_path($confd) || end 'Failed to create config directory: ' . $confd; open( my $fh, ">", $conf ) || end 'Failed to open config file for writing: ' . $conf; say $fh '# This file is used by the ws workspace container manager.'; say $fh "# It's fine to make changes directly and is shared by host."; say $fh "\nname: $name"; say $fh "\nuser:\n name: $username\n id: $userid\n shell: $shell"; say $fh "\ngroup:\n name: $groupname\n id: $groupid"; say $fh ""; if ($ports) { $ports =~ s/ +/,/g; say $fh "ports: [$ports]"; } if ($mounts) { $mounts = s/ +/,/g; say $fh "ports: [$ports]"; } close $fh; x_open($name); } sub x_dir { my $name = shift || x_pick(); say "$wsdir/$name"; } *x_d = *x_dir; # ------------------------ bash tab completion ----------------------- # `complete -C ws ws` -> ~/.bashrc # (any command starting with x_) if ( $ENV{'COMP_LINE'} ) { @completions = grep s/^x_//, keys %{"main::"}; @completions = ( @completions, get_ws_list ); if ( not $ARGV[1] ) { map { say $_} @completions; exit; } map { /^$ARGV[1]/ && say $_} @completions; exit; } # ------------------------------- main ------------------------------- if ( not "$ARGV[0]" ) { ws_get_list and x_open and exit; wx_usage and exit; } my $first = shift @ARGV; $cname = "x_$first"; if (my $func = main->can($cname)) { &$func(@ARGV); exit; } x_open($first); __END__ Workspace Container Management Utility The ws container workspace management utility provides what most engineers, developers, and learners want from a workspace container manager with reasonable defaults: a single consistent bind-mount to home directory, a few configurable volume and port bindings, and --- most importantly --- to be able to *quickly* switch between them and keep them up to date with the latest workspace image as new tools are added and old ones improved, even use the same workspace with different images. Command: pull Pulls the latest current image (see image) if set, otherwise pulls the named image from registry. Command: create Creates a directory for each workspace in the parent directory indicated by the WORKSPACES environment variable (default ~/Workspaces) and automatically bind-mounts it as the home directory for the workspace user. The `.config/ws/config.yml` file will be created within it and used by subsequent open commands (which activate the workspace). A `/var/run/synced` file will also be created after the /usr/share/workspace directory has first copied for the user home directory (much like /etc/skel). Removing it later will cause the ephemeral workspace image entrypoint to do another sync with whatever the current local image is replacing everything that it contains with copies During creation the user will be prompted for the following (defaults taken from the current user): * Workspace Name - name of workspace and directory in $WORKSPACES * User Name - user name for workspace container * User Shell - user shell for workspace container * Group Name - user group name for workspace container * Ports - ports (ex: 80 -> -p 80:80) * Extra Mounts - extra bind-mounts (ex: /tmp -> -v /tmp:/tmp) The directory within $WORKSPACES is always mounted the user home directory. (Hence, "extra mounts") The user and group ID will always match that of the current user. This is sufficient for most work situations. When different IDs are required, first create a user on the local host system that has the wanted IDs and change to that user before running ws. This prevents file owner and group ID problems later. Note that this is a constraint of working with containers in docker itself, not this utility. The hostname of the container is automatically set to the name of the workspace. Ports and mounts can be specified in shorthand as single values without the colon (as well as the normal colon syntax). Separate multiple values with spaces. No other validation of port input values is done allowing any syntax that is valid for the docker -v switch to be passed. Note that it is generally best to just use a single home directory and create symbolic and/or hardlinks within the container to stuff within that home directory. This centralizes the workspace directory for convenience and provides a quick visual representation of what is included in the work. The /var/run/docker.sock socket is always mounted. Unfortunately, the owner user id or group id must exactly match that of the workspace. A warning is printed if these do not coincide since usually the host docker instance is wanted and not another contained within the workspace, but not always. Command: config Prints the path to the configuration file for the given workspace. Command: edit Opens the configuration file (see config) for editing. Command: open Opens the named workspace (or creates if not found). This is the default if no known command is passed as the first argument in which case the first argument it assumed to be the name of the workspace to switch to. Command: rm, remove Remove the specified workspace with an interactive confirmation prompt. Will delete the directory and named container. Command: ls, list List all workspaces (lexigraphically from WORKSPACES directory). Command: image Sets the default image to be used. By default it is rwxrob/workspace but it can be set to any image name that has been pulled into the current Docker installation. Bash Tab Completion This script supports its own completion. Simply add the following to your .bashrc (or other) startup configuration file: complete -C ws ws Note that this assumes the ws command is included in the PATH.