docker_tools.py - Tools for building and using the Runestone Servers in Docker¶
This script provides a set of Docker-related command-line tools. Run docker-tools --help
for details.
Design goals¶
Bootstrap (the
init command
): this script is designed to be downloaded then executed as a single file. In this case, bootstrap code downloads dependencies, the repo it belongs in, etc. This makes it easy to create a development or production environment from a fresh install of the host OS.Multiple modes (the
build command
): this script builds two distinctly different containers.Single-container: these commands creates a container designed to execute on a single server. The Dockerized applications associated with it include Jobe, Redis, and Postgres. A volume is used to persist data, such as updates to the server.
Production mode (the default):
docker-tools build --single
creates a container designed for production (which includes obtaining an HTTPS certificate).Development mode:
docker-tools build --single-dev
creates a container supporting a development mode, which provides additional tools and installs the BookServer and Runestone Components from github, instead of from releases on PyPI.
Multi-container:
docker-tools build --multi
creates a container designed for cluster operation, when many instances of this container accept requests passed on by a load balancer. In this mode, no volumes are mounted; HTTPS certificates cannot be requested, since this is the responsibility of the load balancer.
Flexible: the build process supports a number of options to customize the build. Writing in Python provides the ability to support this complexity.
Minimal: this tool only contains functions specifically related to Docker. Tools which apply more generally reside in rsmanage - command-line tools for managing the Runestone server.
No loops: unlike earlier approaches, this script doesn’t cause the container to restart if something goes wrong. Instead, it catches all errors in a try/except block then stops executing so you can see what happened.
venv: all Python installs are placed in a virtual environment managed by Poetry.
Build approach¶
To build a container, this script walks through three steps:
The first step occurs when this script is invoked from the terminal/command line, outside Docker. It does some preparation, then invokes the Docker build.
Next, Docker invokes this script from the Dockerfile - create a container hosting the Runestone servers. This script installs everything it can.
Finally, Docker invokes this when the container is run. On the first run, this script completes container configuration, then runs the servers. After that, it only runs the servers, since the first-run configuration step is time-consuming.
Since some files are built into the container in step 1 or run only once in step 2, simply editing a file in this repo may not update the file inside the container. Look through the source here to see which files this applies to.
Imports and bootstrap¶
These are listed in the order prescribed by PEP 8, with exceptions noted below.
There’s a fair amount of bootstrap code here to download and install required imports and their dependencies.
Standard library¶
Local application bootstrap¶
Everything after this depends on Unix utilities. We can’t use is_win
because we don’t know if ci_utils
is available.
Globals¶
True if we’re root.
True if this script is running in a Python virtual environment.
The working directory of this script.
True if this script is executing from the Runestone repository; otherwise, we assume only this script was downloaded as a part of the bootstrap process.
Supporting functions¶
Check to see if a program is installed; if not, install it.
The command to run to check if the program is installed.
The name of the package containing this program.
Only run with sudo
if we’re not root.
We need curl for some (possibly missing) imports – make sure it’s installed.
Outside a venv, install locally.
Bootstrap ci_utils
¶
Bootstrap check: if we’re not in the repo, do some tidying.
Add this directory – which is where the ci_utils will be downloaded – to the path; otherwise, ci_utils won’t be found.
This unused import triggers the script download if it’s not present. This only happens outside the container.
import ci_utils # noqa: F401
except ImportError:
assert not os.environ.get("IN_DOCKER")
check_install_curl()
print("Downloading supporting script ci_utils.py...")
subprocess.run(
[
"curl",
"-fLO",
"https://raw.githubusercontent.com/RunestoneInteractive/RunestoneServer/master/tests/ci_utils.py",
],
check=True,
)
from ci_utils import chdir, env, is_darwin, is_linux, pushd, xqt # noqa: E402
Third-party bootstrap¶
This comes after importing ci_utils
, since we use that to install click if necessary.
Ensure pip
is installed before using it.
Outside a venv, install locally.
If pip is upgraded, it won’t find click. Re-load sys.path to fix this.
Local application¶
When bootstrapping, this import fails. This is fine, since these commands require a working container before they’re usable.
Global variables¶
Update the following versions regularly.
These are sometimes not the same base name (depends on the MPLABX release).
CLI¶
Create a series of subcommands for this CLI.
Add the subcommands defined in docker_tools_misc.py - Misc CLI tools for Docker, if it’s available.
init
command¶
Install prereqs needed for all other commands available in this tool.
Did we add the current user to a group?
True if running a Docker container requires use of sudo
.
Ensure Docker is installed.
Ensure the Docker Desktop is running if we’re running in WSL or OS X. On Windows, the docker
command doesn’t exist when the Docker Desktop isn’t running.
Use the convenience script.
Linux only: Ensure the user is in the docker
group. This follows the Docker docs. Put this step here, instead of after the Docker install, for people who manually installed Docker but skipped this step.
Per the Docker docs, enable running Docker as a non-root user.
Ignore errors if the groupadd fails; the group may already exist.
Until group privileges to take effect, use sudo
to run Docker.
The group add doesn’t take effect until the user logs out then back in. Work around it for now.
Ensure the Docker Desktop is running if this is OS X. On OS X, the docker
command exists, but can’t run the hello, world script. It also serves as a sanity check for the other platforms.
print("Checking that Docker works...")
try:
xqt(f"{'sudo' if docker_sudo else ''} docker run hello-world")
except subprocess.CalledProcessError as e:
sys.exit(
f"ERROR: Unable to execute docker run hello-world: {e}"
+ (
(
"\n"
"Docker Desktop not detected or not working. You must install and run\n"
"this before proceeding."
)
if is_darwin
else ""
)
)
Make sure git’s installed.
Are we inside the Runestone repo?
No, we must be running from a downloaded script. Clone the runestone repo.
Check if possible to clone RunestoneServer with Custom Repo
Exit script with Git Clone Error
Make sure we’re in the root directory of the web2py repo.
Ensure the user is in the www-data
group.
print("Checking to see if the current user is in the www-data group...")
if "www-data" not in xqt("groups", capture_output=True, text=True).stdout:
if is_darwin:
xqt(
"sudo dscl . create /Groups/www-data",
"sudo dscl . create /Groups/www-data gid 799",
"sudo dseditgroup -o edit -a $USER -t user www-data",
"sudo dscl . append /Groups/www-data GroupMembership $USER",
)
else:
xqt("sudo usermod -a -G www-data $USER")
did_group_add = True
Provide server-related CLIs. While installing this sooner would be great, we can’t assume that the prereqs (the downloaded repo) are available.
Note: Sometimes, OS X installs these into a place that’s not in the default $PATH, making the scripts docker-tools
and rsmanage
not work unless the user manually adds the path. Add the --verbose
flag to the install commands below to see where the scripts are placed on OS X.
Print these messages last; otherwise, it will be lost in all the build noise.
if change_dir:
print(
"\n" + "*" * 80 + "\n"
"Downloaded the RunestoneServer repo. You must \n"
'"cd RunestoneServer" before running this script again.'
)
if did_group_add:
print(
"\n" + "*" * 80 + "\n"
"Added the current user to the www-data and/or docker group(s).\n"
"You must reboot for this to take effect."
)
build
command¶
Allow users to pass args directly to the underlying docker build
command – see the click docs.
Define the build configuration.
@click.option(
"--multi",
"build_config_name",
flag_value=BuildConfiguration.MULTI.name,
help="Build a worker image for multi-server production deployment.",
)
@click.option(
"--single",
"build_config_name",
flag_value=BuildConfiguration.SINGLE.name,
default=True,
help="Build an image for single-server production deployment.",
)
@click.option(
"--single-dev",
"build_config_name",
flag_value=BuildConfiguration.SINGLE_DEV.name,
help="Build an image for single-server development.",
)
Provide options for cloning from another repo.
@click.option(
"-c",
"--clone-all",
nargs=1,
metavar="<USERNAME>",
help="Clone all System components from repos with specified USERNAME",
)
@click.option(
"--clone-bks",
nargs=1,
metavar="<USERNAME>",
help="Clone BookServer repo with USERNAME",
)
@click.option(
"--clone-rc",
nargs=1,
metavar="<USERNAME>",
help="Clone RunestoneComponents repo with USERNAME",
)
Provide additional build options.
@click.option("--arm/--no-arm", default=False, help="Install the ARMv7 toolchain.")
@click.option(
"--pic24/--no-pic24",
default=False,
help="Install tools needed for development with the PIC24/dsPIC33 family of microcontrollers.",
)
@click.option("--rust/--no-rust", default=False, help="Install the Rust toolchain.")
@click.option("--tex/--no-tex", default=False, help="Install LaTeX and related tools.")
@click.option(
"--verilog/--no-verilog",
default=False,
help="Install the Icarus Verilog simulation tool.",
)
def build(
passthrough: Tuple,
build_config_name: str,
clone_all: str,
clone_bks: str,
clone_rc: str,
arm: bool,
pic24: bool,
rust: bool,
tex: bool,
verilog: bool,
) -> None:
When executed outside a Docker build, build a Docker container for the Runestone webservers.
PASSTHROUGH: These arguments are passed directly to the underlying “docker build” command. To pass options to this command, prefix this argument with “–”. For example, use “docker_tools.py build – -no-cache” instead of “docker_tools.py build -no-cache” (which produces an error).
WARNING: if the flag ‘-c / –clone-all’ is passed an argument, then it will override any other clone flags specified.
Inside a Docker build, install all dependencies as root.
Phase 0 – prepare then start the container build.
Phase 1 – build the container.
Phase 2 - run the startup script for container.
if phase == "2":
base_ready_text = dedent(
"""\
This file reports the status of the Docker containerized
application.
The container is starting up...
"""
)
get_ready_file().write_text(base_ready_text)
try:
_build_phase_2_core(build_config, arm, pic24, rust, tex, verilog)
except Exception:
msg = SERVER_START_FAILURE_MESSAGE
print_exc()
else:
msg = SERVER_START_SUCCESS_MESSAGE
print(msg)
get_ready_file().write_text(base_ready_text + msg)
Notify listener user we’re done.
If this script exits, then Docker re-runs it. So, loop forever.
Flush now, so that text won’t stay hidden in Python’s buffers.
Phase 0: Prepare then start the container build¶
If the clone-all
flag is set, override the other clone-xxx
flags.
Set each individual flag to the clone-all argument
Create docker/.env
if it doesn’t already exist or wasn’t edited.
Edit the prototype file, providing the correct value of SERVER_CONFIG
.
Save a checksum, so we can auto-update this if no hand edits were made.
Do the same for 1.py
.
Remove this if it exists (probably from an earlier build without --multi
). This file is only correct for --single(-dev)
builds.
For single-server operation, include additional services. Define dev-only replacements for this file.
d = {
"DEV_MISC": dedent(
"""\
# Set the base dedent; this defines column 0. The following section of the file should be indented by 2 tabs.
# Set up for VNC.
environment:
# I don't know how to correctly forward X11 traffic (see notes on VNC), so ignore the X11 ``DISPLAY`` outside the container.
DISPLAY: ":0"
ports:
# For VNC.
- "5900:5900"
# For the CodeChat System (author toolkit)
- "27377-27378:27377-27378"
"""
)
if build_config.is_dev()
else "",
"DEV_VOLUMES": dedent(
"""\
# Set the base dedent; this defines column 0. The following section of the file should be indented by 3 tabs.
- ../RunestoneComponents/:/srv/RunestoneComponents
- ../BookServer/:/srv/BookServer
# To make Chrome happy.
- /dev/shm:/dev/shm
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket
"""
)
if build_config.is_dev()
else "",
}
dc.write_text(
replace_vars(
dedent(
"""\
# WARNING: this file is automatically generated and is overwritten on each invocation of ``docker-tools build``.
version: "3"
services:
db:
image:
postgres:13
restart: always
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_DBNAME: ${POSTGRES_DBNAME}
redis:
image: redis
restart: always
runestone:
${DEV_MISC}
volumes:
# This must agree with ``$RUNESTONE_PATH`` in the `Dockerfile`. There's no easy way to share this automatically.
- ./:/srv/web2py/applications/runestone
# For certbot, store certificates outside the (ephemeral) write layer.
- ./docker/letsencrypt:/etc/letsencrypt
${DEV_VOLUMES}
links:
- db
- redis
"""
),
d,
)
)
if build_config.is_dev():
with pushd(".."):
bks = Path("BookServer")
if not bks.exists():
print(f"Dev mode: since {bks} doesn't exist, cloning the BookServer...")
if not clone_bks:
sys.exit(
"ERROR: in development mode, you must provide\n"
"either --clone-all or --clone-bks."
)
try:
Check if possible to clone BookServer with Custom Repo
Exit script with Git Clone Error
sys.exit(
"ERROR: Unable to clone BookServer remote repository\n"
f"via user {clone_bks}."
)
rsc = Path("RunestoneComponents")
if not rsc.exists():
print(
f"Dev mode: since {rsc} doesn't exist, cloning the Runestone Components..."
)
if not clone_rc:
sys.exit(
"ERROR: in development mode, you must provide\n"
"either --clone-all or --clone-rc."
)
try:
Check if possible to clone RunestoneComponents with Custom Repo
Exit script with Git Clone Error
Run the Docker build.
Phase 1: install Runestone dependencies¶
This is run inside the Docker build, from the Dockerfile - create a container hosting the Runestone servers.
Set up apt correctly; include Postgres repo.
xqt(
"apt update",
"apt install -y --no-install-recommends eatmydata lsb-release",
"""echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list""",
"wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -",
"apt update",
)
apt_install = "eatmydata apt install -y --no-install-recommends"
This uses apt; run it after apt is set up.
Add in Chrome repo. Copied from https://tecadmin.net/setup-selenium-with-chromedriver-on-debian/. Unless we are on an ARM64 processor, then we will fall back to using chromium.
xqt(
"curl -sS -o - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -",
)
if platform.uname().machine == "x86_64":
Path("/etc/apt/sources.list.d/google-chrome.list").write_text(
"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main"
)
browser = "google-chrome-stable"
else:
browser = "chromium"
Add node.js per the instructions.
All one big command! Therefore, there are no commas after each line, but instead a trailing space.
For jobe’s runguard.
Some books use the Sphinx graphviz extension, which needs the graphviz`
binary.
TODO: What is this for?
Required for postgres.
TODO: should this only be installed in the dev image?
For use with runguard – must call setfacl
.
Useful tools for debug.
Build runguard and set up Jobe users. Needed by BookServer’s internal/scheduled_builder.py`
.
Install the ARM tools (and the QEMU emulator).
Tests use html5validator, which requires the JDK.
Install Chromedriver. Based on https://tecadmin.net/setup-selenium-with-chromedriver-on-debian/.
Provide VNC access. TODO: just pass the correct DISPLAY value and ports and use X11 locally, but how? Here’s the best info I’ve found. Notes on my failures:
Including
network_mode: host
in docker-compose.yml - Configure containerized Docker application works. However, this breaks everything else (port mapping, links, etc.). It suggests that the correct networking setup would make this work.Passing
volume: - /tmp/.X11-unix:/tmp/.X11-unix
has no effect (on a Ubuntu 20.03.4 LTS host). Per the previous point, it seems that X11 is using TCP as its transport.Mapping X11 ports via
ports: - "6000-6063:6000-6063"
doesn’t work.Setting
DISPLAY
to various values (from the host’shostname -I
, or various names to route to the host) doesn’t work.
Install a VNC server plus a simple window manager.
When changing the xc16 version, update the string below and the path added at the end of this block.
Install the xc16 compiler.
The installer complains if the netserver name isn’t specified. This option isn’t documented in the --help
. So, supply junk, and it seems to work.
MPLAB X install
Needed to run sim30: per https://unix.stackexchange.com/questions/486806/steam-missing-32-bit-libraries-libx11-6, enable 32-bit libs.
Then download and install MPLAB X.
Install just the IDE and the 16-bit tools. This program checks to see if this is being run by root by looking at the USER
env var, which Docker doesn’t set. Fake it out.
Add the path to the xc16 tools. Note that /root/.bashrc
doesn’t get sourced when Docker starts up; therefore, the Dockerfile - create a container hosting the Runestone servers invokes bash when running the startup script.
Just symlink mdb, since that’s the only tool we use.
Microchip tools (mdb) needs write access to these directories.
Make the www-data the owner and place its files in the www-data group. This is because web2py needs to write to this directory tree (log, errors, etc.).
Make any newly created directories have the www-group. Give the group write permission.
Clean up after web2py install.
Install Poetry, putting it in a location already in $PATH
.
Set up nginx (partially – more in step 3 below).
Send celery, gunicorn, and nginx logs to Docker’s stdout/stderr, so they’ll show up in Docker logs even after restarting the servers. (Linking to /dev/stdout
and /dev/stderr
means that on restart, these links point not to Docker, but to the console which invoked the restart). See a related issue on Github.
"ln -sf /proc/1/fd/1 /var/log/nginx/access.log",
"ln -sf /proc/1/fd/2 /var/log/nginx/error.log",
"mkdir -p /var/log/celery",
"ln -sf /proc/1/fd/1 /var/log/celery/access.log",
"ln -sf /proc/1/fd/2 /var/log/celery/error.log",
"mkdir -p /var/log/gunicorn",
"ln -sf /proc/1/fd/1 /var/log/gunicorn/access.log",
"ln -sf /proc/1/fd/2 /var/log/gunicorn/error.log",
Set up web2py routing.
sphinxcontrib.paverutils.run_sphinx
lacks venv support – it doesn’t use sys.executable
, so it doesn’t find sphinx-build
in the system path when executing /srv/venv/bin/runestone
directly, instead of activating the venv first (where it does work). As a huge, ugly hack, symlink it to make it available in the system path.
Deal with a different subdirectory layout inside the container (mandated by web2py) and outside the container by adding these symlinks.
We can’t use $BOOK_SERVER_PATH
here, since we need /srv/bookserver-dev
in lowercase, not CamelCase.
Record info about this build. We can’t provide git
info, since the repo isn’t available (the ${RUNSTONE_PATH}.git
directory is hidden, so it’s not present at this time). Likewise, volumes aren’t mapped, so git
info for the Runestone Components and BookServer isn’t available.
Do any final updates.
Clean up after install.
Remove all the files from the local repo, since they will be replaced by the volume. This must be the last step, since it deletes the script as well.
For the multi-container build, there’s no volume. Install everything that must be saved to disk.
Phase 2 core: Final installs / run servers¶
Check the environment.
Install all required Python packages, then switch to this venv. The multi-container build already did the installs.
Since I don’t know how to forard the X11 $DISPLAY
correctly (see notes on VNC access), run a virtual frame buffer in the container and provide access via VNC. TODO: only do this if the provided $DISPLAY
is not set.
Sometimes, previous runs leave this file behind, which causes Xvfb to output Fatal server error: Server is already active for display 0. If this server is no longer running, remove /tmp/.X0-lock and start again.
Wait a bit for Xvfb to start up before running the following X applications.
nginx config¶
Overwrite the nginx config file unless there is certbot data to preserve. This helps to prevent old builds from leaving behind incorrect config files.
A cert should only be preserved if there’s a cert e-mail address and an existing cert file for that e-mail address. Overwrite the file otherwise.
Set up nginx based on env vars. See runestone.template - nginx configuration.
Since certbot (if enabled) edits this file, avoid overwriting it.
web2py config¶
Create a default auth key for web2py.
Write the ads.txt file
Write the admin password.
Set up admin interface access.
This isn’t needed when HTTPS is available.
Allow admin access in HTTP mode only. Change to False otherwise this allows admin on build –multi
Build the webpack after the Runestone Components are installed.
web2py needs write access to databases, errors, modules, build
Set up Postgres database¶
Wait until Postgres is ready using pg_isready. Note that its --timeout
parameter applies only when it’s waiting for a response from the Postgres server, not to the time spent retrying a refused connection.
print("Waiting for Postgres to start...")
if env.WEB2PY_CONFIG == "production":
effective_dburl = env.DBURL
elif env.WEB2PY_CONFIG == "test":
effective_dburl = env.TEST_DBURL
else:
effective_dburl = env.DEV_DBURL
for junk in range(5):
try:
xqt(f'pg_isready --dbname="{effective_dburl}"')
break
except Exception:
sleep(1)
else:
assert False, "Postgres not available."
print("Creating database if necessary...")
try:
xqt(f"psql {effective_dburl} -c ''")
except Exception:
The expected format of a DBURL is postgresql://user:password@netloc/dbname
, a simplified form of the connection URI.
junk, dbname = effective_dburl.rsplit("/", 1)
xqt(
f"PGPASSWORD=$POSTGRES_PASSWORD PGUSER=$POSTGRES_USER PGHOST=db createdb {dbname}"
)
print("Checking the State of Database and Migration Info")
p = xqt(f"psql {effective_dburl} -c '\\d'", capture_output=True, text=True)
if p.stderr == "Did not find any relations.\n":
Remove any existing web2py migration data, since this is out of date and confuses web2py (an empty db, but migration files claiming it’s populated). TODO: rsmanage should do this eventually; it doesn’t right now.
Should only use alembic in dev mode.
TODO: any checking to see if the db is healthy? Perhaps run Alembic autogenerate to see if it wants to do anything?
See if there’s already a certificate for this host. If not, get one.
Renew the certificate in case it’s near its expiration date. Per the certbot docs, renew
supports automated use, renewing only when a certificate is near expiration.
Utilities¶
A utility to replace all instances of ${var_name}
in a string, where the variables are provided in vars_
. This is an alternative to the built-in str.format()
which doesn’t require escaping all the curly braces.
Perform a replacement if the var_name
is in vars_
.
Otherwise, perform no replacement.
Search for a ${var_name}
.
Run Poetry and associated tools.
Update dependencies. See scripts/poetry_fix.py
. This must come before Poetry, since it will check for the existence of the project created by these commands. (Even calling poetry config
will perform this check!)
By default, Poetry creates a venv in the home directory of the current user (root). However, this isn’t accessible when running as www-data
. So, tell it to create the venv in a subdirectory of the project instead, which is accessible and at a known location (./.venv
).