benvial/gyptis

View on GitHub
docs/install/gyptis.install

Summary

Maintainability
Test Coverage
#!/usr/bin/env bash
#
# Adapted from https://bitbucket.org/fenics-project/docker/src/master/bin/fenicsproject

# Config
DEFAULT_IMAGE_HOST="docker.io/gyptis"
DEFAULT_IMAGE="gyptis"
# DEFAULT_MAKEFLAGS="-j1"
# Alternatively, to detect:
DEFAULT_MAKEFLAGS=$(grep -c '^processor' /proc/cpuinfo)
# Where to mount the Instant/dijitso cache 
# in the container.
CACHE_DIR_CONTAINER="/home/gyptis/.cache/fenics"
# Where to build.
BUILD_DIR_CONTAINER="/home/gyptis/local"
# Where to store the source code.
SRC_DIR_CONTAINER="/home/gyptis/local/src"
# Share in the current working directory
PROJECT_DIR_HOST="'$(pwd)'"
# to the following directory in the container.
PROJECT_DIR_CONTAINER="/home/gyptis/shared"
# Workaround for issue https://github.com/docker/docker/issues/9299
DEFAULT_COMMAND=
#'/bin/bash -l -c "export TERM=xterm; bash -i"'

BIND_PORT_RANGE="8001"
DOCKER_NB_PORT="9000"
DOCKER_MPL_PORT="8988"

# Setup
set -e
RED="\033[1;31m"
GREEN="\033[1;32m"
BLUE="\033[1;34m"
NORMAL="\033[0m"

# Detect OS
# https://stackoverflow.com/questions/3466166/how-to-check-if-running-in-cygwin-mac-or-linux
if [[ "$(docker info | grep -c 'Docker for Mac')" == "1" || "$(docker info | grep -c Alpine)" == "1" ]]; then
    OS="Docker"
elif [ "$(uname)" == "Darwin" ]; then
    # Mac OS X with Docker installed using docker-machine.
    OS="Darwin"
elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then
    # If we are running locally on Linux.
    OS="Linux"
elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW64_NT" ]; then
    # The Docker environment installed by Docker Toolbox is MinGW based.
    # Docker does not run on 32-bit environments.
    # Don't know if we want to support MSYS or Cygwin as well.
    OS="Windows"
else
    echo -e "${RED}Error${NORMAL}: We do not currently support your operating system $(uname)."
    # echo "Contact ${BLUE}fenics-support@googlegroups.com${NORMAL} for assistance."
fi

# Linux users have an issue with sharing files because the UID and GID in the
# container sometimes does not match the UID and GID on the host. By passing
# the UID and GID on the host, the container will modify the fenics user to
# match the user on the host, resulting in seamless sharing.
# Only use this trick on Linux, reduces complexity and not needed on Windows
# and Mac hosts. Problems have been reported using Docker Toolbox for Mac
# that were fixed when not using the UID/GID mapping.
CHANGE_UID_GID=""
if [ "$OS" == "Linux" ]; then
    HOST_UID=$(id -u)
    HOST_GID=$(id -g)
    CHANGE_UID_GID="--env HOST_UID=${HOST_UID} --env HOST_GID=${HOST_GID}"
fi

# For security: 
# On Windows and Mac with docker-machine we bind to the Virtualbox IP. Only the
# host machine can see this IP.
# On Docker for Mac or Docker for Windows we bind
# to the default private IP only visible to the host.
# On Linux we bind only on localhost. Only the host machine
# can see this IP.
if [[ "$OS" == "Darwin" || "$OS" == "Windows" ]]; then
    # Ask docker-machine for the IP address of the VM 
    ACTIVE_MACHINE=$(docker-machine active)
    DOCKER_BIND_IP=$(docker-machine ip $ACTIVE_MACHINE)
elif [[ "$OS" == "Docker" ]]; then
    # Will bind to private IP by default.
    # Can we work out what this IP is? Specific is more secure.
    DOCKER_BIND_IP="127.0.0.1"
