├── .circleci └── config.yml ├── .gitignore ├── .symerc ├── Procfile ├── README.md ├── bin └── lein ├── project.clj ├── resources ├── add-github-key ├── analytics.js ├── faq.html ├── languages │ ├── Clojure.sh │ ├── Erlang.sh │ ├── Haskell.sh │ └── Ruby.sh ├── motd ├── motd-pending ├── static │ ├── favicon.ico │ ├── splash.png │ ├── stylesheets │ │ ├── base.css │ │ ├── skeleton.css │ │ └── style.css │ └── syme.js └── userdata.sh ├── src └── syme │ ├── db.clj │ ├── dns.clj │ ├── html.clj │ ├── instance.clj │ └── web.clj └── test └── syme └── test_main.clj /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | # Since we include lein in the repo, all we need is a JDK. 6 | - image: openjdk:8 7 | - image: postgres:9.4.1 8 | # The official Postgres docker images will create your DB for you 9 | # if you set the POSTGRES_DB environment variable. 10 | environment: 11 | POSTGRES_USER: root 12 | POSTGRES_DB: syme 13 | environment: 14 | DATABASE_URL=postgres://localhost/syme 15 | steps: 16 | - checkout 17 | # Pull the dependencies in a way that they're cached. 18 | - restore_cache: 19 | key: << checksum "project.clj" >> 20 | - run: bin/lein deps 21 | - save_cache: 22 | paths: 23 | - $HOME/.m2 24 | - $HOME/.lein 25 | key: << checksum "project.clj" >> 26 | # Migrate the DB, then test. 27 | - run: bin/lein do run -m syme.db, test 28 | # Push it out to Heroku on a successful master build. 29 | - run: git push --force https://heroku:$HEROKU_API_KEY@git.heroku.com/syme-staging.git master 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | *jar 3 | /classes/ 4 | /.lein-deps-sum 5 | /.lein-failures 6 | /.lein-env 7 | /checkouts 8 | /.env 9 | /target 10 | /profiles.clj 11 | /logs 12 | /.lein-repl-history 13 | /pg 14 | /.nrepl-port 15 | -------------------------------------------------------------------------------- /.symerc: -------------------------------------------------------------------------------- 1 | sudo apt-get install -y postgresql-9.1 postgresql-client-9.1 2 | sudo /etc/init.d/postgresql stop 3 | sudo chown -R syme /var/*/postgresql 4 | 5 | PATH=$PATH:/usr/lib/postgresql/9.1/bin/ 6 | 7 | initdb pg && postgres -D pg & 8 | 9 | # TODO: never gets here... 10 | createdb syme -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java $JVM_OPTS -cp target/uberjar/syme-standalone.jar clojure.main -m syme.web 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Syme 2 | 3 | This project is no longer maintained; if you want to do this kind of 4 | pairing I recommend you try [tmate](https://tmate.io) instead. 5 | 6 | Instant collaboration on GitHub projects. 7 | 8 | > Almost in the act of stepping on board, Gabriel Syme turned to the gaping Gregory. 9 | > 10 | > "You have kept your word," he said gently, with his face in shadow. 11 | > "You are a man of honour, and I thank you. You have kept it even down 12 | > to a small particular. There was one special thing you promised me at 13 | > the beginning of the affair, and which you have certainly given me by 14 | > the end of it." 15 | > 16 | > "What do you mean?" cried the chaotic Gregory. "What did I promise you?" 17 | > 18 | > "A very entertaining evening," said Syme, and he made a military 19 | > salute with the sword-stick as the steamboat slid away. 20 | 21 | — The Man who was Thursday, by G.K. Chesterton 22 | 23 | ## Usage 24 | 25 | 1. Enter the name of a GitHub repo. 26 | (Authorize Syme via GitHub if you haven't already.) 27 | 2. Enter your AWS credentials and names of GitHub users to invite. 28 | 4. SSH into the instance once it's booted using the command shown and launch `tmux`. 29 | 5. Send the login info to the users you have invited. 30 | 31 | To invite a whole GitHub organization, simply prefix the organization 32 | name with a `+` in the form. Syme handles launching the instance, 33 | setting up public keys, and cloning the repository in question. 34 | 35 | Your AWS credentials are kept in an encrypted cookie in your browser 36 | and aren't stored server-side beyond the scope of your request. 37 | 38 | Inspired by [pair.io](http://pair.io). 39 | 40 | ## Setting up your own 41 | 42 | * Create a PostgreSQL DB and export `$DATABASE_URL` to point to it. 43 | 44 | * Create the DB schema with `lein run -m syme.db`. 45 | 46 | ### Additional steps for production 47 | 48 | * [Register as a GitHub OAuth application](https://github.com/settings/applications/new) 49 | 50 | * Export `$OAUTH_CLIENT_ID` and `$OAUTH_CLIENT_SECRET` 51 | 52 | If you don't set these, Syme will use an OAuth registration which is 53 | only valid for `http://localhost:5000`. 54 | 55 | ### Additional optional steps for additional features 56 | 57 | * Generate 16 random characters and export it as `$SESSION_SECRET`. 58 | Needed if you want cookies to outlast server restarts. 59 | 60 | * Export `$CANONICAL_URL` as the fully-qualified URL of the splash page. 61 | Needed if you want hooks to update instance status. 62 | 63 | * Sign up for Amazon Route53 and export `$AWS_ACCESS_KEY` and `$AWS_SECRET_KEY`. 64 | 65 | * Register a domain and export it as `$SUBDOMAIN` formatted like 66 | "%s.%s.syme.in". The `%s` places will be filled with the instance id 67 | and instance owner. 68 | 69 | * Host the DNS under Route53 and export its `$ZONE_ID`. 70 | 71 | ## License 72 | 73 | Copyright © 2013, 2014, 2017 Phil Hagelberg and contributors 74 | 75 | Distributed under the Eclipse Public License, the same as Clojure. 76 | -------------------------------------------------------------------------------- /bin/lein: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Ensure this file is executable via `chmod a+x lein`, then place it 4 | # somewhere on your $PATH, like ~/bin. The rest of Leiningen will be 5 | # installed upon first run into the ~/.lein/self-installs directory. 6 | 7 | export LEIN_VERSION="2.8.1" 8 | 9 | case $LEIN_VERSION in 10 | *SNAPSHOT) SNAPSHOT="YES" ;; 11 | *) SNAPSHOT="NO" ;; 12 | esac 13 | 14 | if [[ "$CLASSPATH" != "" ]]; then 15 | echo "WARNING: You have \$CLASSPATH set, probably by accident." 16 | echo "It is strongly recommended to unset this before proceeding." 17 | fi 18 | 19 | if [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]]; then 20 | delimiter=";" 21 | else 22 | delimiter=":" 23 | fi 24 | 25 | if [[ "$OSTYPE" == "cygwin" ]]; then 26 | cygwin=true 27 | else 28 | cygwin=false 29 | fi 30 | 31 | function command_not_found { 32 | >&2 echo "Leiningen couldn't find $1 in your \$PATH ($PATH), which is required." 33 | exit 1 34 | } 35 | 36 | function make_native_path { 37 | # ensure we have native paths 38 | if $cygwin && [[ "$1" == /* ]]; then 39 | echo -n "$(cygpath -wp "$1")" 40 | elif [[ "$OSTYPE" == "msys" && "$1" == /?/* ]]; then 41 | echo -n "$(sh -c "(cd $1 2 /dev/null 86 | download_failed_message "$LEIN_URL" "$exit_code" 87 | exit 1 88 | fi 89 | } 90 | 91 | NOT_FOUND=1 92 | ORIGINAL_PWD="$PWD" 93 | while [ ! -r "$PWD/project.clj" ] && [ "$PWD" != "/" ] && [ $NOT_FOUND -ne 0 ] 94 | do 95 | cd .. 96 | if [ "$(dirname "$PWD")" = "/" ]; then 97 | NOT_FOUND=0 98 | cd "$ORIGINAL_PWD" 99 | fi 100 | done 101 | 102 | export LEIN_HOME="${LEIN_HOME:-"$HOME/.lein"}" 103 | 104 | for f in "/etc/leinrc" "$LEIN_HOME/leinrc" ".leinrc"; do 105 | if [ -e "$f" ]; then 106 | source "$f" 107 | fi 108 | done 109 | 110 | if $cygwin; then 111 | export LEIN_HOME=$(cygpath -w "$LEIN_HOME") 112 | fi 113 | 114 | LEIN_JAR="$LEIN_HOME/self-installs/leiningen-$LEIN_VERSION-standalone.jar" 115 | 116 | # normalize $0 on certain BSDs 117 | if [ "$(dirname "$0")" = "." ]; then 118 | SCRIPT="$(which "$(basename "$0")")" 119 | if [ -z "$SCRIPT" ]; then 120 | SCRIPT="$0" 121 | fi 122 | else 123 | SCRIPT="$0" 124 | fi 125 | 126 | # resolve symlinks to the script itself portably 127 | while [ -h "$SCRIPT" ] ; do 128 | ls=$(ls -ld "$SCRIPT") 129 | link=$(expr "$ls" : '.*-> \(.*\)$') 130 | if expr "$link" : '/.*' > /dev/null; then 131 | SCRIPT="$link" 132 | else 133 | SCRIPT="$(dirname "$SCRIPT"$)/$link" 134 | fi 135 | done 136 | 137 | BIN_DIR="$(dirname "$SCRIPT")" 138 | 139 | export LEIN_JVM_OPTS="${LEIN_JVM_OPTS-"-Xverify:none -XX:+TieredCompilation -XX:TieredStopAtLevel=1"}" 140 | 141 | # This needs to be defined before we call HTTP_CLIENT below 142 | if [ "$HTTP_CLIENT" = "" ]; then 143 | if type -p curl >/dev/null 2>&1; then 144 | if [ "$https_proxy" != "" ]; then 145 | CURL_PROXY="-x $https_proxy" 146 | fi 147 | HTTP_CLIENT="curl $CURL_PROXY -f -L -o" 148 | else 149 | HTTP_CLIENT="wget -O" 150 | fi 151 | fi 152 | 153 | 154 | # When :eval-in :classloader we need more memory 155 | grep -E -q '^\s*:eval-in\s+:classloader\s*$' project.clj 2> /dev/null && \ 156 | export LEIN_JVM_OPTS="$LEIN_JVM_OPTS -Xms64m -Xmx512m" 157 | 158 | if [ -r "$BIN_DIR/../src/leiningen/version.clj" ]; then 159 | # Running from source checkout 160 | LEIN_DIR="$(dirname "$BIN_DIR")" 161 | 162 | # Need to use lein release to bootstrap the leiningen-core library (for aether) 163 | if [ ! -r "$LEIN_DIR/leiningen-core/.lein-bootstrap" ]; then 164 | echo "Leiningen is missing its dependencies." 165 | echo "Please run \"lein bootstrap\" in the leiningen-core/ directory" 166 | echo "with a stable release of Leiningen. See CONTRIBUTING.md for details." 167 | exit 1 168 | fi 169 | 170 | # If project.clj for lein or leiningen-core changes, we must recalculate 171 | LAST_PROJECT_CHECKSUM=$(cat "$LEIN_DIR/.lein-project-checksum" 2> /dev/null) 172 | PROJECT_CHECKSUM=$(sum "$LEIN_DIR/project.clj" "$LEIN_DIR/leiningen-core/project.clj") 173 | if [ "$PROJECT_CHECKSUM" != "$LAST_PROJECT_CHECKSUM" ]; then 174 | if [ -r "$LEIN_DIR/.lein-classpath" ]; then 175 | rm "$LEIN_DIR/.lein-classpath" 176 | fi 177 | fi 178 | 179 | # Use bin/lein to calculate its own classpath. 180 | if [ ! -r "$LEIN_DIR/.lein-classpath" ] && [ "$1" != "classpath" ]; then 181 | echo "Recalculating Leiningen's classpath." 182 | ORIG_PWD="$PWD" 183 | cd "$LEIN_DIR" 184 | 185 | LEIN_NO_USER_PROFILES=1 $0 classpath .lein-classpath 186 | sum "$LEIN_DIR/project.clj" "$LEIN_DIR/leiningen-core/project.clj" > \ 187 | .lein-project-checksum 188 | cd "$ORIG_PWD" 189 | fi 190 | 191 | mkdir -p "$LEIN_DIR/target/classes" 192 | export LEIN_JVM_OPTS="$LEIN_JVM_OPTS -Dclojure.compile.path=$LEIN_DIR/target/classes" 193 | add_path CLASSPATH "$LEIN_DIR/leiningen-core/src/" "$LEIN_DIR/leiningen-core/resources/" \ 194 | "$LEIN_DIR/test:$LEIN_DIR/target/classes" "$LEIN_DIR/src" ":$LEIN_DIR/resources" 195 | 196 | if [ -r "$LEIN_DIR/.lein-classpath" ]; then 197 | add_path CLASSPATH "$(cat "$LEIN_DIR/.lein-classpath" 2> /dev/null)" 198 | else 199 | add_path CLASSPATH "$(cat "$LEIN_DIR/leiningen-core/.lein-bootstrap" 2> /dev/null)" 200 | fi 201 | else # Not running from a checkout 202 | add_path CLASSPATH "$LEIN_JAR" 203 | 204 | if [ "$LEIN_USE_BOOTCLASSPATH" != "" ]; then 205 | LEIN_JVM_OPTS="-Xbootclasspath/a:$LEIN_JAR $LEIN_JVM_OPTS" 206 | fi 207 | 208 | if [ ! -r "$LEIN_JAR" -a "$1" != "self-install" ]; then 209 | self_install 210 | fi 211 | fi 212 | 213 | if [ ! -x "$JAVA_CMD" ] && ! type -f java >/dev/null 214 | then 215 | >&2 echo "Leiningen couldn't find 'java' executable, which is required." 216 | >&2 echo "Please either set JAVA_CMD or put java (>=1.6) in your \$PATH ($PATH)." 217 | exit 1 218 | fi 219 | 220 | export LEIN_JAVA_CMD="${LEIN_JAVA_CMD:-${JAVA_CMD:-java}}" 221 | 222 | if [[ -z "${DRIP_INIT+x}" && "$(basename "$LEIN_JAVA_CMD")" == *drip* ]]; then 223 | export DRIP_INIT="$(printf -- '-e\n(require (quote leiningen.repl))')" 224 | export DRIP_INIT_CLASS="clojure.main" 225 | fi 226 | 227 | # Support $JAVA_OPTS for backwards-compatibility. 228 | export JVM_OPTS="${JVM_OPTS:-"$JAVA_OPTS"}" 229 | 230 | # Handle jline issue with cygwin not propagating OSTYPE through java subprocesses: https://github.com/jline/jline2/issues/62 231 | cygterm=false 232 | if $cygwin; then 233 | case "$TERM" in 234 | rxvt* | xterm* | vt*) cygterm=true ;; 235 | esac 236 | fi 237 | 238 | if $cygterm; then 239 | LEIN_JVM_OPTS="$LEIN_JVM_OPTS -Djline.terminal=jline.UnixTerminal" 240 | stty -icanon min 1 -echo > /dev/null 2>&1 241 | fi 242 | 243 | # TODO: investigate http://skife.org/java/unix/2011/06/20/really_executable_jars.html 244 | # If you're packaging this for a package manager (.deb, homebrew, etc) 245 | # you need to remove the self-install and upgrade functionality or see lein-pkg. 246 | if [ "$1" = "self-install" ]; then 247 | if [ -r "$BIN_DIR/../src/leiningen/version.clj" ]; then 248 | echo "Running self-install from a checkout is not supported." 249 | echo "See CONTRIBUTING.md for SNAPSHOT-specific build instructions." 250 | exit 1 251 | fi 252 | echo "Manual self-install is deprecated; it will run automatically when necessary." 253 | self_install 254 | elif [ "$1" = "upgrade" ] || [ "$1" = "downgrade" ]; then 255 | if [ "$LEIN_DIR" != "" ]; then 256 | echo "The upgrade task is not meant to be run from a checkout." 257 | exit 1 258 | fi 259 | if [ $SNAPSHOT = "YES" ]; then 260 | echo "The upgrade task is only meant for stable releases." 261 | echo "See the \"Bootstrapping\" section of CONTRIBUTING.md." 262 | exit 1 263 | fi 264 | if [ ! -w "$SCRIPT" ]; then 265 | echo "You do not have permission to upgrade the installation in $SCRIPT" 266 | exit 1 267 | else 268 | TARGET_VERSION="${2:-stable}" 269 | echo "The script at $SCRIPT will be upgraded to the latest $TARGET_VERSION version." 270 | echo -n "Do you want to continue [Y/n]? " 271 | read RESP 272 | case "$RESP" in 273 | y|Y|"") 274 | echo 275 | echo "Upgrading..." 276 | TARGET="/tmp/lein-${$}-upgrade" 277 | if $cygwin; then 278 | TARGET=$(cygpath -w "$TARGET") 279 | fi 280 | LEIN_SCRIPT_URL="https://github.com/technomancy/leiningen/raw/$TARGET_VERSION/bin/lein" 281 | $HTTP_CLIENT "$TARGET" "$LEIN_SCRIPT_URL" 282 | if [ $? == 0 ]; then 283 | cmp -s "$TARGET" "$SCRIPT" 284 | if [ $? == 0 ]; then 285 | echo "Leiningen is already up-to-date." 286 | fi 287 | mv "$TARGET" "$SCRIPT" && chmod +x "$SCRIPT" 288 | exec "$SCRIPT" version 289 | else 290 | download_failed_message "$LEIN_SCRIPT_URL" 291 | fi;; 292 | *) 293 | echo "Aborted." 294 | exit 1;; 295 | esac 296 | fi 297 | else 298 | if $cygwin; then 299 | # When running on Cygwin, use Windows-style paths for java 300 | ORIGINAL_PWD=$(cygpath -w "$ORIGINAL_PWD") 301 | fi 302 | 303 | # apply context specific CLASSPATH entries 304 | if [ -f .lein-classpath ]; then 305 | add_path CLASSPATH "$(cat .lein-classpath)" 306 | fi 307 | 308 | if [ -n "$DEBUG" ]; then 309 | echo "Leiningen's classpath: $CLASSPATH" 310 | fi 311 | 312 | if [ -r .lein-fast-trampoline ]; then 313 | export LEIN_FAST_TRAMPOLINE='y' 314 | fi 315 | 316 | if [ "$LEIN_FAST_TRAMPOLINE" != "" ] && [ -r project.clj ]; then 317 | INPUTS="$* $(cat project.clj) $LEIN_VERSION $(test -f "$LEIN_HOME/profiles.clj" && cat "$LEIN_HOME/profiles.clj")" 318 | 319 | if command -v shasum >/dev/null 2>&1; then 320 | SUM="shasum" 321 | elif command -v sha1sum >/dev/null 2>&1; then 322 | SUM="sha1sum" 323 | else 324 | command_not_found "sha1sum or shasum" 325 | fi 326 | 327 | export INPUT_CHECKSUM=$(echo "$INPUTS" | $SUM | cut -f 1 -d " ") 328 | # Just don't change :target-path in project.clj, mkay? 329 | TRAMPOLINE_FILE="target/trampolines/$INPUT_CHECKSUM" 330 | else 331 | if hash mktemp 2>/dev/null; then 332 | # Check if mktemp is available before using it 333 | TRAMPOLINE_FILE="$(mktemp /tmp/lein-trampoline-XXXXXXXXXXXXX)" 334 | else 335 | TRAMPOLINE_FILE="/tmp/lein-trampoline-$$" 336 | fi 337 | trap 'rm -f $TRAMPOLINE_FILE' EXIT 338 | fi 339 | 340 | if $cygwin; then 341 | TRAMPOLINE_FILE=$(cygpath -w "$TRAMPOLINE_FILE") 342 | fi 343 | 344 | if [ "$INPUT_CHECKSUM" != "" ] && [ -r "$TRAMPOLINE_FILE" ]; then 345 | if [ -n "$DEBUG" ]; then 346 | echo "Fast trampoline with $TRAMPOLINE_FILE." 347 | fi 348 | exec sh -c "exec $(cat "$TRAMPOLINE_FILE")" 349 | else 350 | export TRAMPOLINE_FILE 351 | "$LEIN_JAVA_CMD" \ 352 | -Dfile.encoding=UTF-8 \ 353 | -Dmaven.wagon.http.ssl.easy=false \ 354 | -Dmaven.wagon.rto=10000 \ 355 | $LEIN_JVM_OPTS \ 356 | -Dleiningen.original.pwd="$ORIGINAL_PWD" \ 357 | -Dleiningen.script="$SCRIPT" \ 358 | -classpath "$CLASSPATH" \ 359 | clojure.main -m leiningen.core.main "$@" 360 | 361 | EXIT_CODE=$? 362 | 363 | if $cygterm ; then 364 | stty icanon echo > /dev/null 2>&1 365 | fi 366 | 367 | if [ -r "$TRAMPOLINE_FILE" ] && [ "$LEIN_TRAMPOLINE_WARMUP" = "" ]; then 368 | TRAMPOLINE="$(cat "$TRAMPOLINE_FILE")" 369 | if [ "$INPUT_CHECKSUM" = "" ]; then # not using fast trampoline 370 | rm "$TRAMPOLINE_FILE" 371 | fi 372 | if [ "$TRAMPOLINE" = "" ]; then 373 | exit $EXIT_CODE 374 | else 375 | exec sh -c "exec $TRAMPOLINE" 376 | fi 377 | else 378 | exit $EXIT_CODE 379 | fi 380 | fi 381 | fi 382 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject syme "1.1.0" 2 | :description "Instant collaboration on GitHub projects over tmux." 3 | :url "http://syme.herokuapp.com" 4 | :license "Eclipse Public License 1.0" 5 | :dependencies [[org.clojure/clojure "1.4.0"] 6 | [com.amazonaws/aws-java-sdk "1.3.33" 7 | :exclusions [org.apache.httpcomponents/httpclient 8 | commons-codec]] 9 | [compojure "1.1.1"] 10 | [ring/ring-core "1.2.1"] 11 | [ring/ring-jetty-adapter "1.2.0"] 12 | [hiccup "1.0.2"] 13 | [tentacles "0.2.4"] 14 | [clj-http "0.6.3" :exclusions [commons-logging]] 15 | [cheshire "5.0.1"] 16 | [environ "0.2.1"] 17 | [lib-noir "0.3.4"] 18 | [postgresql "9.1-901-1.jdbc4"] 19 | [org.clojure/java.jdbc "0.2.1"]] 20 | :uberjar-name "syme-standalone.jar" 21 | :target-path "target/%s/" 22 | :min-lein-version "2.0.0" 23 | :plugins [[environ/environ.lein "0.2.1"]] 24 | :hooks [environ.leiningen.hooks] 25 | :profiles {:production {:env {:production true}}}) 26 | -------------------------------------------------------------------------------- /resources/add-github-key: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # For usage outside of Syme: 4 | # curl -L http://git.io/addkey > /usr/local/bin/add-github-key 5 | 6 | set -e 7 | 8 | if [ -z "$1" ]; then 9 | echo "Usage: \$0 GITHUB_USERNAME" 10 | exit 1 11 | else 12 | mkdir -p $HOME/.ssh 13 | wget -qO- https://github.com/$1.keys >> $HOME/.ssh/authorized_keys 14 | echo >> $HOME/.ssh/authorized_keys 15 | fi 16 | -------------------------------------------------------------------------------- /resources/analytics.js: -------------------------------------------------------------------------------- 1 | var _gaq = _gaq || []; 2 | _gaq.push(['_setAccount', '%s']); 3 | _gaq.push(['_trackPageview']); 4 | 5 | (function() { 6 | var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; 7 | ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; 8 | var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); 9 | })(); 10 | -------------------------------------------------------------------------------- /resources/faq.html: -------------------------------------------------------------------------------- 1 |
2 |
So what does Syme offer?
3 |
It sets up disposable EC2 nodes for collaborating on GitHub 4 | projects via ssh and tmux.
5 |
6 | 7 |
8 | 9 |
    10 |
  1. Enter the name of a GitHub repo. (Authorize Syme via GitHub if you haven't already.)
  2. 11 |
  3. Enter your AWS credentials and names of GitHub users to invite.
  4. 12 |
  5. SSH into the instance once it's booted using the command shown and launch tmux.
  6. 13 |
  7. Send the login info to the users you have invited.
  8. 14 |
15 | 16 |
17 | 18 |
19 |
How does it work?
20 |
SSH public keys are fetched via GitHub profiles, so all 21 | collaborators must have a key pair with the public key uploaded to 22 | GitHub. You can add collaborators after the instance has launched 23 | with the add-github-key script. The Syme codebase 24 | is free 25 | software, so you are welcome to inspect its implementation and 26 | contribute improvements.
27 | 28 |
What is tmux?
29 |
It's 30 | a terminal multiplexer. 31 | You can think of it like a window manager; it lets you keep 32 | multiple textual "windows" open and switch among them. But more 33 | importantly it allows multiple users to connect and see the same 34 | thing, kind of like VNC 35 | screen sharing but dramatically faster. The first person 36 | runs tmux and others run tmux attach to 37 | join. There's a 38 | good introductory 39 | video to tmux available.
40 | 41 |
What can I run?
42 |
You will have passwordless sudo on a recent Ubuntu 43 | VM, so anything that runs there is fair game. It limits you to 44 | sharing programs that run inside a terminal, so you'll need to 45 | brush up on your Emacs or Vim skills. It also means it's a bit 46 | more complicated when you need a browser. Most browsers can't run 47 | inside tmux, so each collaborator will have to run their own 48 | browser on their local machine. By default only the SSH port is 49 | open, so you'll either need to tunnel a connection to your HTTP 50 | server using ssh -L 8080:localhost:8080 syme@N.N.N.N or 51 | open more ports in the "syme/$USERNAME" security group in your 52 | the AWS 53 | EC2 console.
54 | 55 |
Is it safe?
56 |
Your AWS credentials are kept in an encrypted cookie in your 57 | browser and aren't stored server-side beyond the scope of your 58 | request. Forwarded SSH agents are disabled since otherwise 59 | collaborators would be able to hijack them, so Git access should 60 | be done over HTTPS instead. This means you'll have to enter your 61 | password when you push. If you don't trust Syme with your full AWS 62 | credentials you can provide credentials 63 | to IAM 64 | account that only has permissions to launch instances. You 65 | need to authorize via GitHub, but this is only to verify your 66 | username; GitHub does not grant access to private data to Syme.
67 | 68 |
All this for free?
69 |
Sort of. You will be billed by Amazon for EC2 time (m1.small) 70 | until the instance is halted, which you can do through Syme or 71 | over SSH via sudo halt. But Syme itself doesn't cost 72 | anything. You may want to periodically check 73 | your AWS 74 | EC2 console to ensure you aren't billed for instances you 75 | intended to stop that stayed running due to problems with Syme. In 76 | particular this happens due to timeout errors.
77 | 78 |
Can it be customized?
79 |
Sure. Adding a .symerc script to any GitHub 80 | repository will let you run project-specific setup; for instance 81 | installing a database server. For customizations that apply to all 82 | instances you launch, you can create 83 | a .symerc 84 | GitHub repository under your account with 85 | a bootstrap script inside it. Finally, customizations 86 | can be applied on a per-language basis by adding shell scripts 87 | into the resources/languages 88 | directory of Syme's own codebase; pull requests for additional 89 | languages are welcome. All those scripts get run with passwordless 90 | sudo privileges after the repository has been checked out.
91 |
92 | 93 |
94 | 95 |

Syme is inspired by pair.io.

96 | -------------------------------------------------------------------------------- /resources/languages/Clojure.sh: -------------------------------------------------------------------------------- 1 | sudo apt-get install -y openjdk-7-jre-headless 2 | sudo wget -qO /usr/local/bin/lein https://raw.github.com/technomancy/leiningen/stable/bin/lein 3 | sudo chmod +x /usr/local/bin/lein 4 | -------------------------------------------------------------------------------- /resources/languages/Erlang.sh: -------------------------------------------------------------------------------- 1 | wget http://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb 2 | wget http://packages.erlang-solutions.com/debian/erlang_solutions.asc 3 | 4 | sudo dpkg -i erlang-solutions_1.0_all.deb && rm erlang-solutions_1.0_all.deb 5 | sudo apt-key add erlang_solutions.asc && rm erlang_solutions.asc 6 | 7 | sudo apt-get update 8 | sudo apt-get install -y erlang 9 | 10 | wget https://github.com/rebar/rebar/wiki/rebar 11 | chmod +x rebar 12 | sudo mv rebar /usr/local/bin 13 | -------------------------------------------------------------------------------- /resources/languages/Haskell.sh: -------------------------------------------------------------------------------- 1 | sudo apt-get install -y haskell-platform 2 | -------------------------------------------------------------------------------- /resources/languages/Ruby.sh: -------------------------------------------------------------------------------- 1 | sudo apt-get install -y ruby-full ruby-dev build-essential 2 | -------------------------------------------------------------------------------- /resources/motd: -------------------------------------------------------------------------------- 1 | _____ 2 | / ___/__ ______ ___ ___ 3 | \__ \/ / / / __ `__ \/ _ \ 4 | ___/ / /_/ / / / / / / __/ 5 | /____/\__, /_/ /_/ /_/\___/ 6 | /____/ 7 | 8 | Run `tmux` to start a shared session or `tmux attach` to join. 9 | 10 | The tmux escape character is control-z, so you can use `C-z d` to detach. 11 | 12 | Use the `add-github-user` script to add a public key to this session. 13 | 14 | When you are done with this node, run `sudo halt` but make sure you've 15 | pushed all your work as it will be gone when the node halts. 16 | 17 | -------------------------------------------------------------------------------- /resources/motd-pending: -------------------------------------------------------------------------------- 1 | _____ 2 | / ___/__ ______ ___ ___ 3 | \__ \/ / / / __ `__ \/ _ \ 4 | ___/ / /_/ / / / / / / __/ 5 | /____/\__, /_/ /_/ /_/\___/ 6 | /____/ 7 | 8 | -------------------------------------------------------------------------------- 9 | 10 | This instance is still in the process of bootstrapping itself. You are 11 | able to log in because your pubkeys are installed, but until the 12 | bootstrap process is finished some things may not be fully configured. 13 | If this message is gone from `/etc/motd` it means the bootstrap is done. 14 | 15 | -------------------------------------------------------------------------------- 16 | 17 | Run `tmux` to start a shared session or `tmux attach` to join. 18 | 19 | The tmux escape character is control-z, so you can use `C-z d` to detach. 20 | 21 | Use the `add-github-user` script to add a public key to this session. 22 | 23 | When you are done with this node, run `sudo halt` but make sure you've 24 | pushed all your work as it will be gone when the node halts. 25 | 26 | -------------------------------------------------------------------------------- /resources/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technomancy/syme/3bf5a92181e040692a4fe9f26f089670d95e2c6e/resources/static/favicon.ico -------------------------------------------------------------------------------- /resources/static/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/technomancy/syme/3bf5a92181e040692a4fe9f26f089670d95e2c6e/resources/static/splash.png -------------------------------------------------------------------------------- /resources/static/stylesheets/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V1.1 3 | * Copyright 2011, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 8/17/2011 8 | */ 9 | 10 | 11 | /* Table of Content 12 | ================================================== 13 | #Reset & Basics 14 | #Basic Styles 15 | #Site Styles 16 | #Typography 17 | #Links 18 | #Lists 19 | #Images 20 | #Buttons 21 | #Tabs 22 | #Forms 23 | #Misc */ 24 | 25 | 26 | /* #Reset & Basics (Inspired by E. Meyers) 27 | ================================================== */ 28 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { 29 | margin: 0; 30 | padding: 0; 31 | border: 0; 32 | font-size: 100%; 33 | font: inherit; 34 | vertical-align: baseline; } 35 | article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { 36 | display: block; } 37 | body { 38 | line-height: 1; } 39 | ol, ul { 40 | list-style: none; } 41 | blockquote, q { 42 | quotes: none; } 43 | blockquote:before, blockquote:after, 44 | q:before, q:after { 45 | content: ''; 46 | content: none; } 47 | table { 48 | border-collapse: collapse; 49 | border-spacing: 0; } 50 | 51 | 52 | /* #Basic Styles 53 | ================================================== */ 54 | body { 55 | background: #fff; 56 | font: 14px/21px "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 57 | color: #444; 58 | -webkit-font-smoothing: antialiased; /* Fix for webkit rendering */ 59 | -webkit-text-size-adjust: 100%; 60 | } 61 | 62 | 63 | /* #Typography 64 | ================================================== */ 65 | h1, h2, h3, h4, h5, h6 { 66 | color: #181818; 67 | font-family: "Georgia", "Times New Roman", Helvetica, Arial, sans-serif; 68 | font-weight: normal; } 69 | h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { font-weight: inherit; } 70 | h1 { font-size: 46px; line-height: 50px; margin-bottom: 14px;} 71 | h2 { font-size: 35px; line-height: 40px; margin-bottom: 10px; } 72 | h3 { font-size: 28px; line-height: 34px; margin-bottom: 8px; } 73 | h4 { font-size: 21px; line-height: 30px; margin-bottom: 4px; } 74 | h5 { font-size: 17px; line-height: 24px; } 75 | h6 { font-size: 14px; line-height: 21px; } 76 | .subheader { color: #777; } 77 | 78 | p { margin: 0 0 20px 0; } 79 | p img { margin: 0; } 80 | p.lead { font-size: 21px; line-height: 27px; color: #777; } 81 | 82 | em { font-style: italic; } 83 | strong { font-weight: bold; color: #333; } 84 | small { font-size: 80%; } 85 | 86 | /* Blockquotes */ 87 | blockquote, blockquote p { font-size: 17px; line-height: 24px; color: #777; font-style: italic; } 88 | blockquote { margin: 0 0 20px; padding: 9px 20px 0 19px; border-left: 1px solid #ddd; } 89 | blockquote cite { display: block; font-size: 12px; color: #555; } 90 | blockquote cite:before { content: "\2014 \0020"; } 91 | blockquote cite a, blockquote cite a:visited, blockquote cite a:visited { color: #555; } 92 | 93 | hr { border: solid #ddd; border-width: 1px 0 0; clear: both; margin: 10px 0 30px; height: 0; } 94 | 95 | 96 | /* #Links 97 | ================================================== */ 98 | a, a:visited { color: #333; text-decoration: underline; outline: 0; } 99 | a:hover, a:focus { color: #000; } 100 | p a, p a:visited { line-height: inherit; } 101 | 102 | 103 | /* #Lists 104 | ================================================== */ 105 | ul, ol { margin-bottom: 20px; } 106 | ul { list-style: none outside; } 107 | ol { list-style: decimal; } 108 | ol, ul.square, ul.circle, ul.disc { margin-left: 30px; } 109 | ul.square { list-style: square outside; } 110 | ul.circle { list-style: circle outside; } 111 | ul.disc { list-style: disc outside; } 112 | ul ul, ul ol, 113 | ol ol, ol ul { margin: 4px 0 5px 30px; font-size: 90%; } 114 | ul ul li, ul ol li, 115 | ol ol li, ol ul li { margin-bottom: 6px; } 116 | li { line-height: 18px; margin-bottom: 12px; } 117 | ul.large li { line-height: 21px; } 118 | li p { line-height: 21px; } 119 | 120 | /* #Images 121 | ================================================== */ 122 | 123 | img.scale-with-grid { 124 | max-width: 100%; 125 | height: auto; } 126 | 127 | 128 | /* #Buttons 129 | ================================================== */ 130 | 131 | a.button, 132 | button, 133 | input[type="submit"], 134 | input[type="reset"], 135 | input[type="button"] { 136 | background: #eee; /* Old browsers */ 137 | background: #eee -moz-linear-gradient(top, rgba(255,255,255,.2) 0%, rgba(0,0,0,.2) 100%); /* FF3.6+ */ 138 | background: #eee -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,.2)), color-stop(100%,rgba(0,0,0,.2))); /* Chrome,Safari4+ */ 139 | background: #eee -webkit-linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* Chrome10+,Safari5.1+ */ 140 | background: #eee -o-linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* Opera11.10+ */ 141 | background: #eee -ms-linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* IE10+ */ 142 | background: #eee linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* W3C */ 143 | border: 1px solid #aaa; 144 | border-top: 1px solid #ccc; 145 | border-left: 1px solid #ccc; 146 | padding: 4px 12px; 147 | -moz-border-radius: 3px; 148 | -webkit-border-radius: 3px; 149 | border-radius: 3px; 150 | color: #444; 151 | display: inline-block; 152 | font-size: 11px; 153 | font-weight: bold; 154 | text-decoration: none; 155 | text-shadow: 0 1px rgba(255, 255, 255, .75); 156 | cursor: pointer; 157 | margin-bottom: 20px; 158 | line-height: 21px; 159 | font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; } 160 | 161 | a.button:hover, 162 | button:hover, 163 | input[type="submit"]:hover, 164 | input[type="reset"]:hover, 165 | input[type="button"]:hover { 166 | color: #222; 167 | background: #ddd; /* Old browsers */ 168 | background: #ddd -moz-linear-gradient(top, rgba(255,255,255,.3) 0%, rgba(0,0,0,.3) 100%); /* FF3.6+ */ 169 | background: #ddd -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,.3)), color-stop(100%,rgba(0,0,0,.3))); /* Chrome,Safari4+ */ 170 | background: #ddd -webkit-linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* Chrome10+,Safari5.1+ */ 171 | background: #ddd -o-linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* Opera11.10+ */ 172 | background: #ddd -ms-linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* IE10+ */ 173 | background: #ddd linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* W3C */ 174 | border: 1px solid #888; 175 | border-top: 1px solid #aaa; 176 | border-left: 1px solid #aaa; } 177 | 178 | a.button:active, 179 | button:active, 180 | input[type="submit"]:active, 181 | input[type="reset"]:active, 182 | input[type="button"]:active { 183 | border: 1px solid #666; 184 | background: #ccc; /* Old browsers */ 185 | background: #ccc -moz-linear-gradient(top, rgba(255,255,255,.35) 0%, rgba(10,10,10,.4) 100%); /* FF3.6+ */ 186 | background: #ccc -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,.35)), color-stop(100%,rgba(10,10,10,.4))); /* Chrome,Safari4+ */ 187 | background: #ccc -webkit-linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* Chrome10+,Safari5.1+ */ 188 | background: #ccc -o-linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* Opera11.10+ */ 189 | background: #ccc -ms-linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* IE10+ */ 190 | background: #ccc linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* W3C */ } 191 | 192 | .button.full-width, 193 | button.full-width, 194 | input[type="submit"].full-width, 195 | input[type="reset"].full-width, 196 | input[type="button"].full-width { 197 | width: 100%; 198 | padding-left: 0 !important; 199 | padding-right: 0 !important; 200 | text-align: center; } 201 | 202 | 203 | /* #Tabs (activate in tabs.js) 204 | ================================================== */ 205 | ul.tabs { 206 | display: block; 207 | margin: 0 0 20px 0; 208 | padding: 0; 209 | border-bottom: solid 1px #ddd; } 210 | ul.tabs li { 211 | display: block; 212 | width: auto; 213 | height: 30px; 214 | padding: 0; 215 | float: left; 216 | margin-bottom: 0; } 217 | ul.tabs li a { 218 | display: block; 219 | text-decoration: none; 220 | width: auto; 221 | height: 29px; 222 | padding: 0px 20px; 223 | line-height: 30px; 224 | border: solid 1px #ddd; 225 | border-width: 1px 1px 0 0; 226 | margin: 0; 227 | background: #f5f5f5; 228 | font-size: 13px; } 229 | ul.tabs li a.active { 230 | background: #fff; 231 | height: 30px; 232 | position: relative; 233 | top: -4px; 234 | padding-top: 4px; 235 | border-left-width: 1px; 236 | margin: 0 0 0 -1px; 237 | color: #111; 238 | -moz-border-radius-topleft: 2px; 239 | -webkit-border-top-left-radius: 2px; 240 | border-top-left-radius: 2px; 241 | -moz-border-radius-topright: 2px; 242 | -webkit-border-top-right-radius: 2px; 243 | border-top-right-radius: 2px; } 244 | ul.tabs li:first-child a.active { 245 | margin-left: 0; } 246 | ul.tabs li:first-child a { 247 | border-width: 1px 1px 0 1px; 248 | -moz-border-radius-topleft: 2px; 249 | -webkit-border-top-left-radius: 2px; 250 | border-top-left-radius: 2px; } 251 | ul.tabs li:last-child a { 252 | -moz-border-radius-topright: 2px; 253 | -webkit-border-top-right-radius: 2px; 254 | border-top-right-radius: 2px; } 255 | 256 | ul.tabs-content { margin: 0; display: block; } 257 | ul.tabs-content > li { display:none; } 258 | ul.tabs-content > li.active { display: block; } 259 | 260 | /* Clearfixing tabs for beautiful stacking */ 261 | ul.tabs:before, 262 | ul.tabs:after { 263 | content: '\0020'; 264 | display: block; 265 | overflow: hidden; 266 | visibility: hidden; 267 | width: 0; 268 | height: 0; } 269 | ul.tabs:after { 270 | clear: both; } 271 | ul.tabs { 272 | zoom: 1; } 273 | 274 | 275 | /* #Forms 276 | ================================================== */ 277 | 278 | form { 279 | margin-bottom: 20px; } 280 | fieldset { 281 | margin-bottom: 20px; } 282 | input[type="text"], 283 | input[type="password"], 284 | input[type="email"], 285 | textarea, 286 | select { 287 | border: 1px solid #ccc; 288 | padding: 6px 4px; 289 | outline: none; 290 | -moz-border-radius: 2px; 291 | -webkit-border-radius: 2px; 292 | border-radius: 2px; 293 | font: 13px "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 294 | color: #777; 295 | margin: 0; 296 | width: 210px; 297 | max-width: 100%; 298 | display: block; 299 | margin-bottom: 20px; 300 | background: #fff; } 301 | select { 302 | padding: 0; } 303 | input[type="text"]:focus, 304 | input[type="password"]:focus, 305 | input[type="email"]:focus, 306 | textarea:focus { 307 | border: 1px solid #aaa; 308 | color: #444; 309 | -moz-box-shadow: 0 0 3px rgba(0,0,0,.2); 310 | -webkit-box-shadow: 0 0 3px rgba(0,0,0,.2); 311 | box-shadow: 0 0 3px rgba(0,0,0,.2); } 312 | textarea { 313 | min-height: 60px; } 314 | label, 315 | legend { 316 | display: block; 317 | font-weight: bold; 318 | font-size: 13px; } 319 | select { 320 | width: 220px; } 321 | input[type="checkbox"] { 322 | display: inline; } 323 | label span, 324 | legend span { 325 | font-weight: normal; 326 | font-size: 13px; 327 | color: #444; } 328 | 329 | /* #Misc 330 | ================================================== */ 331 | .remove-bottom { margin-bottom: 0 !important; } 332 | .half-bottom { margin-bottom: 10px !important; } 333 | .add-bottom { margin-bottom: 20px !important; } 334 | 335 | 336 | -------------------------------------------------------------------------------- /resources/static/stylesheets/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V1.1 3 | * Copyright 2011, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 8/17/2011 8 | */ 9 | 10 | 11 | /* Table of Contents 12 | ================================================== 13 | #Base 960 Grid 14 | #Tablet (Portrait) 15 | #Mobile (Portrait) 16 | #Mobile (Landscape) 17 | #Clearing */ 18 | 19 | 20 | 21 | /* #Base 960 Grid 22 | ================================================== */ 23 | 24 | .container { position: relative; width: 960px; margin: 0 auto; padding: 0; } 25 | .column, .columns { float: left; display: inline; margin-left: 10px; margin-right: 10px; } 26 | .row { margin-bottom: 20px; } 27 | 28 | /* Nested Column Classes */ 29 | .column.alpha, .columns.alpha { margin-left: 0; } 30 | .column.omega, .columns.omega { margin-right: 0; } 31 | 32 | /* Base Grid */ 33 | .container .one.column { width: 40px; } 34 | .container .two.columns { width: 100px; } 35 | .container .three.columns { width: 160px; } 36 | .container .four.columns { width: 220px; } 37 | .container .five.columns { width: 280px; } 38 | .container .six.columns { width: 340px; } 39 | .container .seven.columns { width: 400px; } 40 | .container .eight.columns { width: 460px; } 41 | .container .nine.columns { width: 520px; } 42 | .container .ten.columns { width: 580px; } 43 | .container .eleven.columns { width: 640px; } 44 | .container .twelve.columns { width: 700px; } 45 | .container .thirteen.columns { width: 760px; } 46 | .container .fourteen.columns { width: 820px; } 47 | .container .fifteen.columns { width: 880px; } 48 | .container .sixteen.columns { width: 940px; } 49 | 50 | .container .one-third.column { width: 300px; } 51 | .container .two-thirds.column { width: 620px; } 52 | 53 | /* Offsets */ 54 | .container .offset-by-one { padding-left: 60px; } 55 | .container .offset-by-two { padding-left: 120px; } 56 | .container .offset-by-three { padding-left: 180px; } 57 | .container .offset-by-four { padding-left: 240px; } 58 | .container .offset-by-five { padding-left: 300px; } 59 | .container .offset-by-six { padding-left: 360px; } 60 | .container .offset-by-seven { padding-left: 420px; } 61 | .container .offset-by-eight { padding-left: 480px; } 62 | .container .offset-by-nine { padding-left: 540px; } 63 | .container .offset-by-ten { padding-left: 600px; } 64 | .container .offset-by-eleven { padding-left: 660px; } 65 | .container .offset-by-twelve { padding-left: 720px; } 66 | .container .offset-by-thirteen { padding-left: 780px; } 67 | .container .offset-by-fourteen { padding-left: 840px; } 68 | .container .offset-by-fifteen { padding-left: 900px; } 69 | 70 | 71 | 72 | /* #Tablet (Portrait) 73 | ================================================== */ 74 | 75 | /* Note: Design for a width of 768px */ 76 | 77 | @media only screen and (min-width: 768px) and (max-width: 959px) { 78 | .container { width: 768px; } 79 | .container .column, 80 | .container .columns { margin-left: 10px; margin-right: 10px; } 81 | .column.alpha, .columns.alpha { margin-left: 0; margin-right: 10px; } 82 | .column.omega, .columns.omega { margin-right: 0; margin-left: 10px; } 83 | 84 | .container .one.column { width: 28px; } 85 | .container .two.columns { width: 76px; } 86 | .container .three.columns { width: 124px; } 87 | .container .four.columns { width: 172px; } 88 | .container .five.columns { width: 220px; } 89 | .container .six.columns { width: 268px; } 90 | .container .seven.columns { width: 316px; } 91 | .container .eight.columns { width: 364px; } 92 | .container .nine.columns { width: 412px; } 93 | .container .ten.columns { width: 460px; } 94 | .container .eleven.columns { width: 508px; } 95 | .container .twelve.columns { width: 556px; } 96 | .container .thirteen.columns { width: 604px; } 97 | .container .fourteen.columns { width: 652px; } 98 | .container .fifteen.columns { width: 700px; } 99 | .container .sixteen.columns { width: 748px; } 100 | 101 | .container .one-third.column { width: 236px; } 102 | .container .two-thirds.column { width: 492px; } 103 | 104 | /* Offsets */ 105 | .container .offset-by-one { padding-left: 48px; } 106 | .container .offset-by-two { padding-left: 96px; } 107 | .container .offset-by-three { padding-left: 144px; } 108 | .container .offset-by-four { padding-left: 192px; } 109 | .container .offset-by-five { padding-left: 240px; } 110 | .container .offset-by-six { padding-left: 288px; } 111 | .container .offset-by-seven { padding-left: 336px; } 112 | .container .offset-by-eight { padding-left: 348px; } 113 | .container .offset-by-nine { padding-left: 432px; } 114 | .container .offset-by-ten { padding-left: 480px; } 115 | .container .offset-by-eleven { padding-left: 528px; } 116 | .container .offset-by-twelve { padding-left: 576px; } 117 | .container .offset-by-thirteen { padding-left: 624px; } 118 | .container .offset-by-fourteen { padding-left: 672px; } 119 | .container .offset-by-fifteen { padding-left: 720px; } 120 | } 121 | 122 | 123 | /* #Mobile (Portrait) 124 | ================================================== */ 125 | 126 | /* Note: Design for a width of 320px */ 127 | 128 | @media only screen and (max-width: 767px) { 129 | .container { width: 300px; } 130 | .columns, .column { margin: 0; } 131 | 132 | .container .one.column, 133 | .container .two.columns, 134 | .container .three.columns, 135 | .container .four.columns, 136 | .container .five.columns, 137 | .container .six.columns, 138 | .container .seven.columns, 139 | .container .eight.columns, 140 | .container .nine.columns, 141 | .container .ten.columns, 142 | .container .eleven.columns, 143 | .container .twelve.columns, 144 | .container .thirteen.columns, 145 | .container .fourteen.columns, 146 | .container .fifteen.columns, 147 | .container .sixteen.columns, 148 | .container .one-third.column, 149 | .container .two-thirds.column { width: 300px; } 150 | 151 | /* Offsets */ 152 | .container .offset-by-one, 153 | .container .offset-by-two, 154 | .container .offset-by-three, 155 | .container .offset-by-four, 156 | .container .offset-by-five, 157 | .container .offset-by-six, 158 | .container .offset-by-seven, 159 | .container .offset-by-eight, 160 | .container .offset-by-nine, 161 | .container .offset-by-ten, 162 | .container .offset-by-eleven, 163 | .container .offset-by-twelve, 164 | .container .offset-by-thirteen, 165 | .container .offset-by-fourteen, 166 | .container .offset-by-fifteen { padding-left: 0; } 167 | 168 | } 169 | 170 | 171 | /* #Mobile (Landscape) 172 | ================================================== */ 173 | 174 | /* Note: Design for a width of 480px */ 175 | 176 | @media only screen and (min-width: 480px) and (max-width: 767px) { 177 | .container { width: 420px; } 178 | .columns, .column { margin: 0; } 179 | 180 | .container .one.column, 181 | .container .two.columns, 182 | .container .three.columns, 183 | .container .four.columns, 184 | .container .five.columns, 185 | .container .six.columns, 186 | .container .seven.columns, 187 | .container .eight.columns, 188 | .container .nine.columns, 189 | .container .ten.columns, 190 | .container .eleven.columns, 191 | .container .twelve.columns, 192 | .container .thirteen.columns, 193 | .container .fourteen.columns, 194 | .container .fifteen.columns, 195 | .container .sixteen.columns, 196 | .container .one-third.column, 197 | .container .two-thirds.column { width: 420px; } 198 | } 199 | 200 | 201 | /* #Clearing 202 | ================================================== */ 203 | 204 | /* Self Clearing Goodness */ 205 | .container:after { content: "\0020"; display: block; height: 0; clear: both; visibility: hidden; } 206 | 207 | /* Use clearfix class on parent to clear nested columns, 208 | or wrap each row of columns in a
*/ 209 | .clearfix:before, 210 | .clearfix:after, 211 | .row:before, 212 | .row:after { 213 | content: '\0020'; 214 | display: block; 215 | overflow: hidden; 216 | visibility: hidden; 217 | width: 0; 218 | height: 0; } 219 | .row:after, 220 | .clearfix:after { 221 | clear: both; } 222 | .row, 223 | .clearfix { 224 | zoom: 1; } 225 | 226 | /* You can also use a
to clear columns */ 227 | .clear { 228 | clear: both; 229 | display: block; 230 | overflow: hidden; 231 | visibility: hidden; 232 | width: 0; 233 | height: 0; 234 | } 235 | 236 | 237 | -------------------------------------------------------------------------------- /resources/static/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | #content.container { 2 | width: 600px; 3 | } 4 | 5 | #header h1 { 6 | font-family: 'Passion One', arial, serif; 7 | margin: 0.5em auto; 8 | width: 4em; 9 | } 10 | 11 | h3.project { 12 | font-family: 'Inconsolata', 'Monaco', 'Consolas', monospace; 13 | font-weight: bold; /* ignored, huh? */ 14 | } 15 | 16 | h3.project a { 17 | text-decoration: none; 18 | } 19 | 20 | h3.project a:hover { 21 | color: #555; 22 | } 23 | 24 | #header a { 25 | text-decoration: none; 26 | } 27 | 28 | #footer { 29 | clear: both; 30 | font-size: 80%; 31 | margin: 0.5em auto; 32 | text-align: right; 33 | } 34 | 35 | #splash { 36 | margin: 5em; 37 | } 38 | 39 | #ip { 40 | margin-left: 4em; 41 | font-size: 125%; 42 | font-family: 'Inconsolata', 'Monaco', 'Consolas', monospace; 43 | } 44 | 45 | p#ip.halted { 46 | text-decoration: line-through; 47 | } 48 | 49 | p#ip.bootstrapping { 50 | color: #333; 51 | } 52 | 53 | ul#users li { 54 | float: left; 55 | margin-right: 15px; 56 | margin-top: 10px; 57 | } 58 | 59 | div hr { 60 | margin: 15px 0; 61 | } 62 | 63 | div p#status { 64 | padding: 15px; 65 | float: right; 66 | font-weight: bold; 67 | margin: 0px; 68 | } 69 | 70 | div p#status.bootstrapping { 71 | color: orange; 72 | } 73 | 74 | div p#status.configuring { 75 | color: orange; 76 | } 77 | 78 | div p#status.ready { 79 | color: green; 80 | } 81 | 82 | div p#status.halted { 83 | color: brown; 84 | } 85 | 86 | div p#status.halting { 87 | color: brown; 88 | } 89 | 90 | div p#status.failed { 91 | color: red; 92 | } 93 | 94 | div p#status.unauthorized { 95 | color: red; 96 | } 97 | 98 | div p#status.error { 99 | color: red; 100 | } 101 | 102 | div p#status.timeout { 103 | color: red; 104 | } 105 | 106 | html kbd { 107 | font-family: 'Inconsolata', 'Monaco', 'Consolas', monospace; 108 | } 109 | -------------------------------------------------------------------------------- /resources/static/syme.js: -------------------------------------------------------------------------------- 1 | // instance reload 2 | 3 | var poll_interval = 4000; 4 | 5 | var reload_status = function (request, project) { 6 | if (request.readyState == 4) { 7 | if (request.status == 200) { 8 | location.reload(true); 9 | } else { 10 | setTimeout(function() { wait_for_boot(project); }, poll_interval); 11 | } 12 | } 13 | }; 14 | 15 | var wait_for_boot = function (project) { 16 | var request = new XMLHttpRequest(); 17 | request.onreadystatechange = function(){ reload_status(request, project); }; 18 | request.open("GET", "/project/" + project + "/status", true); 19 | request.send(null); 20 | }; 21 | 22 | var update_status = function (request, project) { 23 | if (request.readyState == 4) { 24 | if (request.status == 200) { 25 | var data = JSON.parse(request.responseText); 26 | document.getElementById("status").innerHTML = data.status; 27 | document.getElementById("status").className = data.status; 28 | setTimeout(function() { watch_status(project); }, poll_interval); 29 | 30 | if(data.status == "halted" || data.status == "halting") { 31 | document.getElementById("haltbutton").style.display = "none"; 32 | } 33 | if(data.status == "halted" || data.status == "halting" || 34 | data.status == "failed") { 35 | document.getElementById("ip").style["text-decoration"] = "line-through"; 36 | } 37 | } 38 | } 39 | }; 40 | 41 | var watch_status = function (project) { 42 | var request = new XMLHttpRequest(); 43 | request.onreadystatechange = function(){ update_status(request, project); }; 44 | request.open("GET", "/project/" + project + "/status", true); 45 | request.send(null); 46 | }; 47 | 48 | // halt 49 | 50 | var show_halt = function () { 51 | document.getElementById("halt").style.display = 'block'; 52 | }; 53 | 54 | var hide_halt = function () { 55 | document.getElementById("halt").style.display = 'none'; 56 | return false; 57 | }; 58 | 59 | var halt = function (project) { 60 | var request = new XMLHttpRequest(); 61 | // TODO: indicate progress is happening 62 | request.onreadystatechange = function(){ }; 63 | request.open("DELETE", "/project/" + project, true); 64 | request.send(null); 65 | hide_halt(); 66 | }; 67 | -------------------------------------------------------------------------------- /resources/userdata.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # arguments 4 | 5 | USERNAME="%s" 6 | PROJECT="%s" 7 | INVITEES="%s" 8 | 9 | FULLNAME="%s" 10 | EMAIL="%s" 11 | UPDATE_URL="%s" 12 | 13 | PROJECT_PARTS=(${PROJECT//\// }) 14 | PROJECT_DIR="/home/syme/${PROJECT_PARTS[1]}/" 15 | 16 | wget -qO /etc/motd.tail https://raw.github.com/technomancy/syme/master/resources/motd-pending & 17 | 18 | # user 19 | 20 | adduser syme --disabled-password --gecos "" --quiet 21 | usermod -G sudo syme 22 | echo "ALL ALL = (ALL) NOPASSWD: ALL" >> /etc/sudoers 23 | echo "AllowAgentForwarding no" >> /etc/ssh/sshd_config 24 | 25 | # the legend tee mucks and the terrible default bindings 26 | 27 | cat > /etc/tmux.conf < /usr/local/bin/add-github-key <> \$HOME/.ssh/authorized_keys 63 | echo >> \$HOME/.ssh/authorized_keys 64 | fi 65 | EOF 66 | 67 | chmod 755 /usr/local/bin/add-github-key 68 | 69 | sudo -iu syme add-github-key $USERNAME 70 | 71 | for invitee in $INVITEES; do 72 | sudo -iu syme add-github-key $invitee 73 | done 74 | 75 | # packages 76 | 77 | echo "APT::Install-Recommends \"0\";" > /etc/apt/apt.conf.d/50norecommends 78 | apt-get update 79 | apt-get install -y git tmux molly-guard 80 | 81 | rm /etc/molly-guard/run.d/30-query-hostname # not using molly-guard for that 82 | 83 | # clone repo 84 | 85 | sudo -iu syme git clone https://github.com/$PROJECT.git 86 | 87 | # configure git 88 | 89 | if [ "$EMAIL" != "" ]; then 90 | sudo -iu syme git config --global user.email "$EMAIL" 91 | fi 92 | 93 | if [ "$FULLNAME" != "" ]; then 94 | sudo -iu syme git config --global user.name "$FULLNAME" 95 | fi 96 | 97 | # Language-specific configuration (spliced in by instance.clj) 98 | 99 | %s 100 | 101 | # Project-specific configuration 102 | 103 | PROJECT_SYMERC="$PROJECT_DIR/.symerc" 104 | [ -x $PROJECT_SYMERC ] && sudo -iu syme $PROJECT_SYMERC 105 | 106 | # User-specific configuration 107 | 108 | sudo -iu syme git clone --depth=1 git://github.com/$USERNAME/.symerc && \ 109 | sudo -iu syme .symerc/bootstrap $PROJECT || true 110 | 111 | # Install shutdown hook 112 | 113 | echo "curl -XPOST '${UPDATE_URL}&status=halted'" > /etc/init.d/syme-shutdown 114 | chmod 755 /etc/init.d/syme-shutdown 115 | update-rc.d syme-shutdown defaults # TODO: runlevel 0 116 | 117 | cat > /etc/molly-guard/run.d/30-syme-clean-checkout < 'halted'" 42 | " AND status <> 'halting'" 43 | " AND status <> 'failed'" 44 | " AND status <> 'error'" 45 | " AND status <> 'timeout'" 46 | " AND status <> 'unconfigured'" 47 | " AND status <> 'unauthorized'")) 48 | " ORDER BY at DESC") username project-name] 49 | ;; whatever I suck at sql 50 | (if instance 51 | (sql/with-query-results invitees 52 | ["SELECT * FROM invites WHERE instance_id = ?" (:id instance)] 53 | (assoc instance 54 | :invitees (mapv :invitee invitees))))))) 55 | 56 | (defn find-all [username] 57 | (sql/with-connection db 58 | (sql/with-query-results instances 59 | ["SELECT * FROM instances WHERE owner = ? ORDER BY at DESC" username] 60 | (doall instances)))) 61 | 62 | (defn by-token [shutdown-token] 63 | (sql/with-connection db 64 | (sql/with-query-results [instance] 65 | ["SELECT * FROM instances WHERE shutdown_token = ?" shutdown-token] 66 | instance))) 67 | 68 | ;; migrations 69 | 70 | (defn initial-schema [] 71 | (sql/create-table "instances" 72 | [:id :serial "PRIMARY KEY"] 73 | [:owner :varchar "NOT NULL"] 74 | [:project :varchar "NOT NULL"] 75 | [:ip :varchar] 76 | [:description :text] 77 | [:status :varchar] 78 | [:at :timestamp "NOT NULL" "DEFAULT CURRENT_TIMESTAMP"]) 79 | (sql/create-table "invites" 80 | [:id :serial "PRIMARY KEY"] 81 | [:invitee :varchar "NOT NULL"] 82 | [:instance_id :integer "NOT NULL"] 83 | [:at :timestamp "NOT NULL" "DEFAULT CURRENT_TIMESTAMP"])) 84 | 85 | (defn add-instance-id [] 86 | (sql/do-commands "ALTER TABLE instances ADD COLUMN instance_id VARCHAR")) 87 | 88 | (defn add-shutdown-token [] 89 | (sql/do-commands "ALTER TABLE instances ADD COLUMN shutdown_token VARCHAR")) 90 | 91 | (defn add-dns [] 92 | (sql/do-commands "ALTER TABLE instances ADD COLUMN dns VARCHAR")) 93 | 94 | (defn add-region [] 95 | (sql/do-commands "ALTER TABLE instances ADD COLUMN region VARCHAR")) 96 | 97 | ;; migrations mechanics 98 | 99 | (defn run-and-record [migration] 100 | (println "Running migration:" (:name (meta migration))) 101 | (migration) 102 | (sql/insert-values "migrations" [:name :created_at] 103 | [(str (:name (meta migration))) 104 | (java.sql.Timestamp. (System/currentTimeMillis))])) 105 | 106 | (defn migrate [& migrations] 107 | (sql/with-connection db 108 | (try (sql/create-table "migrations" 109 | [:name :varchar "NOT NULL"] 110 | [:created_at :timestamp 111 | "NOT NULL" "DEFAULT CURRENT_TIMESTAMP"]) 112 | (catch Exception _)) 113 | (sql/transaction 114 | (let [has-run? (sql/with-query-results run ["SELECT name FROM migrations"] 115 | (set (map :name run)))] 116 | (doseq [m migrations 117 | :when (not (has-run? (str (:name (meta m)))))] 118 | (run-and-record m)))))) 119 | 120 | (defn -main [] 121 | (migrate #'initial-schema 122 | #'add-instance-id 123 | #'add-shutdown-token 124 | #'add-dns 125 | #'add-region)) 126 | -------------------------------------------------------------------------------- /src/syme/dns.clj: -------------------------------------------------------------------------------- 1 | (ns syme.dns 2 | (:require [environ.core :refer [env]]) 3 | (:import (com.amazonaws.auth BasicAWSCredentials) 4 | (com.amazonaws.services.route53 AmazonRoute53Client) 5 | (com.amazonaws.services.route53.model Change ChangeBatch 6 | ChangeResourceRecordSetsRequest 7 | GetHostedZoneRequest 8 | ResourceRecord 9 | ResourceRecordSet))) 10 | 11 | (def client (delay (AmazonRoute53Client. (BasicAWSCredentials. 12 | (env :aws-access-key) 13 | (env :aws-secret-key))))) 14 | 15 | (defn make-request [changes] 16 | (when (:aws-access-key env) 17 | (let [zone-req (GetHostedZoneRequest. (env :zone-id)) 18 | zone (.getHostedZone (.getHostedZone @client zone-req)) 19 | changes (ChangeBatch. changes) 20 | req (ChangeResourceRecordSetsRequest. (.getId zone) changes)] 21 | (.changeResourceRecordSets @client req)))) 22 | 23 | (defn make-change [change-type hostname ip] 24 | (Change. change-type 25 | (doto (ResourceRecordSet. hostname "A") 26 | (.setTTL 5) 27 | (.setResourceRecords [(ResourceRecord. ip)])))) 28 | 29 | (defn register-hostname [hostname new-ip] 30 | (make-request [(make-change "CREATE" hostname new-ip)])) 31 | 32 | (defn deregister-hostname [hostname ip] 33 | (make-request [(make-change "DELETE" hostname ip)])) 34 | -------------------------------------------------------------------------------- /src/syme/html.clj: -------------------------------------------------------------------------------- 1 | (ns syme.html 2 | (:require [clojure.java.io :as io] 3 | [tentacles.repos :as repos] 4 | [tentacles.users :as users] 5 | [environ.core :refer [env]] 6 | [syme.instance :as instance] 7 | [hiccup.page :refer [html5 include-css]] 8 | [hiccup.form :as form])) 9 | 10 | (def login-url (str "https://github.com/login/oauth/authorize?" 11 | "client_id=" (env :oauth-client-id))) 12 | 13 | (defn layout [body username & [project]] 14 | (html5 15 | [:head 16 | [:meta {:charset "utf-8"}] 17 | [:title (if project (str project " - Syme") "Syme")] 18 | (include-css "/stylesheets/style.css" "/stylesheets/base.css" 19 | "/stylesheets/skeleton.css") 20 | (include-css "https://fonts.googleapis.com/css?family=Passion+One:700")] 21 | [:body 22 | (if-let [account (:analytics-account env)] 23 | [:script {:type "text/javascript"} (-> (io/resource "analytics.js") 24 | (slurp) (format account))]) 25 | [:div#header 26 | [:h1.container [:a {:href "/"} "Syme"]]] 27 | [:div#content.container body 28 | [:div#footer 29 | [:p [:a {:href "/faq"} "About"] 30 | " | " [:a {:href "https://github.com/technomancy/syme"} 31 | "Source"] 32 | " | " (if username 33 | (list [:a {:href "/all"} "All Instances"] " | " 34 | [:a {:href "/logout"} "Log out"]) 35 | [:a {:href login-url} "Log in"])]]]])) 36 | 37 | (defn splash [username] 38 | (layout 39 | [:div 40 | [:img {:src "/splash.png" 41 | :style "position: absolute; z-index: -1; top: -10px; left: -30px;"}] 42 | [:form {:action "/launch" :method :get :id "splash" 43 | :style "position: absolute; top: 257px; left: -20px; width: 440px;"} 44 | [:input {:type :submit :value "Collaborate on a GitHub project" 45 | :style "width: 48%; float: right;"}] 46 | [:input {:type :text :name "project" 47 | :style "width: 48%; height: 14px; font-weight: bold;" 48 | :placeholder "user/project"}]] 49 | [:p {:style "margin-bottom: 700px;"} " "]] username)) 50 | 51 | (defn faq [username] 52 | (layout (slurp (io/resource "faq.html")) username)) 53 | 54 | (defn launch [username repo-name identity credential] 55 | (let [repo (try (apply repos/specific-repo (.split repo-name "/")) 56 | (catch Exception _))] 57 | (when-not (:name repo) 58 | (throw (ex-info "Repository not found." {:status 404}))) 59 | (layout 60 | [:div 61 | [:h3.project [:a {:href (:html_url repo)} repo-name]] 62 | [:p {:id "desc"} (:description repo)] 63 | [:hr] 64 | [:form {:action "/launch" :method :post} 65 | [:input {:type :hidden :name "project" :value repo-name}] 66 | [:input {:type :text :name "invite" :id "invite" 67 | :placeholder "users to invite (space-separated)"}] 68 | [:input {:type :text :name "identity" :id "identity" 69 | :value identity :placeholder "AWS Access Key"}] 70 | [:input {:type :text :style "width: 320px" 71 | :name "credential" :id "credential" 72 | :value credential :placeholder "AWS Secret Key"}] 73 | (form/drop-down "region" (->> (keys instance/ami-by-region) 74 | (map name) 75 | sort) 76 | "us-west-2") 77 | [:input {:type :text :name "ami-id" 78 | :style "width: 48%" 79 | :placeholder "ami id (optional)"}] 80 | [:input {:type :text :name "instance-type" 81 | :style "width: 48%" 82 | :placeholder "instance-type (default: m1.small)"}] 83 | [:hr] 84 | [:p {:style "float: right; margin-top: 10px; font-size: 80%"} 85 | "Your credentials are stored in an encrypted cookie, never" 86 | " on the server."] 87 | [:input {:type :submit :value "Launch!"}]]] 88 | username repo-name))) 89 | 90 | (defonce icon (memoize (comp :avatar_url users/user))) 91 | 92 | (defn- link-syme-project [project] 93 | (format "/project/%s" project)) 94 | 95 | (defn- link-github-project [project] 96 | (format "https://github.com/%s" project)) 97 | 98 | (defn- render-instance-info [{:keys [status project description]} link-project] 99 | [:div 100 | [:p {:id "status" :class status} status] 101 | [:h3.project [:a {:href (link-project project)} project]] 102 | [:p {:id "desc"} description]]) 103 | 104 | (defn instance [username {:keys [project status description ip dns invitees] 105 | :as instance-info}] 106 | (layout 107 | [:div 108 | (render-instance-info instance-info link-github-project) 109 | [:hr] 110 | (if (or dns ip) 111 | [:div 112 | ;; TODO: remove inline styles 113 | [:p {:id "haltbutton" :style "float: right; margin: -7px 0;"} 114 | [:button {:onclick "show_halt()"} "Halt"]] 115 | [:div {:id "halt" :style "float: right; clear: right; display: none"} 116 | [:button {:onclick "hide_halt();"} "Cancel"] 117 | [:button {:onclick (format "halt('%s')" project)} "Confirm"]] 118 | [:p {:id "ip" :class status 119 | :title "Send this command to the users you've invited."} 120 | [:tt "ssh syme@" (or dns ip)]]] 121 | [:p "Waiting to boot... could take a few minutes."]) 122 | [:hr] 123 | [:ul {:id "users"} 124 | (for [u invitees] 125 | [:li [:a {:href (str "https://github.com/" u)} 126 | [:img {:src (icon u) :alt u :title u :height 80 :width 80}]]])] 127 | [:script {:type "text/javascript", :src "/syme.js" 128 | :onload (if ip 129 | (format "watch_status('%s')" project) 130 | (format "wait_for_boot('%s')" project))}]] 131 | username project)) 132 | 133 | (defn all [username instances] 134 | (layout 135 | [:div [:h3 "All Instances"] 136 | (if instances 137 | (map #(render-instance-info % link-syme-project) instances) 138 | [:p "You have no instances"]) 139 | [:hr] 140 | [:p "You may want to periodically check your " 141 | [:a {:href 142 | "https://console.aws.amazon.com/ec2/home?region=us-west-2#s=Instances"} 143 | "AWS EC2 console"] 144 | " to ensure you aren't billed for instances you intended to stop that" 145 | " stayed running due to problems with Syme. In particular this happens" 146 | " due to timeout errors." 147 | ]] 148 | username "Status")) 149 | -------------------------------------------------------------------------------- /src/syme/instance.clj: -------------------------------------------------------------------------------- 1 | (ns syme.instance 2 | (:require [clojure.java.io :as io] 3 | [clojure.java.jdbc :as sql] 4 | [syme.db :as db] 5 | [syme.dns :as dns] 6 | [tentacles.repos :as repos] 7 | [tentacles.orgs :as orgs] 8 | [tentacles.users :as users] 9 | [environ.core :refer [env]]) 10 | (:import (com.amazonaws.auth BasicAWSCredentials) 11 | (com.amazonaws.services.ec2 AmazonEC2Client) 12 | (com.amazonaws.services.ec2.model AuthorizeSecurityGroupIngressRequest 13 | CreateSecurityGroupRequest 14 | DescribeInstancesRequest 15 | IpPermission 16 | RunInstancesRequest 17 | TerminateInstancesRequest 18 | DescribeInstancesRequest 19 | TerminateInstancesRequest 20 | CreateTagsRequest 21 | Tag) 22 | (com.amazonaws AmazonServiceException) 23 | (org.apache.commons.codec.binary Base64))) 24 | 25 | (def ami-by-region 26 | {:sa-east-1 "ami-0970d814", 27 | :ap-northeast-1 "ami-e58cd6e4", 28 | :ap-southeast-2 "ami-cd6405f7", 29 | :eu-west-1 "ami-3eba6549", 30 | :ap-southeast-1 "ami-28fda67a", 31 | :us-west-2 "ami-57cf8a67", 32 | :us-west-1 "ami-29777b6c", 33 | :us-east-1 "ami-9ac11df2"}) 34 | 35 | (defn default-ami-id [region] 36 | (get ami-by-region (keyword region))) 37 | 38 | (def default-region "us-west-2") 39 | 40 | (def default-instance-type "m1.small") 41 | 42 | (defn make-endpoint-url [region] 43 | (str "ec2." region ".amazonaws.com")) 44 | 45 | (defn make-client [identity credential region] 46 | (doto (AmazonEC2Client. (BasicAWSCredentials. identity credential)) 47 | (.setEndpoint (make-endpoint-url region)))) 48 | 49 | (defn subdomain-for [owner instance-id] 50 | (format (:subdomain env) owner instance-id)) 51 | 52 | (defn create-security-group [client security-group-name] 53 | (let [group-request (-> (CreateSecurityGroupRequest.) 54 | (.withGroupName security-group-name) 55 | (.withDescription "For Syme instances.")) 56 | ip-permission (-> (IpPermission.) 57 | (.withIpProtocol "tcp") 58 | (.withIpRanges (into-array ["0.0.0.0/0"])) 59 | ;; no longs? you can't be serious. 60 | (.withToPort (Integer. 22)) 61 | (.withFromPort (Integer. 22))) 62 | auth-request (-> (AuthorizeSecurityGroupIngressRequest.) 63 | (.withGroupName security-group-name) 64 | (.withIpPermissions [ip-permission]))] 65 | (try (.createSecurityGroup client group-request) 66 | (.authorizeSecurityGroupIngress client auth-request) 67 | (catch Exception e 68 | (when-not (and (instance? AmazonServiceException e) 69 | (= "InvalidGroup.Duplicate" (.getErrorCode e))) 70 | (throw e)))) 71 | security-group-name)) 72 | 73 | (defn usernames-for [invitees] 74 | (let [invitees (.split invitees ",? +") 75 | [orgs users] ((juxt filter remove) #(.startsWith % "+") invitees) 76 | ;; TODO: is this paginated? 77 | orgs-users (for [org orgs] 78 | (map :login (orgs/members (subs org 1))))] 79 | (apply concat users orgs-users))) 80 | 81 | (defn primary-language [project] 82 | (if-let [langs (seq (apply repos/languages (.split project "/")))] 83 | (-> (apply max-key second langs) first name))) 84 | 85 | (defn user-data [username project invitees] 86 | (let [language (primary-language project) 87 | {:keys [name email]} (users/user username) 88 | {:keys [shutdown_token]} (db/find username project) 89 | language-script (io/resource (str "languages/" language ".sh"))] 90 | (format (slurp (io/resource "userdata.sh")) 91 | username project (clojure.string/join " " invitees) name email 92 | (and (:canonical-url env) 93 | (str (:canonical-url env) "/status?token=" shutdown_token)) 94 | (if language-script (slurp language-script) "")))) 95 | 96 | (defn run-instance [client security-group user-data-script ami-id instance-type] 97 | (.runInstances client (-> (RunInstancesRequest.) 98 | (.withImageId ami-id) 99 | (.withInstanceType instance-type) 100 | (.withMinCount (Integer. 1)) 101 | (.withMaxCount (Integer. 1)) 102 | (.withSecurityGroups [security-group]) 103 | (.withUserData (String. 104 | (Base64/encodeBase64 105 | (.getBytes user-data-script))))))) 106 | 107 | (defn set-instance-name [client id name] 108 | (let [tag-name-request (-> (CreateTagsRequest.) 109 | (.withResources [id]) 110 | (.withTags [(Tag. "Name" name)]))] 111 | (try (.createTags client tag-name-request) 112 | ;; this is a convenience thing; non-critical 113 | (catch Exception e 114 | (println (.getMessage e)))))) 115 | 116 | (defn poll-for-ip [client id tries] 117 | (let [describe-request (-> (DescribeInstancesRequest.) 118 | (.withInstanceIds [id]))] 119 | (if (> tries 60) 120 | (throw (ex-info "Timed out waiting for IP." {:status "timeout"})) 121 | (Thread/sleep 5000)) 122 | (if-let [ip (-> client 123 | (.describeInstances describe-request) 124 | .getReservations first 125 | .getInstances first 126 | .getPublicIpAddress)] 127 | ip 128 | (recur client id (inc tries))))) 129 | 130 | ;; TODO: break this into several defns 131 | (defn launch [username {:keys [project invite identity credential 132 | ami-id region instance-type]}] 133 | (let [region (if (empty? region) default-region region)] 134 | (db/create username project region) 135 | (future 136 | (try 137 | (let [client (make-client identity credential region) 138 | ami-id (if (empty? ami-id) (default-ami-id region) ami-id) 139 | invitees (cons username (if-not (= invite "users to invite") 140 | (usernames-for invite))) 141 | security-group-name (str "syme/" username)] 142 | (sql/with-connection db/db 143 | (doseq [invitee invitees] 144 | (db/invite username project invitee))) 145 | (db/status username project "bootstrapping") 146 | (println "Setting up security group for" project "...") 147 | (create-security-group client security-group-name) 148 | (println "launching" project "...") 149 | (let [result (run-instance client security-group-name 150 | (user-data username project invitees) 151 | ami-id 152 | (if (empty? instance-type) 153 | default-instance-type 154 | instance-type)) 155 | instance-id (-> result .getReservation .getInstances 156 | first .getInstanceId)] 157 | (println "setting instance name to" project) 158 | (set-instance-name client instance-id project) 159 | (println "waiting for IP...") 160 | (let [ip (poll-for-ip client instance-id 0) 161 | {:keys [id]} (db/find username project) 162 | dns (subdomain-for username id)] 163 | (println "got IP:" ip) 164 | (db/status username project "configuring" 165 | {:ip ip :instance_id instance-id :dns dns}) 166 | (dns/register-hostname dns ip)))) 167 | (catch Exception e 168 | (.printStackTrace e) 169 | (db/status username project 170 | (if (and (instance? AmazonServiceException e) 171 | (= "AuthFailure" (.getErrorCode e))) 172 | "unauthorized" 173 | (:status (ex-data e) "error")))))))) 174 | 175 | (defn halt [username {:keys [project identity credential region]}] 176 | (let [client (make-client identity credential region) 177 | {:keys [instance_id]} (db/find username project)] 178 | (.terminateInstances client (TerminateInstancesRequest. [instance_id])) 179 | (db/status username project "halting"))) 180 | -------------------------------------------------------------------------------- /src/syme/web.clj: -------------------------------------------------------------------------------- 1 | (ns syme.web 2 | (:require [cheshire.core :as json] 3 | [clj-http.client :as http] 4 | [compojure.route :as route] 5 | [noir.util.middleware :as noir] 6 | [ring.adapter.jetty :as jetty] 7 | [ring.middleware.file-info :as file-info] 8 | [ring.middleware.resource :as resource] 9 | [ring.middleware.session.cookie :as cookie] 10 | [ring.middleware.stacktrace :as trace] 11 | [ring.util.response :as res] 12 | [syme.db :as db] 13 | [syme.dns :as dns] 14 | [syme.html :as html] 15 | [syme.instance :as instance] 16 | [compojure.core :refer [ANY DELETE GET POST routes]] 17 | [compojure.handler :refer [site]] 18 | [environ.core :refer [env]])) 19 | 20 | ;; pre-registered for http://localhost:5000 21 | (def dev-oauth {:client_id "49f1d4f840b69779374c" 22 | :client_secret "67b9a9fcaffd0c5c47b5dec85223a357ddf3fc46"}) 23 | 24 | (defn get-token [code] 25 | (-> (http/post "https://github.com/login/oauth/access_token" 26 | {:form-params (merge dev-oauth 27 | {:client_id (env :oauth-client-id) 28 | :client_secret (env :oauth-client-secret) 29 | :code code}) 30 | :headers {"Accept" "application/json"}}) 31 | (:body) (json/decode true) :access_token)) 32 | 33 | (defn get-username [token] 34 | (-> (http/get (str "https://api.github.com/user?access_token=" token) 35 | {:headers {"accept" "application/json"}}) 36 | (:body) (json/decode true) :login)) 37 | 38 | (def app 39 | (routes 40 | (GET "/" {{:keys [username]} :session} 41 | {:headers {"Content-Type" "text/html"} 42 | :status 200 43 | :body (html/splash username)}) 44 | (GET "/all" {{:keys [username]} :session} 45 | (html/all username (db/find-all username))) 46 | (GET "/launch" {{:keys [username] :as session} :session 47 | {:keys [project]} :params} 48 | (if-let [instance (db/find username project)] 49 | (res/redirect (str "/project/" project)) 50 | (if username 51 | {:headers {"Content-Type" "text/html"} 52 | :status 200 53 | :body (html/launch username (or project (:project session)) 54 | (:identity session) (:credential session))} 55 | (assoc (res/redirect html/login-url) 56 | :session (merge session {:project project}))))) 57 | (POST "/launch" {{:keys [username] :as session} :session 58 | {:keys [project] :as params} :params} 59 | (when-not username 60 | (throw (ex-info "Must be logged in." {:status 401}))) 61 | (when (db/find username project) 62 | (throw (ex-info "Already launched." {:status 409}))) 63 | (instance/launch username params) 64 | (assoc (res/redirect (str "/project/" project)) 65 | :session (merge session (select-keys params 66 | [:identity :credential])))) 67 | (GET "/project/:gh-user/:project" {{:keys [username]} :session 68 | instance :instance} 69 | (html/instance username instance)) 70 | ;; for polling from JS on instance page 71 | (GET "/project/:gh-user/:project/status" {instance :instance} 72 | {:status (if (:ip instance) 200 202) 73 | :headers {"Content-Type" "application/json"} 74 | :body (json/encode instance)}) 75 | (POST "/status" {{:keys [token status]} :params} 76 | (when-let [{:keys [id dns ip]} (db/by-token token)] 77 | (db/update-status id {:status status}) 78 | (when (= "shutdown" status) 79 | (dns/deregister-hostname dns ip)) 80 | {:status 200 81 | :headers {"Content-Type" "text/plain"} 82 | :body "OK"})) 83 | (DELETE "/project/:gh-user/:project" {{:keys [gh-user project]} :params 84 | {:keys [username identity credential] 85 | :as session} :session 86 | instance :instance} 87 | (do (instance/halt username {:project (str gh-user "/" project) 88 | :identity identity 89 | :credential credential 90 | :region (:region instance)}) 91 | {:status 200 92 | :headers {"Content-Type" "application/json"} 93 | :body (json/encode instance) 94 | :session (dissoc session :project)})) 95 | (GET "/oauth" {{:keys [code]} :params session :session} 96 | (if code 97 | (let [token (get-token code) 98 | username (get-username token)] 99 | (assoc (res/redirect (if (:project session) "/launch" "/")) 100 | :session (merge session {:token token :username username}))) 101 | {:status 403})) 102 | (GET "/logout" [] 103 | (assoc (res/redirect "/") :session nil)) 104 | (GET "/faq" {{:keys [username]} :session} 105 | {:headers {"Content-Type" "text/html"} 106 | :status 200 107 | :body (html/faq username)}) 108 | (ANY "*" [] 109 | (route/not-found 110 | (html/layout "

404

Couldn't find that; sorry.

" nil))))) 111 | 112 | (defn wrap-error-page [handler] 113 | (fn [req] 114 | (try (handler req) 115 | (catch Exception e 116 | (.printStackTrace e) 117 | (let [{:keys [status] :as data :or {status 500}} (ex-data e) 118 | m (or (.getMessage e) "Oops; ran into a problem; sorry.")] 119 | {:status status 120 | :headers {"Content-Type" "text/html"} 121 | :body (html/layout (format "

%s

%s

" 122 | status m) nil)}))))) 123 | 124 | (defn wrap-login [handler] 125 | (fn [req] 126 | (if (or (#{"/" "/launch" "/oauth" "/faq" "/all" "/status"} (:uri req)) 127 | (:username (:session req))) 128 | (handler req) 129 | (throw (ex-info "Must be logged in." {:status 401}))))) 130 | 131 | (defn wrap-find-instance [handler] 132 | (fn [req] 133 | (handler (if-let [project (second (re-find #"/project/([^/]+/[^/]+)" 134 | (:uri req)))] 135 | (if-let [inst (db/find (:username (:session req)) project true)] 136 | (assoc req :instance inst) 137 | (throw (ex-info "Instance not found." {:status 404}))) 138 | req)))) 139 | 140 | (defn -main [& [port]] 141 | (try (db/-main) 142 | (catch Exception e 143 | (println (.getMessage e)))) 144 | (let [port (Integer. (or port (env :port) 5000)) 145 | store (cookie/cookie-store {:key (env :session-secret)})] 146 | (jetty/run-jetty (-> #'app 147 | (wrap-find-instance) 148 | (wrap-login) 149 | (resource/wrap-resource "static") 150 | (file-info/wrap-file-info) 151 | ((if (env :production) 152 | wrap-error-page 153 | trace/wrap-stacktrace)) 154 | ((if (env :production) 155 | noir/wrap-force-ssl 156 | identity)) 157 | (site {:session {:store store}})) 158 | {:port port :join? false}))) 159 | 160 | ;; For interactive development: 161 | ;; (.stop server) 162 | ;; (def server (-main)) 163 | -------------------------------------------------------------------------------- /test/syme/test_main.clj: -------------------------------------------------------------------------------- 1 | (ns syme.test-main 2 | (:require [clojure.test :refer :all] 3 | [syme.web :refer [app]])) 4 | 5 | (deftest test-app 6 | (is (= 200 (:status (app {:uri "/" :request-method :get}))))) 7 | --------------------------------------------------------------------------------