├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── ansible.cfg ├── inventory.example ├── inventory.redirector ├── roles ├── common │ ├── files │ │ ├── 99-apt-yes │ │ └── sshd_config │ ├── handlers │ │ └── main.yml │ └── tasks │ │ ├── docker.yml │ │ └── main.yml ├── notebook │ ├── files │ │ └── docker │ └── tasks │ │ ├── docker.yml │ │ └── main.yml └── proxy │ ├── defaults │ └── main.yml │ ├── files │ └── letsencrypt-renew │ ├── handlers │ └── main.yml │ ├── tasks │ └── main.yml │ └── templates │ ├── Dockerfile_nginx.j2 │ └── nginx.conf.j2 ├── script ├── add-redirect ├── deploy ├── drop-redirect ├── image-clean ├── image-update ├── launch-statuspage ├── launch.py ├── new-instance └── reboot ├── site.yml ├── statuspage-tmpnb-env ├── update.yml └── vars.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | .ipynb_checkpoints 57 | 58 | .DS_Store 59 | novarc 60 | # Ansible secrets and local overrides 61 | vars.local.yml 62 | inventory 63 | inventory.* 64 | secrets.yml 65 | statuspage-env 66 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: "2.7" 3 | sudo: false 4 | install: 5 | - pip install ansible 6 | before_script: 7 | - cp secrets.vault.example secrets.yml 8 | script: 9 | - ansible-playbook -i "localhost," site.yml --syntax-check 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Project Jupyter Contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tmpnb deployment 2 | 3 | This repository contains an Ansible playbook for launching assets to *.tmpnb.org. 4 | 5 | Single [tmpnb](https://github.com/jupyter/tmpnb) setup is currently: 6 | 7 | * nginx on one server for SSL termination, has a DNS record associated 8 | * tmpnb on another server 9 | 10 | Outside of those, we use the [tmpnb-redirector](https://github.com/jupyter/tmpnb-redirector) to redirect to these nodes. 11 | 12 | This is also set up for our own use, which means it may not work well for your own deployment (until we abstract it a bit further). 13 | 14 | ## Launching with Ansible 15 | 16 | ### "Easy" mode 17 | 18 | 19 | ``` 20 | pip install rackpacesdk rackspace-monitoring 21 | source ./novarc 22 | ./script/new-instance 23 | ``` 24 | 25 | This will: 26 | 27 | - allocate new servers (./script/launch.py) 28 | - add them to the redirector (./script/add-redirect) 29 | - deploy tmpnb (./script/deploy) 30 | 31 | ### Updating images on a running instance 32 | 33 | ```bash 34 | ./script/image-update 35 | ``` 36 | 37 | 38 | ### Status page 39 | 40 | The status page daemon for tmpnb availability is run on the tmpnb-status carina cluster. 41 | 42 | You will need to get the API key from statuspage.io, and create `statuspage-env` with: 43 | 44 | STATUS_PAGE_API_KEY= 45 | 46 | Run: 47 | 48 | eval $(carina env tmpnb-status) 49 | ./script/launch-statuspage 50 | 51 | To launch the statuspage daemons. 52 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | pipelining = True 3 | -------------------------------------------------------------------------------- /inventory.example: -------------------------------------------------------------------------------- 1 | [notebook] 2 | notebook_server ansible_ssh_user=root ansible_ssh_host= 3 | 4 | [proxy] 5 | proxy_server ansible_ssh_user=root ansible_ssh_host= notebook_host= 6 | -------------------------------------------------------------------------------- /inventory.redirector: -------------------------------------------------------------------------------- 1 | [redirector] 2 | tmpnb.org ansible_ssh_user=root ansible_ssh_host=multipurpose.jupyter.org 3 | -------------------------------------------------------------------------------- /roles/common/files/99-apt-yes: -------------------------------------------------------------------------------- 1 | APT::Get::Assume-Yes "true"; 2 | -------------------------------------------------------------------------------- /roles/common/files/sshd_config: -------------------------------------------------------------------------------- 1 | # Package generated configuration file 2 | # See the sshd_config(5) manpage for details 3 | 4 | # What ports, IPs and protocols we listen for 5 | Port 22 6 | # Use these options to restrict which interfaces/protocols sshd will bind to 7 | #ListenAddress :: 8 | #ListenAddress 0.0.0.0 9 | Protocol 2 10 | # HostKeys for protocol version 2 11 | HostKey /etc/ssh/ssh_host_rsa_key 12 | HostKey /etc/ssh/ssh_host_dsa_key 13 | HostKey /etc/ssh/ssh_host_ecdsa_key 14 | #Privilege Separation is turned on for security 15 | UsePrivilegeSeparation yes 16 | 17 | # Lifetime and size of ephemeral version 1 server key 18 | KeyRegenerationInterval 3600 19 | ServerKeyBits 768 20 | 21 | # Logging 22 | SyslogFacility AUTH 23 | LogLevel INFO 24 | 25 | # Authentication: 26 | LoginGraceTime 120 27 | PermitRootLogin yes 28 | StrictModes yes 29 | 30 | RSAAuthentication yes 31 | PubkeyAuthentication yes 32 | #AuthorizedKeysFile %h/.ssh/authorized_keys 33 | 34 | # Don't read the user's ~/.rhosts and ~/.shosts files 35 | IgnoreRhosts yes 36 | # For this to work you will also need host keys in /etc/ssh_known_hosts 37 | RhostsRSAAuthentication no 38 | # similar for protocol version 2 39 | HostbasedAuthentication no 40 | # Uncomment if you don't trust ~/.ssh/known_hosts for RhostsRSAAuthentication 41 | #IgnoreUserKnownHosts yes 42 | 43 | # To enable empty passwords, change to yes (NOT RECOMMENDED) 44 | PermitEmptyPasswords no 45 | 46 | # Change to yes to enable challenge-response passwords (beware issues with 47 | # some PAM modules and threads) 48 | ChallengeResponseAuthentication no 49 | 50 | # Change to no to disable tunnelled clear text passwords 51 | PasswordAuthentication no 52 | 53 | # Kerberos options 54 | #KerberosAuthentication no 55 | #KerberosGetAFSToken no 56 | #KerberosOrLocalPasswd yes 57 | #KerberosTicketCleanup yes 58 | 59 | # GSSAPI options 60 | #GSSAPIAuthentication no 61 | #GSSAPICleanupCredentials yes 62 | 63 | X11Forwarding no 64 | # X11DisplayOffset 10 65 | PrintMotd no 66 | PrintLastLog yes 67 | TCPKeepAlive yes 68 | #UseLogin no 69 | 70 | #MaxStartups 10:30:60 71 | #Banner /etc/issue.net 72 | 73 | # Allow client to pass locale environment variables 74 | # AcceptEnv LANG LC_* 75 | 76 | Subsystem sftp /usr/lib/openssh/sftp-server 77 | 78 | # Set this to 'yes' to enable PAM authentication, account processing, 79 | # and session processing. If this is enabled, PAM authentication will 80 | # be allowed through the ChallengeResponseAuthentication and 81 | # PasswordAuthentication. Depending on your PAM configuration, 82 | # PAM authentication via ChallengeResponseAuthentication may bypass 83 | # the setting of "PermitRootLogin without-password". 84 | # If you just want the PAM account and session checks to run without 85 | # PAM authentication, then enable this but set PasswordAuthentication 86 | # and ChallengeResponseAuthentication to 'no'. 87 | UsePAM yes 88 | -------------------------------------------------------------------------------- /roles/common/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart sshd 3 | service: name=ssh state=restarted 4 | become: yes 5 | 6 | - name: update apt 7 | apt: update_cache=yes 8 | become: yes 9 | -------------------------------------------------------------------------------- /roles/common/tasks/docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: see if HTTPS transport is already present 3 | stat: path=/usr/lib/apt/methods/https get_md5=false 4 | register: https_transport_file 5 | 6 | - name: ensure HTTPS transport is available to apt 7 | apt: update_cache=yes cache_valid_time=3600 name=apt-transport-https 8 | become: yes 9 | when: not https_transport_file.stat.exists 10 | 11 | - name: ensure the docker apt key is trusted 12 | apt_key: 13 | keyserver: hkp://p80.pool.sks-keyservers.net:80 14 | id: 58118E89F3A912897C070ADBF76221572C52609D 15 | state: present 16 | become: yes 17 | 18 | - name: ensure the docker apt repository is present 19 | apt_repository: 20 | repo: deb https://apt.dockerproject.org/repo ubuntu-{{ ansible_distribution_release }} main 21 | state: present 22 | become: yes 23 | notify: update apt 24 | 25 | - name: install docker 26 | apt: update_cache=yes cache_valid_time=3600 name=docker-engine=17.05.0~ce-0~ubuntu-{{ ansible_distribution_release }} 27 | become: yes 28 | 29 | - name: python 30 | apt: name=python state=latest 31 | become: yes 32 | 33 | - name: pip install script 34 | get_url: dest=/tmp/get_pip.py url=https://bootstrap.pypa.io/get-pip.py 35 | 36 | - name: pip 37 | command: python /tmp/get_pip.py creates=/usr/local/bin/pip 38 | become: yes 39 | 40 | - name: docker-py 41 | # FIXME: can't upgrade to docker-py 1.10 until ansible 2.2 42 | pip: name=docker-py version=1.10.* 43 | become: yes 44 | -------------------------------------------------------------------------------- /roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: install vim, ruby for ops sanity 3 | apt: update_cache=yes cache_valid_time=3600 name={{ item }} state=latest 4 | with_items: 5 | - vim 6 | - ruby 7 | become: yes 8 | 9 | - name: install rmate 10 | gem: name=rmate user_install=no state=latest 11 | become: yes 12 | 13 | - name: set apt yes default 14 | copy: src=99-apt-yes dest=/etc/apt/apt.conf.d/99-apt-yes 15 | become: yes 16 | 17 | - name: YOLO system upgrade 18 | apt: update_cache=yes cache_valid_time=3600 upgrade=safe 19 | become: yes 20 | 21 | - name: sshd configuration 22 | copy: src=sshd_config dest=/etc/ssh/sshd_config mode=0644 23 | become: yes 24 | notify: 25 | - restart sshd 26 | 27 | - name: directory to hold public keys 28 | file: state=directory path=/tmp/pubkeys mode=0755 29 | 30 | - name: fetch public keys from github 31 | get_url: dest=/tmp/pubkeys/{{ item }}-pubkeys url=https://github.com/{{ item }}.keys 32 | with_items: "{{ github_usernames }}" 33 | 34 | - name: assemble the authorized keys file 35 | assemble: dest=/root/.ssh/authorized_keys mode=0600 src=/tmp/pubkeys 36 | become: yes 37 | 38 | - include_tasks: docker.yml 39 | -------------------------------------------------------------------------------- /roles/notebook/files/docker: -------------------------------------------------------------------------------- 1 | # Docker init file 2 | 3 | # Turn off networking for userland containers by default 4 | DOCKER_OPTS="--icc=false --ip-forward=false" 5 | -------------------------------------------------------------------------------- /roles/notebook/tasks/docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: install docker configuration 3 | copy: src=docker dest=/etc/default/docker mode=0644 4 | become: yes 5 | register: dockerconf 6 | 7 | # This *shouldn't* be necessary because of the DOCKER_OPTS we set. 8 | # Just for good measure, though. 9 | - name: disable IP forwarding 10 | shell: echo 0 > /proc/sys/net/ipv4/ip_forward 11 | become: yes 12 | 13 | - name: restart docker 14 | service: name=docker state=restarted 15 | become: yes 16 | when: dockerconf | changed 17 | -------------------------------------------------------------------------------- /roles/notebook/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: tweak grub settings 3 | lineinfile: 4 | dest: /etc/default/grub 5 | regexp: GRUB_CMDLINE_LINUX=.* 6 | line: GRUB_CMDLINE_LINUX="cgroup_enable=memory swapaccount=1" 7 | register: grubbed 8 | become: yes 9 | 10 | - name: apply changed grub settings 11 | command: update-grub 12 | become: yes 13 | when: grubbed | changed 14 | 15 | - name: reboot 16 | command: shutdown -r now "Ansible updated grub" 17 | async: 0 18 | poll: 0 19 | become: yes 20 | ignore_errors: true 21 | when: grubbed | changed 22 | 23 | - name: wait for server to relaunch 24 | local_action: wait_for host={{ inventory_hostname }} state=started 25 | when: grubbed | changed 26 | 27 | - include_tasks: docker.yml 28 | 29 | - name: pull tmpnb service images 30 | command: docker pull {{ item }} 31 | with_items: 32 | - jupyterhub/configurable-http-proxy 33 | - jupyter/tmpnb 34 | become: yes 35 | 36 | - name: pull docker image for user containers 37 | command: docker pull {{ tmpnb_image }} 38 | become: yes 39 | 40 | 41 | - name: iptables configuration 42 | command: iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to 8000 43 | become: yes 44 | 45 | - name: configproxy 46 | docker_container: 47 | state: started 48 | pull: true 49 | image: jupyterhub/configurable-http-proxy 50 | network_mode: host 51 | name: configproxy 52 | restart_policy: always 53 | env: 54 | CONFIGPROXY_AUTH_TOKEN: "{{ configproxy_auth_token }}" 55 | command: > 56 | --default-target http://127.0.0.1:9999 57 | --ip="{{ notebook_host }}" 58 | --api-ip="127.0.0.1" 59 | 60 | - name: tmpnb 61 | docker_container: 62 | state: started 63 | pull: true 64 | image: jupyter/tmpnb 65 | network_mode: host 66 | name: tmpnb 67 | restart_policy: always 68 | env: 69 | CONFIGPROXY_AUTH_TOKEN: "{{ configproxy_auth_token }}" 70 | volumes: /var/run/docker.sock:/docker.sock 71 | command: > 72 | python orchestrate.py 73 | --pool_size={{ tmpnb_pool_size }} 74 | --cull_timeout={{ tmpnb_cull_timeout }} 75 | --cull_period={{ tmpnb_cull_period }} 76 | --image={{ tmpnb_image }} 77 | --docker_version={{ tmpnb_docker_version }} 78 | --redirect_uri={{ tmpnb_redirect_uri }} 79 | --command='{{ tmpnb_command }}' 80 | --max_dock_workers={{ tmpnb_max_dock_workers }} 81 | --mem-limit={{ tmpnb_mem_limit }} 82 | --cpu-shares={{ tmpnb_cpu_shares }} 83 | --cpu-quota={{ tmpnb_cpu_quota }} 84 | --ip="127.0.0.1" 85 | --allow-origin='*' 86 | --allow-methods='GET, PUT, POST, PATCH, DELETE, OPTIONS' 87 | --allow-headers='Content-Type' 88 | -------------------------------------------------------------------------------- /roles/proxy/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | nginx_config_dir: /etc/nginx 3 | nginx_volumes: 4 | - "/etc/letsencrypt:/etc/letsencrypt:ro" 5 | nginx_ports: 6 | - 80:80 7 | - 443:443 8 | -------------------------------------------------------------------------------- /roles/proxy/files/letsencrypt-renew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | date >> /var/log/letsencrypt.log 6 | 7 | webroot=/etc/letsencrypt/webroot 8 | test -d "$webroot" || mkdir "$webroot" 9 | certbot-auto renew --webroot --webroot-path="$webroot" --no-self-upgrade 2>&1 &>> /var/log/letsencrypt.log 10 | 11 | docker kill -s HUP nginx 12 | -------------------------------------------------------------------------------- /roles/proxy/handlers/main.yml: -------------------------------------------------------------------------------- 1 | - name: reload nginx configuration 2 | command: docker kill -s HUP nginx 3 | become: yes 4 | -------------------------------------------------------------------------------- /roles/proxy/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: configuration directories 3 | file: state=directory dest={{ item }} mode=0755 4 | with_items: 5 | - "{{ nginx_config_dir }}" 6 | become: yes 7 | 8 | - name: install certbot (letsencrypt) 9 | get_url: 10 | url: https://dl.eff.org/certbot-auto 11 | dest: /usr/local/bin/certbot-auto 12 | mode: 755 13 | 14 | - name: SSL credentials with certbot (letsencrypt) 15 | command: /usr/local/bin/certbot-auto certonly --agree-tos --standalone -m {{ letsencrypt_email }} -d {{ inventory_hostname }} creates=/etc/letsencrypt/live/{{ inventory_hostname }}/fullchain.pem 16 | become: yes 17 | notify: 18 | - reload nginx configuration 19 | 20 | - name: Setup letsencrypt renewal with cron 21 | copy: src=letsencrypt-renew dest=/etc/cron.daily/letsencrypt-renew mode=0755 22 | become: yes 23 | 24 | - name: nginx configuration 25 | template: src=nginx.conf.j2 dest={{ nginx_config_dir }}/nginx.conf mode=0644 26 | become: yes 27 | notify: 28 | - reload nginx configuration 29 | 30 | - name: copy the Dockerfile to the nginx configuration directory 31 | template: src=Dockerfile_nginx.j2 dest={{ nginx_config_dir }}/Dockerfile mode=0644 32 | become: yes 33 | 34 | - name: build tmpnb nginx image 35 | docker_image: 36 | path: "{{ nginx_config_dir }}" 37 | name: tmpnb_nginx 38 | state: present 39 | become: yes 40 | 41 | - name: launch nginx 42 | docker_container: 43 | image: tmpnb_nginx 44 | state: started 45 | name: nginx 46 | volumes: "{{ nginx_volumes }}" 47 | ports: "{{ nginx_ports }}" 48 | restart_policy: always 49 | -------------------------------------------------------------------------------- /roles/proxy/templates/Dockerfile_nginx.j2: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | ADD nginx.conf /etc/nginx/nginx.conf 3 | -------------------------------------------------------------------------------- /roles/proxy/templates/nginx.conf.j2: -------------------------------------------------------------------------------- 1 | # {{ ansible_managed }} 2 | 3 | worker_processes {{ ansible_processor_count*2 }}; 4 | 5 | events { 6 | worker_connections 1024; 7 | } 8 | 9 | http { 10 | 11 | include /etc/nginx/mime.types; 12 | default_type application/octet-stream; 13 | 14 | server { 15 | listen 80; 16 | server_name {{ inventory_hostname }}; 17 | rewrite ^ https://$host$request_uri? permanent; 18 | } 19 | 20 | server { 21 | listen 443; 22 | 23 | client_max_body_size 50M; 24 | 25 | server_name {{ inventory_hostname }}; 26 | 27 | ssl on; 28 | ssl_certificate /etc/letsencrypt/live/{{ inventory_hostname }}/fullchain.pem; 29 | ssl_certificate_key /etc/letsencrypt/live/{{ inventory_hostname }}/privkey.pem; 30 | 31 | ssl_ciphers "AES128+EECDH:AES128+EDH"; 32 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 33 | ssl_prefer_server_ciphers on; 34 | ssl_session_cache shared:SSL:10m; 35 | add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; 36 | add_header X-Content-Type-Options nosniff; 37 | ssl_stapling on; # Requires nginx >= 1.3.7 38 | ssl_stapling_verify on; # Requires nginx => 1.3.7 39 | resolver_timeout 5s; 40 | 41 | # letsencrypt authorization area 42 | location /.well-known/ { 43 | alias /etc/letsencrypt/webroot/.well-known/; 44 | } 45 | 46 | # Expose logs to "docker logs". 47 | # See https://github.com/nginxinc/docker-nginx/blob/master/Dockerfile#L12-L14 48 | access_log /var/log/nginx/access.log; 49 | error_log /var/log/nginx/error.log; 50 | 51 | location ~ /(user[-/][a-zA-Z0-9]*)/static/(.*) { 52 | proxy_pass https://cdn.jupyter.org/notebook/try-4.0.4/$2; 53 | } 54 | 55 | location / { 56 | proxy_pass http://{{ notebook_host }}:8000; 57 | 58 | proxy_set_header X-Real-IP $remote_addr; 59 | proxy_set_header Host $host; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | 62 | proxy_set_header X-NginX-Proxy true; 63 | } 64 | 65 | location ~* /(user[-/][a-zA-Z0-9]*)/(api/kernels/[^/]+/(channels|iopub|shell|stdin)|terminals/websocket)/? { 66 | proxy_pass http://{{ notebook_host }}:8000; 67 | 68 | proxy_set_header X-Real-IP $remote_addr; 69 | proxy_set_header Host $host; 70 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 71 | 72 | proxy_set_header X-NginX-Proxy true; 73 | 74 | # WebSocket support 75 | proxy_http_version 1.1; 76 | proxy_set_header Upgrade $http_upgrade; 77 | proxy_set_header Connection "upgrade"; 78 | proxy_read_timeout 86400; 79 | 80 | } 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /script/add-redirect: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT=$(dirname $0)/.. 4 | 5 | n=$1 6 | 7 | if [ -z "$n" ]; then 8 | echo 'specify node number' 9 | exit -1 10 | fi 11 | 12 | HOST=https://tmp$n.tmpnb.org 13 | 14 | exec ansible -i "$ROOT/inventory.redirector" redirector -m shell -a "bash tmpnb-redirector/add_hosts.sh $HOST" 15 | -------------------------------------------------------------------------------- /script/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | ROOT=$(dirname $0)/.. 5 | INVENTORY=${INVENTORY:-${ROOT}/inventory} 6 | 7 | if [ ! -e ${INVENTORY} ]; then 8 | echo "Please create an inventory file with your hosts." 9 | echo " cp inventory.example inventory" 10 | exit 1 11 | fi 12 | 13 | if [ -e ${ROOT}/secrets.yml ]; then 14 | VAULT_ARG= 15 | else 16 | VAULT_ARG=--ask-vault-pass 17 | fi 18 | 19 | exec ansible-playbook ${ROOT}/site.yml -i ${INVENTORY} ${VAULT_ARG} $@ 20 | -------------------------------------------------------------------------------- /script/drop-redirect: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT=$(dirname $0)/.. 4 | 5 | n=$1 6 | 7 | if [ -z "$n" ]; then 8 | echo 'specify node number' 9 | exit -1 10 | fi 11 | 12 | HOST=https://tmp$n.tmpnb.org 13 | 14 | exec ansible -i "$ROOT/inventory.redirector" redirector -m shell -a "bash tmpnb-redirector/drop_hosts.sh $HOST" 15 | -------------------------------------------------------------------------------- /script/image-clean: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT=$(dirname $0)/.. 4 | 5 | if [ ! -e ${ROOT}//inventory ]; then 6 | echo "Please create an inventory file with your hosts." 7 | echo " cp inventory.example inventory" 8 | exit 1 9 | fi 10 | 11 | exec ansible notebook -m shell -a 'docker images -f "dangling=true" -q | xargs docker rmi' -i ${ROOT}/inventory 12 | -------------------------------------------------------------------------------- /script/image-update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | ROOT=$(dirname $0)/.. 5 | INVENTORY=${INVENTORY:-${ROOT}/inventory} 6 | 7 | if [ ! -e ${INVENTORY} ]; then 8 | echo "Please create an inventory file with your hosts." 9 | echo " cp inventory.example inventory" 10 | exit 1 11 | fi 12 | 13 | if [ -e ${ROOT}/secrets.yml ]; then 14 | VAULT_ARG= 15 | else 16 | VAULT_ARG=--ask-vault-pass 17 | fi 18 | 19 | exec ansible-playbook ${ROOT}/update.yml -i ${INVENTORY} ${VAULT_ARG} $@ 20 | -------------------------------------------------------------------------------- /script/launch-statuspage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | docker pull rgbkrk/tmpnb-statuspage 5 | 6 | for app in tmpnb; do 7 | docker run --restart=always -d --env-file statuspage-env --env-file statuspage-$app-env --name statuspage-$app rgbkrk/tmpnb-statuspage 8 | done 9 | -------------------------------------------------------------------------------- /script/launch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | ''' 4 | This script requires 4 environment variables to be declared: 5 | 6 | OS_USERNAME - Rackspace user for account that servers will be launched on 7 | OS_PASSWORD - API Key for the server launch user 8 | 9 | CF_API_KEY - CloudFlare API key 10 | CF_EMAIL - CloudFlare email address 11 | 12 | OS_DNS_USERNAME - Rackspace user with the tmpnb.org domain 13 | OS_DNS_PASSWORD - API key for the DNS user 14 | 15 | Then to run, you specify which node number we're creating like demo-iad-001.tmpnb.org 16 | 17 | python script/launch.py 10 18 | 19 | The Ansible inventory file is spat out to stdout at the end. 20 | ''' 21 | 22 | import binascii 23 | import json 24 | import os 25 | import time 26 | 27 | from rackspace.connection import Connection 28 | import requests 29 | 30 | 31 | CF_API_URL = 'https://api.cloudflare.com/client/v4/' 32 | 33 | 34 | def cf_get_zone_id(s, domain): 35 | """Get cloudflare zone id""" 36 | r = s.get(CF_API_URL + 'zones?name=%s' % domain) 37 | r.raise_for_status() 38 | return r.json()['result'][0]['id'] 39 | 40 | 41 | def get_dns(s, zone_id): 42 | r = s.get(CF_API_URL + 'zones/%s/dns_records' % zone_id) 43 | r.raise_for_status() 44 | records = {} 45 | for res in r.json()['result']: 46 | records[res['name']] = res 47 | return records 48 | 49 | 50 | def add_dns(name, ipv4): 51 | """Add DNS record with cloudflare""" 52 | s = requests.session() 53 | s.headers = { 54 | 'X-Auth-Key': os.environ['CF_API_KEY'], 55 | 'X-Auth-Email': os.environ['CF_EMAIL'], 56 | } 57 | domain = '.'.join(name.split('.')[-2:]) 58 | print(domain) 59 | zone_id = cf_get_zone_id(s, domain) 60 | r = s.post(CF_API_URL + 'zones/%s/dns_records' % zone_id, data=json.dumps({ 61 | 'type': 'A', 62 | 'name': name, 63 | 'content': ipv4, 64 | })) 65 | r.raise_for_status() 66 | 67 | 68 | def name_new_nodes(prefix="demo", region="dfw", node_num=1, domain="tmpnb.org"): 69 | # The naming problem 70 | #node_naming_scheme = "{prefix}-{region}-{node_num:03}" 71 | node_naming_scheme = "{prefix}{node_num:02}" 72 | node_base_name = node_naming_scheme.format(**locals()) 73 | 74 | user_server_name = node_base_name + "-user" + "." + domain 75 | proxy_server_name = node_base_name + "." + domain 76 | 77 | return user_server_name, proxy_server_name 78 | 79 | 80 | def print_server_status(server): 81 | print("{name} {status} progress={progress}".format( 82 | name=server.name, 83 | status=server.status, 84 | progress=server.progress, 85 | )) 86 | 87 | 88 | def wait_for_server(compute, server, timeout=600, interval=10): 89 | # rackspacesdk wait_for_server doesn't work! 90 | tic = time.monotonic() 91 | while time.monotonic() - tic < timeout and server.status != 'ACTIVE': 92 | print_server_status(server) 93 | time.sleep(interval) 94 | server = list(compute.servers(name=server.name))[0] 95 | 96 | if server.status != 'ACTIVE': 97 | raise TimeoutError("{name} is still {status}".format( 98 | name=server.name, 99 | status=server.status)) 100 | print_server_status(server) 101 | return server 102 | 103 | 104 | def launch_node(prefix="demo", region="dfw", node_num=1, domain="tmpnb.org"): 105 | key_name = "main" 106 | 107 | rs = Connection( 108 | username=os.environ['OS_USERNAME'], 109 | api_key=os.environ['OS_PASSWORD'], 110 | region=region.upper(), 111 | ) 112 | 113 | 114 | compute = rs.compute 115 | 116 | 117 | # Get our base images 118 | images = compute.images() 119 | ubs = [image for image in images if "Ubuntu 14.04" in image.name] 120 | user_image = [image for image in ubs if "OnMetal" in image.name][0] 121 | proxy_image = [image for image in ubs if "PVHVM" in image.name][0] 122 | # Get our flavors 123 | flavors = list(compute.flavors()) 124 | proxy_flavor = [flavor for flavor in flavors if flavor.ram == 8192 and "General Purpose" in flavor.name][0] 125 | user_flavor = [flavor for flavor in flavors if "OnMetal" in flavor.name and "Medium" in flavor.name][0] 126 | print("Proxy: %s" % proxy_flavor.name) 127 | print(" User: %s" % user_flavor.name) 128 | 129 | user_server_name, proxy_server_name = name_new_nodes(prefix=prefix, 130 | region=region.lower(), 131 | node_num=node_num, 132 | domain=domain) 133 | 134 | # Launch the servers 135 | try: 136 | user_server = next(iter(compute.servers(name=user_server_name))) 137 | except StopIteration: 138 | user_server = compute.create_server(name=user_server_name, imageRef=user_image.id, flavorRef=user_flavor.id, key_name=key_name) 139 | else: 140 | print("User server %s already started" % user_server_name) 141 | 142 | try: 143 | proxy_server = next(iter(compute.servers(name=proxy_server_name))) 144 | except StopIteration: 145 | proxy_server = compute.create_server(name=proxy_server_name, imageRef=proxy_image.id, flavorRef=proxy_flavor.id, key_name=key_name) 146 | else: 147 | print("Proxy server %s already started" % proxy_server_name) 148 | 149 | # Wait on them 150 | print("Waiting on Proxy server") 151 | proxy_server = wait_for_server(compute, proxy_server) 152 | print("Waiting on Notebook User server") 153 | user_server = wait_for_server(compute, user_server) 154 | 155 | # create ping alarms 156 | ping_alarm(proxy_server) 157 | ping_alarm(user_server) 158 | 159 | inventory = '''[notebook] 160 | {user_server_name} ansible_ssh_user=root ansible_ssh_host={notebook_server_public} configproxy_auth_token={token} notebook_host={notebook_server_private} 161 | 162 | [proxy] 163 | {proxy_server_name} ansible_ssh_user=root ansible_ssh_host={proxy_server_public} notebook_host={notebook_server_private} 164 | '''.format(notebook_server_public=user_server.access_ipv4, 165 | notebook_server_private=user_server.addresses['private'][0]['addr'], 166 | proxy_server_public=proxy_server.access_ipv4, 167 | token=binascii.hexlify(os.urandom(24)).decode('ascii'), 168 | user_server_name=user_server_name, 169 | proxy_server_name=proxy_server_name, 170 | ) 171 | 172 | inventory_name = 'inventory.%i' % node_num 173 | with open(inventory_name, 'w') as f: 174 | f.write(inventory) 175 | 176 | print("Deploy tmpnb on this node with with:") 177 | print(" INVENTORY=%s ./script/deploy" % inventory_name) 178 | 179 | add_dns(proxy_server_name, proxy_server.access_ipv4) 180 | 181 | 182 | PING_ALARM_CRITERIA = """ 183 | if (metric['available'] < 20) { 184 | return new AlarmStatus(CRITICAL, 'Host appears to be unreachable'); 185 | } 186 | 187 | return new AlarmStatus(OK, 'Packet loss is normal'); 188 | """ 189 | 190 | def ping_alarm(server): 191 | """Add a ping alarm, so we get emails whenever a node appears to go down.""" 192 | from rackspace_monitoring.providers import get_driver 193 | from rackspace_monitoring.types import Provider 194 | RaxMon = get_driver(Provider.RACKSPACE) 195 | cm = RaxMon(os.environ['OS_USERNAME'], os.environ['OS_PASSWORD']) 196 | notification_plan = cm.list_notification_plans()[0] 197 | # get monitoring entities 198 | 199 | # get ping check 200 | pings = [] 201 | while not pings: 202 | entities = [ e for e in cm.list_entities() if e.label == server.name ] 203 | pings = [ e for e in entities if cm.list_checks(e) and 'ping' in cm.list_checks(e)[0].label.lower() ] 204 | if not pings: 205 | print('waiting for ping check to be registered') 206 | print([cm.list_checks(e)[0].label.lower() for e in entities]) 207 | time.sleep(1) 208 | ping = pings[0] 209 | ping_check = cm.list_checks(ping)[0] 210 | 211 | cm.create_alarm(ping, 212 | check_id=ping_check.id, 213 | notification_plan_id=notification_plan.id, 214 | criteria=PING_ALARM_CRITERIA, 215 | label='ping') 216 | 217 | 218 | if __name__ == "__main__": 219 | import argparse 220 | 221 | parser = argparse.ArgumentParser(description='Launch nodes for tmpnb') 222 | 223 | parser.add_argument('--prefix', type=str, default='tmp', 224 | help='prefix in the URL base') 225 | parser.add_argument('--region', type=str, default='dfw', 226 | help='region to deploy to, also part of the domain name') 227 | parser.add_argument('node_num', type=int, 228 | help='what this set of servers will be identified as numerically') 229 | parser.add_argument('--domain', type=str, default="tmpnb.org", 230 | help='domain to host the servers on') 231 | 232 | args = parser.parse_args() 233 | launch_node(prefix=args.prefix, 234 | region=args.region, 235 | node_num=args.node_num, 236 | domain=args.domain 237 | ) 238 | 239 | -------------------------------------------------------------------------------- /script/new-instance: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Bring up a complete node from scratch 3 | # 1. create node with launch.py 4 | # 2. add node to redirector with add-redirect 5 | # 3. deploy node with deploy 6 | 7 | set -eu 8 | 9 | SCRIPT="$(dirname $0)" 10 | ROOT="$(dirname $0)/.." 11 | 12 | n=$1 13 | 14 | if [ -z "$n" ]; then 15 | echo 'specify $n' 16 | exit -1 17 | fi 18 | set -x 19 | 20 | "$SCRIPT/launch.py" $n 21 | "$SCRIPT/add-redirect" $n 22 | 23 | export INVENTORY="$ROOT/inventory.$n" 24 | 25 | # disable initial host-key check for new hosts 26 | export ANSIBLE_HOST_KEY_CHECKING=False 27 | 28 | # wait for nodes to respond to ssh: 29 | while true; do 30 | ansible -i "$INVENTORY" all -m ping && break || sleep 5 31 | done 32 | 33 | # deploy 34 | "$SCRIPT/deploy" 35 | -------------------------------------------------------------------------------- /script/reboot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | """ 3 | Reboot user nodes by id 4 | 5 | Usage: 6 | 7 | ./script/reboot 50 [51] [52] 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | import pyrax 14 | 15 | region = 'DFW' 16 | 17 | def reboot_node(n): 18 | name = 'tmp%s-user.tmpnb.org' % n 19 | cs = pyrax.connect_to_cloudservers(region=region) 20 | print("Rebooting %s" % name) 21 | server = cs.servers.find(name=name) 22 | server.reboot() 23 | 24 | def login(): 25 | pyrax.set_setting("identity_type", "rackspace") 26 | pyrax.set_credentials(os.environ["OS_USERNAME"], os.environ["OS_PASSWORD"]) 27 | 28 | 29 | if __name__ == '__main__': 30 | login() 31 | for n in sys.argv[1:]: 32 | reboot_node(n) 33 | -------------------------------------------------------------------------------- /site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: notebook 3 | vars_files: 4 | - ['vars.local.yml', 'vars.yml'] 5 | roles: 6 | - common 7 | - notebook 8 | 9 | - hosts: proxy 10 | vars_files: 11 | - ['vars.local.yml', 'vars.yml'] 12 | roles: 13 | - common 14 | - proxy 15 | -------------------------------------------------------------------------------- /statuspage-tmpnb-env: -------------------------------------------------------------------------------- 1 | STATUS_PAGE_PAGE_ID=fzcq6v7wcg65 2 | STATUS_PAGE_TMPNB_METRIC_ID=9js6q92b8ltx 3 | TMPNB_STATS_ENDPOINT=https://tmpnb.org/stats 4 | TMPNB_STATS_PERIOD=60 5 | -------------------------------------------------------------------------------- /update.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # re-run notebook deploy 3 | # assumes machine is already setup and running 4 | - hosts: notebook 5 | vars_files: 6 | - ['vars.local.yml', 'vars.yml'] 7 | roles: 8 | - notebook 9 | 10 | -------------------------------------------------------------------------------- /vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Override these values by copying to vars.local.yml. 3 | 4 | # Configuration parameters for containers. 5 | 6 | letsencrypt_email: benjaminrk@gmail.com 7 | 8 | tmpnb_cull_timeout: 120 9 | tmpnb_cull_period: 400 10 | tmpnb_image: jupyter/demo:c7fb6660d096 11 | tmpnb_redirect_uri: /tree 12 | tmpnb_command: start-notebook.sh "--NotebookApp.base_url={base_path} --NotebookApp.allow_origin='*' --port={port} --NotebookApp.token=" 13 | tmpnb_max_dock_workers: 8 14 | tmpnb_docker_version: "auto" 15 | 16 | tmpnb_pool_size: 64 17 | tmpnb_cpu_shares: 32 # should be 16 18 | tmpnb_cpu_quota: 200000 # 100 000 = 1 CPU per container 19 | tmpnb_mem_limit: "2g" 20 | 21 | # GitHub users to grab public keys from 22 | 23 | github_usernames: 24 | - rgbkrk 25 | - smashwilson 26 | - minrk 27 | - captainsafia 28 | - betatim 29 | - Carreau 30 | - takluyver 31 | - ellisonbg 32 | - fperez 33 | 34 | --------------------------------------------------------------------------------