I was sidetracked by many other issues, so I never finished it, but no problem to share it now as is. I’ll get back to this later.
The script to start a machine is:
#!/usr/local/bin/python3
import sys
import os
import pwd
import subprocess
import argparse
import textwrap
import json
import time
import pathlib
DOCKERMACHINECOMMAND='/usr/local/opt/docker-machine/bin/docker-machine'
VERSION="1.1"
COPYRIGHT="(C) Gerben Wierda 2019 (with lots of help/copy from\nstackexchange etc.)"
LICENSE="Free under BSD License (look it up)"
STANDARDRETRY=15
LAUNCHDAEMON='/Library/LaunchDaemons/nl.rna.docker-machines.autostart.plist'
DEFAULTJSONLOCATION='/usr/local/etc/managed-docker-machines'
DEFAULTJSONFILENAME='default.json'
DEFAULTJSONFILE=DEFAULTJSONLOCATION + '/' + DEFAULTJSONFILENAME
DEFAULTJSONFILECONTENT="""[
{
"entryname" : "generic",
"machinename" : "default"
}
]
"""
# Override the default json so we can run without a json argument
# Global vars. A bit jukky but OK for this small thing
DEFAULTJSONFILEOVERRIDDENMSG = "\n(can be overridden by the MANAGEDOCKERMACHINESDEFAULT environment variable)\n"
env = os.environ.copy()
if env.get('MANAGEDOCKERMACHINESDEFAULT'):
DEFAULTJSONFILE = env.get('MANAGEDOCKERMACHINESDEFAULT')
DEFAULTJSONFILEOVERRIDDENMSG = "\n(currently overridden by the MANAGEDOCKERMACHINESDEFAULT environment variable)\n"
# Launchd definition plist template
LAUNCHDPLISTTEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>nl.rna.docker-machines.autostart</string>
<key>ProgramArguments</key>
<array>
STARTATBOOTCMDSTRINGS </array>
<key>RunAtLoad</key> <true/>
<key>LaunchOnlyOnce</key> <true/>
<key>KeepAlive</key> <false/>
<key>StandardErrorPath</key>
<string>/var/log/autostart-docker-machines-rna-nl.err</string>
<key>StandardOutPath</key>
<string>/var/log/autostart-docker-machines-rna-nl.out</string>
</dict>
</plist>'''
from argparse import RawDescriptionHelpFormatter
class SmartDescriptionFormatter(argparse.RawDescriptionHelpFormatter):
#def _split_lines(self, text, width): # RawTextHelpFormatter, although function name might change depending on Python
def _fill_text(self, text, width, indent): # RawDescriptionHelpFormatter, although function name might change depending on Python
if text.startswith('R|'):
paragraphs = text[2:].splitlines()
# Next line adapted from the StackExchange version to use textwrap module
rebroken = [textwrap.wrap(tpar, width) for tpar in paragraphs]
rebrokenstr = []
for tlinearr in rebroken:
if (len(tlinearr) == 0):
rebrokenstr.append("")
else:
for tlinepiece in tlinearr:
rebrokenstr.append(tlinepiece)
#print(rebrokenstr)
return '\n'.join(rebrokenstr) #(argparse._textwrap.wrap(text[2:], width))
# this is the RawTextHelpFormatter._split_lines
#return argparse.HelpFormatter._split_lines(self, text, width)
return argparse.RawDescriptionHelpFormatter._fill_text(self, text, width, indent)
parser = argparse.ArgumentParser( formatter_class=SmartDescriptionFormatter,
description=(
'''R|BASICS
Start Docker VMs with docker-machine at macOS boot. This program reads one
or more JSON files that define docker machines, including which VM provider to
use (currently only VirtualBox can be detected), as what user the machine must
be started, the working directory to go to before starting or stopping a
machine, and the name of the docker machine. Example:
[
{
"entryname": "john-default", # required, used for --only flag
"displayname": "John's default docker machine",
# Default: entryname
"vmdriver": "virtualbox", # VM driver to use
# Default: virtualbox
"user": "gerben", # User to run as
# Default: the user that runs the script
# (in which case no sudo/root is needed)
"workingdir": "/private/tmp",# Dir to cd to before running docker-machine
# Default: environment value CWD or else
# /private/tmp
"machinename": "default", # Docker machine name
# Default: entryname
"createoptions": ["--virtualbox-hostonly-cidr","192.168.98.1/24"],
# Example. Sets network for DHCP on machine
# and thus reliable IP address (workaround)
# Default: nothing
"enabled": true # Set to false to ignore entry
# Default: true
},
{
"entryname": "gerben-lunaservices",
"displayname": "Gerben's lunaservices docker machine",
"vmdriver": "vmware",
"user": "gerben",
"workingdir": "/Users/gerben",
"machinename": "lunaservices",
"enabled": false
},
{
"entryname" : "default"
}
]
(Note: vmware support is not implemented) (Also Note: comments are not allowed
in JSON files, they're only here for explanation.)
Preferred install of this script:
sudo cp macos-manage-docker-machines /usr/local/bin
sudo chmod +x /usr/local/bin/macos-manage-docker-machines
The default json file used is:
''' + ' ' + DEFAULTJSONFILE + DEFAULTJSONFILEOVERRIDDENMSG +
'''
The script can install a barebones default configuration for you which
contains a definition that can be shared by all users:
[
{
"entryname" : "generic",
"machinename" : "default"
}
]
This default machine definitions json lets this script act on the 'default'
docker-machine for any user, start the machine in the current directory
and uses virtualbox. It doesn't require root/sudo. It also doesn't manage
anything when the script is run as root, because root machines require
explicit
"user": "root"
in the entry
Actually, it could have been simpler still (but potentially confusing):
[
{
"entryname" : "default"
}
]
as the entryname is used also as machinename if no machinename is given.
So, after running
sudo macos-manage-docker-machines installdefault
You can run
macos-manage-docker-machines create
and get a 'default' docker machine.
START DOCKER MACHINES AT BOOT
To install a basic auto-launch configuration:
sudo macos-manage-docker-machines startatboot
Note that this will not start any docker-machine at boot if the default json
is used, as the launchd runs as root and the default configuration doesn't
have an entry that works for root. So to make your personal machine
'mymachine' start at boot you could add this entry to
/usr/local/etc/managed-docker-machines/myown.json
(change 'me' to your own user name, twice)
[
{
"entryname": "mymachine",
"displayname": "My beautiful docker machine",
"vmdriver": "virtualbox",
"user": "me",
"workingdir": "/Users/me",
"machinename": "mymachine",
"createoptions": ["--virtualbox-hostonly-cidr","192.168.98.1/24"],
"enabled": true
}
]
Note: the create-options is a workaround to make sure your machine gets the
same IP address every time. For other machines, you change the third number of
the IP address, e.g.
"createoptions": ["--virtualbox-hostonly-cidr","192.168.97.1/24"],
That way you can make sure that each machine has a reliable, repatable IP
address.
And then you could run
macos-manage-docker-machines create \\
/usr/local/etc/managed-docker-machines/myown.json
to create your docker machine 'mymachine'
then the way to get these to run at macOS start is
sudo macos-manage-docker-machines startatboot \
/usr/local/etc/managed-docker-machines/myown.json
or, for instance if you have multiple files with definitions:
sudo macos-manage-docker-machines --maxwait 120 startatboot \
/usr/local/etc/managed-docker-machines/*
OVERRIDES
If the combination of machinename and user have already been managed
in a run of this script, a next entry will be ignored. Thus, you can create
definitions that override the later definitions. This is particularly useful
for getting reliable IP addresses on reboot/restart (see example above).
''' +
"\nCopyright: " + COPYRIGHT +
"\nThis is version: " + VERSION +
"\n" + LICENSE +
"\nThe docker-machine command used is: " + DOCKERMACHINECOMMAND))
parser.add_argument( "-v", "--verbosity", action="count", default=0,
help="Increase output verbosity (5 gets the maximum effect)")
parser.add_argument( "-d", "--dry-run", dest="dryrun", action='store_const', const=True,
help="Do everything except actually running subprocesses or commands that change the system.")
parser.add_argument( "--force", dest="force", action='store_const', const=True,
help="Overwrite files on install actions.")
parser.add_argument( "--maxwait", type=int, choices=range(0, 601), default=0,
metavar="[0-600]",
help=("Maximum wait time in seconds for VM provider to become available (if missing)."
" The program will retry every" + str(STANDARDRETRY) + "until the required VM provider"
" becomes available or the maximum wait time is met. Note that this wait is implemented"
" per VM provider so in the worst case the program will try for number of"
" providers times the set maximum retry time."))
parser.add_argument( "--only", nargs="*", dest="VMDeclarations_Machines_Subset",
metavar="machine",
help="Restrict actions to these entrynames only.")
parser.add_argument( "action", choices=['start','stop','create','rm','restart','ls','installdefault','startatboot','nostartatboot'], nargs=1,
help=("Action that is taken. All but installdefault, startatboot and nostartatboot are docker-machine actions."
" The 'ls' action is only executed once for each user, regardless of the number of machine"
" definitions that apply to that user. installdefault installs a default JSON file " +
DEFAULTJSONFILE + " with a basic generic entry."
" startatboot uses the current command invocation to create a launchd plist as " +
LAUNCHDAEMON + "nostartatboot uninstalls the launchd plist."))
parser.add_argument( "VMDeclarations_files", metavar="JSON_file(s)", nargs="*",
help=("JSON file(s) with Docker Machine launch definitions."
" See description above."))
scriptargs = parser.parse_intermixed_args()
# Global vars. A bit jukky but OK for this small thing
PROGNAME=sys.argv[0]
VERBOSITY=scriptargs.verbosity
DRYRUN=scriptargs.dryrun
FORCE=scriptargs.force
ACTION=scriptargs.action[0]
JSONFILES=scriptargs.VMDeclarations_files
if not JSONFILES: JSONFILES = [DEFAULTJSONFILE]
ONLYMACHINES=scriptargs.VMDeclarations_Machines_Subset
MAXWAIT=scriptargs.maxwait
WHOAMI=pwd.getpwuid(os.geteuid()).pw_name
if not WHOAMI:
print( "Fatal error: cannot get user information.")
exit( 1)
# Add VM providers here
vmdrivers = {'virtualbox':False}
def log( message):
if DRYRUN:
logmsg="[" + PROGNAME + " " + time.asctime() + " (DRYRUN)] " + message
else:
logmsg="[" + PROGNAME + " " + time.asctime() + "] " + message
print( logmsg)
def CheckVMProvider( vmdriver):
if vmdriver == 'virtualbox':
if vmdrivers['virtualbox']:
return True
waited=0
while waited <= MAXWAIT:
p1 = subprocess.Popen( ["kextstat"], stdout=subprocess.PIPE)
p2 = subprocess.Popen( ["grep", "org.virtualbox.kext.VBoxNetAdp"], stdin=p1.stdout, stdout=subprocess.PIPE)
p1.stdout.close() # Allow p1 to receive a SIGPIPE if p2 exits.
if p2.wait() == 0:
vmdrivers['virtualbox'] = True
return True
waited = waited + STANDARDRETRY
if waited < MAXWAIT:
if VERBOSITY > 1: log( "Virtual machine provider " + vmdriver + " is not (yet) available. Sleeping " + str(STANDARDRETRY) + "sec and retrying...")
time.sleep( STANDARDRETRY)
else:
if VERBOSITY > 1: log( "Virtual machine provider " + vmdriver + " is not available. Giving up.")
else:
if VERBOSITY > 1: log( "Virtual machine provider " + vmdriver + " is not supported.")
return False
def report_ids( msg):
if VERBOSITY > 4: print( "[" + PROGNAME + "] " + 'uid, gid = %d, %d; %s' % (os.getuid(), os.getgid(), msg))
def demote( user_uid, user_gid):
def result():
if not (DRYRUN or user_uid == os.geteuid()) or WHOAMI == "root":
report_ids( 'starting demotion')
os.setgid( user_gid)
os.setuid( user_uid)
report_ids( 'finished demotion')
return result
processedmachines = {}
processedusers={}
def manageDockerMachine( definition):
entryname = definition.get( 'entryname')
machinename = definition.get( 'machinename') or entryname
displayname = definition.get( 'displayname') or entryname
enabled = definition.get( 'enabled') or True
targetuser = definition.get( 'user') or '<any>'
workingdir = definition.get( 'workingdir') or os.getenv( "CWD") or '/private/tmp'
vmdriver = definition.get( 'vmdriver') or 'virtualbox'
createoptions = definition.get( 'createoptions')
machineidentifier = targetuser + '|' + machinename
if targetuser == '<any>':
# If no user has been defined, it is run for the current user, unless
# the current user is root
if WHOAMI == 'root' and ACTION != 'ls':
if VERBOSITY > 0: log( "Skipping machine " + machinename + " for user " + targetuser + " as we are running as root.")
return True
else:
targetuser = WHOAMI
if machineidentifier in processedmachines:
# if we encounter a later definition for the same combination of machine
# and user it is ignored (thus a more specific definition can override
# a more generic definition)
if VERBOSITY > 0: log( "Skipping machine " + machinename + " for user " + targetuser + " as it has already been managed by an earlier definition.")
return True
if WHOAMI in ['root', targetuser]:
# We can execute if we are the target user for this definition or if we
# are root
pw_record = pwd.getpwnam( targetuser)
username = pw_record.pw_name
homedir = pw_record.pw_dir
uid = pw_record.pw_uid
gid = pw_record.pw_gid
env = os.environ.copy()
env['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/usr/local/sbin'
env['HOME'] = homedir
env['USER'] = username
env['SUDO_USER'] = username
env['PWD'] = workingdir
env['LOGNAME'] = username
dmargs = [DOCKERMACHINECOMMAND, ACTION, machinename]
if ACTION == 'create' and createoptions:
# Add optional options for create
dmargs[2:2] = createoptions
if ACTION == 'ls':
# When the action is 'ls', it is only executed once for each user and only When
# when we are running as root will we get an extra header so it is clear for
# which user each output is
if username in processedusers:
if VERBOSITY > 4: log( "Already processed ls for user " + username + ". Skipping.")
return True
if WHOAMI == 'root':
print( 'docker-machine ls output for user ' + username + ':')
if DRYRUN:
dmargs = ['/bin/echo', "[" + PROGNAME + " " + time.asctime() + " (DRYRUN)] " + 'Would have executed (as user ' + targetuser + ') command:'] + dmargs
if enabled:
if VERBOSITY > 2: log( "Executing " + ACTION + " on " + vmdriver + " docker machine " + machinename + " for user " + targetuser + '".')
if not CheckVMProvider( vmdriver):
if VERBOSITY > 0: log( "Virtual machine provider " + vmdriver + " not found. Ignoring machine definition " + '"' + machinename + '".')
return False
report_ids('starting ' + str( dmargs))
process = subprocess.Popen( dmargs, preexec_fn=demote(uid, gid),
cwd=workingdir,env=env)
result = process.wait()
report_ids( 'finished ' + str(dmargs))
processedmachines[machineidentifier] = True
processedusers[username] = True
else:
if VERBOSITY > 3: log( "Ignoring disabled " + vmdriver + " docker machine " + machinename + " of user " + targetuser + '".')
else:
if VERBOSITY > 0: log( "Skipping machine of " + targetuser + " for which we (" + WHOAMI + ") do not have rights. Run as root to fix.")
return True
def startatboot():
# The current command invocation is used to decide what JSON file arguments
# to give for the automatic start at boot time.
# If --maxwait > 60, it is used instead of the default 60
if WHOAMI == 'root':
if ACTION == 'startatboot':
if FORCE or not os.path.exists( LAUNCHDAEMON):
maxwait = 60
if MAXWAIT>60: maxwait = MAXWAIT
args = (' <string>-v</string>' + "\n" +
' <string>--maxwait</string>' + "\n" +
' <string>' + str(maxwait) + '</string>' + "\n" +
' <string>start</string>' + "\n")
cmd = ''
if VERBOSITY > 0: log( "Installing autostart of docker machines at macOS boot time.")
cmd = cmd + '<string>' + os.path.abspath( PROGNAME) + '</string>' + "\n"
for file in JSONFILES:
args = args + ' <string>' + os.path.abspath( file) + '</string>' + "\n"
launchdplistfilled = LAUNCHDPLISTTEMPLATE.replace( 'STARTATBOOTCMDSTRINGS', cmd + args)
if not DRYRUN:
if VERBOSITY > 0: log( 'Installing ' + LAUNCHDAEMON)
elif VERBOSITY > 3:
log( 'Installing the following content as ' + LAUNCHDAEMON + ':')
print( launchdplistfilled)
print( launchdplistfilled, file=open( LAUNCHDAEMON, 'w'))
else:
log( "(DRYRUN) " + LAUNCHDAEMON + " not created.")
else:
log( LAUNCHDAEMON + " already exists and will not be overwritten. Uninstall first.")
elif ACTION == 'nostartatboot':
if VERBOSITY > -1:
log( 'Uninstalling ' + LAUNCHDAEMON)
log( 'TODO uninstall')
elif ACTION == 'installdefault':
if FORCE or not os.path.exists( DEFAULTJSONFILE):
if not DRYRUN:
pathlib.Path( DEFAULTJSONLOCATION).mkdir(parents=True, exist_ok=True)
if os.path.isdir( DEFAULTJSONLOCATION):
if VERBOSITY > 0: log( DEFAULTJSONFILE + " created.")
print( DEFAULTJSONFILECONTENT, file=open( DEFAULTJSONFILE, 'w'))
else:
log( "Failure to create " + DEFAULTJSONLOCATION + " (is there a file with that name?)")
else:
log( "(DRYRUN) " + DEFAULTJSONFILE + " not created.")
else:
log( DEFAULTJSONFILE + " already exists and will not be overwritten. Remove first.")
else:
exit( 2) # Programmer error
else:
log( "Nothing (un)installed. Installing or uninstalling the startup item or default configuration requires running this script as root.")
return
def executeDockerMachineOn():
for file in JSONFILES:
if VERBOSITY > 0: log( "Processing VM declaration file: " + file)
filedescriptor = open( file, 'r')
machinedefinitions = json.load( filedescriptor)
if VERBOSITY > 4: print( json.dumps( machinedefinitions, sort_keys=True, indent=4))
for machinedefinition in machinedefinitions:
entryname = machinedefinition['entryname']
if not entryname:
log( "Skipping VM declaration as it has no required 'entryname' key.")
else:
if ONLYMACHINES and entryname not in ONLYMACHINES:
if VERBOSITY > 1: log( "Skipping VM declaration: " + entryname + " (excluded by --only).")
else:
manageDockerMachine( machinedefinition)
if DRYRUN and VERBOSITY > 4: log( "Running dry: no system-changing actions will be taken.")
if ACTION in ['installdefault','startatboot','nostartatboot']:
startatboot()
else:
executeDockerMachineOn()
Save as /usr/local/bin/macos-manage-docker-machines
with execute permissions:
sudo cp macos-manage-docker-machines /usr/local/bin
sudo chmod +x /usr/local/bin/macos-manage-docker-machines
then run macos-manage-docker-machines --help
for the documentation. It is still in the state it was two years ago. There were some things I wanted to add, but I forget what they were
I would be interested to hear from your experiences, as I will return to this later, especially regarding networking as I did not yet look at getting the docker machine providing a service to the rest of the LAN.