else
    # Conservative default, works on Linux.
    DOCKER_BIND_IP="127.0.0.1"
fi

BIND_NB="-p ${DOCKER_BIND_IP}:${BIND_PORT_RANGE}:${DOCKER_NB_PORT}"
BIND_MPL="-p ${DOCKER_BIND_IP}:${BIND_PORT_RANGE}:${DOCKER_MPL_PORT}"

help ()
{
    echo "Usage: gyptis <command> [options]"
    echo ""
    echo "  gyptis run [image] [command]   - run a simple session."
    echo "  gyptis create <name> [image]   - create standard project with given name."
    echo "  gyptis notebook <name> [image] - create notebook project with given name."
    echo "  gyptis start <name>            - start session in project with given name."
    echo "  gyptis pull <image>            - pull latest version of given image."
    echo "  gyptis clean-cache [image]     - clean the shared FEniCS (Instant) cache."

    echo ""
    echo "Use 'gyptis run' for simple (non-persistent) sessions."
    echo ""
    echo "For persistent sessions, use 'create' or 'notebook' followed by 'start'"
    echo "to (re)start a session."
    echo ""
    echo "Available images:"
    echo ""
    echo "  latest  - latest stable release [default]"
    # echo "  dev     - latest development version, master branch"
    # echo "  dev-env - development environment including dependencies but not FEniCS"
    echo ""
    echo "You can update to the latest version of this script at any time using:"
    echo ""
    echo "  curl -s https://gyptis.gitlab.io/get | bash"
    echo ""
    # echo "For more details and tips, see our FEniCS Docker page:"
    # echo ""
    # echo "  http://fenics-containers.readthedocs.org/en/latest/"
    # echo ""
}

command ()
{
    echo "[$@]"
    echo ""
    eval $@
}

command-swallow-stderr ()
{
    echo "[$@]"
    echo ""
    eval $@ 2> /dev/null
}

