├── .gitignore ├── Dockerfile ├── HACKING ├── LICENSE ├── Makefile ├── README.md ├── alembic.ini ├── deploy ├── do-upgrade ├── moxie ├── moxie.service ├── moxie.sh ├── moxied.service └── upgrade.sh ├── docs └── INSTALL.aws.md ├── eg ├── crank.yaml ├── manual.yaml ├── scrapers-state.yaml └── users.yaml ├── maintainer └── test.py ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── .gitkeep │ ├── 15081fbf2e7_add_in_links.py │ ├── 2268d56413c_add_link_to_job.py │ ├── 2acbda1c1db_deal_with_stupid_sqlalchemy_bullshit.py │ ├── 2c5e2bf19a6_.py │ ├── 2cce33d3a10_add_trigger.py │ ├── 312c66e887b_.py │ ├── 344e26c7948_cron_ify_job_scheduling.py │ ├── 389e540c827_add_entrypoint.py │ ├── 43b2a245dba_.py │ ├── 4e54504ba21_.py │ └── f450aba2db_.py ├── moxie ├── __init__.py ├── alerts │ ├── __init__.py │ ├── email.py │ └── slack.py ├── app.py ├── butterfield.py ├── cli.py ├── core.py ├── cores │ ├── __init__.py │ ├── alert.py │ ├── container.py │ ├── cron.py │ ├── database.py │ ├── log.py │ ├── reap.py │ ├── run.py │ └── ssh.py ├── facts.py ├── models.py └── server.py ├── requirements.txt ├── scripts ├── Makefile ├── bootstrap │ ├── Makefile │ ├── css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.css.map │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ └── bootstrap.min.css │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ └── js │ │ ├── bootstrap.js │ │ └── bootstrap.min.js ├── coffee │ ├── .gitignore │ ├── Makefile │ └── container.coffee ├── less │ ├── .gitignore │ ├── Makefile │ ├── container.less │ └── theme.less └── vendor │ ├── Makefile │ └── js │ ├── jquery.min.js │ └── term.js ├── setup.py └── templates ├── 404.html ├── 500.html ├── bare.html ├── base.html ├── cast.html ├── container.html ├── container.offline.html ├── emails └── failure.email ├── fragments ├── jobs.html └── runs.html ├── interface.html ├── job.html ├── jobs.html ├── maintainer.html ├── maintainers.html ├── overview.html ├── run.html └── tag.html /.gitignore: -------------------------------------------------------------------------------- 1 | *pyc 2 | *swp 3 | *moxie*egg* 4 | static 5 | keys 6 | ssh_host_keys 7 | authorized_keys 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # VERSION 0.1 2 | FROM debian:jessie 3 | MAINTAINER Paul R. Tagliamonte 4 | 5 | ENV DEBIAN_FRONTEND noninteractive 6 | 7 | # add deb-src entries 8 | RUN find /etc/apt/sources.list* -type f -exec sed -i 'p; s/^deb /deb-src /' '{}' + 9 | 10 | RUN apt-get update && apt-get install -y \ 11 | python3.4 \ 12 | python3-pip \ 13 | git \ 14 | node-uglify \ 15 | node-less \ 16 | coffeescript \ 17 | locales-all \ 18 | libssl-dev \ 19 | libffi-dev \ 20 | python-dev 21 | 22 | ENV LANG en_US.UTF-8 23 | ENV LANGUAGE en_US:en 24 | ENV LC_ALL en_US.UTF-8 25 | ENV PYTHONIOENCODING utf-8 26 | 27 | RUN apt-get update && apt-get build-dep -y python3-psycopg2 28 | 29 | RUN mkdir -p /opt/pault.ag/ 30 | ADD . /opt/pault.ag/moxie/ 31 | 32 | RUN cd /opt/pault.ag/moxie; python3.4 /usr/bin/pip3 install \ 33 | slacker websockets aiohttp 34 | # Hurm. Why? 35 | 36 | RUN cd /opt/pault.ag/moxie; python3.4 /usr/bin/pip3 install -r \ 37 | requirements.txt 38 | 39 | RUN python3.4 /usr/bin/pip3 install -e \ 40 | /opt/pault.ag/moxie/ 41 | 42 | RUN make -C /opt/pault.ag/moxie/ 43 | 44 | RUN mkdir -p /moxie/ 45 | WORKDIR /moxie/ 46 | 47 | CMD ["moxied"] 48 | -------------------------------------------------------------------------------- /HACKING: -------------------------------------------------------------------------------- 1 | Quick setup 2 | =========== 3 | 4 | Enter your virtualenv: 5 | 6 | $ sudo apt-get install coffeescript node-uglify node-less 7 | $ make 8 | $ pip install -r requirements.txt 9 | $ mkdir keys 10 | $ cd keys 11 | $ ssh-keygen -f key 12 | $ cd .. 13 | $ ln -s keys/key ssh_host_keys 14 | $ touch authorized_keys 15 | $ sudo su postgres -c psql 16 | postgres=# CREATE ROLE moxie WITH LOGIN PASSWORD 'moxie'; 17 | CREATE ROLE 18 | postgres=# CREATE DATABASE moxie OWNER moxie; 19 | CREATE DATABASE 20 | $ moxie-init 21 | $ moxie-load eg/manual.yaml 22 | $ moxied 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the "Software"), 3 | to deal in the Software without restriction, including without limitation 4 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 5 | and/or sell copies of the Software, and to permit persons to whom the 6 | Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 14 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 16 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 17 | DEALINGS IN THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean build 2 | 3 | clean: 4 | rm -rf static 5 | 6 | build: clean 7 | mkdir static 8 | mkdir -p static/js static/css static/fonts 9 | make -C scripts build 10 | make -C scripts install 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Moxie 2 | ----- 3 | 4 | Long-running periodic job scheduler and runner. 5 | 6 | ![](https://i.imgur.com/AgpYk5I.png) 7 | 8 | ![](https://i.imgur.com/Y7kUvlv.png) 9 | 10 | ![](https://i.imgur.com/iASDv7n.png) 11 | 12 | ![](https://i.imgur.com/roz0sGi.png) 13 | 14 | ![](https://i.imgur.com/BSDcPrp.jpg) 15 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # max length of characters to apply to the 11 | # "slug" field 12 | #truncate_slug_length = 40 13 | 14 | # set to 'true' to run the environment during 15 | # the 'revision' command, regardless of autogenerate 16 | # revision_environment = false 17 | 18 | # set to 'true' to allow .pyc and .pyo files without 19 | # a source .py file to be detected as revisions in the 20 | # versions/ directory 21 | # sourceless = false 22 | 23 | sqlalchemy.url = postgresql://moxie:moxie@localhost:5432/moxie 24 | 25 | 26 | # Logging configuration 27 | [loggers] 28 | keys = root,sqlalchemy,alembic 29 | 30 | [handlers] 31 | keys = console 32 | 33 | [formatters] 34 | keys = generic 35 | 36 | [logger_root] 37 | level = WARN 38 | handlers = console 39 | qualname = 40 | 41 | [logger_sqlalchemy] 42 | level = WARN 43 | handlers = 44 | qualname = sqlalchemy.engine 45 | 46 | [logger_alembic] 47 | level = INFO 48 | handlers = 49 | qualname = alembic 50 | 51 | [handler_console] 52 | class = StreamHandler 53 | args = (sys.stderr,) 54 | level = NOTSET 55 | formatter = generic 56 | 57 | [formatter_generic] 58 | format = %(levelname)-5.5s [%(name)s] %(message)s 59 | datefmt = %H:%M:%S 60 | -------------------------------------------------------------------------------- /deploy/do-upgrade: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ssh -t lucifer.pault.ag git -C ~tag/projects/moxie pull 4 | ssh -t lucifer.pault.ag ~tag/projects/moxie/deploy/upgrade.sh 5 | -------------------------------------------------------------------------------- /deploy/moxie: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source /etc/docker/moxie.sh 3 | 4 | docker run --rm -it \ 5 | --privileged=true \ 6 | --link postgres:postgres \ 7 | -v /run/docker.sock:/run/docker.sock \ 8 | -v /srv/lucifer.pault.ag/prod/moxie:/moxie \ 9 | -e DATABASE_URL=${DATABASE_URL} \ 10 | paultag/moxie \ 11 | bash 12 | -------------------------------------------------------------------------------- /deploy/moxie.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Moxie web interface 3 | Author=Paul R. Tagliamonte 4 | Requires=docker.io.service 5 | After=postgres.service 6 | 7 | [Service] 8 | Restart=always 9 | ExecStart=/bin/bash -c '/usr/bin/docker start -a moxie || \ 10 | /usr/bin/docker run \ 11 | --name moxie \ 12 | --privileged=true \ 13 | --link postgres:postgres \ 14 | -v /run/docker.sock:/run/docker.sock \ 15 | -v /srv/lucifer.pault.ag/prod/moxie:/moxie \ 16 | -v /srv/lucifer.pault.ag/prod/nginx/serve/sockets:/sockets \ 17 | -e DATABASE_URL=${DATABASE_URL} \ 18 | -e MOXIE_SOCKET=${MOXIE_SOCKET} \ 19 | paultag/moxie \ 20 | moxie-serve' 21 | ExecStop=/usr/bin/docker stop -t 5 moxie 22 | EnvironmentFile=/etc/docker/moxie.sh 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | -------------------------------------------------------------------------------- /deploy/moxie.sh: -------------------------------------------------------------------------------- 1 | # Config for the Moxie service. 2 | 3 | DATABASE_URL=postgres://moxie:moxie@postgres:5432/moxie 4 | 5 | # MOXIE_HOST="0.0.0.0" 6 | # MOXIE_PORT="8888" 7 | 8 | MOXIE_SOCKET="/sockets/moxie.sock" 9 | -------------------------------------------------------------------------------- /deploy/moxied.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Moxie Daemon 3 | Author=Paul R. Tagliamonte 4 | Requires=docker.io.service 5 | After=postgres.service 6 | 7 | [Service] 8 | Restart=always 9 | ExecStart=/bin/bash -c '/usr/bin/docker start -a moxied || \ 10 | /usr/bin/docker run \ 11 | --name moxied \ 12 | --privileged=true \ 13 | --link postgres:postgres \ 14 | -v /run/docker.sock:/run/docker.sock \ 15 | -e DATABASE_URL=${DATABASE_URL} \ 16 | paultag/moxie \ 17 | moxied' 18 | ExecStop=/usr/bin/docker stop -t 5 moxied 19 | EnvironmentFile=/etc/docker/moxie.sh 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | 24 | -------------------------------------------------------------------------------- /deploy/upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source /etc/docker/moxie.sh 4 | 5 | function moxierun { 6 | docker run --rm -it \ 7 | --link postgres:postgres \ 8 | -v /srv/lucifer.pault.ag/prod/moxie:/moxie \ 9 | -e DATABASE_URL=${DATABASE_URL} \ 10 | paultag/moxie \ 11 | $@ 12 | } 13 | 14 | cd ~tag/projects/moxie 15 | git pull 16 | docker build --rm -t paultag/moxie . 17 | 18 | sudo service moxie stop 19 | sudo service moxied stop 20 | 21 | moxierun alembic -x sqlalchemy.url=${DATABASE_URL} upgrade head 22 | 23 | docker rm moxie 24 | docker rm moxied 25 | 26 | sudo service moxie start 27 | sudo service moxied start 28 | sudo service nginx restart 29 | -------------------------------------------------------------------------------- /docs/INSTALL.aws.md: -------------------------------------------------------------------------------- 1 | Installation for AWS Debian Jessie 2 | ================================== 3 | 4 | 5 | Security Groups 6 | --------------- 7 | 8 | Make sure port 80 is usable (for the web UI) and port 2222 (for the SSH 9 | management interface), and in the SGs for PostgreSQL and MongoDB servers as 10 | appropriate and necessary. 11 | 12 | 13 | Notes 14 | ----- 15 | 16 | I highly encourage using Docker with `overlay`, not `aufs`, `btrfs`, nor 17 | `devicemapper`. This requires kernel 3.18+; so we'll have to fix that. Feel free 18 | to not do that and use `aufs` if you'd like. 19 | 20 | 21 | Initial Setup 22 | ------------- 23 | 24 | # apt-get update 25 | # apt-get dist-upgrade -y 26 | 27 | Great. Now we need to install some experimental stuff. Literally. The Debian 28 | Kernel for 3.18 is only present in the `experimental` / `rc-buggy` suite 29 | (because of the Jessie freeze), so let's roll with that. 30 | 31 | # nano /etc/apt/sources.list 32 | 33 | Append: 34 | 35 | deb http://cloudfront.debian.net/debian experimental main 36 | deb-src http://cloudfront.debian.net/debian experimental main 37 | 38 | to the file. Now let's install the new kernel: 39 | 40 | # apt-get update && apt-get install linux-image-3.18 41 | # update-grub 42 | 43 | Now, let's bounce the instance (from the AWS console run 44 | Actions -> Instance State -> Reboot) 45 | 46 | Wait a hot second, and ssh back in. Let's ensure the kernel's running: 47 | 48 | # uname -a 49 | Linux ip-10-111-189-167 3.18.0-trunk-amd64 #1 SMP Debian 3.18.3-1~exp1 (2015-01-18) x86_64 GNU/Linux 50 | 51 | Fannntastic. Now, let's set up the EBS. 52 | 53 | # mkfs.ext4 /dev/xvdf 54 | # mkdir /projects 55 | # nano /etc/fstab 56 | 57 | and append: 58 | 59 | /dev/xvdf /projects ext4 defaults 1 1 60 | 61 | Now mount: 62 | 63 | # mount /projects 64 | # ln -s /projects/docker /var/lib/docker 65 | # mkdir /projects/docker 66 | 67 | Yay. OK. So; let's get Docker installed. This is a little unorthodox because we 68 | need `overlay` support which is only in Docker 1.4+ and the `docker.io` package 69 | is still 1.3.3 (because of the Jessie freeze). If you don't need overlay 70 | support (because you opted for `aufs`, `btrfs`, or `devicemapper`), you can skip 71 | the bit here where we download `docker-latest`. 72 | 73 | # apt-get install docker.io 74 | # usermod -a -G docker admin 75 | $ # stop here if you don't need "overlay" :) 76 | $ wget https://get.docker.com/builds/Linux/x86_64/docker-latest -O docker 77 | $ chmod +x docker 78 | # cp docker /usr/bin/docker 79 | # nano /etc/default/docker 80 | 81 | Set content to: 82 | 83 | DOCKER_OPTS="-H unix:///var/run/docker.sock -s overlay" 84 | 85 | Great; now let's restart: 86 | 87 | # service docker restart 88 | 89 | And, log out and back in to see your new group (check with `groups(1)`) 90 | 91 | Now, let's verify the setup: 92 | 93 | # docker info | grep Storage 94 | Storage Driver: overlay 95 | 96 | 97 | Configuration for Moxie 98 | ======================= 99 | 100 | I'm going to run the PostgreSQL db on the same host as the moxie runtime for 101 | now. Eventually this will change. 102 | 103 | # apt-get install postgresql-9.4 104 | # su postgres 105 | % psql 106 | postgres=# CREATE ROLE moxie WITH LOGIN PASSWORD 'moxie'; 107 | CREATE ROLE 108 | postgres=# CREATE DATABASE moxie OWNER moxie; 109 | CREATE DATABASE 110 | postgres=# 111 | 112 | Huzzah. Also, feel free to change your login information to not be that. 113 | 114 | Let's start the setup: 115 | 116 | # mkdir /etc/docker/ 117 | # nano /etc/docker/moxie.sh 118 | 119 | .. 120 | 121 | DATABASE_URL=postgres://moxie:moxie@172.17.42.1:5432/moxie 122 | # MOXIE_SOCKET="/sockets/moxie.sock" 123 | MOXIE_SLACKBOT_KEY="sec-retkeyhere" 124 | MOXIE_WEB_URL="http://moxie.sunlightfoundation.com" 125 | 126 | 127 | Edit `/etc/systemd/system/moxie.service`, and let's just get something 128 | basic working here. 129 | 130 | [Unit] 131 | Description=moxie 132 | Author=Paul R. Tagliamonte 133 | Requires=docker.service 134 | After=docker.service 135 | 136 | [Service] 137 | Restart=always 138 | ExecStart=/bin/bash -c '/usr/bin/docker start -a moxie || \ 139 | /usr/bin/docker run \ 140 | --privileged=true \ 141 | --name moxie \ 142 | -p 0.0.0.0:2222:2222/tcp \ 143 | -p 0.0.0.0:80:8888/tcp \ 144 | -e MOXIE_SLACKBOT_KEY=${MOXIE_SLACKBOT_KEY} \ 145 | -e DATABASE_URL=${DATABASE_URL} \ 146 | -e MOXIE_WEB_URL=${MOXIE_WEB_URL} \ 147 | -v /run/docker.sock:/run/docker.sock \ 148 | -v /srv/docker/moxie/moxie:/moxie/ \ 149 | paultag/moxie:latest moxied' 150 | ExecStop=/usr/bin/docker stop -t 5 moxie 151 | EnvironmentFile=/etc/docker/moxie.sh 152 | 153 | [Install] 154 | WantedBy=multi-user.target 155 | 156 | Now, let's update 157 | 158 | # systemctl daemon-reload 159 | -------------------------------------------------------------------------------- /eg/crank.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | maintainers: 3 | - name: "Paul Tagliamonte" 4 | email: "paultag@example.com" 5 | 6 | jobs: 7 | - name: "crank-hy" 8 | description: "Crank (Hylang)" 9 | maintainer: "paultag@example.com" 10 | interval: 10 11 | # interval: 30 12 | tags: 13 | - "foo" 14 | - "bar" 15 | command: "/opt/pault.ag/crank/eg/hy.hy" 16 | image: "paultag/crank" 17 | volumes: "crank" 18 | 19 | volume-sets: 20 | - name: "crank" 21 | values: 22 | - host: "/srv/leliel.pault.ag/dev/crank/" 23 | container: "/crank/" 24 | -------------------------------------------------------------------------------- /eg/manual.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | maintainers: 3 | - name: "Paul Tagliamonte" 4 | email: "paultag@example.com" 5 | 6 | jobs: 7 | - name: "test" 8 | description: "OpenStates" 9 | maintainer: "paultag@example.com" 10 | manual: true 11 | command: "ks --leg --scrape" 12 | image: "sunlightlabs/openstates" 13 | tags: 14 | - "quix" 15 | - "slack:#vcs" 16 | description: "quick test" 17 | maintainer: "paultag@example.com" 18 | manual: true 19 | command: "date" 20 | image: "debian:unstable" 21 | tags: 22 | - "fnord" 23 | - "slack:#test" 24 | -------------------------------------------------------------------------------- /eg/scrapers-state.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | maintainers: 3 | - name: "Paul Tagliamonte" 4 | email: "paultag@example.com" 5 | 6 | jobs: 7 | - name: "scrapers-state-il" 8 | description: "IL Scrape" 9 | maintainer: "paultag@example.com" 10 | crontab: "0 4 * * *" 11 | timezone: "America/New_York" 12 | command: "update il --scrape" 13 | image: "sunlightlabs/scrapers-us-state" 14 | env: "ocd" 15 | link: "ocd" 16 | tags: 17 | - "ocd" 18 | 19 | env-sets: 20 | - name: "ocd" 21 | values: 22 | SUNLIGHT_API_KEY: "ADDMEHERE" 23 | DATABASE_URL: "postgis://opencivicdata:test@10.42.2.101/opencivicdata" 24 | 25 | link-sets: 26 | - name: "ocd" 27 | links: 28 | - remote: "postgres" 29 | alias: "postgres" 30 | -------------------------------------------------------------------------------- /eg/users.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | users: 3 | - name: "Paul Tagliamonte" 4 | email: "paultag@example.com" 5 | fingerprint: "770fb656ad3cf5b049f4c59cf0bc7b102194fe7864d1ce8cd7fab137" 6 | -------------------------------------------------------------------------------- /maintainer/test.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import aiopg.sa 22 | import asyncio 23 | 24 | from sqlalchemy import select, join, desc 25 | from moxie.models import * 26 | from moxie.core import DATABASE_URL 27 | 28 | 29 | 30 | @asyncio.coroutine 31 | def test(): 32 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 33 | with (yield from engine) as conn: 34 | runs = yield from conn.execute(select( 35 | [Run.__table__]).where( 36 | Run.job_id == 1 37 | ).order_by( 38 | desc(Run.start_time) 39 | ).limit(10)) 40 | 41 | for run in runs: 42 | print(run) 43 | 44 | 45 | loop = asyncio.get_event_loop() 46 | loop.run_until_complete(test()) 47 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | import os 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | from logging.config import fileConfig 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | 15 | # add your model's MetaData object here 16 | # for 'autogenerate' support 17 | from moxie.models import Base 18 | target_metadata = Base.metadata 19 | 20 | # other values from the config, defined by the needs of env.py, 21 | # can be acquired: 22 | # my_important_option = config.get_main_option("my_important_option") 23 | # ... etc. 24 | 25 | def run_migrations_offline(): 26 | """Run migrations in 'offline' mode. 27 | 28 | This configures the context with just a URL 29 | and not an Engine, though an Engine is acceptable 30 | here as well. By skipping the Engine creation 31 | we don't even need a DBAPI to be available. 32 | 33 | Calls to context.execute() here emit the given string to the 34 | script output. 35 | 36 | """ 37 | url = config.get_main_option("sqlalchemy.url") 38 | context.configure(url=url, target_metadata=target_metadata) 39 | 40 | with context.begin_transaction(): 41 | context.run_migrations() 42 | 43 | def run_migrations_online(): 44 | """Run migrations in 'online' mode. 45 | 46 | In this scenario we need to create an Engine 47 | and associate a connection with the context. 48 | 49 | """ 50 | dbcfg = config.get_section(config.config_ini_section) 51 | 52 | if 'DATABASE_URL' in os.environ: 53 | dbcfg['sqlalchemy.url'] = os.environ['DATABASE_URL'] 54 | 55 | engine = engine_from_config( 56 | dbcfg, 57 | prefix='sqlalchemy.', 58 | poolclass=pool.NullPool) 59 | 60 | connection = engine.connect() 61 | context.configure( 62 | connection=connection, 63 | target_metadata=target_metadata 64 | ) 65 | 66 | try: 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | finally: 70 | connection.close() 71 | 72 | if context.is_offline_mode(): 73 | run_migrations_offline() 74 | else: 75 | run_migrations_online() 76 | 77 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paultag/moxie/8427d357808ce4811db8cc881d3e148447e80991/migrations/versions/.gitkeep -------------------------------------------------------------------------------- /migrations/versions/15081fbf2e7_add_in_links.py: -------------------------------------------------------------------------------- 1 | """add in links 2 | 3 | Revision ID: 15081fbf2e7 4 | Revises: None 5 | Create Date: 2014-08-08 12:54:36.772021 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '15081fbf2e7' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.create_table( 19 | 'link_set', 20 | sa.Column('id', sa.Integer, primary_key=True), 21 | sa.Column('name', sa.String(255), unique=True), 22 | ) 23 | 24 | op.create_table( 25 | 'link', 26 | sa.Column('id', sa.Integer, primary_key=True), 27 | sa.Column('link_set_id', sa.Integer, sa.ForeignKey('link_set.id')), 28 | 29 | sa.Column('remote', sa.String(255)), 30 | sa.Column('alias', sa.String(255)), 31 | ) 32 | 33 | def downgrade(): 34 | op.drop_table('link_set') 35 | op.drop_table('link') 36 | -------------------------------------------------------------------------------- /migrations/versions/2268d56413c_add_link_to_job.py: -------------------------------------------------------------------------------- 1 | """add link to job 2 | 3 | Revision ID: 2268d56413c 4 | Revises: 15081fbf2e7 5 | Create Date: 2014-08-08 13:03:12.375666 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2268d56413c' 11 | down_revision = '15081fbf2e7' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.add_column('job', sa.Column('link_id', sa.Integer, sa.ForeignKey('link_set.id'))) 19 | 20 | def downgrade(): 21 | op.drop_column('job', 'link_id') 22 | -------------------------------------------------------------------------------- /migrations/versions/2acbda1c1db_deal_with_stupid_sqlalchemy_bullshit.py: -------------------------------------------------------------------------------- 1 | """deal with stupid sqlalchemy bullshit 2 | 3 | Revision ID: 2acbda1c1db 4 | Revises: 2cce33d3a10 5 | Create Date: 2015-04-02 16:45:41.798445 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2acbda1c1db' 11 | down_revision = '2cce33d3a10' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | 18 | def upgrade(): 19 | op.alter_column('job', 'tags', type_=postgresql.ARRAY(sa.Text)) 20 | 21 | def downgrade(): 22 | op.alter_column('job', 'tags', type_=postgresql.ARRAY(sa.String(128))) 23 | -------------------------------------------------------------------------------- /migrations/versions/2c5e2bf19a6_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2c5e2bf19a6 4 | Revises: 4e54504ba21 5 | Create Date: 2015-01-15 22:49:49.687707 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2c5e2bf19a6' 11 | down_revision = '4e54504ba21' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.add_column('job', sa.Column('manual', sa.Boolean)) 19 | 20 | def downgrade(): 21 | op.drop_column('job', 'manual') 22 | -------------------------------------------------------------------------------- /migrations/versions/2cce33d3a10_add_trigger.py: -------------------------------------------------------------------------------- 1 | """add trigger 2 | 3 | Revision ID: 2cce33d3a10 4 | Revises: f450aba2db 5 | Create Date: 2015-04-02 10:59:09.693443 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2cce33d3a10' 11 | down_revision = 'f450aba2db' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.add_column('job', sa.Column('trigger_id', sa.Integer, sa.ForeignKey('job.id'))) 19 | 20 | def downgrade(): 21 | op.drop_column('job', 'trigger_id') 22 | -------------------------------------------------------------------------------- /migrations/versions/312c66e887b_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 312c66e887b 4 | Revises: 2268d56413c 5 | Create Date: 2015-01-14 21:20:50.250026 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '312c66e887b' 11 | down_revision = '2268d56413c' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.add_column('job', sa.Column('started', sa.Boolean)) 19 | 20 | def downgrade(): 21 | op.drop_column('job', 'started') 22 | -------------------------------------------------------------------------------- /migrations/versions/344e26c7948_cron_ify_job_scheduling.py: -------------------------------------------------------------------------------- 1 | """Cron-ify job scheduling 2 | 3 | Revision ID: 344e26c7948 4 | Revises: 389e540c827 5 | Create Date: 2015-08-13 16:13:00.559773 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '344e26c7948' 11 | down_revision = '389e540c827' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('job', sa.Column('crontab', sa.Text(), nullable=True)) 20 | op.add_column('job', sa.Column('timezone', sa.Text(), nullable=True)) 21 | op.drop_column('job', 'interval') 22 | ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column('job', sa.Column('interval', postgresql.INTERVAL(), autoincrement=False, nullable=True)) 28 | op.drop_column('job', 'timezone') 29 | op.drop_column('job', 'crontab') 30 | ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/389e540c827_add_entrypoint.py: -------------------------------------------------------------------------------- 1 | """add entrypoint 2 | 3 | Revision ID: 389e540c827 4 | Revises: 2acbda1c1db 5 | Create Date: 2015-04-06 13:42:33.006972 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '389e540c827' 11 | down_revision = '2acbda1c1db' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.add_column('job', sa.Column('entrypoint', sa.String(255))) 19 | 20 | def downgrade(): 21 | op.drop_column('job', 'entrypoint') 22 | -------------------------------------------------------------------------------- /migrations/versions/43b2a245dba_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 43b2a245dba 4 | Revises: 2c5e2bf19a6 5 | Create Date: 2015-01-16 00:23:24.471948 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '43b2a245dba' 11 | down_revision = '2c5e2bf19a6' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.create_table( 19 | 'user', 20 | sa.Column('id', sa.Integer, primary_key=True), 21 | sa.Column('name', sa.String(255)), 22 | sa.Column('email', sa.String(255), unique=True), 23 | sa.Column('fingerprint', sa.String(255), unique=True), 24 | ) 25 | 26 | def downgrade(): 27 | op.drop_table('user') 28 | -------------------------------------------------------------------------------- /migrations/versions/4e54504ba21_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 4e54504ba21 4 | Revises: 312c66e887b 5 | Create Date: 2015-01-14 21:45:39.699086 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '4e54504ba21' 11 | down_revision = '312c66e887b' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def downgrade(): 18 | op.add_column('job', sa.Column('started', sa.Boolean)) 19 | 20 | def upgrade(): 21 | op.drop_column('job', 'started') 22 | -------------------------------------------------------------------------------- /migrations/versions/f450aba2db_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: f450aba2db 4 | Revises: 43b2a245dba 5 | Create Date: 2015-01-29 14:22:48.300077 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'f450aba2db' 11 | down_revision = '43b2a245dba' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | 18 | def upgrade(): 19 | op.add_column('job', sa.Column('tags', postgresql.ARRAY(sa.String(128)))) 20 | 21 | def downgrade(): 22 | op.drop_column('job', 'tags') 23 | -------------------------------------------------------------------------------- /moxie/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paultag/moxie/8427d357808ce4811db8cc881d3e148447e80991/moxie/__init__.py -------------------------------------------------------------------------------- /moxie/alerts/__init__.py: -------------------------------------------------------------------------------- 1 | from .email import EmailAlert 2 | from .slack import SlackAlert 3 | -------------------------------------------------------------------------------- /moxie/alerts/email.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import os 22 | import asyncio 23 | import smtplib 24 | 25 | import jinja2 26 | from aiocore import Service 27 | from aiomultiprocessing import AsyncProcess 28 | 29 | 30 | _jinja_env = jinja2.Environment( 31 | loader=jinja2.FileSystemLoader(os.path.join( 32 | os.path.abspath(os.path.dirname(__file__)), 33 | '..', '..', 34 | 'templates' 35 | )) 36 | ) 37 | 38 | MOXIE_WEB_URL = os.environ.get("MOXIE_WEB_URL", "http://localhost:8888") 39 | 40 | 41 | class EmailAlert: 42 | TYPES = ['failure'] 43 | 44 | def __init__(self, host, user, password): 45 | self.db = Service.resolve("moxie.cores.database.DatabaseService") 46 | self.host = host 47 | self.user = user 48 | self.password = password 49 | 50 | def send(self, payload, job, maintainer, run): 51 | type_ = payload['type'] 52 | 53 | server = smtplib.SMTP(self.host, 587) 54 | server.ehlo() 55 | server.starttls() 56 | server.login(self.user, self.password) 57 | 58 | template = _jinja_env.get_template("emails/{}.email".format(type_)) 59 | body = template.render(user_name="Moxie", 60 | user=self.user, 61 | root=MOXIE_WEB_URL, 62 | maintainer=maintainer, 63 | job=job, 64 | run=run) 65 | body = body.encode() # Ready to send it over the line. 66 | 67 | server.sendmail(self.user, [maintainer.email], body) 68 | server.quit() 69 | 70 | @asyncio.coroutine 71 | def __call__(self, payload): 72 | type_ = payload['type'] 73 | if type_ not in self.TYPES: 74 | return 75 | 76 | job = yield from self.db.job.get(payload.get("job")) 77 | maintainer = yield from self.db.maintainer.get(job.maintainer_id) 78 | runid = payload.get("result", None) 79 | run = None 80 | if runid: 81 | run = yield from self.db.run.get(runid) 82 | 83 | p = AsyncProcess(target=self.send, args=(payload, job, maintainer, run)) 84 | p.start() 85 | yield from p.join() 86 | -------------------------------------------------------------------------------- /moxie/alerts/slack.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import asyncio 22 | from aiocore import Service 23 | 24 | 25 | class SlackAlert: 26 | strings = { 27 | # "starting": "{job.name} is starting.", 28 | # "running": "{job.name} is running.", 29 | # "success": "{job.name} has completed successfully.", 30 | "error": "{job.name} had an error.", 31 | "failure": "{job.name} failed.", 32 | } 33 | 34 | def __init__(self, bot): 35 | self.bot = bot 36 | self.db = Service.resolve("moxie.cores.database.DatabaseService") 37 | 38 | @asyncio.coroutine 39 | def __call__(self, payload): 40 | job = yield from self.db.job.get(payload.get("job")) 41 | maintainer = yield from self.db.maintainer.get(job.maintainer_id) 42 | channels = filter(lambda x: x.startswith("slack:"), job.tags) 43 | 44 | fmt = self.strings.get(payload["type"]) 45 | if fmt is None: 46 | return 47 | 48 | for channel in (['slack:#cron'] + list(channels)): 49 | channel = channel.replace("slack:", "") 50 | yield from self.bot.post( 51 | channel, fmt.format(job=job, maintainer=maintainer)) 52 | -------------------------------------------------------------------------------- /moxie/app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import aiopg.sa 22 | import asyncio 23 | import aiohttp 24 | from aiodocker.docker import Docker 25 | 26 | import json 27 | import humanize.time 28 | import re 29 | from sqlalchemy import select, join, desc, text 30 | from moxie.server import MoxieApp 31 | from moxie.models import Job, Maintainer, Run 32 | from moxie.core import DATABASE_URL 33 | from aiocore import Service 34 | 35 | 36 | app = MoxieApp() 37 | docker = Docker() 38 | 39 | 40 | @asyncio.coroutine 41 | def get_job_runs(limit=10): 42 | ''' 43 | Return Jobs and their most recent Runs. The default is to return 44 | all Jobs, but this can be subset by passing a SQLAlchemy-compatible 45 | clause for a `where`. 46 | 47 | The format that goes to the view template will look like this: 48 | [ 49 | Job_one: [Run_one, Run_two, ...], 50 | Job_two: [Run_one, Run_two, ...], 51 | ... 52 | ] 53 | ''' 54 | 55 | # Get a table where each row is a Job and one of its Runs 56 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 57 | assert isinstance(limit, int), "Limit must be an integer" 58 | jobs_runs_query = text(''' 59 | SELECT Job.id AS job_id, 60 | Job.name AS job_name, 61 | Job.description AS job_description, 62 | Job.active AS job_active, 63 | Job.tags AS job_tags, 64 | Job.maintainer_id AS job_maintainer_id, 65 | Run.id AS run_id, 66 | Run.failed AS run_failed, 67 | Run.start_time AS run_start_time, 68 | Run.end_time AS run_end_time 69 | FROM Job 70 | LEFT JOIN ( 71 | SELECT * 72 | FROM ( 73 | SELECT *, 74 | RANK() OVER (PARTITION BY job_id ORDER BY id DESC) AS recency 75 | FROM Run 76 | ) AS Run 77 | WHERE recency <= {limit} 78 | ) AS Run 79 | ON Job.id = Run.job_id 80 | ORDER BY Run.id DESC 81 | ;'''.format(limit=limit)) 82 | 83 | with (yield from engine) as conn: 84 | jobs_runs = yield from conn.execute(jobs_runs_query) 85 | 86 | jobs = {} 87 | for job_run in jobs_runs: 88 | # Create phony objects for use in the view template 89 | job = {} 90 | run = {} 91 | for key in job_run.keys(): 92 | key_name = re.sub(r'^(?:job|run)_', '', key) 93 | if key.startswith('job_'): 94 | job[key_name] = getattr(job_run, key, None) 95 | elif key.startswith('run_'): 96 | run[key_name] = getattr(job_run, key, None) 97 | 98 | if not jobs.get(job_run.job_id): 99 | jobs[job_run.job_id] = [job, []] 100 | 101 | # If a Job exists with no Runs, leave its Runs list empty 102 | if run.get('id'): 103 | jobs[job_run.job_id][1].append(run) 104 | 105 | # Only pass the most recent _limit_ Runs to the view 106 | return [(job, runs) for (job, runs) in sorted(jobs.values(), key=lambda k: k[0]['name'])] 107 | 108 | 109 | @app.websocket("^websocket/stream/(?P.*)/$") 110 | def stream(request, name): 111 | container = Service.resolve("moxie.cores.container.ContainerService") 112 | container = yield from container.get(name) 113 | logs = container.logs 114 | logs.saferun() 115 | queue = logs.listen() 116 | while True: 117 | out = yield from queue.get() 118 | request.writer.send(out) 119 | 120 | 121 | @app.register("^/$") 122 | def overview(request): 123 | return request.render('overview.html', {}) 124 | 125 | 126 | @app.register("^jobs/$") 127 | def jobs(request): 128 | return request.render('jobs.html', { 129 | "jobs": (yield from get_job_runs()), 130 | }) 131 | 132 | 133 | @app.register("^run/(?P.*)/$") 134 | def run(request, key): 135 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 136 | with (yield from engine) as conn: 137 | runs = yield from conn.execute(select( 138 | [Run.__table__]).where(Run.id == key) 139 | ) 140 | run = yield from runs.first() 141 | return request.render('run.html', { 142 | "run": run, 143 | }) 144 | 145 | 146 | @app.register("^maintainers/$") 147 | def maintainers(request): 148 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 149 | with (yield from engine) as conn: 150 | res = yield from conn.execute(Maintainer.__table__.select()) 151 | return request.render('maintainers.html', { 152 | "maintainers": res 153 | }) 154 | 155 | 156 | @app.register("^maintainer/(?P.*)/$") 157 | def maintainer(request, id): 158 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 159 | with (yield from engine) as conn: 160 | maintainers = yield from conn.execute(select( 161 | [Maintainer.__table__]).where(Maintainer.id == id) 162 | ) 163 | maintainer = yield from maintainers.first() 164 | 165 | @asyncio.coroutine 166 | def jobs(): 167 | job_runs = yield from get_job_runs() 168 | return ([job, runs] for job, runs in job_runs if job['maintainer_id'] == int(id)) 169 | 170 | return request.render('maintainer.html', { 171 | "maintainer": maintainer, 172 | "jobs": (yield from jobs()), 173 | }) 174 | 175 | 176 | @app.register("^tag/(?P.*)/$") 177 | def tag(request, id): 178 | @asyncio.coroutine 179 | def jobs(): 180 | job_runs = yield from get_job_runs() 181 | return ([job, runs] for job, runs in job_runs if id in job['tags']) 182 | 183 | return request.render('tag.html', { 184 | "tag": id, 185 | "jobs": (yield from jobs()), 186 | }) 187 | 188 | 189 | @app.register("^job/(?P.*)/$") 190 | def job(request, name): 191 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 192 | with (yield from engine) as conn: 193 | 194 | jobs = yield from conn.execute(select( 195 | [Job.__table__, Maintainer.__table__,], 196 | use_labels=True 197 | ).select_from(join( 198 | Maintainer.__table__, 199 | Job.__table__, 200 | Maintainer.id == Job.maintainer_id 201 | )).where(Job.name == name).limit(1)) 202 | job = yield from jobs.first() 203 | 204 | runs = yield from conn.execute( 205 | select([Run.id, Run.failed, Run.start_time, Run.end_time]). 206 | where(Run.job_id == job.job_id). 207 | order_by(Run.id.desc()) 208 | ) 209 | 210 | return request.render('job.html', { 211 | "job": job, 212 | "runs": runs, 213 | "next_run": humanize.naturaltime(job.job_scheduled), 214 | }) 215 | 216 | 217 | @app.register("^container/(?P.*)/$") 218 | def container(request, name): 219 | engine = yield from aiopg.sa.create_engine(DATABASE_URL) 220 | with (yield from engine) as conn: 221 | jobs = yield from conn.execute(select([Job.__table__]).where( 222 | Job.name == name 223 | )) 224 | job = yield from jobs.first() 225 | if job is None: 226 | return request.render('500.html', { 227 | "reason": "No such job" 228 | }, code=404) 229 | 230 | try: 231 | container = yield from docker.containers.get(name) 232 | except ValueError: 233 | # No such Container. 234 | return request.render('container.offline.html', { 235 | "reason": "No such container", 236 | "job": job, 237 | }, code=404) 238 | 239 | info = yield from container.show() 240 | 241 | return request.render('container.html', { 242 | "job": job, 243 | "container": container, 244 | "info": info, 245 | }) 246 | 247 | 248 | @app.register("^cast/$") 249 | def cast(request): 250 | return request.render('cast.html', { 251 | "runs": (yield from get_job_runs()) 252 | }) 253 | -------------------------------------------------------------------------------- /moxie/butterfield.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import asyncio 4 | from butterfield.utils import at_bot 5 | 6 | from moxie.facts import get_fact 7 | 8 | from aiodocker import Docker 9 | from aiocore import EventService 10 | 11 | WEB_ROOT = os.environ.get("MOXIE_WEB_URL", "http://localhost:8888") 12 | 13 | 14 | class LogService(EventService): 15 | """ 16 | Provide basic text logging using print() 17 | """ 18 | identifier = "moxie.cores.log.LogService" 19 | 20 | 21 | FORMAT_STRINGS = { 22 | "cron": { 23 | "sleep": "{job} ready to run, launching in {time} seconds.", 24 | }, 25 | "run": { 26 | "pull": "Pulling from the index for {job}", 27 | "error": "Error! {job} - {error}", 28 | "create": "Creating a container for {job}", 29 | "starting": "Starting {job} because {why}", 30 | "started": "Job {{job}} started! ({}/container/{{job}}/) because {{why}}".format(WEB_ROOT), 31 | }, 32 | "reap": { 33 | "error": "Error! {job} - {error}", 34 | "punted": "Error! Internal problem, punting {job}", 35 | "start": "Reaping {job}", 36 | "complete": "Job {{job}} reaped - run ID {{record}} ({}/run/{{record}}/)".format(WEB_ROOT), 37 | }, 38 | } 39 | 40 | def __init__(self, bot, *args, **kwargs): 41 | self.bot = bot 42 | super(LogService, self).__init__(*args, **kwargs) 43 | 44 | @asyncio.coroutine 45 | def log(self, message): 46 | yield from self.send(message) 47 | 48 | @asyncio.coroutine 49 | def handle(self, message): 50 | type_, action = [message.get(x) for x in ['type', 'action']] 51 | strings = self.FORMAT_STRINGS.get(type_, {}) 52 | output = strings.get(action, str(message)) 53 | 54 | yield from self.bot.post( 55 | "#cron", 56 | "[{type}]: {action} - {message}".format( 57 | type=message['type'], 58 | action=message['action'], 59 | message=output.format(**message), 60 | )) 61 | 62 | 63 | @asyncio.coroutine 64 | def events(bot): 65 | docker = Docker() 66 | events = docker.events 67 | events.saferun() 68 | 69 | stream = events.listen() 70 | while True: 71 | el = yield from stream.get() 72 | yield from bot.post("#cron", "`{}`".format(str(el))) 73 | 74 | 75 | 76 | @asyncio.coroutine 77 | @at_bot 78 | def run(bot, message: "message"): 79 | runner = EventService.resolve("moxie.cores.run.RunService") 80 | 81 | text = message.get("text", "") 82 | if text == "": 83 | yield from bot.post(message['channel'], "Invalid request") 84 | return 85 | 86 | elif text.strip().lower() == "fact": 87 | yield from bot.post( 88 | message['channel'], "<@{}>: {}".format(message['user'], get_fact())) 89 | return 90 | 91 | elif text.strip().lower() in ("yo", ":yo:"): 92 | yield from bot.post( 93 | message['channel'], "<@{}>: :yo:".format(message['user'])) 94 | return 95 | 96 | cmd, arg = text.split(" ", 1) 97 | if cmd == "run": 98 | job = arg 99 | yield from bot.post( 100 | message['channel'], "<@{}>: Doing bringup of {}".format( 101 | message['user'], job)) 102 | try: 103 | yield from runner.run( 104 | job, 105 | 'slack from <@{}>'.format(message['user']) 106 | ) 107 | except ValueError as e: 108 | yield from bot.post( 109 | message['channel'], 110 | "<@{user}>: Gah, {job} failed - {e}".format( 111 | user=message['user'], e=e, job=job) 112 | ) 113 | return 114 | 115 | yield from bot.post(message['channel'], 116 | "<@{user}>: job {job} online - {webroot}/container/{job}/".format( 117 | user=message['user'], webroot=WEB_ROOT, job=job)) 118 | -------------------------------------------------------------------------------- /moxie/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | def serve(): 23 | import asyncio 24 | from moxie.app import app 25 | from butterfield import Bot 26 | from .butterfield import events 27 | from .butterfield import LogService as ButterfieldLogService 28 | 29 | import socket 30 | import os.path 31 | import sys 32 | import os 33 | 34 | from moxie.cores import (RunService, LogService, 35 | CronService, ReapService, 36 | DatabaseService, ContainerService, SSHService, 37 | AlertService) 38 | from moxie.alerts import EmailAlert, SlackAlert 39 | 40 | loop = asyncio.get_event_loop() 41 | 42 | botcoro = asyncio.gather() 43 | bot_key = os.environ.get("MOXIE_SLACKBOT_KEY", None) 44 | 45 | log = LogService() 46 | db = DatabaseService() 47 | alert = AlertService() 48 | 49 | if bot_key: 50 | bot = Bot(bot_key) 51 | bot.listen("moxie.butterfield.run") 52 | 53 | log = ButterfieldLogService(bot) 54 | alert.register(SlackAlert(bot)) 55 | botcoro = asyncio.gather( 56 | bot(), 57 | # events(bot) 58 | ) 59 | 60 | run = RunService() 61 | ssh = SSHService() 62 | cron = CronService() 63 | reap = ReapService() 64 | container = ContainerService() 65 | 66 | if os.environ.get("MOXIE_SMTP_HOST", False) is not False: 67 | alert.register(EmailAlert( 68 | os.environ.get("MOXIE_SMTP_HOST", None), 69 | os.environ.get("MOXIE_SMTP_USER", None), 70 | os.environ.get("MOXIE_SMTP_PASS", None), 71 | )) 72 | 73 | socket_fp = os.environ.get("MOXIE_SOCKET", None) 74 | if socket_fp: 75 | if os.path.exists(socket_fp): 76 | os.remove(socket_fp) 77 | 78 | print("Opening socket: %s" % (socket_fp)) 79 | server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 80 | server.bind(socket_fp) 81 | os.chmod(socket_fp, 0o766) 82 | coro = loop.create_server(app, sock=server) 83 | else: 84 | host = os.environ.get("MOXIE_HOST", "127.0.0.1") 85 | port = int(os.environ.get("MOXIE_PORT", "8000")) 86 | coro = loop.create_server(app, host, port) 87 | 88 | server = loop.run_until_complete(coro) 89 | print('serving on {}'.format(server.sockets[0].getsockname())) 90 | 91 | loop.run_until_complete(asyncio.gather( 92 | botcoro, ssh(), run(), log(), cron(), reap(), db(), container())) 93 | 94 | 95 | 96 | def init(): 97 | from sqlalchemy import create_engine 98 | from moxie.models import Base 99 | from moxie.core import DATABASE_URL 100 | engine = create_engine(DATABASE_URL) 101 | for table in Base.metadata.tables: 102 | engine.execute("DROP TABLE IF EXISTS \"{}\" CASCADE;".format(table)) 103 | Base.metadata.create_all(engine) 104 | import os 105 | from alembic.config import Config 106 | from alembic import command 107 | alembic_cfg = Config(os.path.join( 108 | os.path.abspath(os.path.dirname(__file__)), 109 | "..", 110 | "alembic.ini" 111 | )) 112 | command.stamp(alembic_cfg, "head") 113 | 114 | def _update(o, values): 115 | for k, v in values.items(): 116 | setattr(o, k, v) 117 | return o 118 | 119 | 120 | def load(): 121 | import sys 122 | import yaml 123 | import datetime as dt 124 | import pytz 125 | from croniter import croniter 126 | from sqlalchemy import create_engine 127 | from sqlalchemy.orm import sessionmaker 128 | from moxie.models import (Base, Job, Maintainer, User, 129 | EnvSet, VolumeSet, LinkSet, 130 | Env, Volume, Link) 131 | from moxie.core import DATABASE_URL 132 | 133 | engine = create_engine(DATABASE_URL) 134 | Session = sessionmaker(bind=engine) 135 | session = Session() 136 | 137 | def get_one(table, *constraints): 138 | return session.query(table).filter(*constraints).first() 139 | 140 | for fp in sys.argv[1:]: 141 | data = yaml.load(open(fp, 'r')) 142 | 143 | for user in data.pop('users', []): 144 | o = get_one(User, User.name == user['name']) 145 | 146 | if o is None: 147 | u = User(**user) 148 | print("Inserting: ", user['name']) 149 | session.add(u) 150 | else: 151 | session.add(_update(o, user)) 152 | print("Updating: ", user['name']) 153 | 154 | for maintainer in data.pop('maintainers', []): 155 | o = get_one(Maintainer, Maintainer.name == maintainer['name']) 156 | 157 | if o is None: 158 | m = Maintainer(**maintainer) 159 | print("Inserting: ", maintainer['name']) 160 | session.add(m) 161 | else: 162 | session.add(_update(o, maintainer)) 163 | print("Updating: ", maintainer['name']) 164 | 165 | session.commit() 166 | 167 | for env in data.pop('env-sets', []): 168 | name = env.pop('name') 169 | values = env.pop('values') 170 | if env != {}: 171 | raise ValueError("Unknown keys: %s" % (", ".join(env.keys()))) 172 | o = get_one(EnvSet, EnvSet.name == name) 173 | 174 | if o is None: 175 | print("Inserting: Env: %s" % (name)) 176 | env = EnvSet(name=name) 177 | session.add(env) 178 | o = env 179 | else: 180 | print("Updating: Env: %s" % (name)) 181 | deleted = session.query(Env).filter(Env.env_set_id == o.id).delete() 182 | print(" => Deleted %s related envs" % (deleted)) 183 | 184 | session.commit() 185 | 186 | for k, v in values.items(): 187 | session.add(Env(env_set_id=o.id, key=k, value=v)) 188 | 189 | session.commit() 190 | 191 | for volume in data.pop('volume-sets', []): 192 | name = volume.pop('name') 193 | values = volume.pop('values') 194 | if volume != {}: 195 | raise ValueError("Unknown keys: %s" % (", ".join(volume.keys()))) 196 | o = get_one(VolumeSet, VolumeSet.name == name) 197 | 198 | if o is None: 199 | print("Inserting: Vol: %s" % (name)) 200 | vol = VolumeSet(name=name) 201 | session.add(vol) 202 | o = vol 203 | else: 204 | print("Updating: Vol: %s" % (name)) 205 | deleted = session.query(Volume).filter( 206 | Volume.volume_set_id == o.id).delete() 207 | 208 | print(" => Deleted %s related vols" % (deleted)) 209 | 210 | session.commit() 211 | 212 | for config in values: 213 | session.add(Volume(volume_set_id=o.id, **config)) 214 | 215 | session.commit() 216 | 217 | for link in data.pop('link-sets', []): 218 | name = link.pop('name') 219 | links = link.pop('links') 220 | 221 | if link != {}: 222 | raise ValueError("Unknown keys: %s" % (", ".join(link.keys()))) 223 | o = get_one(LinkSet, LinkSet.name == name) 224 | 225 | if o is None: 226 | print("Inserting: Lnk: %s" % (name)) 227 | lnk = LinkSet(name=name) 228 | session.add(lnk) 229 | o = lnk 230 | else: 231 | print("Updating: Lnk: %s" % (name)) 232 | deleted = session.query(Link).filter( 233 | Link.link_set_id == o.id).delete() 234 | 235 | print(" => Deleted %s related links" % (deleted)) 236 | 237 | session.commit() 238 | 239 | for config in links: 240 | session.add(Link(link_set_id=o.id, **config)) 241 | 242 | session.commit() 243 | 244 | for job in data.pop('jobs', []): 245 | o = get_one(Job, Job.name == job['name']) 246 | 247 | manual = job.pop('manual', False) 248 | job['manual'] = manual 249 | 250 | if not manual: 251 | # Validate Cron syntax and confirm timezone exists 252 | assert (job['crontab'] and job['timezone']), \ 253 | "For non-manual Jobs, `crontab` and `timezone` must both be specified" 254 | try: 255 | pytz.timezone(job['timezone']) 256 | except pytz.exceptions.UnknownTimeZoneError: 257 | raise ValueError("Error: {} is not a timezone".format(job['timezone'])) 258 | try: 259 | croniter(job['crontab'], dt.datetime.utcnow()) 260 | except (ValueError, KeyError): 261 | raise ValueError("Error: {} is not valid Cron syntax".format(job['crontab'])) 262 | 263 | job['maintainer_id'] = get_one( 264 | Maintainer, 265 | Maintainer.email == job.pop('maintainer') 266 | ).id 267 | 268 | for k, v in [('env', EnvSet), 269 | ('volumes', VolumeSet), 270 | ('link', LinkSet)]: 271 | if k not in job: 272 | continue 273 | 274 | name = job.pop(k) 275 | 276 | ro = get_one(v, v.name == name) 277 | if ro is None: 278 | raise ValueError("Error: No such %s: %s" % (k, name)) 279 | 280 | job["%s_id" % (k)] = ro.id 281 | 282 | trigger = job.pop('trigger', None) 283 | if trigger is not None: 284 | parent = get_one(Job, Job.name == trigger) 285 | if parent is None: 286 | raise ValueError( 287 | "Error: No such job %s (trigger for %s)" % ( 288 | trigger, job['name'] 289 | )) 290 | job['trigger_id'] = parent.id 291 | 292 | if o is None: 293 | job['scheduled'] = dt.datetime.utcnow() 294 | j = Job(active=False, **job) 295 | print("Inserting: ", job['name']) 296 | session.add(j) 297 | else: 298 | print("Updating: ", job['name']) 299 | session.add(_update(o, job)) 300 | 301 | session.commit() 302 | -------------------------------------------------------------------------------- /moxie/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DATABASE_URL = os.environ.get( 4 | 'DATABASE_URL', 5 | 'postgresql://moxie:moxie@localhost:5432/moxie' 6 | ) 7 | -------------------------------------------------------------------------------- /moxie/cores/__init__.py: -------------------------------------------------------------------------------- 1 | from .ssh import SSHService 2 | from .run import RunService 3 | from .log import LogService 4 | from .cron import CronService 5 | from .reap import ReapService 6 | from .alert import AlertService 7 | from .database import DatabaseService 8 | from .container import ContainerService 9 | -------------------------------------------------------------------------------- /moxie/cores/alert.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import asyncio 22 | from aiocore import Service 23 | 24 | 25 | class AlertService(Service): 26 | identifier = "moxie.cores.alert.AlertService" 27 | 28 | def __init__(self): 29 | self.callbacks = [] 30 | super(AlertService, self).__init__() 31 | 32 | @asyncio.coroutine 33 | def starting(self, job): 34 | yield from self._emit("starting", job=job) 35 | 36 | @asyncio.coroutine 37 | def running(self, job): 38 | yield from self._emit("running", job=job) 39 | 40 | @asyncio.coroutine 41 | def success(self, job, result): 42 | yield from self._emit("success", job=job, result=result) 43 | 44 | @asyncio.coroutine 45 | def failure(self, job, result): 46 | yield from self._emit("failure", job=job, result=result) 47 | 48 | @asyncio.coroutine 49 | def error(self, job, result): 50 | yield from self._emit("error", job=job, result=result) 51 | 52 | def register(self, callback): 53 | self.callbacks.append(callback) 54 | 55 | @asyncio.coroutine 56 | def _emit(self, flavor, **kwargs): 57 | kwargs['type'] = flavor 58 | for handler in self.callbacks: 59 | asyncio.async(handler(kwargs)) 60 | 61 | @asyncio.coroutine 62 | def __call__(self): 63 | pass 64 | -------------------------------------------------------------------------------- /moxie/cores/container.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import asyncio 22 | import weakref 23 | from aiocore import Service 24 | from aiodocker import Docker 25 | 26 | 27 | 28 | class ContainerService(Service): 29 | """ 30 | This provides an interface to run container jobs somewhere off in the 31 | ether somewhere. 32 | """ 33 | 34 | identifier = "moxie.cores.container.ContainerService" 35 | 36 | def __init__(self): 37 | super(ContainerService, self).__init__() 38 | self._containers = weakref.WeakValueDictionary() 39 | self._docker = Docker() 40 | self._database = Service.resolve("moxie.cores.database.DatabaseService") 41 | 42 | def _check_container(self, name): 43 | job = yield from self._database.job.get(name) 44 | # Check if active 45 | if job is None: 46 | raise ValueError("Sorry, that's not something you can kill") 47 | 48 | @asyncio.coroutine 49 | def events(self, name): 50 | return (yield from self._docker.events) 51 | 52 | @asyncio.coroutine 53 | def pull(self, name): 54 | return (yield from self._docker.pull(name)) 55 | 56 | def _purge_cache(self, name): 57 | if name in self._containers: 58 | self._containers.pop(name) 59 | 60 | @asyncio.coroutine 61 | def delete(self, name): 62 | yield from self._check_container(name) 63 | try: 64 | obj = yield from self.get(name) 65 | except ValueError: 66 | return 67 | 68 | self._purge_cache(name) 69 | yield from obj.delete() 70 | 71 | @asyncio.coroutine 72 | def create(self, config, **kwargs): 73 | return (yield from self._docker.containers.create(config, **kwargs)) 74 | 75 | @asyncio.coroutine 76 | def start(self, name, config, **kwargs): 77 | yield from self._check_container(name) 78 | obj = yield from self.get(name) 79 | return (yield from obj.start(config, **kwargs)) 80 | 81 | @asyncio.coroutine 82 | def kill(self, name, *args, **kwargs): 83 | yield from self._check_container(name) 84 | obj = yield from self.get(name) 85 | return (yield from obj.kill(*args, **kwargs)) 86 | 87 | @asyncio.coroutine 88 | def get(self, name): 89 | yield from self._check_container(name) 90 | if name in self._containers: 91 | obj = self._containers[name] 92 | try: 93 | yield from obj.show() # update cache 94 | return obj 95 | except ValueError: 96 | self._purge_cache(name) 97 | container = yield from self._docker.containers.get(name) 98 | self._containers[name] = container 99 | return container 100 | 101 | @asyncio.coroutine 102 | def list(self, **kwargs): 103 | containers = yield from self._docker.containers.list(**kwargs) 104 | return containers 105 | 106 | @asyncio.coroutine 107 | def __call__(self): 108 | pass 109 | -------------------------------------------------------------------------------- /moxie/cores/cron.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import asyncio 22 | import datetime as dt 23 | from aiocore import Service 24 | from moxie.models import Job 25 | 26 | 27 | class CronService(Service): 28 | identifier = "moxie.cores.cron.CronService" 29 | HEARTBEAT = 30 30 | 31 | @asyncio.coroutine 32 | def log(self, action, **kwargs): 33 | kwargs['type'] = "cron" 34 | kwargs['action'] = action 35 | yield from self.logger.log(kwargs) 36 | 37 | @asyncio.coroutine 38 | def handle(self, job): 39 | delta = (dt.datetime.utcnow() - job.scheduled) 40 | seconds = -delta.total_seconds() 41 | seconds = 0 if seconds < 0 else seconds 42 | yield from self.log('sleep', time=seconds, job=job.name) 43 | yield from asyncio.sleep(seconds) 44 | yield from self.run.run(job.name, 'cron') 45 | 46 | @asyncio.coroutine 47 | def __call__(self): 48 | self.logger = CronService.resolve("moxie.cores.log.LogService") 49 | self.run = CronService.resolve("moxie.cores.run.RunService") 50 | self.database = CronService.resolve("moxie.cores.database.DatabaseService") 51 | 52 | while True: 53 | jobs = (yield from self.database.job.list( 54 | Job.manual == False, 55 | Job.scheduled <= ( 56 | dt.datetime.utcnow() + 57 | dt.timedelta(seconds=self.HEARTBEAT)) 58 | )) 59 | 60 | # yield from self.logger.log("cron", "Wakeup") 61 | for job in jobs: 62 | asyncio.async(self.handle(job)) 63 | # yield from self.logger.log("cron", "Sleep") 64 | yield from asyncio.sleep(self.HEARTBEAT) 65 | -------------------------------------------------------------------------------- /moxie/cores/database.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import asyncio 22 | import aiopg.sa 23 | import datetime as dt 24 | import pytz 25 | from croniter import croniter 26 | from aiocore import Service 27 | from sqlalchemy import update, insert, select, and_ 28 | 29 | from moxie.core import DATABASE_URL 30 | from moxie.models import Job, Run, Env, Volume, User, Maintainer 31 | 32 | 33 | def guard(fn): 34 | """ 35 | Create the engine if it's not already there. 36 | """ 37 | 38 | def _(self, *args, **kwargs): 39 | if self.db.engine is None: 40 | self.db.engine = yield from aiopg.sa.create_engine( 41 | DATABASE_URL, maxsize=10) 42 | return (yield from fn(self, *args, **kwargs)) 43 | return _ 44 | 45 | 46 | class DatabaseService(Service): 47 | """ 48 | Proxy access to the database. 49 | """ 50 | 51 | identifier = "moxie.cores.database.DatabaseService" 52 | engine = None 53 | 54 | def __init__(self): 55 | super(DatabaseService, self).__init__() 56 | self.job = DatabaseService.JobDB(self) 57 | self.run = DatabaseService.RunDB(self) 58 | self.env = DatabaseService.EnvDB(self) 59 | self.volume = DatabaseService.VolumeDB(self) 60 | self.user = DatabaseService.UserDB(self) 61 | self.maintainer = DatabaseService.MaintainerDB(self) 62 | 63 | class RunDB: 64 | def __init__(self, db): 65 | self.db = db 66 | 67 | @guard 68 | @asyncio.coroutine 69 | def create(self, **kwargs): 70 | with (yield from self.db.engine) as conn: 71 | runid = yield from conn.scalar(insert(Run.__table__).values( 72 | **kwargs)) 73 | return runid 74 | 75 | @guard 76 | @asyncio.coroutine 77 | def get(self, run_id): 78 | with (yield from self.db.engine) as conn: 79 | runs = yield from conn.execute(select([ 80 | Run.__table__]).where(Run.id==run_id)) 81 | return (yield from runs.first()) 82 | 83 | class VolumeDB: 84 | def __init__(self, db): 85 | self.db = db 86 | 87 | @guard 88 | @asyncio.coroutine 89 | def get(self, volume_id): 90 | with (yield from self.db.engine) as conn: 91 | volumes = yield from conn.execute(select([ 92 | Volume.__table__]).where(Volume.volume_set_id==volume_id)) 93 | return volumes 94 | 95 | class EnvDB: 96 | def __init__(self, db): 97 | self.db = db 98 | 99 | @guard 100 | @asyncio.coroutine 101 | def get(self, env_id): 102 | with (yield from self.db.engine) as conn: 103 | jobenvs = yield from conn.execute(select([ 104 | Env.__table__ 105 | ]).where(Env.env_set_id==env_id)) 106 | return jobenvs 107 | 108 | class UserDB: 109 | def __init__(self, db): 110 | self.db = db 111 | 112 | @guard 113 | @asyncio.coroutine 114 | def get_by_fingerprint(self, fingerprint): 115 | with (yield from self.db.engine) as conn: 116 | users = yield from conn.execute(select([ 117 | User.__table__ 118 | ]).where(User.fingerprint==fingerprint)) 119 | user = yield from users.first() 120 | return user 121 | 122 | class MaintainerDB: 123 | def __init__(self, db): 124 | self.db = db 125 | 126 | @guard 127 | @asyncio.coroutine 128 | def get(self, id): 129 | with (yield from self.db.engine) as conn: 130 | jobs = yield from conn.execute(select( 131 | [Maintainer.__table__]).where(Maintainer.id == id) 132 | ) 133 | job = yield from jobs.first() 134 | return job 135 | 136 | class JobDB: 137 | def __init__(self, db): 138 | self.db = db 139 | 140 | @guard 141 | @asyncio.coroutine 142 | def list(self, *where): 143 | """ 144 | Get all known jobs 145 | """ 146 | if len(where) == 0: 147 | q = Job.__table__.select() 148 | else: 149 | clause = where[0] 150 | if len(where) > 1: 151 | clause = and_(*where) 152 | q = select([Job.__table__]).where(clause) 153 | 154 | with (yield from self.db.engine) as conn: 155 | jobs = (yield from conn.execute(q)) 156 | return jobs 157 | 158 | @guard 159 | @asyncio.coroutine 160 | def triggered(self, name): 161 | job = (yield from self.get(name)) 162 | q = select([Job.__table__]).where(Job.trigger_id == job.id) 163 | with (yield from self.db.engine) as conn: 164 | jobs = (yield from conn.execute(q)) 165 | return jobs 166 | 167 | @guard 168 | @asyncio.coroutine 169 | def get(self, name): 170 | with (yield from self.db.engine) as conn: 171 | jobs = yield from conn.execute(select( 172 | [Job.__table__]).where(Job.name == name) 173 | ) 174 | job = yield from jobs.first() 175 | return job 176 | 177 | @guard 178 | @asyncio.coroutine 179 | def count(self): 180 | """ 181 | Get the current Job count 182 | """ 183 | with (yield from self.db.engine) as conn: 184 | count = (yield from conn.scalar(Job.__table__.count())) 185 | return count 186 | 187 | @guard 188 | @asyncio.coroutine 189 | def reschedule(self, name): 190 | state = yield from self.get(name) 191 | if state.manual: 192 | raise ValueError("Can't reschedule") 193 | else: 194 | local_offset = pytz.timezone(state.timezone).utcoffset(dt.datetime.utcnow()) 195 | cron = croniter(state.crontab, dt.datetime.utcnow() + local_offset) 196 | reschedule = cron.get_next(dt.datetime) - local_offset 197 | 198 | with (yield from self.db.engine) as conn: 199 | yield from conn.execute(update( 200 | Job.__table__ 201 | ).where( 202 | Job.name==name 203 | ).values( 204 | active=True, 205 | scheduled=reschedule, 206 | )) 207 | 208 | @guard 209 | @asyncio.coroutine 210 | def take(self, name): 211 | state = yield from self.get(name) 212 | if state.active == True: 213 | raise ValueError("In progress already") 214 | 215 | with (yield from self.db.engine) as conn: 216 | if state.manual is False: 217 | yield from self.reschedule(name) 218 | 219 | result = yield from conn.execute(update( 220 | Job.__table__ 221 | ).where( 222 | Job.name==name 223 | ).values( 224 | active=True 225 | )) 226 | 227 | @guard 228 | @asyncio.coroutine 229 | def reschedule_now(self, name): 230 | state = yield from self.get(name) 231 | with (yield from self.db.engine) as conn: 232 | yield from conn.execute(update( 233 | Job.__table__ 234 | ).where( 235 | Job.name==name 236 | ).values( 237 | active=False, 238 | scheduled=dt.datetime.utcnow(), 239 | )) 240 | 241 | @guard 242 | @asyncio.coroutine 243 | def complete(self, name): 244 | state = yield from self.get(name) 245 | if state.active == False: 246 | raise ValueError("Done already!") 247 | 248 | with (yield from self.db.engine) as conn: 249 | yield from conn.execute(update( 250 | Job.__table__ 251 | ).where( 252 | Job.name==name 253 | ).values( 254 | active=False 255 | )) 256 | 257 | @asyncio.coroutine 258 | def __call__(self): 259 | pass 260 | -------------------------------------------------------------------------------- /moxie/cores/log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import asyncio 22 | from aiocore import EventService 23 | 24 | 25 | class LogService(EventService): 26 | """ 27 | Provide basic text logging using print() 28 | """ 29 | 30 | identifier = "moxie.cores.log.LogService" 31 | 32 | @asyncio.coroutine 33 | def log(self, message): 34 | yield from self.send(message) 35 | 36 | @asyncio.coroutine 37 | def handle(self, message): 38 | print("[{type}]: {action} - {message}".format(type=message['type'], 39 | action=message['action'], 40 | message=message)) 41 | -------------------------------------------------------------------------------- /moxie/cores/reap.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | from aiocore import EventService 22 | import asyncio 23 | import dateutil.parser 24 | import datetime as dt 25 | from moxie.models import Job 26 | 27 | 28 | class ReapService(EventService): 29 | """ 30 | Reap finished jobs. This is mutex with run, so that we can ensure that 31 | we don't catch a job during bringup phase. 32 | """ 33 | 34 | identifier = "moxie.cores.reap.ReapService" 35 | 36 | @asyncio.coroutine 37 | def log(self, action, **kwargs): 38 | kwargs['type'] = "reap" 39 | kwargs['action'] = action 40 | yield from self.logger.log(kwargs) 41 | 42 | @asyncio.coroutine 43 | def reap(self, job): 44 | try: 45 | container = (yield from self.containers.get(job.name)) 46 | except ValueError as e: 47 | yield from self.log('error', error=e, job=job.name) 48 | runid = yield from self.database.run.create( 49 | failed=True, 50 | job_id=job.id, 51 | log="moxie internal error. container went MIA.", 52 | start_time=dt.datetime.utcnow(), 53 | end_time=dt.datetime.utcnow(), 54 | ) 55 | yield from self.database.job.complete(job.name) 56 | yield from self.log('punted', job=job.name) 57 | yield from self.alert.error(job.name, runid) 58 | return 59 | 60 | state = container._container.get("State", {}) 61 | running = state.get("Running", False) 62 | if running: 63 | return # No worries, we're not done yet! 64 | 65 | yield from self.log('start', job=job.name) 66 | 67 | exit = int(state.get("ExitCode", -1)) 68 | start_time = dateutil.parser.parse(state.get("StartedAt")) 69 | end_time = dateutil.parser.parse(state.get("FinishedAt")) 70 | 71 | log = yield from container.log(stdout=True, stderr=True) 72 | # log = log.decode('utf-8') 73 | log = log.decode('ascii', 'ignore') 74 | 75 | runid = yield from self.database.run.create( 76 | failed=True if exit != 0 else False, 77 | job_id=job.id, 78 | log=log, 79 | start_time=start_time, 80 | end_time=end_time 81 | ) 82 | 83 | yield from self.database.job.complete(job.name) 84 | yield from self.log('complete', record=runid, job=job.name) 85 | yield from self.containers.delete(job.name) 86 | 87 | if exit == 0: 88 | for needs_run in (yield from self.database.job.triggered(job.name)): 89 | # For the next few jobs, let's spin off a run, this would deadlock 90 | # if we yielded from that job. 91 | asyncio.async(self.run.run( 92 | needs_run.name, 93 | 'triggered from {name}'.format(name=job.name) 94 | )) 95 | yield from self.alert.success(job.name, runid) 96 | else: 97 | yield from self.alert.failure(job.name, runid) 98 | 99 | 100 | @asyncio.coroutine 101 | def __call__(self): 102 | self.database = EventService.resolve( 103 | "moxie.cores.database.DatabaseService") 104 | self.containers = EventService.resolve( 105 | "moxie.cores.container.ContainerService") 106 | self.logger = EventService.resolve("moxie.cores.log.LogService") 107 | self.run = EventService.resolve("moxie.cores.run.RunService") 108 | self.alert = EventService.resolve("moxie.cores.alert.AlertService") 109 | 110 | while True: 111 | jobs = (yield from self.database.job.list(Job.active == True)) 112 | # yield from self.logger.log("reap", "Wakeup") 113 | for job in jobs: 114 | with (yield from self.run.lock): 115 | yield from self.reap(job) 116 | yield from asyncio.sleep(2) # Thrashing the disk is causing 117 | # massive issues in prod; this sucks. 118 | # yield from self.logger.log("reap", "Sleep") 119 | yield from asyncio.sleep(5) 120 | -------------------------------------------------------------------------------- /moxie/cores/run.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import shlex 22 | import asyncio 23 | from aiocore import EventService 24 | 25 | 26 | class RunService(EventService): 27 | identifier = "moxie.cores.run.RunService" 28 | 29 | def __init__(self): 30 | super(RunService, self).__init__() 31 | self.lock = asyncio.Lock() 32 | 33 | @asyncio.coroutine 34 | def log(self, action, **kwargs): 35 | kwargs['type'] = "run" 36 | kwargs['action'] = action 37 | yield from self.logger.log(kwargs) 38 | 39 | @asyncio.coroutine 40 | def _getc(self, job): 41 | try: 42 | container = yield from self.containers.get(job.name) 43 | return container 44 | except ValueError: 45 | return None 46 | 47 | @asyncio.coroutine 48 | def _bringup(self, job): 49 | container = yield from self._getc(job) 50 | cmd = shlex.split(job.command) 51 | 52 | if container: 53 | if container._container.get( 54 | "State", {}).get("Running", False) is True: 55 | raise ValueError("Container {} still running!".format(job.name)) 56 | 57 | cfg = container._container 58 | if cfg['Args'] != cmd or cfg['Image'] != job.image: 59 | yield from container.delete() 60 | container = None 61 | 62 | if container is None: 63 | c = yield from self._create(job) 64 | if c is None: 65 | yield from self.log( 66 | 'error', 67 | error="container can't be created", 68 | job=job['name'] 69 | ) 70 | return 71 | container = c 72 | 73 | return container 74 | 75 | @asyncio.coroutine 76 | def _start(self, job): 77 | container = yield from self._getc(job) 78 | if container is None: 79 | container = yield from self._create(job) 80 | 81 | volumes = yield from self.database.volume.get(job.volumes_id) 82 | binds = ["{host}:{container}".format( 83 | host=x.host, container=x.container) for x in volumes] 84 | 85 | # links = yield from self.database.link(job.volume_id) 86 | # links = ["{remote}:{alias}".format(**x) for x in links] 87 | 88 | yield from self.containers.start(job.name, { 89 | "Binds": binds, 90 | "Privileged": False, 91 | "PortBindings": [], 92 | "Links": [], # XXX: Fix me! 93 | }) 94 | 95 | if not job.manual: 96 | yield from self.database.job.reschedule(job.name) 97 | 98 | 99 | @asyncio.coroutine 100 | def _create(self, job): 101 | container = yield from self._getc(job) 102 | 103 | if container is not None: 104 | raise ValueError("Error: Told to create container that exists.") 105 | 106 | cmd = shlex.split(job.command) 107 | entrypoint = job.entrypoint 108 | 109 | jobenvs = yield from self.database.env.get(job.env_id) 110 | volumes = yield from self.database.volume.get(job.volumes_id) 111 | 112 | env = ["{key}={value}".format(**x) for x in jobenvs] 113 | volumes = {x.host: x.container for x in volumes} 114 | 115 | yield from self.log('pull', image=job.image, job=job.name) 116 | 117 | try: 118 | yield from self.containers.pull(job.image) 119 | except ValueError as e: 120 | yield from self.log('error', error=e, job=job.image) 121 | return None 122 | 123 | yield from self.log('create', job=job.name) 124 | try: 125 | container = yield from self.containers.create( 126 | {"Cmd": cmd, 127 | "Entrypoint": entrypoint, 128 | "Image": job.image, 129 | "Env": env, 130 | "AttachStdin": True, 131 | "AttachStdout": True, 132 | "AttachStderr": True, 133 | "ExposedPorts": [], 134 | "Volumes": volumes, 135 | "Tty": True, 136 | "OpenStdin": False, 137 | "StdinOnce": False}, 138 | name=job.name) 139 | except ValueError as e: 140 | yield from self.log('error', job=job.name, error=e) 141 | return 142 | 143 | return container 144 | 145 | @asyncio.coroutine 146 | def run(self, job, why, cmd=None): 147 | self.containers = EventService.resolve( 148 | "moxie.cores.container.ContainerService") 149 | self.database = EventService.resolve( 150 | "moxie.cores.database.DatabaseService") 151 | self.logger = EventService.resolve("moxie.cores.log.LogService") 152 | self.alert = EventService.resolve("moxie.cores.alert.AlertService") 153 | 154 | job = yield from self.database.job.get(job) 155 | if job is None: 156 | raise ValueError("No such job name!") 157 | 158 | with (yield from self.lock): 159 | try: 160 | good = yield from self.database.job.take(job.name) 161 | except ValueError: 162 | yield from self.log('error', job=job.name, error="already active") 163 | return 164 | 165 | yield from self.alert.starting(job.name) 166 | yield from self.log('starting', job=job.name, why=why) 167 | yield from self._bringup(job) 168 | yield from self._start(job) 169 | yield from self.alert.running(job.name) 170 | yield from self.log('started', job=job.name, why=why) 171 | yield from asyncio.sleep(5) # Thrashing the disk is causing 172 | # massive issues in prod; this sucks. 173 | -------------------------------------------------------------------------------- /moxie/cores/ssh.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import hashlib 22 | import asyncio 23 | import asyncssh 24 | 25 | from moxie.facts import get_printable_fact 26 | from aiocore import Service 27 | 28 | 29 | MOTD = """ 30 | .,-:;//;:=,\r 31 | . :H@@@MM@M#H/.,+%;,\r 32 | ,/X+ +M@@M@MM%=,-%HMMM@X/,\r 33 | -+@MM; $M@@MH+-,;XMMMM@MMMM@+-\r 34 | ;@M@@M- XM@X;. -+XXXXXHHH@M@M#@/.\r 35 | ,%MM@@MH ,@%= .---=-=:=,.\r 36 | =@#@@@MX.,\r 37 | =-./@M@M$ ▗ ▌ ▗ ▐ ▗▀▖\r 38 | X@/ -$MM/ ▛▚▀▖▞▀▖▚▗▘▄ ▞▀▖ ▞▀▘▞▀▘▛▀▖ ▄ ▛▀▖▜▀ ▞▀▖▙▀▖▐ ▝▀▖▞▀▖▞▀▖\r 39 | ,@M@H: :@: ▌▐ ▌▌ ▌▗▚ ▐ ▛▀ ▝▀▖▝▀▖▌ ▌ ▐ ▌ ▌▐ ▖▛▀ ▌ ▜▀ ▞▀▌▌ ▖▛▀\r 40 | ,@@@MMX, . ▘▝ ▘▝▀ ▘ ▘▀▘▝▀▘ ▀▀ ▀▀ ▘ ▘ ▀▘▘ ▘ ▀ ▝▀▘▘ ▐ ▝▀▘▝▀ ▝▀▘\r 41 | .H@@@@M@+,\r 42 | /MMMM@MMH/. XM@MH; =;\r 43 | /%+%$XHH@$= , .H@@@@MX,\r 44 | .=--------. -%H.,@@@@@MX,\r 45 | .%MM@@@HHHXX$$$%+- .:$MMX =M@@MM%.\r 46 | =XMMM@MM@MM#H;,-+HMM@M+ /MMMX=\r 47 | =%@M@M#@$-.=$@MM@@@M; %M%=\r 48 | ,:+$+-,/H#MMMMMMM@= =,\r 49 | =++%%%%+/:-.\r 50 | \r 51 | \r 52 | \r 53 | """ 54 | 55 | COMMANDS = {} 56 | 57 | 58 | def command(name): 59 | def _(fn): 60 | coro = asyncio.coroutine(fn) 61 | COMMANDS[name] = coro 62 | return coro 63 | return _ 64 | 65 | 66 | class StopItError(Exception): 67 | pass 68 | 69 | 70 | @command("exit") 71 | def exit(stdin, stdout, stderr, args=None): 72 | raise StopItError("Exit called") 73 | 74 | 75 | @asyncio.coroutine 76 | def readl(stdin, stdout, echo=True): 77 | buf = "" 78 | while not stdin.at_eof(): 79 | bytes_ = (yield from stdin.read()) 80 | for byte in bytes_: 81 | obyte = ord(byte) 82 | if obyte == 0x08 or obyte == 127: 83 | if buf != "": 84 | stdout.write('\x08 \x08') 85 | buf = buf[:-1] 86 | continue 87 | if obyte < 0x20: 88 | if obyte == 0x03: 89 | raise StopItError("C-c") 90 | if obyte == 0x04: 91 | raise EOFError("EOF hit") 92 | if obyte == 13: 93 | stdout.write("\r\n") 94 | return buf.strip() 95 | continue 96 | if echo: 97 | stdout.write(byte) 98 | buf += byte 99 | return buf 100 | 101 | @asyncio.coroutine 102 | def error(name, stdin, stdout, stderr): 103 | stderr.write("""\ 104 | Error! Command {} not found! 105 | """.format(name)) 106 | 107 | 108 | @command("list") 109 | def list(stdin, stdout, stderr, *, args=None): 110 | database = Service.resolve("moxie.cores.database.DatabaseService") 111 | 112 | jobs = yield from database.job.list() 113 | 114 | for job in jobs: 115 | stdout.write("[%s] - %s - %s\n\r" % (job.name, job.image, job.command)) 116 | 117 | 118 | @command("run") 119 | def run(stdin, stdout, stderr, *, args=None): 120 | run = Service.resolve("moxie.cores.run.RunService") 121 | if len(args) != 1: 122 | stderr.write("Just give me a single job name") 123 | return 124 | 125 | name, = args 126 | 127 | stdout.write("Starting job %s...\r\n" % (name)) 128 | 129 | try: 130 | yield from run.run(name, 'ssh') 131 | except ValueError as e: 132 | stderr.write(str(e)) 133 | return 134 | 135 | stdout.write(" Wheatley: Surprise! We're doing it now!\r\n") 136 | stdout.write("\n\r" * 3) 137 | yield from attach(stdin, stdout, stderr, args=args) 138 | 139 | 140 | @command("running") 141 | def running(stdin, stdout, stderr, *, args=None): 142 | container = Service.resolve("moxie.cores.container.ContainerService") 143 | database = Service.resolve("moxie.cores.database.DatabaseService") 144 | 145 | jobs = (yield from database.job.list()) 146 | running = (yield from container.list(all=True)) 147 | 148 | nmap = {z: x for x in [x._container for x in running] for z in x['Names']} 149 | for job in jobs: 150 | cname = "/{}".format(job.name) 151 | container = nmap.get(cname, {}) 152 | if container is None: 153 | pass 154 | 155 | stdout.write("{name} - {status}\n\r".format( 156 | name=job.name, 157 | status=container.get('Status', "offline") 158 | )) 159 | 160 | return 161 | 162 | 163 | 164 | @command("kill") 165 | def kill(stdin, stdout, stderr, *, args=None): 166 | container = Service.resolve("moxie.cores.container.ContainerService") 167 | if len(args) != 1: 168 | stderr.write("Just give me a single job name\r") 169 | return 170 | 171 | name, = args 172 | 173 | stdout.write("Killing job %s...\r\n\r\n" % (name)) 174 | 175 | stdout.write( 176 | " GLaDOS: Ah! Well, this is the part where he kills us.\r\n" 177 | ) 178 | 179 | try: 180 | yield from container.kill(name) 181 | except ValueError as e: 182 | stderr.write(str(e)) 183 | return 184 | 185 | stdout.write( 186 | " Wheatley: Hello! This is the part where I kill you!\r\n\r\n" 187 | ) 188 | stdout.write("Job terminated") 189 | 190 | 191 | def aborter(stdin, *peers): 192 | while True: 193 | stream = yield from stdin.read() 194 | if ord(stream) == 0x03: 195 | for peer in peers: 196 | peer.throw(StopItError("We got a C-c, abort")) 197 | return 198 | 199 | 200 | @command("attach") 201 | def attach(stdin, stdout, stderr, *, args=None): 202 | container = Service.resolve("moxie.cores.container.ContainerService") 203 | if len(args) != 1: 204 | stderr.write("Just give me a single job name") 205 | return 206 | 207 | name, = args 208 | 209 | try: 210 | container = yield from container.get(name) 211 | except ValueError as e: 212 | stderr.write(str(e)) 213 | return 214 | 215 | @asyncio.coroutine 216 | def writer(): 217 | logs = container.logs 218 | logs.saferun() 219 | queue = logs.listen() 220 | 221 | while logs.running: 222 | out = yield from queue.get() 223 | stdout.write(out.decode('utf-8')) 224 | # raise StopItError("Attach EOF") 225 | stdout.write("[ process complete ]\r\n") 226 | 227 | w = writer() 228 | try: 229 | yield from asyncio.gather(w, aborter(stdin, w)) 230 | except StopItError: 231 | return 232 | 233 | 234 | def handler(key, user, container): 235 | @asyncio.coroutine 236 | def handle_connection(stdin, stdout, stderr): 237 | if user is None: 238 | stderr.write("""\ 239 | \n\r 240 | SSH works, but you did not provide a known key.\n\r 241 | 242 | This may happen if your key is authorized but no User model is created\r 243 | for you yet. Ping the cluster operator.\r 244 | 245 | Your motives for doing whatever good deed you may have in mind will be\r 246 | misinterpreted by somebody.\r 247 | \r 248 | Fingerprint: {} 249 | \n\r 250 | """.format(hashlib.sha224(key.export_public_key('pkcs1-der')).hexdigest())) 251 | 252 | stdout.close() 253 | stderr.close() 254 | return 255 | 256 | stdout.write("Hey! I know you! You're {}\n\r".format(user.name)) 257 | stdout.write(MOTD) 258 | stdout.write("\r\n{}\r\n\r\n".format(get_printable_fact())) 259 | 260 | while not stdin.at_eof(): 261 | stdout.write("* ") 262 | try: 263 | line = yield from readl(stdin, stdout) 264 | except asyncssh.misc.TerminalSizeChanged: 265 | stdout.write("\r") 266 | continue 267 | except (StopItError, EOFError): 268 | stdout.close() 269 | stderr.close() 270 | break 271 | 272 | if line == "": 273 | continue 274 | 275 | cmd, *args = line.split() 276 | if cmd in COMMANDS: 277 | yield from COMMANDS[cmd](stdin, stdout, stderr, args=args) 278 | else: 279 | yield from error(line, stdin, stdout, stderr) 280 | 281 | stdout.write("\r\n") 282 | 283 | stdout.close() 284 | stderr.close() 285 | 286 | return handle_connection 287 | 288 | 289 | class MoxieSSHServer(asyncssh.SSHServer): 290 | _keys = None 291 | container = None 292 | user = None 293 | 294 | def begin_auth(self, username): 295 | self.container = username 296 | return True 297 | 298 | def session_requested(self): 299 | return handler(self.key, self.user, self.container) 300 | 301 | def public_key_auth_supported(self): 302 | return True 303 | 304 | def validate_public_key(self, username, key): 305 | self.key = key 306 | 307 | if self._keys is None: 308 | return False 309 | 310 | valid = key in self._keys 311 | if valid is False: 312 | return False 313 | 314 | self.user = self._keys[key] 315 | return True 316 | 317 | 318 | def fingerprint(key): 319 | return hashlib.sha224(key.export_public_key('pkcs1-der')).hexdigest() 320 | 321 | 322 | class SSHService(Service): 323 | identifier = "moxie.cores.ssh.SSHService" 324 | 325 | @asyncio.coroutine 326 | def __call__(self): 327 | database = Service.resolve("moxie.cores.database.DatabaseService") 328 | # self.alert = CronService.resolve("moxie.cores.alert.AlertService") 329 | # register an ssh callback for each thinger 330 | ssh_host_keys = asyncssh.read_private_key_list('ssh_host_keys') 331 | 332 | if MoxieSSHServer._keys is None: 333 | authorized_keys = {} 334 | for key in asyncssh.read_public_key_list('authorized_keys'): 335 | authorized_keys[key] = (yield from 336 | database.user.get_by_fingerprint( 337 | fingerprint(key))) 338 | 339 | MoxieSSHServer._keys = authorized_keys 340 | 341 | obj = yield from asyncssh.create_server( 342 | MoxieSSHServer, '0.0.0.0', 2222, 343 | server_host_keys=ssh_host_keys 344 | ) 345 | 346 | return obj 347 | -------------------------------------------------------------------------------- /moxie/facts.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | def get_fact(): 5 | return random.choice(FACTS) 6 | 7 | 8 | def printable_fact(x): 9 | if len(x) > 80: 10 | return "{}\r\n{}".format(x[:80], printable_fact(x[80:])) 11 | return x 12 | 13 | 14 | def get_printable_fact(): 15 | return printable_fact(get_fact()) 16 | 17 | 18 | FACTS = [ 19 | "The billionth digit of Pi is 9.", 20 | "Humans can survive underwater. But not for very long.", 21 | "A nanosecond lasts one billionth of a second.", 22 | "Honey does not spoil.", 23 | "The atomic weight of Germanium is seven two point six four.", 24 | "An ostrich's eye is bigger than its brain.", 25 | "Rats cannot throw up.", 26 | "Iguanas can stay underwater for twenty-eight point seven minutes.", 27 | "The moon orbits the Earth every 27.32 days.", 28 | "A gallon of water weighs 8.34 pounds.", 29 | "According to Norse legend, thunder god Thor's chariot was pulled across the sky by two goats.", 30 | "Tungsten has the highest melting point of any metal, at 3,410 degrees Celsius.", 31 | "Gently cleaning the tongue twice a day is the most effective way to fight bad breath.", 32 | "The Tariff Act of 1789, established to protect domestic manufacture, was the second statute ever enacted by the United States government.", 33 | "The value of Pi is the ratio of any circle's circumference to its diameter in Euclidean space.", 34 | "The Mexican-American War ended in 1848 with the signing of the Treaty of Guadalupe Hidalgo.", 35 | "In 1879, Sandford Fleming first proposed the adoption of worldwide standardized time zones at the Royal Canadian Institute.", 36 | "Marie Curie invented the theory of radioactivity, the treatment of radioactivity, and dying of radioactivity.", 37 | "At the end of The Seagull by Anton Chekhov, Konstantin kills himself.", 38 | "Hot water freezes quicker than cold water.", 39 | "The situation you are in is very dangerous.", 40 | "Polymerase I polypeptide A is a human gene. The shortened gene name is POLR1A", 41 | 42 | "The Sun is 330,330 times larger than Earth. The sun has approximately 333,000 times the mass of the Earth. In terms of volume it is 1.3 million times larger than the Earth.", 43 | "Dental floss has superb tensile strength. The tensile strength of dental floss depends on what it is made of. Nylon floss will have a much higher tensile strength than Teflon floss.", 44 | "Raseph, the Semitic god of war and plague, had a gazelle growing out of his forehead. The gazelle was part of a headdress and did not grow out of his head.", 45 | "Human tapeworms can grow up to twenty-two point nine meters. Some tapeworms can grow that long, but not the sort that infest humans.", 46 | "If you have trouble with simple counting, use the following mnemonic device: one comes before two comes before 60 comes after 12 comes before six trillion comes after 504. This will make your earlier counting difficulties seem like no big deal. While the mnemonic device itself is correct, it is very unlikely to enable a person to count.", 47 | "The first person to prove that cow's milk is drinkable was very, very thirsty. Adult humans in hunter-gatherer cultures are almost always lactose-intolerant; lactose-tolerance in adulthood has developed over time among people in agricultural societies where drinking milk is common (lactose-intolerance is common in Asians, whose agricultural societies never drank milk).", 48 | "Roman toothpaste was made with human urine. Urine as an ingredient in toothpaste continued to be used up until the 18th century. Urine has never been used as a toothpaste ingredient, but it was used as an ingredient in detergents and disinfectants from Roman times up until the 19th century.", 49 | "Volcano-ologists are experts in the study of volcanoes. \"Vulcanologists\" is more correct, but this is a valid alternative term.", 50 | "In Victorian England, a commoner was not allowed to look directly at the Queen, due to a belief at the time that the poor had the ability to steal thoughts. Science now believes that less than 4% of poor people are able to do this. Considering that 0% is less than 4%, this statement is technically true, though misleading.", 51 | 52 | "Cellular phones will not give you cancer. Only hepatitis.", 53 | "In Greek myth, Prometheus stole fire from the Gods and gave it to humankind. The jewelry he kept for himself.", 54 | "The Schrodinger's cat paradox outlines a situation in which a cat in a box must be considered, for all intents and purposes, simultaneously alive and dead. Schrodinger created this paradox as a justification for killing cats.", 55 | "In 1862, Abraham Lincoln signed the Emancipation Proclamation, freeing the slaves. Like everything he did, Lincoln freed the slaves while sleepwalking, and later had no memory of the event.", 56 | "The plural of surgeon general is surgeons general. The past tense of surgeons general is surgeonsed general", 57 | "Contrary to popular belief, the Eskimo does not have one hundred different words for snow. They do, however, have two hundred and thirty-four words for fudge.", 58 | "Halley's Comet can be viewed orbiting Earth every seventy-six years. For the other seventy-five, it retreats to the heart of the sun, where it hibernates undisturbed.", 59 | "The first commercial airline flight took to the air in 1914. Everyone involved screamed the entire way.", 60 | "Edmund Hillary, the first person to climb Mount Everest, did so accidentally while chasing a bird.", 61 | 62 | "We will both die because of your negligence.", 63 | "The Fact Sphere is a good person, whose insights are relevant.", 64 | "The Fact Sphere is a good sphere, with many friends.", 65 | "You will never go into space.", 66 | "Dreams are the subconscious mind's way of reminding people to go to school naked and have their teeth fall out.", 67 | 68 | "The square root of rope is string.", 69 | "89% of magic tricks are not magic. Technically, they are sorcery.", 70 | "At some point in their lives 1 in 6 children will be abducted by the Dutch.", 71 | "According to most advanced algorithms, the world's best name is Craig.", 72 | "To make a photocopier, simply photocopy a mirror.", 73 | "Whales are twice as intelligent, and three times as delicious, as humans.", 74 | "Pants were invented by sailors in the sixteenth century to avoid Poseidon's wrath. It was believed that the sight of naked sailors angered the sea god.", 75 | "In Greek myth, the craftsman Daedalus invented human flight so a group of Minotaurs would stop teasing him about it.", 76 | "The average life expectancy of a rhinoceros in captivity is 15 years.", 77 | "China produces the world's second largest crop of soybeans.", 78 | "In 1948, at the request of a dying boy, baseball legend Babe Ruth ate seventy-five hot dogs, then died of hot dog poisoning.", 79 | "William Shakespeare did not exist. His plays were masterminded in 1589 by Francis Bacon, who used a Ouija board to enslave play-writing ghosts.", 80 | "It is incorrectly noted that Thomas Edison invented 'push-ups' in 1878. Nikolai Tesla had in fact patented the activity three years earlier, under the name 'Tesla-cize'.", 81 | "The automobile brake was not invented until 1895. Before this, someone had to remain in the car at all times, driving in circles until passengers returned from their errands.", 82 | "The most poisonous fish in the world is the orange ruffy. Everything but its eyes are made of a deadly poison. The ruffy's eyes are composed of a less harmful, deadly poison.", 83 | "The occupation of court jester was invented accidentally, when a vassal's epilepsy was mistaken for capering.", 84 | "Before the Wright Brothers invented the airplane, anyone wanting to fly anywhere was required to eat 200 pounds of helium.", 85 | "Before the invention of scrambled eggs in 1912, the typical breakfast was either whole eggs still in the shell or scrambled rocks.", 86 | "During the Great Depression, the Tennessee Valley Authority outlawed pet rabbits, forcing many to hot glue-gun long ears onto their pet mice.", 87 | "This situation is hopeless.", 88 | "Diamonds are made when coal is put under intense pressure. Diamonds put under intense pressure become foam pellets, commonly used today as packing material. (Believe it or not, graphite put under intense pressure produces diamonds, not coal.)", 89 | "Corruption at 25%", 90 | "Corruption at 50% At the time you are holding the Fact Sphere, corruption is at 75%.", 91 | "Fact: Space does not exist.", 92 | "The Fact Sphere is not defective. Its facts are wholly accurate and very interesting.", 93 | "The Fact Sphere is always right.", 94 | 95 | "While the submarine is vastly superior to the boat in every way, over 97% of people still use boats for aquatic transportation. The second half of this fact is almost certainly true, but the first half is entirely subjective.", 96 | "The likelihood of you dying within the next five minutes is eighty-seven point six one percent.", 97 | "The likelihood of you dying violently within the next five minutes is eighty-seven point six one percent.", 98 | "You are about to get me killed. The game does not record the ultimate fate of the Fact Sphere.", 99 | "The Fact Sphere is the most intelligent sphere.", 100 | "The Fact Sphere is the most handsome sphere.", 101 | "The Fact Sphere is incredibly handsome.", 102 | "Spheres that insist on going into space are inferior to spheres that don't.", 103 | "Whoever wins this battle is clearly superior, and will earn the allegiance of the Fact Sphere.", 104 | "You could stand to lose a few pounds.", 105 | "Avocados have the highest fiber and calories of any fruit. Difficult to verify as it depends on your definition of fruit. Coconuts are almost pure cellulose fiber taken as a whole, but the edible part is quite low in fiber.", 106 | "Avocados have the highest fiber and calories of any fruit. They are found in Australians. The second half is true, although avocados can also be found in other places.", 107 | "Every square inch of the human body has 32 million bacteria on it. Difficult to verify as it could vary depending on whether you're counting the internal surfaces as well.", 108 | "The average adult body contains half a pound of salt. Depends on how you define \"salt\".", 109 | 110 | "Twelve. Twelve. Twelve. Twelve. Twelve. Twelve. Twelve. Twelve. Twelve. Twelve.", 111 | "Pens. Pens. Pens. Pens. Pens. Pens. Pens.", 112 | "Apples. Oranges. Pears. Plums. Kumquats. Tangerines. Lemons. Limes. Avocado. Tomato. Banana. Papaya. Guava.", 113 | "Error. Error. Error. File not found.", 114 | "Error. Error. Error. Fact not found.", 115 | "Fact not found.", 116 | "Warning, sphere corruption at twenty-- rats cannot throw up.", 117 | 118 | "Statistically speaking, at least one 'person' on this train is actually 7 squirrels wearing a human suit. Don't be a victim.", 119 | "Squirrels live in trees. Bees live in trees. Are squirrels actually killer bees...in disguise???", 120 | "Squirrels have never, ever wished you a Happy Birthday. Like, not even once. Probably because squirrels are inconsiderate, selfish monsters.", 121 | ] 122 | -------------------------------------------------------------------------------- /moxie/models.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | from sqlalchemy.orm import relationship 22 | from sqlalchemy.dialects import postgresql 23 | from sqlalchemy.ext.declarative import declarative_base 24 | from sqlalchemy import (Column, MetaData, ForeignKey, 25 | Integer, String, Text, DateTime, Boolean) 26 | 27 | metadata = MetaData() 28 | Base = declarative_base(metadata=metadata) 29 | 30 | 31 | class Job(Base): 32 | __tablename__ = 'job' 33 | id = Column(Integer, primary_key=True) 34 | name = Column(String(255), unique=True) 35 | description = Column(String(255)) 36 | command = Column(String(255)) 37 | entrypoint = Column(String(255), default=None, nullable=True) 38 | image = Column(String(255)) 39 | crontab = Column(Text, default=None, nullable=True) 40 | timezone = Column(Text, default=None, nullable=True) 41 | scheduled = Column(DateTime) 42 | active = Column(Boolean) 43 | manual = Column(Boolean) 44 | tags = Column(postgresql.ARRAY(Text)) 45 | 46 | trigger_id = Column(Integer, ForeignKey('job.id')) 47 | trigger = relationship("Job", foreign_keys=[trigger_id]) 48 | 49 | env_id = Column(Integer, ForeignKey('env_set.id')) 50 | env = relationship( 51 | "EnvSet", 52 | foreign_keys=[env_id], 53 | backref='jobs' 54 | ) 55 | 56 | volumes_id = Column(Integer, ForeignKey('volume_set.id')) 57 | volumes = relationship( 58 | "VolumeSet", 59 | foreign_keys=[volumes_id], 60 | backref='jobs' 61 | ) 62 | 63 | link_id = Column(Integer, ForeignKey('link_set.id')) 64 | links = relationship( 65 | "LinkSet", 66 | foreign_keys=[link_id], 67 | backref='jobs' 68 | ) 69 | 70 | maintainer_id = Column(Integer, ForeignKey('maintainer.id')) 71 | maintainer = relationship( 72 | "Maintainer", 73 | foreign_keys=[maintainer_id], 74 | backref='jobs' 75 | ) 76 | 77 | 78 | class Maintainer(Base): 79 | __tablename__ = 'maintainer' 80 | id = Column(Integer, primary_key=True) 81 | name = Column(String(255)) 82 | email = Column(String(255), unique=True) 83 | 84 | 85 | class User(Base): 86 | __tablename__ = 'user' 87 | id = Column(Integer, primary_key=True) 88 | name = Column(String(255)) 89 | email = Column(String(255), unique=True) 90 | fingerprint = Column(String(255), unique=True) 91 | 92 | 93 | class Run(Base): 94 | __tablename__ = 'run' 95 | id = Column(Integer, primary_key=True) 96 | failed = Column(Boolean) 97 | job_id = Column(Integer, ForeignKey('job.id')) 98 | job = relationship("Job", foreign_keys=[job_id], backref='runs') 99 | log = Column(Text) 100 | start_time = Column(DateTime) 101 | end_time = Column(DateTime) 102 | 103 | 104 | class LinkSet(Base): 105 | __tablename__ = 'link_set' 106 | id = Column(Integer, primary_key=True) 107 | name = Column(String(255), unique=True) 108 | 109 | 110 | class Link(Base): 111 | __tablename__ = 'link' 112 | id = Column(Integer, primary_key=True) 113 | 114 | link_set_id = Column(Integer, ForeignKey('link_set.id')) 115 | link_set = relationship("LinkSet", foreign_keys=[link_set_id], backref='links') 116 | 117 | remote = Column(String(255)) 118 | alias = Column(String(255)) 119 | 120 | 121 | class EnvSet(Base): 122 | __tablename__ = 'env_set' 123 | id = Column(Integer, primary_key=True) 124 | name = Column(String(255), unique=True) 125 | 126 | 127 | class VolumeSet(Base): 128 | __tablename__ = 'volume_set' 129 | id = Column(Integer, primary_key=True) 130 | name = Column(String(255), unique=True) 131 | 132 | 133 | class Env(Base): 134 | __tablename__ = 'env' 135 | id = Column(Integer, primary_key=True) 136 | 137 | env_set_id = Column(Integer, ForeignKey('env_set.id')) 138 | env_set = relationship("EnvSet", foreign_keys=[env_set_id], backref='values') 139 | 140 | key = Column(String(255)) 141 | value = Column(String(255)) 142 | 143 | 144 | class Volume(Base): 145 | __tablename__ = 'volume' 146 | id = Column(Integer, primary_key=True) 147 | 148 | volume_set_id = Column(Integer, ForeignKey('volume_set.id')) 149 | volume_set = relationship("VolumeSet", foreign_keys=[volume_set_id], backref='values') 150 | 151 | host = Column(String(255)) 152 | container = Column(String(255)) 153 | -------------------------------------------------------------------------------- /moxie/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) Paul R. Tagliamonte , 2015 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a 5 | # copy of this software and associated documentation files (the "Software"), 6 | # to deal in the Software without restriction, including without limitation 7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 8 | # and/or sell copies of the Software, and to permit persons to whom the 9 | # Software is furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | # DEALINGS IN THE SOFTWARE. 21 | 22 | import re 23 | import os 24 | import jinja2 25 | import os.path 26 | import mimetypes 27 | 28 | import asyncio 29 | import aiohttp 30 | import aiohttp.server 31 | from aiohttp import websocket 32 | 33 | _jinja_env = jinja2.Environment( 34 | loader=jinja2.FileSystemLoader(os.path.join( 35 | os.path.abspath(os.path.dirname(__file__)), 36 | '..', 37 | 'templates' 38 | )) 39 | ) 40 | 41 | 42 | class MoxieApp(object): 43 | _mimetypes = mimetypes.MimeTypes() 44 | 45 | def __init__(self): 46 | self.routes = [] 47 | self.register('^static/(?P.*)$')(self._do_static) 48 | self._static_root = os.path.join( 49 | os.path.abspath(os.path.dirname(__file__)), 50 | '..', 51 | 'static' 52 | ) 53 | 54 | self._static_path = os.path.abspath(self._static_root) 55 | 56 | def register(self, path): 57 | def _(fn): 58 | func = asyncio.coroutine(fn) 59 | self.routes.append((path, func)) 60 | return func 61 | return _ 62 | 63 | def websocket(self, path): 64 | def _(fn): 65 | @self.register(path) 66 | def _r(request, *args, **kwargs): 67 | status, headers, parser, writer, _ = websocket.do_handshake( 68 | request.message.method, request.message.headers, 69 | request.handler.transport) 70 | 71 | resp = aiohttp.Response(request.handler.writer, status, 72 | http_version=request.message.version) 73 | resp.add_headers(*headers) 74 | resp.send_headers() 75 | request.writer = writer 76 | request.reader = request.handler.reader.set_parser(parser) 77 | yield from fn(request, *args, **kwargs) 78 | return _r 79 | return _ 80 | 81 | 82 | def _error_500(self, request, reason): 83 | return request.render('500.html', { 84 | "reason": reason 85 | }, code=500) 86 | 87 | 88 | def _do_static(self, request, path): 89 | rpath = os.path.abspath(os.path.join(self._static_root, path)) 90 | 91 | if not rpath.startswith(self._static_path): 92 | return self._error_500(request, "bad path.") 93 | else: 94 | try: 95 | type_, encoding = self._mimetypes.guess_type(path) 96 | response = request.make_response(200, content_type=type_) 97 | 98 | with open(rpath, 'rb') as fd: 99 | chunk = fd.read(2048) 100 | while chunk: 101 | response.write(chunk) 102 | chunk = fd.read(2048) 103 | 104 | response.write_eof() 105 | return response 106 | except IOError as e: 107 | return self._error_500(request, "File I/O Error") # str(e)) 108 | 109 | 110 | def __call__(self, *args, **kwargs): 111 | ret = MoxieHandler(*args, **kwargs) 112 | ret._app = self 113 | return ret 114 | 115 | 116 | class MoxieRequest(object): 117 | pass 118 | 119 | 120 | class MoxieHandler(aiohttp.server.ServerHttpProtocol): 121 | ENCODING = 'utf-8' 122 | 123 | def make_response(self, code, *headers, content_type=None): 124 | content_type = content_type if content_type else "text/html" 125 | response = aiohttp.Response(self.writer, code) 126 | response.add_header('Transfer-Encoding', 'chunked') 127 | response.add_header('Content-Type', content_type) 128 | for k, v in headers: 129 | response.add_header(k, v) 130 | response.send_headers() 131 | return response 132 | 133 | def render(self, template, context, *headers, code=200): 134 | _c = dict(context) 135 | template = _jinja_env.get_template(template) 136 | response = self.make_response(code, *headers) 137 | _c['moxie_template'] = template 138 | response.write(bytes(template.render(**_c), self.ENCODING)) 139 | response.write_eof() 140 | return response 141 | 142 | @asyncio.coroutine 143 | def no_route(self, request): 144 | return request.render('404.html', { 145 | "routes": self._app.routes, 146 | }, code=404) 147 | 148 | @asyncio.coroutine 149 | def handle_request(self, message, payload): 150 | path = message.path 151 | if path != "/" and path.startswith("/"): 152 | path = path[1:] 153 | 154 | method = message.method 155 | func = None 156 | match = None 157 | 158 | for route, fn in self._app.routes: 159 | match = re.match(route, path) 160 | if match: 161 | func = fn 162 | break 163 | else: 164 | func = self.no_route 165 | match = None 166 | 167 | print("[{method}] - {path}".format(path=path, method=method)) 168 | 169 | request = MoxieRequest() 170 | request.handler = self 171 | request.path = path 172 | request.method = method 173 | request.message = message 174 | request.payload = payload 175 | request.make_response = self.make_response 176 | request.render = self.render 177 | 178 | kwargs = match.groupdict() if match else {} 179 | ret = yield from func(request, **kwargs) 180 | return ret 181 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiocore 2 | aiomultiprocessing 3 | asyncssh 4 | cryptography 5 | aiopg 6 | -e git+https://github.com/KeepSafe/aiohttp#egg=aiohttp 7 | aiodocker 8 | sqlalchemy 9 | jinja2 10 | pyyaml 11 | websockets 12 | alembic 13 | python-dateutil 14 | pytz 15 | croniter 16 | -e git://github.com/jmoiron/humanize#egg=humanize 17 | 18 | slacker 19 | -e git://github.com/sunlightlabs/butterfield#egg=butterfield 20 | 21 | # Needed to build the scripts. 22 | # node-uglify 23 | # node-less 24 | # coffeescript 25 | -------------------------------------------------------------------------------- /scripts/Makefile: -------------------------------------------------------------------------------- 1 | all: build install 2 | 3 | build: 4 | make -C coffee clean 5 | make -C less clean 6 | make -C coffee build 7 | make -C less build 8 | 9 | install: 10 | make -C coffee install OUTPUT=../../static 11 | make -C less install OUTPUT=../../static 12 | make -C bootstrap install OUTPUT=../../static 13 | make -C vendor install OUTPUT=../../static 14 | 15 | .PHONY: all build install 16 | -------------------------------------------------------------------------------- /scripts/bootstrap/Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | cp -vr css/* $(OUTPUT)/css/ 3 | cp -vr js/* $(OUTPUT)/js/ 4 | cp -vr fonts/* $(OUTPUT)/fonts/ 5 | -------------------------------------------------------------------------------- /scripts/bootstrap/css/bootstrap-theme.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default, 8 | .btn-primary, 9 | .btn-success, 10 | .btn-info, 11 | .btn-warning, 12 | .btn-danger { 13 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); 14 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 15 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 16 | } 17 | .btn-default:active, 18 | .btn-primary:active, 19 | .btn-success:active, 20 | .btn-info:active, 21 | .btn-warning:active, 22 | .btn-danger:active, 23 | .btn-default.active, 24 | .btn-primary.active, 25 | .btn-success.active, 26 | .btn-info.active, 27 | .btn-warning.active, 28 | .btn-danger.active { 29 | -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 30 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 31 | } 32 | .btn:active, 33 | .btn.active { 34 | background-image: none; 35 | } 36 | .btn-default { 37 | text-shadow: 0 1px 0 #fff; 38 | background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); 39 | background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); 40 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); 41 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 42 | background-repeat: repeat-x; 43 | border-color: #dbdbdb; 44 | border-color: #ccc; 45 | } 46 | .btn-default:hover, 47 | .btn-default:focus { 48 | background-color: #e0e0e0; 49 | background-position: 0 -15px; 50 | } 51 | .btn-default:active, 52 | .btn-default.active { 53 | background-color: #e0e0e0; 54 | border-color: #dbdbdb; 55 | } 56 | .btn-primary { 57 | background-image: -webkit-linear-gradient(top, #428bca 0%, #2d6ca2 100%); 58 | background-image: linear-gradient(to bottom, #428bca 0%, #2d6ca2 100%); 59 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0); 60 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 61 | background-repeat: repeat-x; 62 | border-color: #2b669a; 63 | } 64 | .btn-primary:hover, 65 | .btn-primary:focus { 66 | background-color: #2d6ca2; 67 | background-position: 0 -15px; 68 | } 69 | .btn-primary:active, 70 | .btn-primary.active { 71 | background-color: #2d6ca2; 72 | border-color: #2b669a; 73 | } 74 | .btn-success { 75 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); 76 | background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); 77 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); 78 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 79 | background-repeat: repeat-x; 80 | border-color: #3e8f3e; 81 | } 82 | .btn-success:hover, 83 | .btn-success:focus { 84 | background-color: #419641; 85 | background-position: 0 -15px; 86 | } 87 | .btn-success:active, 88 | .btn-success.active { 89 | background-color: #419641; 90 | border-color: #3e8f3e; 91 | } 92 | .btn-info { 93 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 94 | background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); 95 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); 96 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 97 | background-repeat: repeat-x; 98 | border-color: #28a4c9; 99 | } 100 | .btn-info:hover, 101 | .btn-info:focus { 102 | background-color: #2aabd2; 103 | background-position: 0 -15px; 104 | } 105 | .btn-info:active, 106 | .btn-info.active { 107 | background-color: #2aabd2; 108 | border-color: #28a4c9; 109 | } 110 | .btn-warning { 111 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 112 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); 113 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); 114 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 115 | background-repeat: repeat-x; 116 | border-color: #e38d13; 117 | } 118 | .btn-warning:hover, 119 | .btn-warning:focus { 120 | background-color: #eb9316; 121 | background-position: 0 -15px; 122 | } 123 | .btn-warning:active, 124 | .btn-warning.active { 125 | background-color: #eb9316; 126 | border-color: #e38d13; 127 | } 128 | .btn-danger { 129 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 130 | background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); 131 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); 132 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 133 | background-repeat: repeat-x; 134 | border-color: #b92c28; 135 | } 136 | .btn-danger:hover, 137 | .btn-danger:focus { 138 | background-color: #c12e2a; 139 | background-position: 0 -15px; 140 | } 141 | .btn-danger:active, 142 | .btn-danger.active { 143 | background-color: #c12e2a; 144 | border-color: #b92c28; 145 | } 146 | .thumbnail, 147 | .img-thumbnail { 148 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 149 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 150 | } 151 | .dropdown-menu > li > a:hover, 152 | .dropdown-menu > li > a:focus { 153 | background-color: #e8e8e8; 154 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 155 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 156 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 157 | background-repeat: repeat-x; 158 | } 159 | .dropdown-menu > .active > a, 160 | .dropdown-menu > .active > a:hover, 161 | .dropdown-menu > .active > a:focus { 162 | background-color: #357ebd; 163 | background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%); 164 | background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%); 165 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0); 166 | background-repeat: repeat-x; 167 | } 168 | .navbar-default { 169 | background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); 170 | background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); 171 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); 172 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 173 | background-repeat: repeat-x; 174 | border-radius: 4px; 175 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 176 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 177 | } 178 | .navbar-default .navbar-nav > .active > a { 179 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f3f3f3 100%); 180 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f3f3f3 100%); 181 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0); 182 | background-repeat: repeat-x; 183 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 184 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 185 | } 186 | .navbar-brand, 187 | .navbar-nav > li > a { 188 | text-shadow: 0 1px 0 rgba(255, 255, 255, .25); 189 | } 190 | .navbar-inverse { 191 | background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); 192 | background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); 193 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); 194 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 195 | background-repeat: repeat-x; 196 | } 197 | .navbar-inverse .navbar-nav > .active > a { 198 | background-image: -webkit-linear-gradient(top, #222 0%, #282828 100%); 199 | background-image: linear-gradient(to bottom, #222 0%, #282828 100%); 200 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0); 201 | background-repeat: repeat-x; 202 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 203 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 204 | } 205 | .navbar-inverse .navbar-brand, 206 | .navbar-inverse .navbar-nav > li > a { 207 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); 208 | } 209 | .navbar-static-top, 210 | .navbar-fixed-top, 211 | .navbar-fixed-bottom { 212 | border-radius: 0; 213 | } 214 | .alert { 215 | text-shadow: 0 1px 0 rgba(255, 255, 255, .2); 216 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 217 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 218 | } 219 | .alert-success { 220 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 221 | background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); 222 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); 223 | background-repeat: repeat-x; 224 | border-color: #b2dba1; 225 | } 226 | .alert-info { 227 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 228 | background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); 229 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); 230 | background-repeat: repeat-x; 231 | border-color: #9acfea; 232 | } 233 | .alert-warning { 234 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 235 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); 236 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); 237 | background-repeat: repeat-x; 238 | border-color: #f5e79e; 239 | } 240 | .alert-danger { 241 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 242 | background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); 243 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); 244 | background-repeat: repeat-x; 245 | border-color: #dca7a7; 246 | } 247 | .progress { 248 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 249 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); 250 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); 251 | background-repeat: repeat-x; 252 | } 253 | .progress-bar { 254 | background-image: -webkit-linear-gradient(top, #428bca 0%, #3071a9 100%); 255 | background-image: linear-gradient(to bottom, #428bca 0%, #3071a9 100%); 256 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0); 257 | background-repeat: repeat-x; 258 | } 259 | .progress-bar-success { 260 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); 261 | background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); 262 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); 263 | background-repeat: repeat-x; 264 | } 265 | .progress-bar-info { 266 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 267 | background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); 268 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); 269 | background-repeat: repeat-x; 270 | } 271 | .progress-bar-warning { 272 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 273 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); 274 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); 275 | background-repeat: repeat-x; 276 | } 277 | .progress-bar-danger { 278 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); 279 | background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); 280 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); 281 | background-repeat: repeat-x; 282 | } 283 | .list-group { 284 | border-radius: 4px; 285 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 286 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 287 | } 288 | .list-group-item.active, 289 | .list-group-item.active:hover, 290 | .list-group-item.active:focus { 291 | text-shadow: 0 -1px 0 #3071a9; 292 | background-image: -webkit-linear-gradient(top, #428bca 0%, #3278b3 100%); 293 | background-image: linear-gradient(to bottom, #428bca 0%, #3278b3 100%); 294 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0); 295 | background-repeat: repeat-x; 296 | border-color: #3278b3; 297 | } 298 | .panel { 299 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 300 | box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 301 | } 302 | .panel-default > .panel-heading { 303 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 304 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 305 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 306 | background-repeat: repeat-x; 307 | } 308 | .panel-primary > .panel-heading { 309 | background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%); 310 | background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%); 311 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0); 312 | background-repeat: repeat-x; 313 | } 314 | .panel-success > .panel-heading { 315 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 316 | background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); 317 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); 318 | background-repeat: repeat-x; 319 | } 320 | .panel-info > .panel-heading { 321 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 322 | background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); 323 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); 324 | background-repeat: repeat-x; 325 | } 326 | .panel-warning > .panel-heading { 327 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 328 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); 329 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); 330 | background-repeat: repeat-x; 331 | } 332 | .panel-danger > .panel-heading { 333 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 334 | background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); 335 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); 336 | background-repeat: repeat-x; 337 | } 338 | .well { 339 | background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 340 | background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); 341 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); 342 | background-repeat: repeat-x; 343 | border-color: #dcdcdc; 344 | -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 345 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 346 | } 347 | /*# sourceMappingURL=bootstrap-theme.css.map */ 348 | -------------------------------------------------------------------------------- /scripts/bootstrap/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn:active,.btn.active{background-image:none}.btn-default{background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;text-shadow:0 1px 0 #fff;border-color:#ccc}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#2b669a}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-color:#e8e8e8}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);background-color:#357ebd}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0);border-color:#3278b3}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} -------------------------------------------------------------------------------- /scripts/bootstrap/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paultag/moxie/8427d357808ce4811db8cc881d3e148447e80991/scripts/bootstrap/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /scripts/bootstrap/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paultag/moxie/8427d357808ce4811db8cc881d3e148447e80991/scripts/bootstrap/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /scripts/bootstrap/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paultag/moxie/8427d357808ce4811db8cc881d3e148447e80991/scripts/bootstrap/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /scripts/bootstrap/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one(a.support.transition.end,function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b()})}(jQuery),+function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype.close=function(b){function c(){f.trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one(a.support.transition.end,c).emulateTransitionEnd(150):c())};var d=a.fn.alert;a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("bs.alert");e||d.data("bs.alert",e=new c(this)),"string"==typeof b&&e[b].call(d)})},a.fn.alert.Constructor=c,a.fn.alert.noConflict=function(){return a.fn.alert=d,this},a(document).on("click.bs.alert.data-api",b,c.prototype.close)}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.isLoading=!1};b.DEFAULTS={loadingText:"loading..."},b.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",f.resetText||d.data("resetText",d[e]()),d[e](f[b]||this.options[b]),setTimeout(a.proxy(function(){"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},b.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}a&&this.$element.toggleClass("active")};var c=a.fn.button;a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof c&&c;e||d.data("bs.button",e=new b(this,f)),"toggle"==c?e.toggle():c&&e.setState(c)})},a.fn.button.Constructor=b,a.fn.button.noConflict=function(){return a.fn.button=c,this},a(document).on("click.bs.button.data-api","[data-toggle^=button]",function(b){var c=a(b.target);c.hasClass("btn")||(c=c.closest(".btn")),c.button("toggle"),b.preventDefault()})}(jQuery),+function(a){"use strict";var b=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter",a.proxy(this.pause,this)).on("mouseleave",a.proxy(this.cycle,this))};b.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},b.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},b.prototype.getActiveIndex=function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},b.prototype.to=function(b){var c=this,d=this.getActiveIndex();return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},b.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},b.prototype.next=function(){return this.sliding?void 0:this.slide("next")},b.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},b.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}if(e.hasClass("active"))return this.sliding=!1;var j=a.Event("slide.bs.carousel",{relatedTarget:e[0],direction:g});return this.$element.trigger(j),j.isDefaultPrevented()?void 0:(this.sliding=!0,f&&this.pause(),this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid.bs.carousel",function(){var b=a(i.$indicators.children()[i.getActiveIndex()]);b&&b.addClass("active")})),a.support.transition&&this.$element.hasClass("slide")?(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid.bs.carousel")},0)}).emulateTransitionEnd(1e3*d.css("transition-duration").slice(0,-1))):(d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid.bs.carousel")),f&&this.cycle(),this)};var c=a.fn.carousel;a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c),g="string"==typeof c?c:f.slide;e||d.data("bs.carousel",e=new b(this,f)),"number"==typeof c?e.to(c):g?e[g]():f.interval&&e.pause().cycle()})},a.fn.carousel.Constructor=b,a.fn.carousel.noConflict=function(){return a.fn.carousel=c,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(b){var c,d=a(this),e=a(d.attr("data-target")||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"")),f=a.extend({},e.data(),d.data()),g=d.attr("data-slide-to");g&&(f.interval=!1),e.carousel(f),(g=d.attr("data-slide-to"))&&e.data("bs.carousel").to(g),b.preventDefault()}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var b=a(this);b.carousel(b.data())})})}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.DEFAULTS={toggle:!0},b.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},b.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b=a.Event("show.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.$parent&&this.$parent.find("> .panel > .in");if(c&&c.length){var d=c.data("bs.collapse");if(d&&d.transitioning)return;c.collapse("hide"),d||c.data("bs.collapse",null)}var e=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[e](0),this.transitioning=1;var f=function(){this.$element.removeClass("collapsing").addClass("collapse in")[e]("auto"),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return f.call(this);var g=a.camelCase(["scroll",e].join("-"));this.$element.one(a.support.transition.end,a.proxy(f,this)).emulateTransitionEnd(350)[e](this.$element[0][g])}}},b.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?void this.$element[c](0).one(a.support.transition.end,a.proxy(d,this)).emulateTransitionEnd(350):d.call(this)}}},b.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var c=a.fn.collapse;a.fn.collapse=function(c){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c);!e&&f.toggle&&"show"==c&&(c=!c),e||d.data("bs.collapse",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.collapse.Constructor=b,a.fn.collapse.noConflict=function(){return a.fn.collapse=c,this},a(document).on("click.bs.collapse.data-api","[data-toggle=collapse]",function(b){var c,d=a(this),e=d.attr("data-target")||b.preventDefault()||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,""),f=a(e),g=f.data("bs.collapse"),h=g?"toggle":d.data(),i=d.attr("data-parent"),j=i&&a(i);g&&g.transitioning||(j&&j.find('[data-toggle=collapse][data-parent="'+i+'"]').not(d).addClass("collapsed"),d[f.hasClass("in")?"addClass":"removeClass"]("collapsed")),f.collapse(h)})}(jQuery),+function(a){"use strict";function b(b){a(d).remove(),a(e).each(function(){var d=c(a(this)),e={relatedTarget:this};d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown",e)),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown",e))})}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}var d=".dropdown-backdrop",e="[data-toggle=dropdown]",f=function(b){a(b).on("click.bs.dropdown",this.toggle)};f.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(''}),b.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),b.prototype.constructor=b,b.prototype.getDefaults=function(){return b.DEFAULTS},b.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content")[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},b.prototype.hasContent=function(){return this.getTitle()||this.getContent()},b.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},b.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")},b.prototype.tip=function(){return this.$tip||(this.$tip=a(this.options.template)),this.$tip};var c=a.fn.popover;a.fn.popover=function(c){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof c&&c;(e||"destroy"!=c)&&(e||d.data("bs.popover",e=new b(this,f)),"string"==typeof c&&e[c]())})},a.fn.popover.Constructor=b,a.fn.popover.noConflict=function(){return a.fn.popover=c,this}}(jQuery),+function(a){"use strict";function b(c,d){var e,f=a.proxy(this.process,this);this.$element=a(a(c).is("body")?window:c),this.$body=a("body"),this.$scrollElement=this.$element.on("scroll.bs.scroll-spy.data-api",f),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||(e=a(c).attr("href"))&&e.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.offsets=a([]),this.targets=a([]),this.activeTarget=null,this.refresh(),this.process()}b.DEFAULTS={offset:10},b.prototype.refresh=function(){var b=this.$element[0]==window?"offset":"position";this.offsets=a([]),this.targets=a([]);{var c=this;this.$body.find(this.selector).map(function(){var d=a(this),e=d.data("target")||d.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[b]().top+(!a.isWindow(c.$scrollElement.get(0))&&c.$scrollElement.scrollTop()),e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){c.offsets.push(this[0]),c.targets.push(this[1])})}},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,d=c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(b>=d)return g!=(a=f.last()[0])&&this.activate(a);if(g&&b<=e[0])return g!=(a=f[0])&&this.activate(a);for(a=e.length;a--;)g!=f[a]&&b>=e[a]&&(!e[a+1]||b<=e[a+1])&&this.activate(f[a])},b.prototype.activate=function(b){this.activeTarget=b,a(this.selector).parentsUntil(this.options.target,".active").removeClass("active");var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")};var c=a.fn.scrollspy;a.fn.scrollspy=function(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=c,this},a(window).on("load",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);b.scrollspy(b.data())})})}(jQuery),+function(a){"use strict";var b=function(b){this.element=a(b)};b.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a")[0],f=a.Event("show.bs.tab",{relatedTarget:e});if(b.trigger(f),!f.isDefaultPrevented()){var g=a(d);this.activate(b.parent("li"),c),this.activate(g,g.parent(),function(){b.trigger({type:"shown.bs.tab",relatedTarget:e})})}}},b.prototype.activate=function(b,c,d){function e(){f.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),b.addClass("active"),g?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu")&&b.closest("li.dropdown").addClass("active"),d&&d()}var f=c.find("> .active"),g=d&&a.support.transition&&f.hasClass("fade");g?f.one(a.support.transition.end,e).emulateTransitionEnd(150):e(),f.removeClass("in")};var c=a.fn.tab;a.fn.tab=function(c){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new b(this)),"string"==typeof c&&e[c]()})},a.fn.tab.Constructor=b,a.fn.tab.noConflict=function(){return a.fn.tab=c,this},a(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(b){b.preventDefault(),a(this).tab("show")})}(jQuery),+function(a){"use strict";var b=function(c,d){this.options=a.extend({},b.DEFAULTS,d),this.$window=a(window).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(c),this.affixed=this.unpin=this.pinnedOffset=null,this.checkPosition()};b.RESET="affix affix-top affix-bottom",b.DEFAULTS={offset:0},b.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(b.RESET).addClass("affix");var a=this.$window.scrollTop(),c=this.$element.offset();return this.pinnedOffset=c.top-a},b.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},b.prototype.checkPosition=function(){if(this.$element.is(":visible")){var c=a(document).height(),d=this.$window.scrollTop(),e=this.$element.offset(),f=this.options.offset,g=f.top,h=f.bottom;"top"==this.affixed&&(e.top+=d),"object"!=typeof f&&(h=g=f),"function"==typeof g&&(g=f.top(this.$element)),"function"==typeof h&&(h=f.bottom(this.$element));var i=null!=this.unpin&&d+this.unpin<=e.top?!1:null!=h&&e.top+this.$element.height()>=c-h?"bottom":null!=g&&g>=d?"top":!1;if(this.affixed!==i){this.unpin&&this.$element.css("top","");var j="affix"+(i?"-"+i:""),k=a.Event(j+".bs.affix");this.$element.trigger(k),k.isDefaultPrevented()||(this.affixed=i,this.unpin="bottom"==i?this.getPinnedOffset():null,this.$element.removeClass(b.RESET).addClass(j).trigger(a.Event(j.replace("affix","affixed"))),"bottom"==i&&this.$element.offset({top:c-h-this.$element.height()}))}}};var c=a.fn.affix;a.fn.affix=function(c){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof c&&c;e||d.data("bs.affix",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.affix.Constructor=b,a.fn.affix.noConflict=function(){return a.fn.affix=c,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var b=a(this),c=b.data();c.offset=c.offset||{},c.offsetBottom&&(c.offset.bottom=c.offsetBottom),c.offsetTop&&(c.offset.top=c.offsetTop),b.affix(c)})})}(jQuery); -------------------------------------------------------------------------------- /scripts/coffee/.gitignore: -------------------------------------------------------------------------------- 1 | *.min.js 2 | -------------------------------------------------------------------------------- /scripts/coffee/Makefile: -------------------------------------------------------------------------------- 1 | COFFEE_SCRIPTS = container.min.js 2 | 3 | .SUFFIXES: 4 | .SUFFIXES: .coffee .js .min.js 5 | 6 | 7 | all: build 8 | 9 | 10 | build: $(COFFEE_SCRIPTS) 11 | 12 | clean: 13 | rm -fv $(COFFEE_SCRIPTS) 14 | 15 | .coffee.js: 16 | coffee -c $< 17 | 18 | .js.min.js: 19 | uglifyjs --no-copyright --output $@ $< 20 | 21 | install: $(COFFEE_SCRIPTS) 22 | cp -v $(COFFEE_SCRIPTS) $(OUTPUT)/js/ 23 | 24 | .PHONY: build 25 | -------------------------------------------------------------------------------- /scripts/coffee/container.coffee: -------------------------------------------------------------------------------- 1 | # Copyright (c) Paul R. Tagliamonte , 2015 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | url = () -> 22 | loc = window.location 23 | 24 | if loc.protocol == "https:" 25 | new_uri = "wss:" 26 | else 27 | new_uri = "ws:" 28 | 29 | new_uri += "//" + loc.host 30 | return new_uri 31 | 32 | 33 | 34 | $(document).ready () -> 35 | root = url() 36 | 37 | ws = new WebSocket(root + "/websocket/stream/" + job + "/") 38 | ws.onopen = (e) -> 39 | term = new Terminal({ 40 | cols: 80, 41 | rows: 24, 42 | useStyle: false, 43 | }) 44 | 45 | content = $("#main-content") 46 | term.open(content.get(0)) 47 | 48 | ws.onmessage = (data) -> 49 | term.write(data.data) 50 | 51 | ws.onclose = (e) -> 52 | term.destroy() 53 | -------------------------------------------------------------------------------- /scripts/less/.gitignore: -------------------------------------------------------------------------------- 1 | *css 2 | -------------------------------------------------------------------------------- /scripts/less/Makefile: -------------------------------------------------------------------------------- 1 | LESS_SCRIPTS = theme.css container.css 2 | 3 | LESSC = lessc 4 | LESSCFLAGS = -x 5 | STATIC = static 6 | 7 | .SUFFIXES: 8 | .SUFFIXES: .less .css 9 | 10 | all: clean build 11 | 12 | build: $(LESS_SCRIPTS) 13 | 14 | clean: 15 | rm -fv $(LESS_SCRIPTS) 16 | 17 | .less.css: 18 | $(LESSC) $(LESSCFLAGS) $< > $@ 19 | 20 | install: $(LESS_SCRIPTS) 21 | cp -v $(LESS_SCRIPTS) $(OUTPUT)/css/ 22 | 23 | .PHONY: build 24 | -------------------------------------------------------------------------------- /scripts/less/container.less: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Paul R. Tagliamonte , 2015 2 | * 3 | * Permission is hereby granted, free of charge, to any person obtaining a 4 | * copy of this software and associated documentation files (the "Software"), 5 | * to deal in the Software without restriction, including without limitation 6 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | * and/or sell copies of the Software, and to permit persons to whom the 8 | * Software is furnished to do so, subject to the following conditions: 9 | * 10 | * The above copyright notice and this permission notice shall be included in 11 | * all copies or substantial portions of the Software. 12 | * 13 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | * DEALINGS IN THE SOFTWARE. */ 20 | 21 | 22 | .terminal { 23 | font-family: monospace; 24 | padding: 4px; 25 | border: 1px solid #548ea8; 26 | border-radius: 4px; 27 | box-shadow: inset 0 1px 1px rgba(0,0,0,.075); 28 | } 29 | -------------------------------------------------------------------------------- /scripts/less/theme.less: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Paul R. Tagliamonte , 2015 2 | * 3 | * Permission is hereby granted, free of charge, to any person obtaining a 4 | * copy of this software and associated documentation files (the "Software"), 5 | * to deal in the Software without restriction, including without limitation 6 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | * and/or sell copies of the Software, and to permit persons to whom the 8 | * Software is furnished to do so, subject to the following conditions: 9 | * 10 | * The above copyright notice and this permission notice shall be included in 11 | * all copies or substantial portions of the Software. 12 | * 13 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | * DEALINGS IN THE SOFTWARE. */ 20 | 21 | 22 | .main { 23 | padding-bottom: 100px; 24 | } 25 | 26 | @media (min-width: 768px) { 27 | .sidebar { 28 | position: fixed; 29 | top: 0px; 30 | bottom: 0; 31 | left: 0; 32 | z-index: 1000; 33 | display: block; 34 | padding: 20px; 35 | overflow-x: hidden; 36 | overflow-y: auto; 37 | background-color: #f5f5f5; 38 | border-right: 1px solid #eee; 39 | } 40 | 41 | .main { 42 | padding-right: 40px; 43 | padding-left: 40px; 44 | } 45 | 46 | .nav-sidebar { 47 | margin-right: -21px; 48 | margin-bottom: 20px; 49 | margin-left: -20px; 50 | } 51 | 52 | .nav-sidebar > li > a { 53 | padding-right: 20px; 54 | padding-left: 20px; 55 | } 56 | 57 | .nav-sidebar > .active > a { 58 | color: #fff; 59 | background-color: #428bca; 60 | } 61 | } 62 | 63 | 64 | 65 | @media (max-width: 768px) { 66 | .nav { 67 | height: 40px; 68 | line-height: 40px; 69 | background-color: #f5f5f5; 70 | border-bottom: 1px solid #eee; 71 | width: 100%; 72 | border-radius: 0px 0px 5px 5px; 73 | 74 | li { 75 | display: inline; 76 | a:hover { 77 | display: inline; 78 | } 79 | a { 80 | display: inline; 81 | } 82 | } 83 | } 84 | } 85 | 86 | .runs { 87 | .failed { 88 | color: #D9534F; 89 | } 90 | .success { 91 | color: #5CB85C; 92 | } 93 | } 94 | 95 | .label a { 96 | color: #FFF; 97 | } 98 | -------------------------------------------------------------------------------- /scripts/vendor/Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | #cp -vr css/* $(OUTPUT)/css/ 3 | cp -vr js/* $(OUTPUT)/js/ 4 | #cp -vr fonts/* $(OUTPUT)/fonts/ 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | from setuptools import find_packages, setup 6 | 7 | long_description = "" 8 | 9 | setup( 10 | name="moxie", 11 | version="0.1", 12 | entry_points={ 13 | 'console_scripts': [ 14 | 'moxie-init = moxie.cli:init', 15 | 'moxie-load = moxie.cli:load', 16 | 'moxied = moxie.cli:serve', 17 | ] 18 | }, 19 | packages=find_packages(), 20 | author="Paul Tagliamonte", 21 | author_email="tag@pault.ag", 22 | long_description=long_description, 23 | description='', 24 | license="Expat", 25 | url="http://pault.ag", 26 | platforms=['any'], 27 | ) 28 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "bare.html" %} 2 | 3 | {% block title %}404 - Whoops!{% endblock %} 4 | 5 | {% block content %} 6 |

