diff --git a/.gitignore b/.gitignore
index 23e580b7..40c39306 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ prepare-vms/ips.html
prepare-vms/ips.pdf
prepare-vms/settings.yaml
prepare-vms/tags
+docs/*.yml.html
+autotest/nextstep
diff --git a/README.md b/README.md
index 4ada1a07..6db58feb 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,27 @@ non-stop since June 2015.
## Content
-- Chapter 1: Getting Started: running apps with docker-compose
-- Chapter 2: Scaling out with Swarm Mode
-- Chapter 3: Operating the Swarm (networks, updates, logging, metrics)
-- Chapter 4: Deeper in Swarm (stateful services, scripting, DAB's)
+The workshop introduces a demo app, "DockerCoins," built
+around a micro-services architecture. First, we run it
+on a single node, using Docker Compose. Then, we pretend
+that we need to scale it, and we use an orchestrator
+(SwarmKit or Kubernetes) to deploy and scale the app on
+a cluster.
+
+We explain the concepts of the orchestrator. For SwarmKit,
+we setup the cluster with `docker swarm init` and `docker swarm join`.
+For Kubernetes, we use pre-configured clusters.
+
+Then, we cover more advanced concepts: scaling, load balancing,
+updates, global services or daemon sets.
+
+There are a number of advanced optional chapters about
+logging, metrics, secrets, network encryption, etc.
+
+The content is very modular: it is broken down in a large
+number of Markdown files, that are put together according
+to a YAML manifest. This allows to re-use content
+between different workshops very easily.
## Quick start (or, "I want to try it!")
@@ -32,8 +49,8 @@ own cluster, we have multiple solutions for you!
### Using [play-with-docker](http://play-with-docker.com/)
-This method is very easy to get started (you don't need any extra account
-or resources!) but will require a bit of adaptation from the workshop slides.
+This method is very easy to get started: you don't need any extra account
+or resources! It works only for the SwarmKit version of the workshop, though.
To get started, go to [play-with-docker](http://play-with-docker.com/), and
click on _ADD NEW INSTANCE_ five times. You will get five "docker-in-docker"
@@ -44,31 +61,9 @@ the tab corresponding to that node.
The nodes are not directly reachable from outside; so when the slides tell
you to "connect to the IP address of your node on port XYZ" you will have
-to use a different method.
-
-We suggest to use "supergrok", a container offering a NGINX+ngrok combo to
-expose your services. To use it, just start (on any of your nodes) the
-`jpetazzo/supergrok` image. The image will output further instructions:
-
-```
-docker run --name supergrok -d jpetazzo/supergrok
-docker logs --follow supergrok
-```
-
-The logs of the container will give you a tunnel address and explain you
-how to connected to exposed services. That's all you need to do!
-
-We are also working on a native proxy, embedded to Play-With-Docker.
-Stay tuned!
-
-
+to use a different method: click on the port number that should appear on
+top of the play-with-docker window. This only works for HTTP services,
+though.
Note that the instances provided by Play-With-Docker have a short lifespan
(a few hours only), so if you want to do the workshop over multiple sessions,
@@ -119,14 +114,16 @@ check the [prepare-vms](prepare-vms) directory for more information.
## Slide Deck
- The slides are in the `docs` directory.
-- To view them locally open `docs/index.html` in your browser. It works
- offline too.
-- To view them online open https://jpetazzo.github.io/orchestration-workshop/
- in your browser.
-- When you fork this repo, be sure GitHub Pages is enabled in repo Settings
- for "master branch /docs folder" and you'll have your own website for them.
-- They use https://remarkjs.com to allow simple markdown in a html file that
- remark will transform into a presentation in the browser.
+- For each slide deck, there is a `.yml` file referencing `.md` files.
+- The `.md` files contain Markdown snippets.
+- When you run `build.sh once`, it will "compile" all the `.yml` files
+ into `.yml.html` files that you can open in your browser.
+- You can also run `build.sh forever`, which will watch the directory
+ and rebuild slides automatically when files are modified.
+- If needed, you can fine-tune `workshop.css` and `workshop.html`
+ (respectively the CSS style used, and the boilerplate template).
+- The slides use https://remarkjs.com to render Markdown into HTML in
+ a web browser.
## Sample App: Dockercoins!
@@ -181,7 +178,7 @@ want to become an instructor), keep reading!*
they need for class.
- Typically you create the servers the day before or morning of workshop, and
leave them up the rest of day after workshop. If creating hundreds of servers,
- you'll likely want to run all these `trainer` commands from a dedicated
+ you'll likely want to run all these `workshopctl` commands from a dedicated
instance you have in same region as instances you want to create. Much faster
this way if you're on poor internet. Also, create 2 sets of servers for
yourself, and use one during workshop and the 2nd is a backup.
@@ -203,7 +200,7 @@ want to become an instructor), keep reading!*
### Creating the VMs
-`prepare-vms/trainer` is the script that gets you most of what you need for
+`prepare-vms/workshopctl` is the script that gets you most of what you need for
setting up instances. See
[prepare-vms/README.md](prepare-vms)
for all the info on tools and scripts.
diff --git a/autotest/autotest.py b/autotest/autotest.py
index 8299836e..27001237 100755
--- a/autotest/autotest.py
+++ b/autotest/autotest.py
@@ -1,15 +1,28 @@
#!/usr/bin/env python
+import uuid
+import logging
import os
import re
-import signal
import subprocess
+import sys
import time
+import uuid
-def print_snippet(snippet):
- print(78*'-')
- print(snippet)
- print(78*'-')
+logging.basicConfig(level=logging.DEBUG)
+
+
+TIMEOUT = 60 # 1 minute
+
+
+def hrule():
+ return "="*int(subprocess.check_output(["tput", "cols"]))
+
+# A "snippet" is something that the user is supposed to do in the workshop.
+# Most of the "snippets" are shell commands.
+# Some of them can be key strokes or other actions.
+# In the markdown source, they are the code sections (identified by triple-
+# quotes) within .exercise[] sections.
class Snippet(object):
@@ -29,26 +42,22 @@ class Slide(object):
def __init__(self, content):
Slide.current_slide += 1
self.number = Slide.current_slide
+
# Remove commented-out slides
# (remark.js considers ??? to be the separator for speaker notes)
content = re.split("\n\?\?\?\n", content)[0]
self.content = content
+
self.snippets = []
exercises = re.findall("\.exercise\[(.*)\]", content, re.DOTALL)
for exercise in exercises:
- if "```" in exercise and "
`" in exercise:
- print("! Exercise on slide {} has both ``` and
` delimiters, skipping."
- .format(self.number))
- print_snippet(exercise)
- elif "```" in exercise:
+ if "```" in exercise:
for snippet in exercise.split("```")[1::2]:
self.snippets.append(Snippet(self, snippet))
- elif "
`" in exercise:
- for snippet in re.findall("
`(.*)`", exercise):
- self.snippets.append(Snippet(self, snippet))
else:
- print(" Exercise on slide {} has neither ``` or
` delimiters, skipping."
- .format(self.number))
+ logging.warning("Exercise on slide {} does not have any ``` snippet."
+ .format(self.number))
+ self.debug()
def __str__(self):
text = self.content
@@ -56,136 +65,165 @@ class Slide(object):
text = text.replace(snippet.content, ansi("7")(snippet.content))
return text
+ def debug(self):
+ logging.debug("\n{}\n{}\n{}".format(hrule(), self.content, hrule()))
+
def ansi(code):
return lambda s: "\x1b[{}m{}\x1b[0m".format(code, s)
-slides = []
-with open("index.html") as f:
- content = f.read()
- for slide in re.split("\n---?\n", content):
- slides.append(Slide(slide))
-is_editing_file = False
-placeholders = {}
+def wait_for_string(s):
+ logging.debug("Waiting for string: {}".format(s))
+ deadline = time.time() + TIMEOUT
+ while time.time() < deadline:
+ output = capture_pane()
+ if s in output:
+ return
+ time.sleep(1)
+ raise Exception("Timed out while waiting for {}!".format(s))
+
+
+def wait_for_prompt():
+ logging.debug("Waiting for prompt.")
+ deadline = time.time() + TIMEOUT
+ while time.time() < deadline:
+ output = capture_pane()
+ # If we are not at the bottom of the screen, there will be a bunch of extra \n's
+ output = output.rstrip('\n')
+ if output[-2:] == "\n$":
+ return
+ time.sleep(1)
+ raise Exception("Timed out while waiting for prompt!")
+
+
+def check_exit_status():
+ token = uuid.uuid4().hex
+ data = "echo {} $?\n".format(token)
+ logging.debug("Sending {!r} to get exit status.".format(data))
+ send_keys(data)
+ time.sleep(0.5)
+ wait_for_prompt()
+ screen = capture_pane()
+ status = re.findall("\n{} ([0-9]+)\n".format(token), screen, re.MULTILINE)
+ logging.debug("Got exit status: {}.".format(status))
+ if len(status) == 0:
+ raise Exception("Couldn't retrieve status code {}. Timed out?".format(token))
+ if len(status) > 1:
+ raise Exception("More than one status code {}. I'm seeing double! Shoot them both.".format(token))
+ code = int(status[0])
+ if code != 0:
+ raise Exception("Non-zero exit status: {}.".format(code))
+ # Otherwise just return peacefully.
+
+
+slides = []
+content = open(sys.argv[1]).read()
+for slide in re.split("\n---?\n", content):
+ slides.append(Slide(slide))
+
+actions = []
for slide in slides:
for snippet in slide.snippets:
content = snippet.content
- # Multi-line snippets should be ```highlightsyntax...
- # Single-line snippets will be interpreted as shell commands
+ # Extract the "method" (e.g. bash, keys, ...)
+ # On multi-line snippets, the method is alone on the first line
+ # On single-line snippets, the data follows the method immediately
if '\n' in content:
- highlight, content = content.split('\n', 1)
+ method, data = content.split('\n', 1)
else:
- highlight = "bash"
- content = content.strip()
- # If the previous snippet was a file fragment, and the current
- # snippet is not YAML or EDIT, complain.
- if is_editing_file and highlight not in ["yaml", "edit"]:
- print("! On slide {}, previous snippet was YAML, so what do what do?"
- .format(slide.number))
- print_snippet(content)
- is_editing_file = False
- if highlight == "yaml":
- is_editing_file = True
- elif highlight == "placeholder":
- for line in content.split('\n'):
- variable, value = line.split(' ', 1)
- placeholders[variable] = value
- elif highlight == "bash":
- for variable, value in placeholders.items():
- quoted = "`{}`".format(variable)
- if quoted in content:
- content = content.replace(quoted, value)
- del placeholders[variable]
- if '`' in content:
- print("! The following snippet on slide {} contains a backtick:"
- .format(slide.number))
- print_snippet(content)
- continue
- print("_ "+content)
- snippet.actions.append((highlight, content))
- elif highlight == "edit":
- print(". "+content)
- snippet.actions.append((highlight, content))
- elif highlight == "meta":
- print("^ "+content)
- snippet.actions.append((highlight, content))
- else:
- print("! Unknown highlight {!r} on slide {}.".format(highlight, slide.number))
-if placeholders:
- print("! Remaining placeholder values: {}".format(placeholders))
+ method, data = content.split(' ', 1)
+ actions.append((slide, snippet, method, data))
-actions = sum([snippet.actions for snippet in sum([slide.snippets for slide in slides], [])], [])
-# Strip ^{ ... ^} for now
-def strip_curly_braces(actions, in_braces=False):
- if actions == []:
- return []
- elif actions[0] == ("meta", "^{"):
- return strip_curly_braces(actions[1:], True)
- elif actions[0] == ("meta", "^}"):
- return strip_curly_braces(actions[1:], False)
- elif in_braces:
- return strip_curly_braces(actions[1:], True)
+def send_keys(data):
+ subprocess.check_call(["tmux", "send-keys", data])
+
+def capture_pane():
+ return subprocess.check_output(["tmux", "capture-pane", "-p"])
+
+
+try:
+ i = int(open("nextstep").read())
+ logging.info("Loaded next step ({}) from file.".format(i))
+except Exception as e:
+ logging.warning("Could not read nextstep file ({}), initializing to 0.".format(e))
+ i = 0
+
+interactive = True
+
+while i < len(actions):
+ with open("nextstep", "w") as f:
+ f.write(str(i))
+ slide, snippet, method, data = actions[i]
+
+ # Remove extra spaces (we don't want them in the terminal) and carriage returns
+ data = data.strip()
+
+ print(hrule())
+ print(slide.content.replace(snippet.content, ansi(7)(snippet.content)))
+ print(hrule())
+ if interactive:
+ print("[{}/{}] Shall we execute that snippet above?".format(i, len(actions)))
+ print("(ENTER to execute, 'c' to continue until next error, N to jump to step #N)")
+ command = raw_input("> ")
else:
- return [actions[0]] + strip_curly_braces(actions[1:], False)
+ command = ""
-actions = strip_curly_braces(actions)
+ # For now, remove the `highlighted` sections
+ # (Make sure to use $() in shell snippets!)
+ if '`' in data:
+ logging.info("Stripping ` from snippet.")
+ data = data.replace('`', '')
-background = []
-cwd = os.path.expanduser("~")
-env = {}
-for current_action, next_action in zip(actions, actions[1:]+[("bash", "true")]):
- if current_action[0] == "meta":
- continue
- print(ansi(7)(">>> {}".format(current_action[1])))
- time.sleep(1)
- popen_options = dict(shell=True, cwd=cwd, stdin=subprocess.PIPE, preexec_fn=os.setpgrp)
- # The follow hack allows to capture the environment variables set by `docker-machine env`
- # FIXME: this doesn't handle `unset` for now
- if any([
- "eval $(docker-machine env" in current_action[1],
- "DOCKER_HOST" in current_action[1],
- "COMPOSE_FILE" in current_action[1],
- ]):
- popen_options["stdout"] = subprocess.PIPE
- current_action[1] += "\nenv"
- proc = subprocess.Popen(current_action[1], **popen_options)
- proc.cmd = current_action[1]
- if next_action[0] == "meta":
- print(">>> {}".format(next_action[1]))
- time.sleep(3)
- if next_action[1] == "^C":
- os.killpg(proc.pid, signal.SIGINT)
- proc.wait()
- elif next_action[1] == "^Z":
- # Let the process run
- background.append(proc)
- elif next_action[1] == "^D":
- proc.communicate()
- proc.wait()
+ if command == "c":
+ # continue until next timeout
+ interactive = False
+ elif command.isdigit():
+ i = int(command)
+ elif command == "":
+ logging.info("Running with method {}: {}".format(method, data))
+ if method == "keys":
+ send_keys(data)
+ elif method == "bash":
+ # Make sure that we're ready
+ wait_for_prompt()
+ # Strip leading spaces
+ data = re.sub("\n +", "\n", data)
+ # Add "RETURN" at the end of the command :)
+ data += "\n"
+ # Send command
+ send_keys(data)
+ # Force a short sleep to avoid race condition
+ time.sleep(0.5)
+ _, _, next_method, next_data = actions[i+1]
+ if next_method == "wait":
+ wait_for_string(next_data)
+ else:
+ wait_for_prompt()
+ # Verify return code FIXME should be optional
+ check_exit_status()
+ elif method == "copypaste":
+ screen = capture_pane()
+ matches = re.findall(data, screen, flags=re.DOTALL)
+ if len(matches) == 0:
+ raise Exception("Could not find regex {} in output.".format(data))
+ # Arbitrarily get the most recent match
+ match = matches[-1]
+ # Remove line breaks (like a screen copy paste would do)
+ match = match.replace('\n', '')
+ send_keys(match + '\n')
+ # FIXME: we should factor out the "bash" method
+ wait_for_prompt()
+ check_exit_status()
else:
- print("! Unknown meta action {} after snippet:".format(next_action[1]))
- print_snippet(next_action[1])
- print(ansi(7)("<<< {}".format(current_action[1])))
- else:
- proc.wait()
- if "stdout" in popen_options:
- stdout, stderr = proc.communicate()
- for line in stdout.split('\n'):
- if line.startswith("DOCKER_"):
- variable, value = line.split('=', 1)
- env[variable] = value
- print("=== {}={}".format(variable, value))
- print(ansi(7)("<<< {} >>> {}".format(proc.returncode, current_action[1])))
- if proc.returncode != 0:
- print("Got non-zero status code; aborting.")
- break
- if current_action[1].startswith("cd "):
- cwd = os.path.expanduser(current_action[1][3:])
-for proc in background:
- print("Terminating background process:")
- print_snippet(proc.cmd)
- proc.terminate()
- proc.wait()
+ logging.warning("Unknown method {}: {!r}".format(method, data))
+ i += 1
+ else:
+ i += 1
+ logging.warning("Unknown command {}, skipping to next step.".format(command))
+
+# Reset slide counter
+with open("nextstep", "w") as f:
+ f.write(str(0))
diff --git a/autotest/index.html b/autotest/index.html
deleted file mode 120000
index f8d31667..00000000
--- a/autotest/index.html
+++ /dev/null
@@ -1 +0,0 @@
-../www/htdocs/index.html
\ No newline at end of file
diff --git a/docs/TODO b/docs/TODO
new file mode 100644
index 00000000..2329bf59
--- /dev/null
+++ b/docs/TODO
@@ -0,0 +1,8 @@
+Black belt references that I want to add somewhere:
+
+What Have Namespaces Done for You Lately?
+https://www.youtube.com/watch?v=MHv6cWjvQjM&list=PLkA60AVN3hh-biQ6SCtBJ-WVTyBmmYho8&index=8
+
+Cilium: Network and Application Security with BPF and XDP
+https://www.youtube.com/watch?v=ilKlmTDdFgk&list=PLkA60AVN3hh-biQ6SCtBJ-WVTyBmmYho8&index=9
+
diff --git a/docs/aj-containers.jpeg b/docs/aj-containers.jpeg
new file mode 100644
index 00000000..907dec69
Binary files /dev/null and b/docs/aj-containers.jpeg differ
diff --git a/docs/apiscope.md b/docs/apiscope.md
new file mode 100644
index 00000000..b924a0e0
--- /dev/null
+++ b/docs/apiscope.md
@@ -0,0 +1,41 @@
+## A reminder about *scope*
+
+- Out of the box, Docker API access is "all or nothing"
+
+- When someone has access to the Docker API, they can access *everything*
+
+- If your developers are using the Docker API to deploy on the dev cluster ...
+
+ ... and the dev cluster is the same as the prod cluster ...
+
+ ... it means that your devs have access to your production data, passwords, etc.
+
+- This can easily be avoided
+
+---
+
+## Fine-grained API access control
+
+A few solutions, by increasing order of flexibility:
+
+- Use separate clusters for different security perimeters
+
+ (And different credentials for each cluster)
+
+--
+
+- Add an extra layer of abstraction (sudo scripts, hooks, or full-blown PAAS)
+
+--
+
+- Enable [authorization plugins]
+
+ - each API request is vetted by your plugin(s)
+
+ - by default, the *subject name* in the client TLS certificate is used as user name
+
+ - example: [user and permission management] in [UCP]
+
+[authorization plugins]: https://docs.docker.com/engine/extend/plugins_authorization/
+[UCP]: https://docs.docker.com/datacenter/ucp/2.1/guides/
+[user and permission management]: https://docs.docker.com/datacenter/ucp/2.1/guides/admin/manage-users/
diff --git a/docs/blackbelt.png b/docs/blackbelt.png
new file mode 100644
index 00000000..d478fd83
Binary files /dev/null and b/docs/blackbelt.png differ
diff --git a/docs/build.sh b/docs/build.sh
new file mode 100755
index 00000000..a610c0bc
--- /dev/null
+++ b/docs/build.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+case "$1" in
+once)
+ for YAML in *.yml; do
+ ./markmaker.py < $YAML > $YAML.html || {
+ rm $YAML.html
+ break
+ }
+ done
+ ;;
+
+forever)
+ # There is a weird bug in entr, at least on MacOS,
+ # where it doesn't restore the terminal to a clean
+ # state when exitting. So let's try to work around
+ # it with stty.
+ STTY=$(stty -g)
+ while true; do
+ find . | entr -d $0 once
+ STATUS=$?
+ case $STATUS in
+ 2) echo "Directory has changed. Restarting.";;
+ 130) echo "SIGINT or q pressed. Exiting."; break;;
+ *) echo "Weird exit code: $STATUS. Retrying in 1 second."; sleep 1;;
+ esac
+ done
+ stty $STTY
+ ;;
+
+*)
+ echo "$0 "
+ ;;
+esac
diff --git a/docs/chat/index.html b/docs/chat/index.html
deleted file mode 100644
index 880a844b..00000000
--- a/docs/chat/index.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-https://dockercommunity.slack.com/messages/docker-mentor
-
-
diff --git a/docs/chat/index.html.sh b/docs/chat/index.html.sh
deleted file mode 100755
index a33f3cbe..00000000
--- a/docs/chat/index.html.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/sh
-#LINK=https://gitter.im/jpetazzo/workshop-20170322-sanjose
-LINK=https://dockercommunity.slack.com/messages/docker-mentor
-#LINK=https://usenix-lisa.slack.com/messages/docker
-sed "s,@@LINK@@,$LINK,g" >index.html <
-
-
-
-
-
-$LINK
-
-