clean-string () {
    # Docker cannot name any objects (volumes, containers)
    # with strings containing / or :. As we use the image
    # name as a (somewhat) unique identifier to create
    # various caches, strip these components out.
    local IMAGE="$1"
    # Remove slashes
    IMAGE=${IMAGE////}
    # Remove colons
    IMAGE=${IMAGE/:/}
    # Remove ampersands
    IMAGE=${IMAGE/@/}
    echo $IMAGE
}

get_ip_port () {
    # get host ip, port for connecting to a docker ip, port
    NAME=$1
    PORT=$2

    HOST_PORT=$(docker port $NAME $PORT | cut -d: -f2)

    if [[ "$OS" == "Darwin" || "$OS" == "Windows" ]]; then
        # Ask docker-machine for the IP address of the instance
        ACTIVE_MACHINE=$(docker-machine active)
        IP=$(docker-machine ip $ACTIVE_MACHINE)
    elif [[ "$OS" == "Docker" ]]; then
        IP=$(docker inspect --format="{{ ( index (index .NetworkSettings.Ports \"$PORT/tcp\") 0).HostIp }}" $NAME 2>/dev/null)
        if [[ "$IP" = "0.0.0.0" ]]; then
            # 0.0.0.0 isn't a valid connnect, so use 127
            IP="127.0.0.1"
        fi
    else
        IP="localhost"
    fi
    echo $IP $HOST_PORT
}

log_mpl_port () {
    read IP MPL_PORT < <(get_ip_port $1 ${DOCKER_MPL_PORT})
    echo -e "${BLUE}After calling 'plt.show()' you can access matplotlib plots at http://${IP}:${MPL_PORT}${NORMAL}"
}

log_nb_port () {
    read IP NB_PORT < <(get_ip_port $1 ${DOCKER_NB_PORT})
    echo -e "${BLUE}You can access the Jupyter notebook at http://${IP}:${NB_PORT}${NORMAL}"
}

run ()
{
    IMAGE="$1"
    COMMAND="$2"
    # Setup a container for instant cache
    create-cache-container $IMAGE
    
    

    CMD="docker create -ti \
           ${BIND_MPL} \
           ${CHANGE_UID_GID} \
           --env MAKEFLAGS=${DEFAULT_MAKEFLAGS} \
           -v instant-cache-$(clean-string $IMAGE):$CACHE_DIR_CONTAINER \
           --env INSTANT_CACHE_DIR=$CACHE_DIR_CONTAINER/instant \
           --env DIJITSO_CACHE_DIR=$CACHE_DIR_CONTAINER/dijitso \
           -v $PROJECT_DIR_HOST:$PROJECT_DIR_CONTAINER \
           -w $PROJECT_DIR_CONTAINER \
           --label gyptis.created_by_script=true \
           $IMAGE"

     echo "[$CMD]" | tr -s " "; echo ""
     NAME=$(command $CMD | tail -n 1)
     
    command docker start $NAME

    log_mpl_port $NAME

    command docker attach $NAME
    command docker rm $NAME
}

create-cache-container ()
{
    IMAGE="$1"
    CMD="docker volume create --name instant-cache-$(clean-string $IMAGE)"
    command $CMD
}

create-build-container ()
{
    NAME="$1"
    CMD="docker volume create --name fenics-build-${NAME}"
    command $CMD
}

clean-cache ()
{
    IMAGE="$1"
    CMD="docker run --rm -v instant-cache-$(clean-string $IMAGE):$CACHE_DIR_CONTAINER $IMAGE 'rm -rf $CACHE_DIR_CONTAINER/*'"
    command $CMD
}

create ()
{
    NAME="$1"
    IMAGE="$2"

    echo -e "Creating new ${GREEN}FEniCS Project${NORMAL} terminal project ${BLUE}$NAME${NORMAL}."
    echo ""

    # Share FENICS_SRC_DIR if set
    if [ ! -z "$FENICS_SRC_DIR" ]; then
        SHARE_SRC_DIR="-v '$FENICS_SRC_DIR':$SRC_DIR_CONTAINER"
    fi
    # 
    # # In the case that a container is created from a dev-env image, we store
    # # the compiled FEniCS library inside a Docker volume.  This will allow us
    # # to share compiled FEniCS versions between containers by mounting the
    # # volume read-only in a new dev-env container.
    # if [[ $IMAGE == *dev-env ]]; then
    #    create-build-container $NAME
    #    VOLUME_BUILD_DIR="-v fenics-build-${NAME}:$BUILD_DIR_CONTAINER" 
    # fi
    # 
    # # People using dev-env-dbg might want to use gdb. Because docker
    # # has very strict isolation, we need to add some capabilities
    # # into the container to allow gdb to work.
    # if [[ $IMAGE == *dev-env-dbg ]]; then
    #     # First option is less than ideal security wise.
    #     # should be improved.
    #     CAPABILITIES="--security-opt=seccomp=unconfined --cap-add=SYS_PTRACE"
    # fi

    # Create a shared container to hold the instant cache
    create-cache-container $IMAGE
    CMD="docker create -ti --name $NAME \
           ${BIND_NB} ${BIND_MPL}\
           ${CHANGE_UID_GID} \
           --env MAKEFLAGS=${DEFAULT_MAKEFLAGS} \
           -v instant-cache-$(clean-string $IMAGE):$CACHE_DIR_CONTAINER \
           --env INSTANT_CACHE_DIR=$CACHE_DIR_CONTAINER/instant \
           --env DIJITSO_CACHE_DIR=$CACHE_DIR_CONTAINER/dijitso \
           --label gyptis.created_by_script=true \
           --label gyptis.project_type='standard' \
           -v $PROJECT_DIR_HOST:$PROJECT_DIR_CONTAINER \
           $SHARE_SRC_DIR \
           $VOLUME_BUILD_DIR \
           $CAPABILITIES \
           -w $PROJECT_DIR_CONTAINER \
           $IMAGE $DEFAULT_COMMAND"
    command $CMD

    # Print summary
    echo ""
    echo -e "To ${BLUE}start the session${NORMAL}, type the following command:"
    echo ""
    echo "  gyptis start $NAME"
    echo ""
    echo "You will find the current working directory $PROJECT_DIR_HOST under ~/shared."
}

start ()
{
    NAME="$1"

    # Determine whether container is already running
    # If not, then start a new session (bash or notebook).
    # If it is, then launch a new bash login terminal.
    IS_RUNNING=$(docker inspect -f '{{.State.Running}}' $NAME 2>/dev/null)
    PROJECT_TYPE=$(docker inspect -f '{{index .Config.Labels "gyptis.project_type" }}' $NAME 2>/dev/null)
    if [ "$IS_RUNNING" == "false" ]; then
        echo "Starting project named $NAME."
        CMD="docker start $NAME"
        command $CMD

        log_mpl_port $NAME

        if [ "$PROJECT_TYPE" == "notebook" ]; then
            log_nb_port $NAME
        fi
        CMD="docker attach $NAME"
        command $CMD
    else
        echo "Starting new session in project named $NAME."
        CMD="docker exec -u fenics -ti $NAME $DEFAULT_COMMAND"
        command $CMD
    fi
}

pull ()
{
    IMAGE="$1"
    CMD="docker pull $IMAGE"
    command $CMD
}

notebook ()
{
    IMAGE="$2"

    NAME="$1"
    echo -e "Creating new ${GREEN}Gyptis Project${NORMAL} notebook project ${BLUE}$NAME${NORMAL}."
    echo ""

    # Setup a container for instant cache
    create-cache-container $IMAGE

    CMD="docker create \
           ${BIND_NB} ${BIND_MPL}\
           ${CHANGE_UID_GID} \
           --env MAKEFLAGS=${DEFAULT_MAKEFLAGS} \
           -v instant-cache-$(clean-string $IMAGE):$CACHE_DIR_CONTAINER \
           --env INSTANT_CACHE_DIR=$CACHE_DIR_CONTAINER/instant \
           --env DIJITSO_CACHE_DIR=$CACHE_DIR_CONTAINER/dijitso \
           -v $PROJECT_DIR_HOST:$PROJECT_DIR_CONTAINER \
           -w /home/gyptis \
           --label gyptis.created_by_script=true \
           --label gyptis.project_type='notebook' \
           --name $NAME \
           $IMAGE 'jupyter-notebook --ip=0.0.0.0'"
    command $CMD

    # Print summary
    echo ""
    echo -e "To ${BLUE}start the session${NORMAL}, type the following command:"
    echo ""
    echo "  gyptis start $NAME"
    echo ""
    echo "You will find the current working directory $PROJECT_DIR_HOST under ~/shared."
}

check_name () {
    NAME=$1
    if [ -z "$NAME" ]; then
        echo -e "${RED}Error${NORMAL}: You must specify the name of the project you want to start."
        exit 1
    fi
    set +e
    IS_RUNNING=$(docker inspect -f '{{.State.Running}}' $NAME 2> /dev/null) 
    if [ $? -eq 1 ]; then
        echo -e "${RED}Error${NORMAL}: Project $NAME does not exist."
        echo "You can create a project with the command:"
        echo ""
        echo "    gyptis create $NAME"
        echo ""
        echo "and then try running this command again." 
        exit 1 
    fi
    set -e
}

fail_if_project_already_exists () {
    NAME=$1
    set +e
    IS_RUNNING=$(docker inspect -f '{{.State.Running}}' $NAME 2> /dev/null)
    if [ $? -eq 0 ]; then
        echo -e "${RED}Error${NORMAL}: Project $NAME already exists!"
        echo ""
        echo -e "You can try a different name, or ${BLUE}permanently${NORMAL} delete the existing project"
        echo "with the command:"
        echo ""
        echo "    docker rm $NAME"
        echo ""
        echo -e "Files in the folder ~/shared will ${BLUE}not${NORMAL} be deleted."
        exit 1
    fi
    set -e
}

preprocess_image_name () {
    IMAGE=$1
    # Check if we have a fully qualified image name, e.g. quay.io/dolfinadjoint/dolfin-adjoint
    if [[ "$IMAGE" == *\/* ]]; then
        :
        # Do nothing, user passing their own fully qualified image name and we assume
        # they know what they are doing. 
    else
        # Otherwise, they are passing an abbreviated name, e.g. latest.
        # so we need to make it a fully qualified name.
        if [ "$DEFAULT_IMAGE_HOST" == "docker.io/gyptis" ]; then
            fail=true
            # List should contain all of our official images suitable for end-users.
            # add : to allow users to specify tags
            # for image in 'stable' 'dev' 'dev-env'; do
            for image in 'gyptis'; do
                if [[ "$IMAGE:" == "$image:"* ]]; then
                   fail=false
                fi
            done
            # We keep a tag stable:current so we can shift stable conservatively.
            # All other images shift with default tag :latest.
            if [ "$IMAGE" == "gyptis" ]; then
                IMAGE=${IMAGE}":latest"
            fi
            if [ $fail == true ]; then
                echo -e "${RED}Error${NORMAL}: Image with name $IMAGE does not exist. Try gyptis."
                #" Try stable, dev-env or dev."
                exit 1
            fi
        fi
        # Prepend DEFAULT_IMAGE_HOST so we have a fully qualified name.
        IMAGE=$DEFAULT_IMAGE_HOST"/"$IMAGE 
    fi
}

fail_if_home_directory ()
{
    if [ "$(pwd)" == $HOME ]; then
        echo -e "${RED}Error${NORMAL}: We strongly advise against sharing your entire home directory"
        echo "into a container. Instead, make a logical folder for each project:"
        echo ""
        echo "    mkdir ${HOME}/my-project"
        echo ""
        echo -e "and then run the ${BLUE}gyptis${NORMAL} script there:"
        echo ""
        echo "    cd ${HOME}/my-project"
        echo "    gyptis $@"
        exit 1
    fi
}

# Check command-line arguments
if [ "$1" == "run" ]; then
    IMAGE="$2" 
    : ${IMAGE:="$DEFAULT_IMAGE"}
    preprocess_image_name $IMAGE
    fail_if_home_directory
    # Select command (if any)
    if [ $# -ge 3 ]; then
        shift; shift;
        COMMAND="$@"
    else
        COMMAND=$DEFAULT_COMMAND
    fi
    run $IMAGE "$COMMAND"
elif [ "$1" == "create" ]; then
    # Select image
    NAME="$2"
    IMAGE="$3" 
    : ${IMAGE:="$DEFAULT_IMAGE"}
    preprocess_image_name $IMAGE
    fail_if_project_already_exists $NAME
    fail_if_home_directory
    create $NAME $IMAGE
elif [ "$1" == "notebook" ]; then
    NAME="$2"
    IMAGE="$3" 
    : ${IMAGE:="$DEFAULT_IMAGE"}
    preprocess_image_name $IMAGE
    fail_if_project_already_exists $NAME
    fail_if_home_directory
    notebook $NAME $IMAGE
elif [ "$1" == "start" ]; then
    NAME="$2"
    check_name $NAME 
    start $NAME
elif [ "$1" == "pull" ]; then
    IMAGE="$2" 
    : ${IMAGE:="$DEFAULT_IMAGE"}
    preprocess_image_name $IMAGE
    pull $IMAGE
elif [ "$1" == "clean-cache" ]; then
    IMAGE="$2" 
    : ${IMAGE:="$DEFAULT_IMAGE"}
    preprocess_image_name $IMAGE
    clean-cache $IMAGE
else
    help
    exit 1
fi