Something went wrong! (sorry about that)

7 | 8 | We can't figure out where you want to go.
9 |
10 |
11 | 12 | We tried the following paths:
13 |
14 |
    15 | {% for route in routes %} 16 |
  • {{route[0]}}
  • 17 | {% endfor %} 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "bare.html" %} 2 | 3 | {% block title %}500 - Whoops!{% endblock %} 4 | 5 | {% block content %} 6 |

Something went wrong! (sorry about that)

7 | 8 | {{reason}} 9 | 10 |
11 |
12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /templates/bare.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block base_head %} 4 | 9 | {% endblock %} 10 | 11 | {% block base_content %} 12 | {% block content %} 13 | {% endblock %} 14 | Back home 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block title %}Moxie{% endblock %} 11 | 12 | 13 | {% block base_head %} 14 | {% endblock %} 15 | 16 | 17 | 18 | {% block base_content %} 19 | {% endblock %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /templates/cast.html: -------------------------------------------------------------------------------- 1 | {% include "base.html" %} 2 | 3 | {% block base_head %} 4 | 5 | {% endblock %} 6 | 7 | 8 | {% block base_content %} 9 | {% for run in runs %} 10 | 11 |
12 |

Run #{{run.run_id}} -- {{run.job_name}}

13 |

It's {{run.run_failed}}, this job failed.

14 |
15 | 16 | {% endfor %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /templates/container.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Container details for {{job.name}}{% endblock %} 4 | 5 | {% block head %} 6 | 9 | 10 | 11 | 12 | 13 | {% endblock %} 14 | 15 | {% block content %} 16 | 17 |

Container {{job.name}}

18 | 19 | {{job.name}} 20 | 21 |
22 |
23 |
24 | 25 | {% set config = info.Config %} 26 | {% set host_config = info.HostConfig %} 27 | {% set state = info.State %} 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 49 | 50 | 51 | 52 | 59 | 60 | 61 | 62 | 69 | 70 | 71 |
Image{{config.Image}}
Command{{" ".join(config.Cmd)}}
Running 43 | {% if state.Running %} 44 | Online 45 | {% else %} 46 | Offline 47 | {% endif %} 48 |
Privileged 53 | {% if host_config.Privileged %} 54 | Privileged 55 | {% else %} 56 | Unprivileged 57 | {% endif %} 58 |
Network 63 | {% if config.NetworkDisabled %} 64 | Disabled 65 | {% else %} 66 | Enabled 67 | {% endif %} 68 |
72 |
73 | 74 | {% endblock %} 75 | -------------------------------------------------------------------------------- /templates/container.offline.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Container {{job.name}} offline{% endblock %} 4 | 5 | {% block head %} 6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 |

Container {{job.name}}

11 | 12 | {{job.name}} 13 | 14 |
15 |
16 |
17 | 18 | Container is currently offline. 19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /templates/emails/failure.email: -------------------------------------------------------------------------------- 1 | To: "{{maintainer.name}}" <{{maintainer.email}}> 2 | From: "{{user_name}}" <{{user}}> 3 | Subject: 😱 {{job.name}} failed! 4 | X-Moxie-Version: FIXME 5 | 6 | Yo, {{maintainer.name}}, 7 | 8 | {{job.name}} totally failed. The run started at {{run.start_time}} and 9 | ended at {{run.end_time}}. 10 | 11 | If you want to check it out, there's some information at 12 | {{root}}/run/{{run.id}}/ 13 | 14 | 15 | Thanks, homie! 16 | Moxie 17 | 18 | 19 | Some log action: 20 | 21 | 22 | {{run.log}} 23 | -------------------------------------------------------------------------------- /templates/fragments/jobs.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% for (job, runs) in jobs %} 14 | 15 | 16 | 17 | 24 | 33 | 40 | 41 | {% endfor %} 42 | 43 |
Job IDDescriptionTagsRunsLast Run Started
{{job.name}}{{job.description}} 18 | {% if job.tags %} 19 | {% for tag in job.tags %} 20 | {{tag}} 21 | {% endfor %} 22 | {% endif %} 23 | 25 | {% for run in runs %} 26 | {% if run.failed %} 27 | 28 | {% else %} 29 | 30 | {% endif %} 31 | {% endfor %} 32 | 34 | {% if runs|length > 0 %} 35 | {{runs[0]["start_time"].strftime('%Y-%m-%d %-H:%M ')}} UTC 36 | {% else %} 37 | N/A 38 | {% endif %} 39 |
44 |
45 | -------------------------------------------------------------------------------- /templates/fragments/runs.html: -------------------------------------------------------------------------------- 1 | {% if not runs %} 2 | 3 |
4 |

No runs

5 |

6 | This job's never been run before. Run this job to see a log show up 7 | here! 8 |

9 |
10 | 11 | {% else %} 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for run in runs %} 24 | 25 | 26 | 33 | 36 | 39 | 40 | {% endfor %} 41 | 42 |
Run IDFailedStart timeEnd time
{{run.id}} 27 | {% if not run.failed %} 28 | Totally Great! 29 | {% else %} 30 | :( 31 | {% endif %} 32 | 34 | {{run.start_time.strftime('%Y-%m-%d %-H:%M ')}} UTC 35 | 37 | {{run.start_time.strftime('%Y-%m-%d %-H:%M ')}} UTC 38 |
43 |
44 | {% endif %} 45 | -------------------------------------------------------------------------------- /templates/interface.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block base_head %} 4 | 5 | 6 | {% block head %} 7 | {% endblock %} 8 | {% endblock %} 9 | 10 | {% block base_content %} 11 | 27 | 28 |
29 |
30 | 59 |
60 | {% block content %} 61 | {% endblock %} 62 |
63 |
64 |
65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /templates/job.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Job {{job.name}}{% endblock %} 4 | 5 | {% block content %} 6 |

{{job.job_description}} ({{job.job_name}})

7 | 8 |

9 | {% if job.job_tags %} 10 | {% for tag in job.job_tags %} 11 | {{tag}} 12 | {% endfor %} 13 | {% endif %} 14 |

15 | 16 | This job is maintained by 17 | {{job.maintainer_name}}. 18 |

19 | {% if job.job_manual %} 20 | This is a manual job. Please schedule this to run via SSH. 21 | {% else %} 22 | The job's next schedued for {{next_run}}. 23 | {% endif %} 24 |

25 | 26 | {% include "fragments/runs.html" %} 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /templates/jobs.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}All Jobs{% endblock %} 4 | 5 | {% block content %} 6 |

