diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..59c4a5a --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,10 @@ +version: 2 +jobs: + build: + machine: true + steps: + - checkout + - run: docker login -u $DOCKER_USER -p $DOCKER_PASS + - run: cd imports/docker && docker build -t shotgunosine/mindcontrol:$CIRCLE_BUILD_NUM-${CIRCLE_SHA1:0:6} -f Dockerfile_services . + - run: docker tag shotgunosine/mindcontrol:$CIRCLE_BUILD_NUM-${CIRCLE_SHA1:0:6} shotgunosine/mindcontrol:latest + - run: docker push shotgunosine/mindcontrol \ No newline at end of file diff --git a/.gitignore b/.gitignore index b42c133..353bbbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules/ .meteor/dev_bundle +.mindcontrol/ +imports/docker/log/ +imports/docker/temp_for_nginx/ \ No newline at end of file diff --git a/README.md b/README.md index ab83f6c..01ce659 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![CircleCI](https://circleci.com/gh/Shotgunosine/mindcontrol/tree/master.svg?style=svg)](https://circleci.com/gh/Shotgunosine/mindcontrol/tree/master) +[![https://www.singularity-hub.org/static/img/hosted-singularity--hub-%23e32929.svg](https://www.singularity-hub.org/static/img/hosted-singularity--hub-%23e32929.svg)](https://singularity-hub.org/collections/1293) + # mindcontrol MindControl is an app for quality control of neuroimaging pipeline outputs. @@ -34,6 +37,44 @@ Create a database json file similar to [http://dxugxjm290185.cloudfront.net/hbn/ * Host your database json file on a server and copy/paste its url into the "startup_json" value on `settings.dev.json` * Define each module in `settings.dev.json` to point to your `entry_type`, and define the module's `staticURL` +## Bulid and deploy with singularity +Connect to the system where you'd like to host mindcontrol with a port forwarded: +``` +ssh -L 3000:localhost:3000 server +``` +Clone mindcontrol to a directory where you've got ~ 10 GB of free space. +``` +git clone https://github.com/Shotgunosine/mindcontrol +cd mindcontrol +``` +You'll need to have a python 3 environment with access to nipype, pybids, freesurfer, and singularity. +Run start_singularity_mindcontrol.py. You can see its documentation with `python start_singularity_mindcontrol.py -h`. The script is now set up to take the name of a linux group as it's first argument. Any member of this group will be able to run the mindcontrol instance and access the mindcontrol files. + +``` +python start_singularity_mindcontrol.py [name of the group you want to own mindcontrol files] [name you want to give container] —freesurfer_dir [path to directory containing subdirectories for all the subjects] --sing_out_dir [path where mindcontrol can output files] --freesurfer +``` + +This command does a number of things: +1) Prompt you to create users and passwords. I would just create one user for your lab and a password that you don’t mind sharing with others in the lab.creates folders with all the settings files that need to be loaded. +2) Runs mriconvert to convert .mgz to .nii.gz for T1, aparc+aseg, ribbion, and wm. Right now I’m just dropping those converted niftis in the directory beside the .mgzs, let me know if that’s a problem though. +3) Pulls the singularity image. +4) Starts an instance of the singularity image running with everything mounted appropriately. + +Inside the image, there’s a fair bit of file copying that has to get done. It takes a while, potentially quite a while if you are on a system with a slow filesystem. +You can check the progress with `cat log/simg_out/out`. +Once that says `Starting mindcontrol and nginx`, you can `cat log/simg_out/mindcontrol.out` to see what mindcontrol is doing. +Once that says `App running at: http://localhost:2998/`, mindcontrol is all set up and running (but ignore that port number, it’s running on port 3000). + +Anyone who wants to see mindcontrol can then `ssh -L 3000:localhost:3000` to the server and browse to http://localhost:3000 in their browser. They’ll be prompted to login with the username and password you created way back in step 1. + +Once you're done running mindcontrol you can stop it with `/bin/bash stop_mindcontrol.sh`. This command dumps the databse to log/simg_out/mindcontrol.out and compresses any previous database dump if finds there. It also fixes permissions on all of the files in the mindcontrol directory, so it may take a while to run. Killing the script before it finishes may prevent another user from being able to run your mindcontrol instance. + +The next time a user in the appropriate group would like to start it, they should just run `/bin/bash start_mindcontrol.sh`. This will start the instance and restore the database from the previously dumped data. + +Don't use the `singularity instance.start` and `singularity instance.stop` commands, as the additional permissions fixing and database restoring or dumping won't run. + +The startscript should write `my_readme.md` to the output directory you specify with instructions on running and connecting to the mindcontrol instance you've created with all of the appropriate paths and port numbers filled in. + ## Demo Check out the [demo](http://mindcontrol.herokuapp.com/). [This data is from the 1000 Functional Connectomes Project](http://fcon_1000.projects.nitrc.org/fcpClassic/FcpTable.html) diff --git a/auto_mindcontrol.py b/auto_local_mindcontrol.py similarity index 100% rename from auto_mindcontrol.py rename to auto_local_mindcontrol.py diff --git a/auto_singularity_mindcontrol.py b/auto_singularity_mindcontrol.py new file mode 100644 index 0000000..d0fa1fa --- /dev/null +++ b/auto_singularity_mindcontrol.py @@ -0,0 +1,512 @@ +#! python +import argparse +from pathlib import Path +import json +from shutil import copyfile +import os +import getpass +import subprocess +import random +import sys + + +# HT password code from https://gist.github.com/eculver/1420227 + +# We need a crypt module, but Windows doesn't have one by default. Try to find +# one, and tell the user if we can't. +try: + import crypt +except ImportError: + try: + import fcrypt as crypt + except ImportError: + sys.stderr.write("Cannot find a crypt module. " + "Possibly http://carey.geek.nz/code/python-fcrypt/\n") + sys.exit(1) + + +def salt(): + """Returns a string of 2 randome letters""" + letters = 'abcdefghijklmnopqrstuvwxyz' \ + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' \ + '0123456789/.' + return random.choice(letters) + random.choice(letters) + + +class HtpasswdFile: + """A class for manipulating htpasswd files.""" + + def __init__(self, filename, create=False): + self.entries = [] + self.filename = filename + if not create: + if os.path.exists(self.filename): + self.load() + else: + raise Exception("%s does not exist" % self.filename) + + def load(self): + """Read the htpasswd file into memory.""" + lines = open(self.filename, 'r').readlines() + self.entries = [] + for line in lines: + username, pwhash = line.split(':') + entry = [username, pwhash.rstrip()] + self.entries.append(entry) + + def save(self): + """Write the htpasswd file to disk""" + open(self.filename, 'w').writelines(["%s:%s\n" % (entry[0], entry[1]) + for entry in self.entries]) + + def update(self, username, password): + """Replace the entry for the given user, or add it if new.""" + pwhash = crypt.crypt(password, salt()) + matching_entries = [entry for entry in self.entries + if entry[0] == username] + if matching_entries: + matching_entries[0][1] = pwhash + else: + self.entries.append([username, pwhash]) + + def delete(self, username): + """Remove the entry for the given user.""" + self.entries = [entry for entry in self.entries + if entry[0] != username] + + + +def write_passfile(passfile_path): + """Collects usernames and passwords and writes them to + the provided path with encryption. + Parameters + ---------- + passfile: pathlib.Path object + The path to which to write the usernames and hashed passwords. + """ + users = set() + done = False + passfile = HtpasswdFile(passfile_path.as_posix(), create=True) + while not done: + user = "" + print("Please enter usernames and passwords for all the users you'd like to create.", flush=True) + user = input("Input user, leave blank if you are finished entering users:") + if len(users) > 0 and user == "": + print("All users entered, generating auth.htpass file", flush=True) + done= True + passfile.save() + elif len(users) == 0 and user == "": + print("Please enter at least one user", flush=True) + else: + if user in users: + print("Duplicate user, overwriting previously entered password for %s."%user, flush=True) + hs = None + while hs is None: + a = getpass.getpass(prompt="Enter Password for user: %s\n"%user) + b = getpass.getpass(prompt="Re-enter Password for user: %s\n"%user) + if a == b: + hs = "valid_pass" + passfile.update(user, a) + else: + print("Entered passwords don't match, please try again.", flush=True) + users.add(user) + + +def write_meteorconf(mcfile, startup_port=3003, nginx_port=3000, meteor_port=2998): + """Write nginx configuration file for meteor given user specified ports. + Parameters + ---------- + mcfile: pathlib.Path object + The path to which to write the config file. + startup_port: int, default is 3003 + Port number at which mindcontrol will look for startup manifest. + nginx_port: int, default is 3000 + Port number at nginx will run. This is the port you connect to reach mindcontrol. + meteor_port: int, default is 2998 + Port number at meteor will run. This is mostly under the hood, but you might + need to change it if there is a port conflict. Mongo will run on the port + one above the meteor_port. + """ + mc_string=f"""error_log /var/log/nginx/nginx_error.log info; + +server {{ + listen {startup_port} default_server; + root /mc_startup_data; + location / {{ + autoindex on; + }} + + }} + +server {{ + listen {nginx_port} default_server; + auth_basic "Restricted"; + auth_basic_user_file auth.htpasswd; + + location / {{ + proxy_pass http://localhost:{meteor_port}/; + }} + location /files/ {{ + alias /mc_data/; + }} + }}""" + mcfile.write_text(mc_string) + + +def write_nginxconf(ncfile): + """Write top level nginx configuration file. + Parameters + ---------- + ncfile: pathlib.Path object + The path to which to write the config file. + """ + nc_string=f"""worker_processes 1; +pid /var/cache/nginx/nginx.pid; +error_log /var/log/nginx/error.log warn; + + +events {{ + worker_connections 1024; +}} + + +http {{ + disable_symlinks off; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + #gzip on; + + include /etc/nginx/conf.d/meteor.conf; +}} +""" + ncfile.write_text(nc_string) + + +def write_mcsettings(mcsetfile, entry_types=None, freesurfer=False, startup_port=3003, nginx_port=3000): + """ Write the mindcontrol settings json. This determines which panels mindcontrol + displays and which that information comes from. + Parameters + ---------- + mcfile: pathlib.Path object + The path to which to write the json file. + entry_types: optional, list of strings + List of names of modules you would like mindcontrol to display + freesurfer: optional, bool + True if you would like the settings generated modules for qcing aparc-aseg, wm, and ribbon + startup_port: int, default is 3003 + Port number at which mindcontrol will look for startup manifest. + nginx_port: int, default is 3000 + Port number at nginx will run. This is the port you connect to reach mindcontrol. + """ + file_server = f"http://localhost:{nginx_port}/files/" + startup_file_server = f'http://localhost:{startup_port}/' + + default_module = { + "fields": [ + { + "function_name": "get_qc_viewer", + "id": "name", + "name": "Image File" + }, + { + "function_name": "get_qc_ave_field", + "id": "average_vote", + "name": "QC vote" + }, + { + "function_name": None, + "id": "num_votes", + "name": "# votes" + }, + { + "function_name": None, + "id": "quality_check.notes_QC", + "name": "Notes" + } + ], + "metric_names": None, + "graph_type": None, + "staticURL": file_server, + "usePeerJS": False, + "logPainter": False, + "logContours": False, + "logPoints": True, + "qc_options": {"pass": 1, "fail": 1, "needs_edits": 0, "edited": 0, "assignTo": 0, "notes": 1, "confidence": 1} + } + + fs_module = { + "fields": [ + { + "function_name": "get_filter_field", + "id": "subject", + "name": "Exam ID" + }, + { + "function_name": "get_qc_viewer", + "id": "name", + "name": "Freesurfer ID" + }, + { + "function_name": "get_qc_filter_field", + "id": "quality_check.QC", + "name": "QC" + }, + { + "function_name": "get_filter_field", + "id": "checkedBy", + "name": "checked by" + }, + { + "function_name": "get_filter_field", + "id": "quality_check.user_assign", + "name": "Assigned To" + }, + { + "function_name": None, + "id": "quality_check.notes_QC", + "name": "Notes" + } + ], + "metric_names": None, + "graph_type": "histogram", + "staticURL": file_server, + "usePeerJS": False, + "logPainter": False, + "logContours": False, + "logPoints": True + } + + fs_cm_dict = {'aparcaseg': + { + "0":{"name": "Grayscale", + "alpha": 1, + "min": 0, + "max": 255 + }, + "1": { + "name": "custom.Freesurfer", + "alpha": 0.5 + } + }, + 'brainmask': + { + "0":{"name": "Grayscale", + "alpha": 1, + "min": 0, + "max": 255 + }, + "1": { + "name": "Red Overlay", + "alpha": 0.2, + "min": 0, + "max": 2000 + } + }, + 'wm': + { + "0":{"name": "Grayscale", + "alpha": 1, + "min": 0, + "max": 255 + }, + "1": { + "name": "Green Overlay", + "alpha": 0.5, + "min": 0, + "max": 2000 + }, + "2": { + "name": "Blue Overlay", + "alpha": 0.5, + "min":0, + "max": 2000 + } + } + } + fs_name_dict = {'brainmask': 'Brain Mask', + 'aparcaseg': 'Segmentation', + 'wm': 'White Matter', + } + if entry_types is None and not freesurfer: + raise Exception("You must either define entry types or have freesurfer == True") + + modules = [] + if entry_types is not None: + for et in entry_types: + et_module = default_module.copy() + et_module["name"] = et + et_module["entry_type"] = et + modules.append(et_module) + + if freesurfer: + for et, cm in fs_cm_dict.items(): + et_module = fs_module.copy() + et_module["name"] = fs_name_dict[et] + et_module["entry_type"] = et + et_module["num_overlays"] = len(cm) + et_module['colormaps'] = cm + modules.append(et_module) + + # autogenerated settings files + pub_set = {"startup_json": startup_file_server+"startup.json", + "load_if_empty": True, + "use_custom": True, + "needs_consent": False, + "modules": modules} + settings = {"public": pub_set} + with mcsetfile.open("w") as h: + json.dump(settings, h) + +if __name__ == "__main__": + docker_build_path = Path(__file__).resolve().parent / 'imports/docker' + parser = argparse.ArgumentParser(description='Autogenerate singularity image and settings files, ' + 'for running mindcontrol in a singularity image on ' + 'another system that is hosting the data.') + parser.add_argument('--sing_out_dir', + default='.', + help='Directory to bulid singularirty image and files in.') + parser.add_argument('--custom_settings', + help='Path to custom settings json') + parser.add_argument('--freesurfer', action='store_true', + help='Generate settings for freesurfer QC in mindcontrol.') + parser.add_argument('--entry_type', action='append', + help='Name of mindcontrol module you would like to have autogenerated.' + ' This should correspond to the bids image type ' + '(specified after the final _ of the image name). ' + ' Pass this argument multiple times to add additional modules.') + parser.add_argument('--dockerfile', default=docker_build_path, + help='Path to the mindcontrol nginx dockerfile, defaults to %s'%docker_build_path) + parser.add_argument('--startup_port', + default=3003, + help='Port number at which mindcontrol will look for startup manifest.') + parser.add_argument('--nginx_port', + default=3000, + help='Port number at nginx will run. This is the port you connect to reach mindcontrol.') + parser.add_argument('--meteor_port', + default=2998, + help='Port number at meteor will run. ' + 'This is mostly under the hood, ' + 'but you might need to change it if there is a port conflict.' + 'Mongo will run on the port one above this one.') + + args = parser.parse_args() + sing_out_dir = args.sing_out_dir + if args.custom_settings is not None: + custom_settings = Path(args.custom_settings) + else: + custom_settings = None + freesurfer = args.freesurfer + entry_types = args.entry_type + startup_port = args.startup_port + nginx_port = args.nginx_port + meteor_port = args.meteor_port + + # Set up directory to be copied + basedir = Path(sing_out_dir) + setdir = basedir/"settings" + mcsetdir = setdir/"mc_settings" + manifest_dir = setdir/"mc_manifest_init" + meteor_ldir = basedir/".meteor" + + logdir = basedir/"log" + scratch_dir = logdir/"scratch" + nginx_scratch = scratch_dir/"nginx" + mc_hdir = scratch_dir/"singularity_home" + + if not basedir.exists(): + basedir.mkdir() + if not setdir.exists(): + setdir.mkdir() + if not logdir.exists(): + logdir.mkdir() + if not scratch_dir.exists(): + scratch_dir.mkdir() + if not nginx_scratch.exists(): + nginx_scratch.mkdir() + if not mcsetdir.exists(): + mcsetdir.mkdir() + if not manifest_dir.exists(): + manifest_dir.mkdir() + if not mc_hdir.exists(): + mc_hdir.mkdir() + if not meteor_ldir.exists(): + meteor_ldir.mkdir() + dockerfile = basedir/"Dockerfile_nginx" + entrypoint = basedir/"entrypoint_nginx.sh" + passfile = setdir/"auth.htpasswd" + mcfile = setdir/"meteor.conf" + ncfile = setdir/"nginx.conf" + mcsetfile = mcsetdir/"mc_nginx_settings.json" + infofile = setdir/"mc_info.json" + + # Write settings files + write_passfile(passfile) + write_nginxconf(ncfile) + write_meteorconf(mcfile, startup_port=startup_port, + nginx_port=nginx_port, meteor_port=meteor_port) + if custom_settings is not None: + copyfile(custom_settings, mcsetfile.as_posix()) + else: + write_mcsettings(mcsetfile, entry_types=entry_types, freesurfer=freesurfer, + startup_port=startup_port, nginx_port=nginx_port) + # Copy singularity run script to directory + srun_source = Path(__file__).resolve().parent / 'start_singularity_mindcontrol.py' + srun_dest = basedir / 'start_singularity_mindcontrol.py' + copyfile(srun_source.as_posix(), srun_dest.as_posix()) + + # Copy entrypoint script to directory + copyfile((docker_build_path / 'entrypoint_nginx.sh').as_posix(), entrypoint.as_posix()) + + # Copy dockerfile to base directory + copyfile((docker_build_path / 'Dockerfile_nginx').as_posix(), dockerfile.as_posix()) + + # Run docker build + subprocess.run("docker build -f Dockerfile_nginx -t auto_mc_nginx .", + cwd=basedir.as_posix(), shell=True, check=True) + + # Run docker2singularity + subprocess.run("docker run -v /var/run/docker.sock:/var/run/docker.sock " + "-v ${PWD}:/output --privileged -t --rm " + "singularityware/docker2singularity auto_mc_nginx", + cwd=basedir.as_posix(), shell=True, check=True) + + # Get name of singularity image + simg = [si for si in basedir.glob("auto_mc_nginx*.img")][0] + + info = dict(entry_types=entry_types, + freesurfer=freesurfer, + startup_port=startup_port, + nginx_port=nginx_port, + meteor_port=meteor_port, + simg=simg.parts[-1]) + + with infofile.open("w") as h: + json.dump(info, h) + + # Print next steps + print("Finished building singuliarity image and settings files.") + print(f"Copy {basedir} to machine that will be hosting the mindcontrol instance.") + print("Consider the following command: ") + print(f"rsync -avch {basedir} [host machine]:[destination path]") + print("Then, on the machine hosting the mindcontrol instance") + print(f"run the start_singularity_mindcontrol.py script included in {basedir}.") + print("Consider the following commands: ") + print(f"cd [destination path]/{basedir.parts[-1]}") + if freesurfer: + print(f"python start_singularity_mindcontrol.py --bids_dir [path to bids dir] --freesurfer_dir [path to freesurfer outputs]") + else: + print(f"python start_singularity_mindcontrol.py --bids_dir [path to bids dir]") + + diff --git a/imports/docker/Dockerfile_nginx b/imports/docker/Dockerfile_nginx new file mode 100644 index 0000000..a22772c --- /dev/null +++ b/imports/docker/Dockerfile_nginx @@ -0,0 +1,105 @@ +FROM nginx:1.15.1 + +MAINTAINER Dylan Nielson + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + git \ + vim \ + supervisor \ + rsync \ + procps \ + && rm -rf /var/lib/apt/lists/* + +ENV METEOR_RELEASE 1.7.0.3 +RUN curl https://install.meteor.com/ 2>/dev/null | sed 's/^RELEASE/#RELEASE/'| RELEASE=$METEOR_RELEASE sh + +RUN ln -s ~/.meteor/packages/meteor-tool/*/mt-os.linux.x86_64/dev_bundle/bin/node /usr/bin/ && \ + ln -s ~/.meteor/packages/meteor-tool/*/mt-os.linux.x86_64/dev_bundle/bin/npm /usr/bin/ && \ + rm /etc/nginx/conf.d/default.conf + + +# Installing and setting up miniconda +RUN curl -sSLO https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh && \ + bash Miniconda*.sh -b -p /usr/local/miniconda && \ + rm Miniconda*.sh + +ENV PATH=/usr/local/miniconda/bin:$PATH \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# Installing precomputed python packages +RUN conda install -c conda-forge -y \ + awscli \ + boto3 \ + dipy \ + git \ + matplotlib \ + numpy \ + python=3.6 \ + scikit-image \ + scikit-learn \ + wget; \ + sync && \ + chmod +x /usr/local/miniconda/bin/* && \ + conda clean --all -y; sync && \ + python -c "from matplotlib import font_manager" && \ + sed -i 's/\(backend *: \).*$/\1Agg/g' $( python -c "import matplotlib; print(matplotlib.matplotlib_fname())" ) + +RUN npm install http-server -g + +ENV MC_DIR /home/mindcontrol +ENV LC_ALL C + + +COPY entrypoint_nginx.sh /home/entrypoint.sh +#COPY ndmg_launch.sh /home/ndmg_launch.sh + +RUN useradd --create-home --home-dir ${MC_DIR} mindcontrol +RUN chown mindcontrol:mindcontrol /home/entrypoint.sh &&\ + chmod +x /home/entrypoint.sh &&\ + mkdir -p ${MC_DIR}/mindcontrol &&\ + chown -R mindcontrol /home/mindcontrol &&\ + chmod -R a+rx /home/mindcontrol + +USER mindcontrol + +RUN cd ${MC_DIR}/mindcontrol &&\ + git clone https://github.com/akeshavan/mindcontrol.git ${MC_DIR}/mindcontrol &&\ + meteor update &&\ + meteor npm install --save @babel/runtime &&\ + meteor npm install --save bcrypt &&\ + #git clone https://github.com/clowdcontrol/mindcontrol.git ${MC_DIR}/mindcontrol + + +####### Attempt at nginx security + +WORKDIR /opt +COPY ./settings/meteor.conf /etc/nginx/conf.d/meteor.conf +COPY ./settings/auth.htpasswd /etc/nginx +COPY ./settings/nginx.conf /etc/nginx/nginx.conf +USER root +RUN chmod 777 /var/log/nginx /var/cache/nginx /opt /etc &&\ + mkdir -p /mc_data /mc_startup_data /mc_settings /mc_fs &&\ + chown mindcontrol:mindcontrol /etc/nginx/auth.htpasswd /mc_data /mc_startup_data /mc_settings /mc_fs +USER mindcontrol +###### + +###### Load in local settings +COPY ./settings/mc_settings/mc_nginx_settings.json /mc_settings/mc_nginx_settings.json + +# Make a spot that we can put our data +RUN mkdir -p /mc_data /mc_startup_data + +WORKDIR ${MC_DIR}/mindcontrol + +ENTRYPOINT ["/home/entrypoint.sh"] + + +EXPOSE 3000 +EXPOSE 2998 +ENV PORT 3000 + + diff --git a/imports/docker/Dockerfile_services b/imports/docker/Dockerfile_services new file mode 100644 index 0000000..bb198ce --- /dev/null +++ b/imports/docker/Dockerfile_services @@ -0,0 +1,94 @@ +FROM nginx:1.15.1 + +MAINTAINER Dylan Nielson + +RUN apt-get update && apt-get install -my wget gnupg \ + && apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 9DA31620334BD75D9DCB49F368818C72E52529D4 \ + && echo "deb http://repo.mongodb.org/apt/debian stretch/mongodb-org/4.0 main" | tee /etc/apt/sources.list.d/mongodb-org-4.0.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + git \ + vim \ + supervisor \ + rsync \ + procps \ + mongodb-org \ + && rm -rf /var/lib/apt/lists/* + +ENV METEOR_RELEASE 1.7.0.3 +RUN curl https://install.meteor.com/ 2>/dev/null | sed 's/^RELEASE/#RELEASE/'| RELEASE=$METEOR_RELEASE sh + +RUN ln -s ~/.meteor/packages/meteor-tool/*/mt-os.linux.x86_64/dev_bundle/bin/node /usr/bin/ && \ + ln -s ~/.meteor/packages/meteor-tool/*/mt-os.linux.x86_64/dev_bundle/bin/npm /usr/bin/ && \ + rm /etc/nginx/conf.d/default.conf + + +# Installing and setting up miniconda +RUN curl -sSLO https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh && \ + bash Miniconda*.sh -b -p /usr/local/miniconda && \ + rm Miniconda*.sh + +ENV PATH=/usr/local/miniconda/bin:$PATH \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# Installing precomputed python packages +RUN conda install -c conda-forge -y \ + awscli \ + boto3 \ + dipy \ + git \ + matplotlib \ + numpy \ + python=3.6 \ + scikit-image \ + scikit-learn \ + wget; \ + sync && \ + chmod +x /usr/local/miniconda/bin/* && \ + conda clean --all -y; sync && \ + python -c "from matplotlib import font_manager" && \ + sed -i 's/\(backend *: \).*$/\1Agg/g' $( python -c "import matplotlib; print(matplotlib.matplotlib_fname())" ) + +RUN npm install http-server -g + +ENV MC_DIR /home/mindcontrol +ENV LC_ALL C + + +COPY entrypoint_nginx.sh /home/entrypoint.sh +#COPY ndmg_launch.sh /home/ndmg_launch.sh + +RUN useradd mindcontrol &&\ + mkdir -p /mc_files/mindcontrol &&\ + chown -R mindcontrol:mindcontrol mc_files &&\ + ln -s /mc_files/mindcontrol /home/mindcontrol +RUN chown mindcontrol:mindcontrol /home/entrypoint.sh &&\ + chmod +x /home/entrypoint.sh &&\ + mkdir -p ${MC_DIR}/mindcontrol &&\ + chown -R mindcontrol:mindcontrol /home/mindcontrol &&\ + chmod -R a+rx /home/mindcontrol &&\ + chmod 777 /var/log/nginx /var/cache/nginx /opt /etc &&\ + mkdir -p /mc_data /mc_startup_data /mc_settings /mc_fs /output /opt/settings /mc_files/singularity_home &&\ + chmod -R 777 /output /opt/settings &&\ + chown -R mindcontrol:mindcontrol /mc_data /mc_startup_data /mc_settings /mc_fs /mc_files &&\ + rm -f /etc/nginx/nginx.conf &&\ + ln -s /opt/settings/auth.htpasswd /etc/nginx/auth.htpasswd &&\ + ln -s /opt/settings/nginx.conf /etc/nginx/nginx.conf &&\ + ln -s /opt/settings/meteor.conf /etc/nginx/conf.d/meteor.conf + +USER mindcontrol + +RUN cd ${MC_DIR}/mindcontrol &&\ + git clone https://github.com/akeshavan/mindcontrol.git ${MC_DIR}/mindcontrol &&\ + meteor update &&\ + meteor npm install --save @babel/runtime &&\ + meteor npm install --save bcrypt &&\ + meteor remove meteorhacks:aggregate &&\ + meteor add sakulstra:aggregate &&\ + meteor reset + #git clone https://github.com/clowdcontrol/mindcontrol.git ${MC_DIR}/mindcontrol + diff --git a/imports/docker/Singularity b/imports/docker/Singularity new file mode 100644 index 0000000..8a8e6f4 --- /dev/null +++ b/imports/docker/Singularity @@ -0,0 +1,50 @@ +Bootstrap: docker +Namespace:shotgunosine +From: mindcontrol:latest + +%labels + Mainteiner Dylan Nielson\ + +%startscript + export HOME=$(find /home/ -maxdepth 1 -mindepth 1 -writable -not -name "singularity_home") + if [ ! -d /mc_files/singularity_home/mindcontrol ] || [ ! -d /mc_files/singularity_home/.meteor ] || [ ! -d /mc_files/singularity_home/.cordova ] ; then + echo "Copying meteor files into singularity_home" > /output/out + rsync -rlD /mc_files/mindcontrol/mindcontrol /mc_files/singularity_home/ > /output/rsync 2>&1 + chmod -R 770 /mc_files/singularity_home/mindcontrol + echo "/mc_files/mindcontrol/mindcontrol copied to $HOME" >> /output/out + rsync -rlD /mc_files/mindcontrol/.meteor /mc_files/singularity_home/ >> /output/rsync 2>&1 + chmod -R 770 /mc_files/singularity_home/.meteor + echo "/mc_files/mindcontrol/.meteor copied to $HOME" >> /output/out + rsync -rlD /mc_files/mindcontrol/.cordova /mc_files/singularity_home/ >> /output/rsync 2>&1 + chmod -R 770 /mc_files/singularity_home/.cordova + echo "/mc_files/mindcontrol/.cordova copied to $HOME" >> /output/out + fi + cd $HOME/mindcontrol + # grab proper meteor port from settings file + MC_PORT=$(cat /mc_settings/mc_port) + MONGO_PORT=$(expr ${MC_PORT} + 1) + + # Check if we need to reset meteor + DB_OWNER="NONE" + if [ -d /mc_files/singularity_home/mindcontrol/.meteor/local/db ] ; then + DB_OWNER=$(stat -c %U /mc_files/singularity_home/mindcontrol/.meteor/local/db) + fi + RESTORE_DB=0 + if [ ${DB_OWNER} != $(stat -c %U $HOME) ] && [ ${DB_OWNER} != "NONE" ] ; then + if [ ! -d /output/mindcontrol_database ] ; then + echo "Someone else owns the mongo database and there's no database dump to load from at /output/mindcontrol_database. Something's gone wrong. Exiting so we don't destroy data." >> /output/out + exit 64 + fi + echo "Someone else owns the db and a database dump was found, resetting Meteor" >> /output/out + meteor reset + RESTORE_DB=1 + fi + echo "Starting mindcontrol and nginx" >> /output/out + nginx + nohup meteor --settings /mc_settings/mc_nginx_settings.json --port $MC_PORT > /output/mindcontrol.out 2>&1 & + if [ ${RESTORE_DB} -eq 1 ] ; then + echo "a" + bash -c 'grep -m 1 "=> Started your app." <( tail -f /output/mindcontrol.out)' + echo "b" + mongorestore --port=${MONGO_PORT} --drop --preserveUUID --gzip /output/mindcontrol_database/ + fi diff --git a/imports/docker/entrypoint_nginx.sh b/imports/docker/entrypoint_nginx.sh new file mode 100644 index 0000000..34585ff --- /dev/null +++ b/imports/docker/entrypoint_nginx.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +cd ~ +if [ ! -x .meteor ]; then + echo "Copying meteor files into singularity_home" + rsync -ach /home/mindcontrol/mindcontrol . + rsync -ach /home/mindcontrol/.meteor . + rsync -ach /home/mindcontrol/.cordova . + ln -s /home/mindcontrol/mindcontrol/.meteor/local ~/mindcontrol/.meteor/local/ +fi +cd ~/mindcontrol +nohup meteor --settings /mc_settings/mc_nginx_settings.json --port 2998 > ~/mindcontrol.out 2>&1 & +nginx -g "daemon off;" diff --git a/imports/docker/settings/auth.conf b/imports/docker/settings/auth.conf new file mode 100755 index 0000000..5ad4cc0 --- /dev/null +++ b/imports/docker/settings/auth.conf @@ -0,0 +1,12 @@ +error_log /var/log/nginx/nginx_error.log info; + +server { + listen 3002 default_server; + root /data; + auth_basic "Restricted"; + auth_basic_user_file auth.htpasswd; + location / { + autoindex on; + } + + } diff --git a/imports/docker/settings/auth.htpasswd b/imports/docker/settings/auth.htpasswd new file mode 100755 index 0000000..8082836 --- /dev/null +++ b/imports/docker/settings/auth.htpasswd @@ -0,0 +1 @@ +dylan:$apr1$ieFel5ou$qOZXoFEVATdKZs9J/dKdA0 diff --git a/imports/docker/settings/log/access.log b/imports/docker/settings/log/access.log new file mode 100644 index 0000000..d3b047f --- /dev/null +++ b/imports/docker/settings/log/access.log @@ -0,0 +1,2 @@ +172.17.0.1 - - [03/Jul/2018:15:00:10 +0000] "GET / HTTP/1.1" 401 597 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36" "-" +172.17.0.1 - - [03/Jul/2018:15:00:11 +0000] "GET / HTTP/1.1" 401 597 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36" "-" diff --git a/imports/docker/settings/log/error.log b/imports/docker/settings/log/error.log new file mode 100644 index 0000000..e69de29 diff --git a/imports/docker/settings/log/nginx_error.log b/imports/docker/settings/log/nginx_error.log new file mode 100644 index 0000000..a8d79cb --- /dev/null +++ b/imports/docker/settings/log/nginx_error.log @@ -0,0 +1,2 @@ +2018/07/03 15:00:10 [info] 7#7: *1 no user/password was provided for basic authentication, client: 172.17.0.1, server: , request: "GET / HTTP/1.1", host: "localhost:3002" +2018/07/03 15:00:11 [info] 7#7: *1 no user/password was provided for basic authentication, client: 172.17.0.1, server: , request: "GET / HTTP/1.1", host: "localhost:3002" diff --git a/imports/docker/settings/meteor.conf b/imports/docker/settings/meteor.conf new file mode 100644 index 0000000..6aa8373 --- /dev/null +++ b/imports/docker/settings/meteor.conf @@ -0,0 +1,23 @@ +error_log /var/log/nginx/nginx_error.log info; + +server { + listen 3003 default_server; + root /mc_startup_data; + location / { + autoindex on; + } + + } + +server { + listen 3000 default_server; + auth_basic "Restricted"; + auth_basic_user_file auth.htpasswd; + + location / { + proxy_pass http://localhost:2998/; + } + location /files/ { + alias /mc_data/; + } + } \ No newline at end of file diff --git a/imports/docker/settings/nginx.conf b/imports/docker/settings/nginx.conf new file mode 100755 index 0000000..f2845ee --- /dev/null +++ b/imports/docker/settings/nginx.conf @@ -0,0 +1,31 @@ + +worker_processes 1; +pid /var/cache/nginx/nginx.pid; +error_log /var/log/nginx/error.log warn; + + +events { + worker_connections 1024; +} + + +http { + disable_symlinks off; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + #gzip on; + + include /etc/nginx/conf.d/meteor.conf; +} diff --git a/start_singularity_mindcontrol.py b/start_singularity_mindcontrol.py new file mode 100644 index 0000000..0aa32fd --- /dev/null +++ b/start_singularity_mindcontrol.py @@ -0,0 +1,913 @@ +#! python +import argparse +from pathlib import Path +import json +from bids.grabbids import BIDSLayout +import subprocess +import os +from shutil import copyfile +import getpass +import random +import sys +import grp +import socket + +from nipype import MapNode, Workflow, Node +from nipype.interfaces.freesurfer import MRIConvert +from nipype.interfaces.io import DataSink +from nipype.interfaces.utility import IdentityInterface, Function + + +# HT password code from https://gist.github.com/eculver/1420227 + +# We need a crypt module, but Windows doesn't have one by default. Try to find +# one, and tell the user if we can't. +try: + import crypt +except ImportError: + try: + import fcrypt as crypt + except ImportError: + sys.stderr.write("Cannot find a crypt module. " + "Possibly http://carey.geek.nz/code/python-fcrypt/\n") + sys.exit(1) + + +def salt(): + """Returns a string of 2 randome letters""" + letters = 'abcdefghijklmnopqrstuvwxyz' \ + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' \ + '0123456789/.' + return random.choice(letters) + random.choice(letters) + + +class HtpasswdFile: + """A class for manipulating htpasswd files.""" + + def __init__(self, filename, create=False): + self.entries = [] + self.filename = filename + if not create: + if os.path.exists(self.filename): + self.load() + else: + raise Exception("%s does not exist" % self.filename) + + def load(self): + """Read the htpasswd file into memory.""" + lines = open(self.filename, 'r').readlines() + self.entries = [] + for line in lines: + username, pwhash = line.split(':') + entry = [username, pwhash.rstrip()] + self.entries.append(entry) + + def save(self): + """Write the htpasswd file to disk""" + open(self.filename, 'w').writelines(["%s:%s\n" % (entry[0], entry[1]) + for entry in self.entries]) + + def update(self, username, password): + """Replace the entry for the given user, or add it if new.""" + pwhash = crypt.crypt(password, salt()) + matching_entries = [entry for entry in self.entries + if entry[0] == username] + if matching_entries: + matching_entries[0][1] = pwhash + else: + self.entries.append([username, pwhash]) + + def delete(self, username): + """Remove the entry for the given user.""" + self.entries = [entry for entry in self.entries + if entry[0] != username] + + +def write_passfile(passfile_path): + """Collects usernames and passwords and writes them to + the provided path with encryption. + Parameters + ---------- + passfile: pathlib.Path object + The path to which to write the usernames and hashed passwords. + """ + users = set() + done = False + passfile = HtpasswdFile(passfile_path.as_posix(), create=True) + while not done: + user = "" + print("Please enter usernames and passwords for all the users you'd like to create.", flush=True) + user = input("Input user, leave blank if you are finished entering users:") + if len(users) > 0 and user == "": + print("All users entered, generating auth.htpass file", flush=True) + done= True + passfile.save() + elif len(users) == 0 and user == "": + print("Please enter at least one user", flush=True) + else: + if user in users: + print("Duplicate user, overwriting previously entered password for %s."%user, flush=True) + hs = None + while hs is None: + a = getpass.getpass(prompt="Enter Password for user: %s\n"%user) + b = getpass.getpass(prompt="Re-enter Password for user: %s\n"%user) + if a == b: + hs = "valid_pass" + passfile.update(user, a) + else: + print("Entered passwords don't match, please try again.", flush=True) + users.add(user) + + +def write_meteorconf(mcfile, startup_port=3003, nginx_port=3000, meteor_port=2998): + """Write nginx configuration file for meteor given user specified ports. + Parameters + ---------- + mcfile: pathlib.Path object + The path to which to write the config file. + startup_port: int, default is 3003 + Port number at which mindcontrol will look for startup manifest. + nginx_port: int, default is 3000 + Port number at nginx will run. This is the port you connect to reach mindcontrol. + meteor_port: int, default is 2998 + Port number at meteor will run. This is mostly under the hood, but you might + need to change it if there is a port conflict. Mongo will run on the port + one above the meteor_port. + """ + mc_string = f"""error_log /var/log/nginx/nginx_error.log info; + +server {{ + listen {startup_port} default_server; + root /mc_startup_data; + location / {{ + autoindex on; + }} + }} + +server {{ + listen {nginx_port} default_server; + auth_basic "Restricted"; + auth_basic_user_file auth.htpasswd; + + location / {{ + proxy_pass http://localhost:{meteor_port}/; + }} + location /files/ {{ + alias /mc_data/; + }} + location /fs/ {{ + alias /mc_fs/; + }} + }}""" + mcfile.write_text(mc_string) + + +def write_nginxconf(ncfile): + """Write top level nginx configuration file. + Parameters + ---------- + ncfile: pathlib.Path object + The path to which to write the config file. + """ + nc_string = f"""worker_processes 1; +pid /var/cache/nginx/nginx.pid; +error_log /var/log/nginx/error.log warn; + + +events {{ + worker_connections 1024; +}} + + +http {{ + disable_symlinks off; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + #gzip on; + + include /etc/nginx/conf.d/meteor.conf; +}} +""" + ncfile.write_text(nc_string) + + +def write_mcsettings(mcsetfile, entry_types=None, freesurfer=False, startup_port=3003, nginx_port=3000): + """ Write the mindcontrol settings json. This determines which panels mindcontrol + displays and which that information comes from. + Parameters + ---------- + mcfile: pathlib.Path object + The path to which to write the json file. + entry_types: optional, list of strings + List of names of modules you would like mindcontrol to display + freesurfer: optional, bool + True if you would like the settings generated modules for qcing aparc-aseg, wm, and ribbon + startup_port: int, default is 3003 + Port number at which mindcontrol will look for startup manifest. + nginx_port: int, default is 3000 + Port number at nginx will run. This is the port you connect to reach mindcontrol. + """ + file_server = f"http://localhost:{nginx_port}/files/" + fs_server = f"http://localhost:{nginx_port}/fs/" + startup_file_server = f'http://localhost:{startup_port}/' + + default_module = { + "fields": [ + { + "function_name": "get_qc_viewer", + "id": "name", + "name": "Image File" + }, + { + "function_name": "get_qc_ave_field", + "id": "average_vote", + "name": "QC vote" + }, + { + "function_name": None, + "id": "num_votes", + "name": "# votes" + }, + { + "function_name": None, + "id": "quality_check.notes_QC", + "name": "Notes" + } + ], + "metric_names": None, + "graph_type": None, + "staticURL": file_server, + "usePeerJS": False, + "logPainter": False, + "logContours": False, + "logPoints": True, + "qc_options": {"pass": 1, "fail": 1, "needs_edits": 0, "edited": 0, "assignTo": 0, "notes": 1, "confidence": 1} + } + + fs_module = { + "fields": [ + { + "function_name": "get_filter_field", + "id": "subject", + "name": "Exam ID" + }, + { + "function_name": "get_qc_viewer", + "id": "name", + "name": "Freesurfer ID" + }, + { + "function_name": "get_qc_filter_field", + "id": "quality_check.QC", + "name": "QC" + }, + { + "function_name": "get_filter_field", + "id": "checkedBy", + "name": "checked by" + }, + { + "function_name": "get_filter_field", + "id": "quality_check.user_assign", + "name": "Assigned To" + }, + { + "function_name": None, + "id": "quality_check.notes_QC", + "name": "Notes" + } + ], + "metric_names": None, + "graph_type": "histogram", + "staticURL": fs_server, + "usePeerJS": False, + "logPainter": False, + "logContours": False, + "logPoints": True + } + + fs_cm_dict = {'aparcaseg': + { + "0":{"name": "Grayscale", + "alpha": 1, + "min": 0, + "max": 255 + }, + "1": { + "name": "custom.Freesurfer", + "alpha": 0.5 + } + }, + 'brainmask': + { + "0":{"name": "Grayscale", + "alpha": 1, + "min": 0, + "max": 255 + }, + "1": { + "name": "Red Overlay", + "alpha": 0.2, + "min": 0, + "max": 2000 + } + }, + 'wm': + { + "0":{"name": "Grayscale", + "alpha": 1, + "min": 0, + "max": 255 + }, + "1": { + "name": "Green Overlay", + "alpha": 0.5, + "min": 0, + "max": 2000 + }, + "2": { + "name": "Blue Overlay", + "alpha": 0.5, + "min":0, + "max": 2000 + } + } + } + fs_name_dict = {'brainmask': 'Brain Mask', + 'aparcaseg': 'Segmentation', + 'wm': 'White Matter', + } + if entry_types is None and not freesurfer: + raise Exception("You must either define entry types or have freesurfer == True") + + modules = [] + if entry_types is not None: + for et in entry_types: + et_module = default_module.copy() + et_module["name"] = et + et_module["entry_type"] = et + modules.append(et_module) + + if freesurfer: + for et, cm in fs_cm_dict.items(): + et_module = fs_module.copy() + et_module["name"] = fs_name_dict[et] + et_module["entry_type"] = et + et_module["num_overlays"] = len(cm) + et_module['colormaps'] = cm + modules.append(et_module) + + # autogenerated settings files + pub_set = {"startup_json": startup_file_server+"startup.json", + "load_if_empty": True, + "use_custom": False, + "needs_consent": False, + "modules": modules} + settings = {"public": pub_set} + with mcsetfile.open("w") as h: + json.dump(settings, h) + + +def write_startfile(startfile, workdir, cmd): + + script = f"""#! /bin/bash +cd {workdir.absolute()} +if [ ! -d scratch/singularity_home_${{USER}} ]; then + mkdir scratch/singularity_home_${{USER}} + cd scratch/singularity_home_${{USER}} + ln -s /mc_files/singularity_home/.cordova + ln -s /mc_files/singularity_home/.meteor + ln -s /mc_files/singularity_home/mindcontrol +fi +{cmd} +""" + startfile.write_text(script) + + +def write_stopfile(stopfile, workdir, group, cmd, meteor_port, container_name, run_stop=True): + #find scratch/singularity_home ! -group {group} -exec chmod 770 {{}} \; -exec chown :{group} {{}} \; + + if run_stop: + script = f"""#! /bin/bash +cd {workdir.absolute()} +if [ -d log/simg_out/mindcontrol_database ] ; then + DATE=$(date +"%Y%m%d%H%M%S") + echo "Saving previous database dump to log/simg_out/mindcontrol_database_${{DATE}}.tar.gz" + tar -czf log/simg_out/mindcontrol_database_${{DATE}}.tar.gz log/simg_out/mindcontrol_database/ +fi +singularity exec instance://{container_name} mongodump --out=/output/mindcontrol_database --port={meteor_port+1} --gzip +singularity exec instance://{container_name} mongod --dbpath=/home/${{USER}}/mindcontrol/.meteor/local/db --shutdown +{cmd} +echo "Waiting 30 seconds for everything to finish writing" +sleep 30 +chown -R :{group} scratch/singularity_home/mindcontrol/.meteor/local +chmod -R 770 scratch/singularity_home/mindcontrol/.meteor/local +chmod -R 770 log +chmod -R 770 scratch/nginx +""" + else: + raise NotImplementedError + stopfile.write_text(script) + +#this function finds data in the subjects_dir +def data_grabber(subjects_dir, subject, volumes): + import os + volumes_list = [os.path.join(subjects_dir, subject, 'mri', volume) for volume in volumes] + return volumes_list + + +#this function parses the aseg.stats, lh.aparc.stats and rh.aparc.stats and returns a dictionary +def parse_stats(subjects_dir, subject): + from os.path import join, exists + + aseg_file = join(subjects_dir, subject, "stats", "aseg.stats") + lh_aparc = join(subjects_dir, subject, "stats", "lh.aparc.stats") + rh_aparc = join(subjects_dir, subject, "stats", "rh.aparc.stats") + + assert exists(aseg_file), "aseg file does not exists for %s" % subject + assert exists(lh_aparc), "lh aparc file does not exists for %s" % subject + assert exists(rh_aparc), "rh aparc file does not exists for %s" % subject + + def convert_stats_to_json(aseg_file, lh_aparc, rh_aparc): + import pandas as pd + import numpy as np + + def extract_other_vals_from_aseg(f): + value_labels = ["EstimatedTotalIntraCranialVol", + "Mask", + "TotalGray", + "SubCortGray", + "Cortex", + "CerebralWhiteMatter", + "CorticalWhiteMatter", + "CorticalWhiteMatterVol"] + value_labels = list(map(lambda x: 'Measure ' + x + ',', value_labels)) + output = pd.DataFrame() + with open(f, "r") as q: + out = q.readlines() + relevant_entries = [x for x in out if any(v in x for v in value_labels)] + for val in relevant_entries: + sname = val.split(",")[1][1:] + vol = val.split(",")[-2] + output = output.append(pd.Series({"StructName": sname, + "Volume_mm3": vol}), + ignore_index=True) + return output + + df = pd.DataFrame(np.genfromtxt(aseg_file, dtype=str), + columns=["Index", + "SegId", + "NVoxels", + "Volume_mm3", + "StructName", + "normMean", + "normStdDev", + "normMin", + "normMax", + "normRange"]) + + df = df.append(extract_other_vals_from_aseg(aseg_file), ignore_index=True) + + aparc_columns = ["StructName", "NumVert", "SurfArea", "GrayVol", + "ThickAvg", "ThickStd", "MeanCurv", "GausCurv", + "FoldInd", "CurvInd"] + tmp_lh = pd.DataFrame(np.genfromtxt(lh_aparc, dtype=str), + columns=aparc_columns) + tmp_lh["StructName"] = "lh_"+tmp_lh["StructName"] + tmp_rh = pd.DataFrame(np.genfromtxt(rh_aparc, dtype=str), + columns=aparc_columns) + tmp_rh["StructName"] = "rh_"+tmp_rh["StructName"] + + aseg_melt = pd.melt(df[["StructName", "Volume_mm3"]], + id_vars=["StructName"]) + aseg_melt.rename(columns={"StructName": "name"}, + inplace=True) + aseg_melt["value"] = aseg_melt["value"].astype(float) + + lh_aparc_melt = pd.melt(tmp_lh, id_vars=["StructName"]) + lh_aparc_melt["value"] = lh_aparc_melt["value"].astype(float) + lh_aparc_melt["name"] = lh_aparc_melt["StructName"] + "_" + lh_aparc_melt["variable"] + + rh_aparc_melt = pd.melt(tmp_rh, id_vars=["StructName"]) + rh_aparc_melt["value"] = rh_aparc_melt["value"].astype(float) + rh_aparc_melt["name"] = rh_aparc_melt["StructName"] + "_" + rh_aparc_melt["variable"] + + output = aseg_melt[["name", + "value"]].append(lh_aparc_melt[["name", + "value"]], + ignore_index=True).append(rh_aparc_melt[["name", + "value"]], + ignore_index=True) + outdict = output.to_dict(orient="records") + final_dict = {} + for pair in outdict: + final_dict[pair["name"]] = pair["value"] + return final_dict + + output_dict = convert_stats_to_json(aseg_file, lh_aparc, rh_aparc) + return output_dict + + +# This function creates valid Mindcontrol entries that are saved as .json files. # This f +# They can be loaded into the Mindcontrol database later +def create_mindcontrol_entries(output_dir, subject, stats): + import os + from nipype.utils.filemanip import save_json + + cortical_wm = "CerebralWhiteMatterVol" # for later FS version + if not stats.get(cortical_wm): + cortical_wm = "CorticalWhiteMatterVol" + if not stats.get(cortical_wm): + cortical_wm = "CorticalWhiteMatter" + + metric_split = {"brainmask": ["eTIV", "CortexVol", "TotalGrayVol"], + "wm": [cortical_wm, "WM-hypointensities", + "Right-WM-hypointensities", "Left-WM-hypointensities"], + "aparcaseg": []} + + volumes = {'aparcaseg': ['T1.nii.gz', 'aparc+aseg.nii.gz'], + 'brainmask': ['T1.nii.gz', 'brainmask.nii.gz'], + 'wm': ['T1.nii.gz', 'ribbon.nii.gz', 'wm.nii.gz']} + + all_entries = [] + + for idx, entry_type in enumerate(["brainmask", "wm", "aparcaseg"]): + entry = {"entry_type": entry_type, + "subject_id": subject, + "name": subject} + volumes_list = [os.path.join(subject, 'mri', volume) + for volume in volumes[entry_type]] + entry["check_masks"] = volumes_list + entry["metrics"] = {} + for metric_name in metric_split[entry_type]: + entry["metrics"][metric_name] = stats.pop(metric_name) + if not len(metric_split[entry_type]): + entry["metrics"] = stats + all_entries.append(entry) + + output_json = os.path.abspath("mindcontrol_entries.json") + save_json(output_json, all_entries) + return output_json + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Start mindcontrol in a previously built' + ' singularity container and create initial manifest' + ' if needed.') + parser.add_argument('group', + help='Name of the group under which mindcontrol directories should be created') + parser.add_argument('container_name', + help='Name for the container. Should be unique.') + parser.add_argument('--sing_out_dir', + help='Directory to bulid singularirty image and files in. Dafaults to ./[container_name]') + parser.add_argument('--custom_settings', + help='Path to custom settings json') + parser.add_argument('--freesurfer', action='store_true', + help='Generate settings for freesurfer QC in mindcontrol.') + parser.add_argument('--entry_type', action='append', + help='Name of mindcontrol module you would like to have autogenerated.' + ' This should correspond to the bids image type ' + '(specified after the final _ of the image name). ' + ' Pass this argument multiple times to add additional modules.') + parser.add_argument('--startup_port', + default=3003, + type=int, + help='Port number at which mindcontrol will look for startup manifest.') + parser.add_argument('--nginx_port', + default=3000, + type=int, + help='Port number at nginx will run. This is the port you connect to reach mindcontrol.') + parser.add_argument('--meteor_port', + default=2998, + type=int, + help='Port number at meteor will run. ' + 'This is mostly under the hood, ' + 'but you might need to change it if there is a port conflict.' + 'Mongo will run on the port one above this one.') + parser.add_argument('--bids_dir', help='The directory with the input dataset ' + 'formatted according to the BIDS standard.') + parser.add_argument('--freesurfer_dir', help='The directory with the freesurfer dirivatives,' + ' should be inside the bids directory') + parser.add_argument('--no_mriconvert', action='store_true', + help="Don't convert mgzs to nifti, just assume the images are present.") + parser.add_argument('--no_server', action='store_true', + help="Don't start the mindcontrol server, just generate the manifest.") + parser.add_argument('--nipype_plugin', + help="Run the mgz to nii.gz conversion with the specified nipype plugin." + "see https://nipype.readthedocs.io/en/latest/users/plugins.html") + parser.add_argument('--nipype_plugin_args', + help='json formatted string of keyword arguments for nipype_plugin') + + args = parser.parse_args() + mc_gnam = args.group + mc_gid = grp.getgrnam(mc_gnam)[2] + # Check if username and gnam are the same and print a warning + if mc_gnam == getpass.getuser(): + print("WARNING: You've set the group to your user group, no one else " + "will be able to start this mindcontrol instance.") + # Check if user is in group and if not throw an error + if mc_gid not in os.getgroups(): + raise ValueError("You must be a member of the group specified for" + " mindcontrol.") + # Check current umask, if it's not 002, throw an error + current_umask = os.umask(0) + os.umask(current_umask) + if current_umask > 2: + raise Exception("This command must be run with a umask of 2, run" + " 'umask 002' to set the umask then try" + " this command again") + container_name = args.container_name + if args.sing_out_dir is None: + sing_out_dir = container_name + else: + sing_out_dir = args.sing_out_dir + + if args.custom_settings is not None: + custom_settings = Path(args.custom_settings) + else: + custom_settings = None + freesurfer = args.freesurfer + if args.entry_type is not None: + entry_types = set(args.entry_type) + else: + entry_types = set([]) + startup_port = args.startup_port + nginx_port = args.nginx_port + meteor_port = args.meteor_port + + if args.bids_dir is not None: + bids_dir = Path(args.bids_dir) + try: + layout = BIDSLayout(bids_dir.as_posix()) + except ValueError as e: + print("Invalid bids directory, skipping none freesurfer files. BIDS error:", e) + else: + bids_dir = None + + if args.freesurfer_dir is not None: + freesurfer_dir = Path(args.freesurfer_dir) + else: + freesurfer_dir = None + + no_server = args.no_server + no_mriconvert = args.no_mriconvert + nipype_plugin = args.nipype_plugin + if args.nipype_plugin_args is not None: + nipype_plugin_args = json.loads(args.nipype_plugin_args) + else: + nipype_plugin_args = {} + + # Set up directory to be copied + basedir = Path(sing_out_dir).resolve() + setdir = basedir/"settings" + mcsetdir = setdir/"mc_settings" + manifest_dir = setdir/"mc_manifest_init" + simg_path = basedir/"mc_service.simg" + + logdir = basedir/"log" + simg_out = logdir/"simg_out" + scratch_dir = basedir/"scratch" + nginx_scratch = scratch_dir/"nginx" + mc_hdir = scratch_dir/"singularity_home" + + if not basedir.exists(): + basedir.mkdir() + chmod_cmd = f'chgrp -R {mc_gnam} {basedir} && chmod -R g+s {basedir} && chmod -R 770 {basedir}' + _chmod_res = subprocess.check_output(chmod_cmd, shell=True) + if not setdir.exists(): + setdir.mkdir() + if not logdir.exists(): + logdir.mkdir() + if not simg_out.exists(): + simg_out.mkdir() + if not scratch_dir.exists(): + scratch_dir.mkdir() + if not nginx_scratch.exists(): + nginx_scratch.mkdir() + if not mcsetdir.exists(): + mcsetdir.mkdir() + if not manifest_dir.exists(): + manifest_dir.mkdir() + if not mc_hdir.exists(): + mc_hdir.mkdir() + + dockerfile = basedir/"Dockerfile_nginx" + entrypoint = basedir/"entrypoint_nginx.sh" + passfile = setdir/"auth.htpasswd" + mcfile = setdir/"meteor.conf" + ncfile = setdir/"nginx.conf" + mcsetfile = mcsetdir/"mc_nginx_settings.json" + mcportfile = mcsetdir/"mc_port" + infofile = setdir/"mc_info.json" + startfile = basedir/"start_mindcontrol.sh" + stopfile = basedir/"stop_mindcontrol.sh" + readme = basedir/"my_readme.md" + readme_str = "# Welcome to your mindcontrol instance \n" + + # Write settings files + write_passfile(passfile) + write_nginxconf(ncfile) + write_meteorconf(mcfile, startup_port=startup_port, + nginx_port=nginx_port, meteor_port=meteor_port) + # write the meteor port to a file so we can load it in the Singularity start script + mcportfile.write_text(str(meteor_port)) + if custom_settings is not None: + copyfile(custom_settings, mcsetfile.as_posix()) + else: + write_mcsettings(mcsetfile, entry_types=entry_types, freesurfer=freesurfer, + startup_port=startup_port, nginx_port=nginx_port) + + # infofile = mc_singularity_path/'settings/mc_info.json' + # with infofile.open('r') as h: + # info = json.load(h) + manifest_dir = basedir/'settings/mc_manifest_init/' + manifest_json = (manifest_dir/'startup.json').resolve() + + # First create the initial manifest + manifest = [] + if len(entry_types) != 0 and bids_dir is not None: + unused_types = set() + for img in layout.get(extensions=".nii.gz"): + if img.type in entry_types: + img_dict = {} + img_dict["check_masks"] = [img.filename.replace(bids_dir.as_posix(), "")] + img_dict["entry_type"] = img.type + img_dict["metrics"] = {} + img_dict["name"] = os.path.split(img.filename)[1].split('.')[0] + img_dict["subject"] = 'sub-' + img.subject + img_dict["session"] = 'ses-' + img.session + manifest.append(img_dict) + else: + unused_types.add(img.type) + if freesurfer: + if freesurfer_dir is None: + # TODO: look in default location for freesurfer directory + raise Exception("Must specify the path to freesurfer files.") + + subjects = [] + for path in freesurfer_dir.glob('*'): + subject = path.parts[-1] + # check if mri dir exists, and don't add fsaverage + if os.path.exists(os.path.join(path, 'mri')) and 'average' not in subject: + subjects.append(subject) + + volumes = ["brainmask.mgz", "wm.mgz", "aparc+aseg.mgz", "T1.mgz", "ribbon.mgz"] + input_node = Node(IdentityInterface(fields=['subject_id', + "subjects_dir", + "output_dir", + "startup_json_path"]), + name='inputnode') + + input_node.iterables = ("subject_id", subjects) + input_node.inputs.subjects_dir = freesurfer_dir + input_node.inputs.output_dir = freesurfer_dir.as_posix() + + dg_node = Node(Function(input_names=["subjects_dir", "subject", "volumes"], + output_names=["volume_paths"], + function=data_grabber), + name="datagrab") + #dg_node.inputs.subjects_dir = subjects_dir + dg_node.inputs.volumes = volumes + + mriconvert_node = MapNode(MRIConvert(out_type="niigz"), + iterfield=["in_file"], + name='convert') + + get_stats_node = Node(Function(input_names=["subjects_dir", "subject"], + output_names=["output_dict"], + function=parse_stats), name="get_freesurfer_stats") + + write_mindcontrol_entries = Node(Function(input_names=["output_dir", + "subject", + "stats", + "startup_json_path"], + output_names=["output_json"], + function=create_mindcontrol_entries), + name="get_mindcontrol_entries") + + datasink_node = Node(DataSink(), + name='datasink') + subst = [('out_file', ''), + ('_subject_id_', ''), + ('_out', '')] + subst += [("_convert%d" % index, "mri") for index in range(len(volumes))] + datasink_node.inputs.substitutions = subst + workflow_working_dir = scratch_dir.absolute() + + wf = Workflow(name="MindPrepFS") + wf.base_dir = workflow_working_dir + wf.connect(input_node, "subject_id", dg_node, "subject") + wf.connect(input_node, "subjects_dir", dg_node, "subjects_dir") + wf.connect(input_node, "subject_id", get_stats_node, "subject") + wf.connect(input_node, "subjects_dir", get_stats_node, "subjects_dir") + wf.connect(input_node, "subject_id", write_mindcontrol_entries, "subject") + wf.connect(input_node, "output_dir", write_mindcontrol_entries, "output_dir") + wf.connect(get_stats_node, "output_dict", write_mindcontrol_entries, "stats") + wf.connect(input_node, "output_dir", datasink_node, "base_directory") + if not no_mriconvert: + wf.connect(dg_node, "volume_paths", mriconvert_node, "in_file") + wf.connect(mriconvert_node, 'out_file', datasink_node, 'out_file') + wf.connect(write_mindcontrol_entries, "output_json", datasink_node, "out_file.@json") + #wf.write_graph(graph2use='exec') + wf.run(plugin=nipype_plugin, plugin_args=nipype_plugin_args) + + #load all the freesurfer jsons into the manifest + for path in freesurfer_dir.glob('*'): + subject = path.parts[-1] + # check if mri dir exists, and don't add fsaverage + if os.path.exists(os.path.join(path, 'mri')) and 'average' not in subject: + subj_json = path / 'mindcontrol_entries.json' + with subj_json.open('r') as h: + manifest.extend(json.load(h)) + + with manifest_json.open('w') as h: + json.dump(manifest, h) + + # Find out if singularity settings allow for pid namespaces + singularity_prefix = (subprocess.check_output("grep '^prefix' $(which singularity)", shell=True) + .decode() + .split('"')[1]) + sysconfdir = (subprocess.check_output("grep '^sysconfdir' $(which singularity)", shell=True) + .decode() + .split('"')[1]) + try: + sysconfdir = sysconfdir.split('}')[1] + conf_path = os.path.join(singularity_prefix, sysconfdir[1:], 'singularity/singularity.conf') + except IndexError: + conf_path = os.path.join(sysconfdir, 'singularity/singularity.conf') + + allow_pidns = (subprocess.check_output(f"grep '^allow pid ns' {conf_path}", shell=True) + .decode() + .split('=')[1] + .strip()) == "yes" + if not allow_pidns: + stop_cmd = '\n'.join(["Host is not configured to allow pid namespaces!", + "You won't see the instance listed when you run ", + "'singularity instance.list'", + "To stop the mindcontrol server you'll need to ", + "find the process group id for the startscript ", + "with the following command: ", + "`ps -u $(whoami) -o pid,ppid,pgid,sess,cmd --forest`", + "then run:", + "`pkill -9 -g [the PGID for the startscript process]`", + "Then you'll need to delete the mongo socket file with: ", + f"rm /tmp/mongodb-{meteor_port + 1}.sock" + ]) + else: + stop_cmd = f"singularity instance.stop {container_name}" + + build_command = f"singularity build {simg_path.absolute()} shub://Shotgunosine/mindcontrol" + if bids_dir is None: + bids_dir = freesurfer_dir + elif freesurfer_dir is None: + freesurfer_dir = bids_dir + startcmd = f"singularity instance.start -B {logdir.absolute()}:/var/log/nginx" \ + + f" -B {bids_dir.absolute()}:/mc_data" \ + + f" -B {freesurfer_dir.absolute()}:/mc_fs" \ + + f" -B {setdir.absolute()}:/opt/settings" \ + + f" -B {manifest_dir.absolute()}:/mc_startup_data" \ + + f" -B {nginx_scratch.absolute()}:/var/cache/nginx" \ + + f" -B {simg_out.absolute()}:/output" \ + + f" -B {mcsetdir.absolute()}:/mc_settings" \ + + f" -B {mc_hdir.absolute()}:/mc_files/singularity_home" \ + + f" -H {mc_hdir.absolute().as_posix() + '_'}${{USER}}:/home/${{USER}} {simg_path.absolute()}" \ + + f" {container_name}" + write_startfile(startfile, basedir, startcmd) + write_stopfile(stopfile, basedir, mc_gnam, stop_cmd, meteor_port, container_name, allow_pidns) + cmd = f"/bin/bash {startfile.absolute()}" + if not args.no_server: + readme_str += "## Sinularity image was built with this comand \n" + print(build_command, flush=True) + subprocess.run(build_command, cwd=basedir, shell=True, check=True) + print(cmd, flush=True) + subprocess.run(cmd, cwd=basedir, shell=True, check=True) + else: + readme_str += "## To build the singularity image \n" + print("Not starting server, but here's the command you would use if you wanted to:") + print(build_command, flush=True) + print(cmd, flush=True) + print("To stop the mindcontrol server run:", flush=True) + print(f'/bin/bash {stopfile.absolute()}', flush=True) + readme_str += f"`{build_command}` \n" + readme_str += "## Check to see if the singularity instance is running \n" + readme_str += "`singularity instance.list mindcontrol` \n" + readme_str += "## Start a singularity mindcontrol instance \n" + readme_str += f"`{cmd}` \n" + readme_str += "## Connect to this instance \n" + readme_str += f"`ssh -L {nginx_port}:localhost:{nginx_port} {socket.gethostname()}` \n" + readme_str += f"then browse to http:\\localhost:{nginx_port} on the machine you connected from. \n" + readme_str += "## Stop a singularity mindcontrol instance \n" + readme_str += f'/bin/bash {stopfile.absolute()}' + readme.write_text(readme_str) +