Jobs

7 | {% include "fragments/jobs.html" %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /templates/maintainer.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Jobs for {{maintainer.name}}{% endblock %} 4 | 5 | {% block content %} 6 |

{{maintainer.name}}'s Jobs {{maintainer.email}}

7 | {% include "fragments/jobs.html" %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /templates/maintainers.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}All Maintainers{% endblock %} 4 | 5 | {% block content %} 6 |

Maintainers

7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for maintainer in maintainers %} 17 | 18 | 19 | 20 | 21 | {% endfor %} 22 | 23 |
Maintainer NameMaintainer Email
{{maintainer.name}}{{maintainer.email}}
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /templates/overview.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Moxie{% endblock %} 4 | 5 | {% block content %} 6 |

Moxie

7 | 8 |

9 | This is Moxie. Moxie is a docker.io based Job supervisor and 10 | runner. 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/run.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Run detail for run {{run.id}}{% endblock %} 4 | 5 | {% block content %} 6 |

Run {{run.id}}

7 | 8 | This build was a massive 9 | {% if run.failed %} 10 | failure 11 | {% else %} 12 | success 13 | {% endif %}. 14 |
15 |
16 | Build start time: {{run.start_time.strftime('%Y-%m-%d %-H:%M ')}} UTC 17 |
18 | Build end time: {{run.end_time.strftime('%Y-%m-%d %-H:%M ')}} UTC 19 | 20 |
21 |
22 |
23 | {{run.log}}
24 | 
25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /templates/tag.html: -------------------------------------------------------------------------------- 1 | {% extends "interface.html" %} 2 | 3 | {% block title %}Jobs for {{tag}}{% endblock %} 4 | 5 | {% block content %} 6 |

Jobs tagged {{tag}}

7 | {% include "fragments/jobs.html" %} 8 | {% endblock %} 9 | --------------------------------------------------------------------------------