Initial commit.
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2015-09-27 04:18:16 +02:00
commit 7411bfccbd
223 changed files with 14785 additions and 0 deletions

120
.drone.yml Normal file
View File

@@ -0,0 +1,120 @@
kind: pipeline
type: kubernetes
name: release
trigger:
branch:
- master
- develop
steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --recursive --remote --init
- name: production:build
when:
branch:
- master
image: docker.io/klakegg/hugo:0.82.0-alpine
commands:
- >-
hugo
--baseURL=https://hkoerber.de/
--cleanDestinationDir
--minify
--environment production
--destination ./public/
- chmod -R o+rX ./public/
- name: preview:build
image: docker.io/klakegg/hugo:0.82.0-alpine
when:
branch:
- develop
commands:
- >-
hugo
--baseURL=https://preview.hkoerber.de/
--cleanDestinationDir
--minify
--buildDrafts
--buildFuture
--environment preview
--destination ./public/
- chmod -R o+rX ./public/
- name: production:image
image: registry.hkoerber.de/drone-kaniko:latest
when:
branch:
- master
settings:
dockerfile: Dockerfile.nginx
registry: registry.hkoerber.de
repo: blog
tags:
- ${DRONE_COMMIT_SHA}
- name: preview:image
image: registry.hkoerber.de/drone-kaniko:latest
when:
branch:
- develop
settings:
dockerfile: Dockerfile.nginx
registry: registry.hkoerber.de
repo: blog
tags:
- ${DRONE_COMMIT_SHA}-preview
- name: production:update k8s
image: alpine/git
when:
branch:
- master
commands:
# the explicit setting of the env variables is required because drone does
# not "export" the environment variables set with "environment:", they are
# only usable inside the scripts.
- export GIT_AUTHOR_NAME="Drone"
- export GIT_AUTHOR_EMAIL="drone@hkoerber.de"
- export GIT_COMMITTER_NAME="$$GIT_AUTHOR_NAME"
- export GIT_COMMITTER_EMAIL="$$GIT_AUTHOR_EMAIL"
- git clone https://code.hkoerber.de/hannes/mycloud mycloud
- cd mycloud/k8s/manifests/blog
- "sed -i 's#image: registry.hkoerber.de/blog:.*$#image: registry.hkoerber.de/blog:${DRONE_COMMIT_SHA}#' 50-deployment.yaml"
# Check if file actually changed, we're done if it did not. Most likely a
# pipeline re-run
- git diff --exit-code --quiet -- 50-deployment.yaml && exit 0 || true
- git add 50-deployment.yaml
- >-
git commit
-m 'k8s: Update blog container image'
-m "Triggered in repo $DRONE_REPO by commit $DRONE_COMMIT"
- git push origin master
- name: preview:update k8s
image: alpine/git
when:
branch:
- develop
commands:
# the explicit setting of the env variables is required because drone does
# not "export" the environment variables set with "environment:", they are
# only usable inside the scripts.
- export GIT_AUTHOR_NAME="Drone"
- export GIT_AUTHOR_EMAIL="drone@hkoerber.de"
- export GIT_COMMITTER_NAME="$$GIT_AUTHOR_NAME"
- export GIT_COMMITTER_EMAIL="$$GIT_AUTHOR_EMAIL"
- git clone https://code.hkoerber.de/hannes/mycloud mycloud
- cd mycloud/k8s/manifests/blog-preview
- "sed -i 's#image: registry.hkoerber.de/blog:.*$#image: registry.hkoerber.de/blog:${DRONE_COMMIT_SHA}-preview#' 50-deployment.yaml"
# Check if file actually changed, we're done if it did not. Most likely a
# pipeline re-run
- git diff --exit-code --quiet -- 50-deployment.yaml && exit 0 || true
- git add 50-deployment.yaml
- >-
git commit
-m 'k8s: Update blog preview container image'
-m "Triggered in repo $DRONE_REPO by commit $DRONE_COMMIT"
- git push origin master

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/public
/themes
/resources

0
.gitmodules vendored Normal file
View File

4
Dockerfile.nginx Normal file
View File

@@ -0,0 +1,4 @@
FROM nginx:1.18.0
ADD public/ /usr/share/nginx/html/
ADD nginx.server.conf /etc/nginx/conf.d/default.conf

50
Makefile Normal file
View File

@@ -0,0 +1,50 @@
REGISTRY := registry.hkoerber.de
APPNAME := blog
PUSHURL := $(REGISTRY)/$(APPNAME)
.PHONY: build
build-production:
git diff-index --quiet HEAD || { echo >&2 "Local changes, refusing to build" ; exit 1 ; }
docker run \
--rm \
--net host \
-v $(PWD):/workdir \
-w /workdir \
registry.hkoerber.de/hugo:f216de6b127620641bcaf1d28fe16bf1ea2db884 \
/app/bin/hugo \
--baseURL=https://hkoerber.de/ \
--cleanDestinationDir \
--minify \
--destination ./public/
sudo chown -R $(shell id -u):$(shell id -g) ./public
sudo chmod -R o+rX ./public
.PHONY: image
image-production: build-production
git diff-index --quiet HEAD || { echo >&2 "Local changes, refusing to build" ; exit 1 ; }
docker build \
--file ./Dockerfile.nginx \
--tag $(REGISTRY)/$(APPNAME):latest \
--tag $(REGISTRY)/$(APPNAME):$(shell git rev-parse HEAD) \
.
.PHONY: push-production
push-production: image-production
docker push $(REGISTRY)/$(APPNAME):latest
docker push $(REGISTRY)/$(APPNAME):$(shell git rev-parse HEAD)
.PHONY: release
release: push-production
.PHONY: preview
preview:
docker run \
--rm \
--net host \
-v $(PWD):/workdir \
-w /workdir \
registry.hkoerber.de/hugo:f000054616d7789202b06872a6535bcb9fd500c9 \
hugo serve \
--watch \
--buildDrafts

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# Inspirations
# Ideas
- timeline (like https://leerob.io/)
- "views" aka values, methods (like https://dynamicwebpaige.github.io/info/)
General thoughts: https://paulstamatiou.com/about-this-website/
Stats like on the right at https://paulstamatiou.com/about/
Stuff I use: https://paulstamatiou.com/stuff-i-use/
trips and mapbox
this is a good one: https://rymc.io/about/

1
_config.production.yml Normal file
View File

@@ -0,0 +1 @@
url: "https://blog.hkoerber.de"

1
_config.staging.yml Normal file
View File

@@ -0,0 +1 @@
url: "https://staging.blog.hkoerber.de"

31
_data/social.yml Normal file
View File

@@ -0,0 +1,31 @@
---
sites:
- fa_icon: fa-map-marker
name: Ansbach, Germany
- url: "https://www.tradebyte.com/"
fa_icon: " fa-suitcase"
name: Tradebyte Software GmbH
- url: "https://github.com/hakoerber"
fa_icon: "fa-github"
name: Github
- url: "https://gitlab.com/whatevsz"
fa_icon: "fa-gitlab"
name: Gitlab
- url: "https://code.hkoerber.de/explore/projects"
fa_icon: "fa-code"
name: Code
- url: "https://www.linkedin.com/in/hannes-k%C3%B6rber-479ab5122"
fa_icon: "fa-linkedin-square"
name: LinkedIn
- url: "https://twitter.com/whatevsz"
fa_icon: "fa-twitter-square"
name: Twitter
- url: "https://keybase.io/hakoerber"
fa_icon: "fa-lock"
name: Keybase
- url: "mailto:hannes.koerber@gmail.com"
fa_icon: "fa-envelope"
name: E-Mail
- url: "https://status.haktec.de"
fa_icon: "fa-plus-circle"
name: Status

6
archetypes/default.md Normal file
View File

@@ -0,0 +1,6 @@
---
title = "{{ replace .TranslationBaseName "-" " " | title }}"
date = "{{ .Date }}"
description = "About the page"
draft = true
---

119
config/_default/config.yaml Normal file
View File

@@ -0,0 +1,119 @@
baseURL: http://localhost:1313/
title: Hannes Körber
uglyurls: false
disablePathToLower: true
languageCode: en
canonifyURLs: false
# pygmentsStyle: "monokai"
pygmentsCodefences: true
pygmentsUseClasses: true
MetaDataFormat: yaml
enableRobotsTXT: true
frontmatter:
date:
- date
markup:
goldmark:
renderer:
unsafe: true
taxonomies:
tag: tags
category: categories
related:
threshold: 50
includeNewer: true
toLower: true
indices:
- name: tags
weight: 100
- name: keywords
weight: 50
- name: date
weight: 10
author: Hannes Körber
params:
description: "My Blog"
author: Hannes Körber
social:
- name: github
link: https://github.com/hakoerber
icon: fa-github
style: fab
- name: gitlab
link: https://gitlab.com/whatevsz
icon: fa-gitlab
style: fab
- name: linkedin
link: https://www.linkedin.com/in/hannes-koerber
icon: fa-linkedin
style: fab
- name: xing
link: https://www.xing.com/profile/Hannes_Koerber
icon: fa-xing
style: fab
- name: keybase
link: https://keybase.io/hakoerber
icon: fa-key
style: fas
# - name: twitter
# link: https://twitter.com/whatevsz
# icon: fa-twitter
# style: fab
- name: E-Mail
link: mailto:hannes.koerber@gmail.com
icon: fa-envelope
style: fas
description: Send me a mail!
- name: RSS
link: /blog/index.xml
icon: fa-rss
style: fas
description: Follow my blog on RSS!
permalinks:
blog: /:section/:year/:month/:day/:title/
menu:
main:
- name: "Blog"
url : "/blog/"
weight: 1
- name: "Skills"
url : "/skills/"
weight: 2
- name: "Projects"
url : "/projects/"
weight: 3
- name: "About Me"
url : "/about/"
weight: 4
- name: "More"
identifier: more
weight: 5
- name: "Work"
url : "/work/"
parent: more
weight: 2
- name: "Events"
url: "/events/"
parent: more
weight: 4
- name: "Talks"
url: "/talks/"
parent: more
weight: 5

View File

@@ -0,0 +1,2 @@
params:
allow_robots: false

View File

@@ -0,0 +1,2 @@
params:
allow_robots: false

View File

@@ -0,0 +1,2 @@
params:
allow_robots: true

127
content/_index.html Normal file
View File

@@ -0,0 +1,127 @@
---
---
<div class="hero block">
<div class="hero-body is-clearfix">
<div class="columns is-vcentered">
<div class="column content">
<h1 class="subtitle is-2 has-text-weight-normal">Welcome!</h1>
<p class="block has-text-justified">
Hello, welcome to my homepage! Here, you will find some articles (mostly tech),
some info about myself and whatever else I am thinking of.
</p>
</div>
<div class="column is-narrow is-hidden-mobile">
<figure class="image is-pulled-right" style="height:200px;width:200px">
<img class="is-rounded" alt="Me" src="/assets/images/me.jpg">
</figure>
</div>
</div>
</div>
</div>
<div class="tile is-ancestor is-vertical">
<div class="tile">
<div class="tile is-parent">
<a href="/blog/" class="tile is-child box is-clearfix">
<div class="columns is-vcentered is-mobile">
<div class="column">
<p class="subtitle is-4">Blog</p>
</div>
<div class="column is-narrow">
<figure class="mr-2 my-2 ml-0 image is-48x48 is-pulled-right">
<span class="icon is-large">
<i class="fas fa-3x fa-pen-fancy"></i>
</span>
</figure>
</div>
</div>
</a>
</div>
<div class="tile is-parent">
<a href="/about/" class="tile is-child box is-clearfix">
<div class="columns is-vcentered is-mobile">
<div class="column">
<p class="subtitle is-4">About Me</p>
</div>
<div class="column is-narrow">
<figure class="mr-2 my-2 ml-0 image is-48x48 is-pulled-right">
<span class="icon is-large">
<i class="fa fa-3x fa-users"></i>
</span>
</figure>
</div>
</div>
</a>
</div>
</div>
<div class="tile">
<div class="tile is-parent">
<a href="/work/" class="tile is-child box is-clearfix">
<div class="columns is-vcentered is-mobile">
<div class="column">
<p class="subtitle is-4">Work</p>
</div>
<div class="column is-narrow">
<figure class="mr-2 my-2 ml-0 image is-48x48 is-pulled-right">
<span class="icon is-large">
<i class="fas fa-3x fa-briefcase"></i>
</span>
</figure>
</div>
</div>
</a>
</div>
<div class="tile is-parent">
<a href="/skills/" class="tile is-child box is-clearfix">
<div class="columns is-vcentered is-mobile">
<div class="column">
<p class="subtitle is-4">Skills</p>
</div>
<div class="column is-narrow">
<figure class="mr-2 my-2 ml-0 image is-48x48 is-pulled-right">
<span class="icon is-large">
<i class="fas fa-3x fa-university"></i>
</span>
</figure>
</div>
</div>
</a>
</div>
<div class="tile is-parent">
<a href="/projects/" class="tile is-child box is-clearfix">
<div class="columns is-vcentered is-mobile">
<div class="column">
<p class="subtitle is-4">Projects</p>
</div>
<div class="column is-narrow">
<figure class="mr-2 my-2 ml-0 image is-48x48 is-pulled-right">
<span class="icon is-large">
<i class="fas fa-3x fa-keyboard"></i>
</span>
</figure>
</div>
</div>
</a>
</div>
</div>
<div class="tile">
<div class="tile is-parent">
<a href="/about/" class="tile is-child box is-clearfix">
<div class="columns is-vcentered is-mobile">
<div class="column">
<p class="subtitle is-4">Contact</p>
</div>
<div class="column is-narrow">
<figure class="mr-2 my-2 ml-0 image is-48x48 is-pulled-right">
<span class="icon is-large">
<i class="fas fa-3x fa-phone"></i>
</span>
</figure>
</div>
</div>
</a>
</div>
</div>
</div>

176
content/about/index.html Normal file
View File

@@ -0,0 +1,176 @@
---
---
<div class="columns is-centered">
<div class="column">
<h1 class="subtitle is-3 has-text-weight-normal">About Me</h1>
<hr>
<p>
I'm Hannes Körber, a technology enthusiast currently living in Ansbach, Germany.
</p>
</div>
<div class="column is-narrow is-flex" style="justify-content: center;">
<div class="image ml-5 my-5">
<img class="is-rounded" src="/assets/images/me.jpg" alt="Me" style="height:200px;width:200px;min-width:200px;">
</div>
</div>
</div>
<hr>
<h2 class="subtitle is-3 has-text-weight-normal">Why I do what I am doing</h2>
<div class="has-text-justified">
<p>
I started working with computers when I was around ten years old. In the beginning, I mainly used them for gaming, but got more and more interested in the internals --- how a computer actually works.
</p>
<p>
In school, I started programming (Visual Basic and C#) and was completely blown away that I could TELL the computer what to do, whatever it was. I then began building my own computers, and after the German "Abitur" (comparable to a high school degree), I started studying Information and Communications Technology at Friedrich-Alexander-Universität Erlangen-Nürnberg.
</p>
<p>
During my university years, I first came in contact with Linux. It was like discovering computers all over again. With Linux, I was free to do everything I wanted with my computer. A few months after having my first contact with Linux, I abandoned Windows for good and have not looked back. I quickly learned everything I could about Linux and computer science in general. By choosing computer science courses over Electrical engineering courses (which I still like and do as a hobby) I decided on my career path: Information Technology.
</p>
<p>
During my mandatory internship I worked at Tradebyte Software GmbH, a startup-become-medium-sized company offering SaaS solutions for eCommerce in Ansbach. After my internship, I stayed as a working student while finishing my master's thesis and started working full time right after graduation (ok, a one-month holiday in New Zealand was necessary!)
</p>
</div>
<hr>
<h2 class="subtitle is-3 has-text-weight-normal">What I do in my free time</h2>
<div class="has-text-justified">
<p>
I once read somewhere that you should have one hobby in each of the following three categories:
</p>
<ul>
<li>A <b>physical hobby</b>, where you do some physical activity, preferably outside</li>
<li>A <b>creative hobby</b>, where you create something new</li>
<li>A hobby that makes you <b>money</b></li>
</ul>
<p>
Well, the last one for me is the one that comes most naturally: I take care of my own
private "cloud" that encompasses a few services that for me replace google, dropbox etc.
I use this to keep up to date on a lot of technologies that I cannot work with in my
day-to-day job. So it indirectly makes me money by increasing my market value.
</p>
<hr>
<div class="columns is-variable is-8">
<div class="column">
<h3 class="subtitle is-4 has-text-weight-normal">Sports</h3>
<p>
For a physical hobby, I do not have a single one or even one I focus one, but I do
numerous different things. Quantity over quality if you want:
</p>
<div class="has-text-left">
<ul>
<li>Cycling, from multi-day {{< myhighlight >}}Bike touring{{< /myhighlight >}} to {{< myhighlight >}}Mountain biking{{< /myhighlight >}} and some mild {{< myhighlight >}}Downhill{{< /myhighlight >}}. I bought a new bike mid of 2020 (a Radon Skeen Trail AL 2020, see picture), a mountain bike that has to fill all those roles in one</li>
<li>{{< myhighlight >}}Hiking{{< /myhighlight >}} and some "entry-level" {{< myhighlight >}}Mountaineering{{< /myhighlight >}}</li>
<li>{{< myhighlight >}}Kayaking{{< /myhighlight >}}</li>
<li>Climbing, both {{< myhighlight >}}Boudering{{< /myhighlight >}} and {{< myhighlight >}}Rock climbing{{< /myhighlight >}}</li>
</ul>
</div>
</div>
<div class="column is-narrow is-flex my-5" style="flex-direction: column; align-items: center;">
<figure class="image mb-6">
<img src="/assets/images/nebelhorn.jpg" alt="Photo from the tour to the Nebelhorn" style="width:240px;">
{{< figcaption >}}Nebelhorn, Oberstdorf, February 2020{{< /figcaption >}}
</figure>
<figure class="image mb-6">
<img src="/assets/images/kayak-naab.jpg" alt="Photo from the kayaking tour on the Naab" style="width:320px;">
{{< figcaption >}}Naab, Schwandorf, September 2020{{< /figcaption >}}
</figure>
<figure class="image">
<img src="/assets/images/skeen-trail-al-2020.jpg" alt="The Radom Skeen Trail Mountain Bike" style="width:240px;">
{{< figcaption >}}Radon Skeen Trail AL 2020{{< /figcaption >}}
</figure>
</div>
</div>
<hr>
<div class="columns is-variable is-8">
<div class="column">
<h3 class="subtitle is-4 has-text-weight-normal">Creativity</h3>
<p>
The last kind of hobby&mdash;the creative one&mdash;is the one I have to force myself to do the most.
</p>
<p>
I have been learning the {{< myhighlight >}}Guitar{{< /myhighlight >}}
since mid of 2019. I'm using <a href="https://www.justinguitar.com/">JustinGuitar's course</a> to get to
know the basics. It's enough for some simple strumming, but don't expect any concerts right now.
My goal is do be campfire-ready.
</p>
</div>
<div class="column is-narrow is-flex my-5" style="flex-direction: column; align-items: center;">
<figure class="image">
<img src="/assets/images/guitar.jpg" alt="Playing guitar" style="width:320px;">
{{< figcaption >}}Amsterdam, July 2019{{< /figcaption >}}
</figure>
</div>
</div>
<div class="columns is-variable is-8">
<div class="column">
<p>
When I was younger, I also took some piano lessions with my grandma. After a ten-year hiatus,
I've been relearning the {{< myhighlight >}}Piano{{< /myhighlight >}}
since beginning of 2020, after buying an electrical piano.
</p>
<p>
I bought a Yamaha P-45 (see picture). It has weighted keys and feels nearly like a "real" piano.
</p>
</div>
<div class="column is-narrow is-flex my-5" style="flex-direction: column; align-items: center;">
<figure class="image">
<img src="/assets/images/yamaha-p45.jpg" alt="The Yamaha P45 eletrical piano" style="width:320px;">
{{< figcaption >}}Yamaha P-45{{< /figcaption >}}
</figure>
</div>
</div>
<p>
I also started attending a local church choir to work on my
{{< myhighlight >}}Singing{{< /myhighlight >}}, but with the
COVID-19 situation this is currently not possible. Maybe that's better for everyone,
because no one has to listen to me singing. ;)
</p>
<div class="columns is-variable is-8">
<div class="column">
<p>
During Corona, I got back into {{< myhighlight >}}Chess{{< /myhighlight >}}.
My <a href="https://lichess.org/@/whatevsz">lichess rating</a> is currently around 1450.
My goal is ~1550, which is around the 50th percentile / median, meaning I'd have a
50/50 chance of beating a random opponent on lichess. Unfortunately, I'm mainly
focussing on puzzles and not really playing longer games, mainly due to time contraints.
</p>
<p>
Chess is both fascinating (due to the rules' simplicity) and frustrating
(due to having no skill ceiling). It can be a grind to improve, but
then one day you have this one game that makes it worth it.
</p>
</div>
<div class="column is-narrow is-flex my-5" style="flex-direction: column; align-items: center;">
<div class="image">
<img src="/assets/images/chess.jpg" alt="Chess pieces" style="width:240px;">
</div>
</div>
</div>
<p>
This webpage itself could also be considered a creative hobby.
{{< myhighlight >}}Writing{{< /myhighlight >}} down technical
stuff makes me internalize complex topics very efficiently.
</p>
</div>

View File

@@ -0,0 +1,145 @@
---
date: 2015-09-27
excerpt: How this blog is run
tags:
- meta
- hexo
- nginx
title: About This Blog
toc: true
---
I am going to start this blog with a post about the blog itself.
In my opinion, simple text files and command line tools is where it's at, so after some googling, I stumbled about Hexo, and just decided to try it out, because I wanted to get experience with blogging and the accompanying software.
[Hexo](https://hexo.io/) is not actually a complete blogging platform, but simply a static site generator. It takes markdown files, and turns them into nice HTML files with CSS and everything. The big advantage of this approach is that you can write those markdown files in whichever way you like.
Also, setup is much easier than with a complete, integrated solution like WordPress. You do not need a database, it's much more lightweight and one can change to another platform more easily. There are far fewer security concerns, too. Static content served by a webserver without and backend for PHP or whatever just provides a very small attack surface.
## The content
Right now, I am writing this with vim as a simple markdown file. The following plugins make this a bit easier:
* [Goyo.vim](https://github.com/junegunn/goyo.vim)
* [vim-pencil](https://github.com/reedes/vim-pencil)
The first one makes for distraction-free writing (it simply disables most of the vim UI and resizes the editing area), and the second makes writing prose a breeze. A simple
```vim
:Goyo
:PencilSoft
```
and we are ready to go.
## Hexo
Ok, so much about actually writing the text, but how do convert this text to a nice webpage? This is where Hexo comes into play. The installation is actually as easy as the website makes it look. Hexo is built with Node.js, so this has to be installed beforehand. Then, the following is enough:
```shell
[~/projects]$ npm install hexo-cli -g
[~/projects]$ hexo init blog
[~/projects]$ cd blog
[~/projects]$ npm install
```
I am not going to show all the stuff Hexo is capable off, you can read through [the official documentation](https://hexo.io/docs/) to get a nice overview.
## Serving the content
In the end, we get some publishable HTML files with CSS and everything in the `public/` subfolder. For "production" use, I am simply going to deploy [nginx](http://nginx.org/) to serve these static files. All of this is deployed on a dedicated blogging virtual machine on my home server. Because I do not have a publicly reachable IPv4 address (yay CGNAT!), a small [DigitalOcean](https://www.digitalocean.com/) droplet serves as a reverse proxy over a VPN.
Because the content is simply a static web page, there is no need for a database or web framework or anything, and serving the content is super fast! The following nginx configuration is enough, assuming your Hexo root is in `/var/lib/hexo/blog` and is readable by group `hexo`:
```
/etc/nginx/nginx.conf
```
```nginx
user nginx hexo;
worker_processes auto;
http {
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
default_type application/octet-stream;
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
server {
listen 80 default_server;
server_name _;
root /var/lib/hexo/blog/public;
location / {
}
}
}
```
## Automating the deployment
I'm lazy. Right now, I would have to log into my blogging VM, use the `hexo` command to create a new blog post, actually write the post, and generate the static files manually. No preview to ensure that I didn't screw up markdown syntax, no version control.
Version control is of course always a good idea when you are working with text files. I prefer [git](https://git-scm.com/) as VCS, and I though about using git as a simple deployment tool for the blog. I envisioned a workflow like this on my local machine:
* run `hexo new "new post"` to create a new post
* edit the post in `source/_posts/`
* commit the new file to git and push it to the blogging server
To achieve this, I initialized a new git repository in the hexo blog directory, created a bare repository on the blogging server, and configured the local repository to push to the remote one.
On the remote server, there is a decicated `hexo` user with the home directory in `/var/lib/hexo`. In there, the direcotry `blog.git/` is the bare repository mentioned above, and `blog/` is configured to always checkout the `master` branch, so these files can be served by nginx.
The following git hook ([more info here](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks)) is used to always update the web root when there are new commits on the `master` branch:
```
/var/lib/hexo/blog.git/hooks/post-receive
```
```shell
#!/usr/bin/env bash
_logfile="$HOME/hook.log"
echo "update hook $(date +%FT%T)" >> "$_logfile"
git --work-tree="$HOME/blog" --git-dir="$HOME/blog.git" checkout master --force &>> "$_logfile"
hexo generate --cwd "$HOME/blog" &>> "$_logfile"
```
Because I don't really care about the commit messages in this case, I also wrote a little bash script on the local machine to automatically make a new commit and push it to the remote server:
```
~/bin/publish-blog
```
```shell
#!/usr/bin/env bash
cd ~/projects/blog || exit 1
git commit --message="Update $(date +%F)"
git push server master
```
This assumes the local blog repository is in `~/projects/blog` and the remote is simply called `server`.
Now with a simple
```shell
[~/projects]$ git add <post>
[~/projects]$ publish-blog
```
a new post is pushed to the server, the static content is generated and can be accessed over the internet. As everything is in git, a post can easily be removed with `git revert`.
## Preview server
While editing a post, it is quite helpful to have a local preview of blog, so you can view if everything looks as it should before publishing. This functionality is already built into Hexo, with the following command we start a web server on the local machine to view our blog which is automatically updates when we make changes:
```shell
[~]$ (cd ~/projects/blog && hexo server --ip 127.0.0.1)
```
Now you can get a nice preview by going to `http://localhost:4000` in your browser.

View File

@@ -0,0 +1,260 @@
---
date: 2015-09-27
excerpt: Combining Rsyslog, Logstash and Kibana to make nice logging dashboards
tags:
- homelab
- logging
- elk
- elasticsearch
- logstash
- rsyslog
- json
- grok
title: Using The ELK Stack With Rsyslog
toc: true
---
This post will detail my setup that uses [rsyslog](http://www.rsyslog.com/) to send JSON-formatted log messages to an [ELK stack](https://www.elastic.co/webinars/introduction-elk-stack).
## The result
Let's start with an overview of what we get in the end:
![ScreenShot](/assets/images/kibana.png)
## The log structure
The setup uses rsyslog to send two different kinds of logs to the logserver: the good old `syslog`, and logfiles written by applications, for example nginx access logs. Both will be formatted as JSON and sent to the logserver via TCP for further processing. Every message has certain attributes that describes their origin:
* `host`: identifes the host that sent the message, subfields are `ip` and `name`
* `type`: can either be `syslog` or `application` and distinguishes a syslog entry from an application logfile
* `content`: the actual log message
`content` can either be a string (in case of a logfile, this is simply a line in the file) or a dictionary that contains attributes of the message. For syslog, these attributes are:
* `host`: syslog host field
* `severity`: syslog severity
* `facility`: syslog facility
* `tag`: syslog tag
* `message`: syslog message
* `program`: syslog program
On the server side, the `content` attribute can be parsed depending on the application. For example, nginx access logs can be parsed to include response code, verbs, user agents and many more.
All of this makes for easy searching in Kibana. Here are some examples for filters that can be used:
Get all messages from a specific host:
```
host.name:"host.domain"
```
Show all firewall events (Note that this is kind of redudant, the first expression can be left out because it is implied in the second):
```
logtype:"application" AND application:"iptables-block"
```
Gather all serverside errors of nginx servers:
```
application:"nginx-access" AND nginx-access.response:[500 TO 599]
```
## Sending JSON with rsyslog
On every server that sends its logs to our logserver, rsyslog is installed and configured to send all logs in the JSON format described above. Of course, local logging is also done.
Sending data over TCP can be done via the [omfwd](http://www.rsyslog.com/doc/v8-stable/configuration/modules/omfwd.html) output module that is included in rsyslog by default. The configuration looks like this:
```java
action(
type="omfwd"
Template="syslog-json"
Target="logserver.example.com"
Port="515"
Protocol="tcp"
)
```
Here we use TCP port 515, because 514 is commonly used for plain syslog. The `template` directive defines which template we use to format the logs. The template for syslog messages looks like this and must be defined **before** the accompanying `action`:
```java
template(name="syslog-json" type="list") {
constant(value="{")
constant(value="\"logtype\":\"") constant(value="syslog" format="json")
constant(value="\",\"content\":{")
constant(value="\"@timestamp\":\"") property(name="timegenerated" format="json" dateFormat="rfc3339")
constant(value="\",\"host\":\"") property(name="hostname" format="json")
constant(value="\",\"severity\":\"") property(name="syslogseverity-text" format="json")
constant(value="\",\"facility\":\"") property(name="syslogfacility-text" format="json")
constant(value="\",\"tag\":\"") property(name="syslogtag" format="json")
constant(value="\",\"message\":\"") property(name="msg" format="json")
constant(value="\",\"program\":\"") property(name="programname" format="json")
constant(value="\"}")
constant(value=",\"hostinfo\":{")
constant(value="\"name\":\"") property(name="$myhostname" format="json")
constant(value="\"}")
constant(value="}")
}
```
Note that the `host.ip` attribute is missing. It will be added later at the server, because syslog does not provide a way to get the IP of the server it is running on (which might be quite difficult to do on servers with multiple interfaces).
The `format="json"` option for the property replacers makes sure that the string is properly quoted if it contains curly braces for example.
Forwarding logfiles is a bit more complex: For each file, a template and input module definition is needed, together with ruleset to bind both to a output module. The input is defined as a [imfile](http://www.rsyslog.com/doc/v8-stable/configuration/modules/imfile.html) module. For an nginx access logfile, it would look like this:
```java
input(type="imfile"
File="/var/log/nginx/access.log"
Tag="nginx-access"
StateFile="-var-log-nginx-access.log.state"
ruleset="forward-nginx-access"
)
```
The `Tag` can be an arbitrary string and would correspond to the `syslogtag` attribute. Because we are not using syslog for file forwarding, it does not matter at all, but is required and is set to something descriptive.
`StateFile` defines the path to a file that rsyslog uses to keep track of its current position in the file. This is needed to preserve state between reboots or rsyslog daemon restarts. Otherwise, every time rsyslog starts it would forward the **entire** file to our logserver. The value defines the filename, which is kept under `/var/lib/rsyslog/`. Here, we simply use the full path to the logfile, with slashes replaced by hyphens. Anything else is fine, as long as it is unique among all input definitions.
Lastly, the `ruleset` determines which ruleset to bind this input to. This will be explained further down.
The template that is used to pack the information into JSON looks like this:
```java
template(name="nginx-access-json" type="list") {
constant(value="{")
constant(value="\"logtype\":\"") constant(value="application" format="json")
constant(value="\",\"application\":\"") constant(value="nginx-access" format="json")
constant(value="\",\"content\":{")
constant(value="\"message\":\"") property(name="msg" format="json")
constant(value="\"}")
constant(value=",\"hostinfo\":{")
constant(value="\"name\":\"") property(name="$myhostname" format="json")
constant(value="\"}")
constant(value="}")
}
```
The `action` that sends the logs to the logging server looks the same for both syslog and file forwarding. But because each file action only applies to a single file, a `ruleset` needs to be defined to bind the `action` and the `template` together:
```java
ruleset(name="forward-nginx-access") {
action(
type="omfwd"
Template="nginx-access-json"
Target="logserver.example.com"
Port="515"
Protocol="tcp"
)
}
```
## Receiving and parsing logs with logstash
Now that logs are sent in a nice format, the logging server has to be configured to receive and store these logs. This is done using [logstash](https://www.elastic.co/products/logstash), which is part of the ELK stack.
The logstash configuration file is separated into three parts: input, filter, and output. The input part is configured to simply listen on TCP port 515 for messages, and logstash can automatically parse the JSON it receives:
```
/etc/logstash/conf.d/10_listen_tcp_json.conf
```
```ruby
input {
tcp {
type => "log_json"
port => 515
codec => json
}
}
```
`type` is an arbitrary string that will later be used to distinguish JSON logs from other inputs (logstash could also listen for syslog on port 514, for example)
```
/etc/logstash/conf.d/50_filter.conf
```
```ruby
filter {
if [type] == "log_json" {
# complete the host attribute to contain both hostname and IP
mutate {
add_field => {
"host[name]" => "[hostinfo][name]"
"host[ip]" => "%{host}"
}
remove_field => "hostinfo"
}
# remove timestamp in syslog
if [logtype] == "syslog" {
mutate {
remove_field => "content[@timestamp]"
}
}
# application-specific parsing
if [logtype] == "application" {
if [application] == "nginx-access" {
grok {
match => { "content[message]" => "%{NGINXACCESS}" }
patterns_dir => "./patterns"
remove_field => "content[message]"
}
mutate {
rename => {
"clientip" => "[content][clientip]"
"ident" => "[content][ident]"
"auth" => "[content][auth]"
"timestamp" => "[content][timestamp]"
"request" => "[content][request]"
"httpversion" => "[content][httpversion]"
"response" => "[content][response]"
"bytes" => "[content][bytes]"
"referrer" => "[content][referrer]"
"agent" => "[content][agent]"
"verb" => "[content][verb]"
}
rename => {
"content" => "nginx-access"
}
}
}
}
}
}
```
This big rename for nginx access logs is necessary because logstash dumps all parsed variables into the top level of the dictionary, which then have to moved into the `content` field.
Now that the logs are formatted, they can be shipped to a local [elasticsearch](https://www.elastic.co/products/elasticsearch) instance:
```
/etc/logstash/conf.d/80_output_elasticsearch.conf
```
```ruby
output {
elasticsearch {
host => localhost
protocol => transport
index => "logstash-%{+YYYY.MM.dd}"
}
}
```
By default, logstash puts all logs into the same elasticsearch index, namely `logstash`. By using a separate index for each day, old logs can be more easily deleted by simply removing old indices.
Grok is used for parsing the logfiles. There are several patterns shipped with logstash by default, which can be found [here](https://github.com/elastic/logstash/tree/v1.4.1/patterns). Because there is no pattern for nginx, the following custom one is used:
```
/opt/logstash/patterns/nginx
```
```
NGUSERNAME [a-zA-Z\.\@\-\+_%]+
NGUSER %{NGUSERNAME}
NGINXACCESS %{IPORHOST:clientip} %{NGUSER:ident} %{NGUSER:auth} \[%{HTTPDATE:timestamp}\] "%{WORD:verb} %{URIPATHPARAM:request} HTTP/%{NUMBER:httpversion}" %{NUMBER:response} (?:%{NUMBER:bytes}|-) (?:"(?:%{URI:referrer}|-)"|%{QS:referrer}) %{QS:agent
```
Kibana should pick up the data automatically, so you get the result seen at the beginning.

View File

@@ -0,0 +1,328 @@
---
date: 2015-12-29
excerpt: Using a local package repository and automating updates of a dozen machines
tags:
- homelab
- package
- yum
- saltstack
title: Homelab CentOS Package Management
toc: true
---
Keeping a dozen virtual machines up-to-date can be quite a task. In this post, I will show how to do it automatically and efficiently using yum-cron, a local mirror with rsync, and saltstack.
I will also describe the setup of a "custom" RPM repository to distribute packages built with the awesome [fpm](https://github.com/jordansissel/fpm)
## Automatic updates with yum-cron
Downloading and applying updates with yum can be automated using yum-cron, which is more or less a wrapper around `yum` that runs peridodically with `cron` (hence the name). The setup is quite straightforward, the good old package+config+service triangle, and can be automated using salt:
```yaml
yum-cron:
pkg.installed:
- name: yum-cron
service.running:
- name: yum-cron
- enable: True
- require:
- pkg: yum-cron
file.managed:
- name: /etc/yum/yum-cron.conf
- user: root
- group: root
- mode: 644
- source: salt://files/yum-cron.conf
- require:
- pkg: yum-cron
- watch_in:
- service: yum-cron
```
```ini
[commands]
update_cmd = default
download_updates = yes
apply_updates = yes
random_sleep = 360
```
My actual "production" state can be found [here](https://github.com/hakoerber/salt-states-parameterized/tree/master/package/autoupdate), and a role tying it all together is available [here](https://github.com/hakoerber/salt-roles-parameterized/blob/master/autoupdate.sls), but the above still does what it should.
That's it. yum-cron will update the system nightly at a random time between 0:00 and 6:00.
## The local package repository mirror
Now all servers pull their updates every day and apply them automatically. There is one problem though: Every server contacts some upstream mirror on the internet, which puts unnecessary strain on their and our connection. To remedy this, we will create a local mirror that is updated regularly and that all other servers can pull packages from.
First we have to decide which repositories to mirror. Because all servers are exclusively CentOS7 64bit boxes, only repositories matching this release and architecture will be used. The default repos enabled after installing CentOS are the following:
* `base`
* `updates`
* `extras`
In addition to this, [EPEL](https://fedoraproject.org/wiki/EPEL) is also mirrored because it contains some important packages.
To make managing and updating the repositories easier, I wrote a small python script called [syncrepo](https://github.com/hakoerber/syncrepo). It reads a configuration file (`/etc/syncrepo.conf` in this example) and syncronizes all repositories defined there. The file format is easy to understand and looks like this:
```json
{
"base": "/srv/www/packages",
"repos": {
"centos/7/os": "ftp.fau.de",
"centos/7/updates": "ftp.fau.de",
"centos/7/extras": "ftp.fau.de",
"epel/7/x86_64": "ftp.fau.de"
}
}
```
`base` refers to the local filesystem path where all files will be stored. `repo` maps the paths of the repositories to the upstream mirrors they will be downloaded from.
The mentinoned mirrors will use about 27GB of space, so we have to make sure there is plenty of space available. This is done by mounting a NFS export from the NAS there.
Now it's time for a first sync:
```shell
[~]$ sudo mkdir -p /srv/www/packages/centos/7/
[~]$ sudo mkdir -p /srv/www/packages/epel/7/
[~]$ sudo /usr/local/bin/syncrepo --config /etc/syncrepo.conf
```
This simply executes the following four commands (one for each repo):
```
rsync $OPTIONS rsync://ftp.fau.de/centos/7/extras/ /srv/www/packages/centos/7/extras
rsync $OPTIONS rsync://ftp.fau.de/centos/7/updates/ /srv/www/packages/centos/7/updates
rsync $OPTIONS rsync://ftp.fau.de/centos/7/os/ /srv/www/packages/centos/7/os
rsync $OPTIONS rsync://ftp.fau.de/epel/7/x86_64/ /srv/www/packages/epel/7/x86_64
```
with OPTIONS being
```bash
--hard-links --out-format "%t %i %n%L " --stats --recursive --update --delete --delete-after --delay-updates
```
to make updates as atomic as possible and give some sensible output.
This is going to take a while. In the meantime, we can setup a webserver to serve those files over HTTP. I'm going to use nginx here. This can be done using the `repomirror` salt role from the [salt role collection](https://github.com/hakoerber/salt-roles) ([direct link](https://raw.githubusercontent.com/hakoerber/salt-roles/master/repomirror.sls)):
```shell
[~]$ sudo salt-call state.sls roles.repomirror
```
This installs nginx to serve `/srv/www/packages`, configures iptables and sets up rsync and logstash. Yay salt!
For reference, here is an equivalent `nginx.conf`:
```nginx
user nginx;
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80 default_server;
server_name _;
root /srv/www/packages;
location / {
autoindex on;
}
}
}
```
If using the salt role, nginx should already be running, otherwise
```shell
[~]$ sudo systemctl start nginx
```
will do it manually. Note that when `/srv/www/packages` is a NFS mount and SELinux is enabled, a boolean needs to be set to allow nginx to use NFS:
```shell
[~]$ sudo setsebool -P httpd_use_nfs=1
```
Now, when `syncrepo` is done, the server is a functioning mirror, ready to distribute packages to clients. The last thing to do is automating a repo sync at a certain interval. Cron is perfect for this. The following line in `/etc/crontab` will run the sync each day at 22:00 with a random one hour max delay, which gives it enough time to finish before the clients retrieve their updates (which is between 0:00 and 6:00 as mentioned above):
```
0 22 * * * root perl -le 'sleep rand 60*60' ; /usr/local/bin/syncrepo --config /etc/syncrepo.conf >>/var/log/syncrepo.log 2>&1
```
That's it. The next thing will be configuring the other servers to use our new local mirror.
## Using the local mirror on the other servers
This task is quite simple: The `baseurl` setting has to be changed to point to the local mirror for all repositories in `/etc/yum.repos.d`. Changing
```
baseurl=http://mirror.centos.org/centos/$releasever/os/$basearch/
```
or
```
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=os&infra=$infr
```
to
```
baseurl=http://pkg01.lab/centos/$releasever/os/$basearch/
```
does the trick for the `base` repo, and the other repositories are similar. Of course it is super tedious to do this for every single server, so let's use salt to automate the process. The `pkgrepo` state makes this possible:
```yaml
repo-base:
pkgrepo.managed:
- name: base
- humanname: CentOS-$releasever - Base
- baseurl: http://pkg01.lab/centos/$releasever/os/$basearch/
```
The tricky part is integrating this with reclass. First, the file for `pkg01.lab` has to be extended to define all exported repositories:
```yaml
applications:
- roles.localrepo
parameters:
applications:
localrepo:
domain: "lab"
repos:
base:
url: "centos/$releasever/os/$basearch"
updates:
url: "centos/$releasever/updates/$basearch"
extras:
url: "centos/$releasever/extras/$basearch"
epel:
url: "epel/$releasever/$basearch"
```
Then, the mirror will be "advertised" to all servers on the `.lab` domain:
```yaml
parameters:
domain:
lab:
applications:
localrepo:
servers: $<aggregate_list("lab" in node.get('domain', {}).keys() and node.get('applications', {}).get('localrepo', None) is not None; dict(name=node['hostname'], repos=node['applications']['localrepo'].get('repos', [])))>
```
Now, the `repos` role (from [here](https://github.com/hakoerber/salt-roles) [[direct link](https://raw.githubusercontent.com/hakoerber/salt-roles/master/repos.sls)]) parses this information and passes it to the relevant states.
This *would* even work with multiple mirrors exporting different repositories (the logic is there) to form kind of a high availability mirror cluster, but fails because the `pkgrepo` state ignores all URLs for `baseurl` except the first one, even though multiple URLs are supported by yum (see `yum.conf(5)`). Anyways, when using only a single mirror (which should be enough), it works as intended.
## A custom repository for non-default packages
Installing packages manually is always a bit of a bad habit in an automated environment. Updating and uninstalling is a pain, as is keeping an overview of what is installed where. For this reason, installing from packages should be preferred when possible. The problem is that building packages is a nightmare (at least RPMs and DEBs). This is what [fpm](https://github.com/jordansissel/fpm) aims to solve, by providing a way to create packages as easily as possible. This, together with a custom repo to distribute the packages, makes management of custom software much easier. It works like this:
First, a new repository is needed, called `custom`, that contains -- well -- custom packages. On our mirror server:
```shell
[~]$ sudo mkdir -p /srv/www/packages/custom/centos/7/x86_64/
```
Now we need something to put there. As an example, let's package the `syncrepo` script mentioned above. We need a user for building packages (building as root is evil™), and install fpm:
```shell
[~]$ sudo useradd -d /var/build -m build
[~]$ sudo yum install -y ruby-devel gcc rpmbuild createrepo
[~]$ sudo -su build
build[~]$ cd ~
build[~]$ gem install fpm
```
Now, set up the directory structure for building the package and get the code:
```shell
build[~]$ mkdir syncrepo
build[~]$ mkdir syncrepo/package
build[~]$ mkdir syncrepo/upstream
build[~]$ cd syncrepo/upstream
build[~]$ git clone https://github.com/hakoerber/syncrepo
```
A Makefile is used to call fpm:
```makefile
VERSION=1.0
DESCRIPTION="Script to create and maintain a local yum package repository"
URL=https://github.com/hakoerber/syncrepo
.PHONY: package
package:
(cd upstream && git pull origin master)
fpm \
-t rpm \
-s dir \
--package ./package/ \
--name $(NAME) \
--version $(VERSION) \
--description $(DESCRIPTION) \
--url $(URL) \
--force \
--depends rsync \
--depends "python34" \
--exclude "*/.git" \
--config-files /etc/ \
./upstream/syncrepo=/usr/bin/ \
./upstream/repos.example=/etc/syncrepo.conf
```
A simple
```shell
build[~/syncrepo]$ make
```
will now build the package and put it into the `package` directory. Nearly done! The only thing left is to make the package available over HTTP. First, it has to be copied into our custom repository:
```shell
[~]$ sudo cp /var/build/syncrepo/package/syncrepo-1.0-1.x86_64.rpm /srv/srv/www/packages/custom/centos/7/x86_64/
```
The last thing that has to be done on the server is building the repository metadata wtth `createrepo` (that was installed above):
```shell
[~]$ sudo createrepo -v --no-database /srv/srv/www/packages/custom/centos/7/x86_64/
```
To make the other servers use the repo, they have to know about it. Let's make salt do this. First, we again have to "advertise" the repo in reclass:
```yaml
parameters:
applications:
localrepo:
domain: "lab"
repos:
...
custom:
url: "custom/centos/$releasever/$basearch"
```
After the next salt run, all servers will be able to access the custom repo, and we can install `syncrepo` the "clean" way with
```shell
[~]$ sudo yum install -y syncrepo
```
## Conclusion
That's all! Now, every server in the lab gets all its packages from a central, always up-to-date mirror, which speeds up downloading and is much nicer to the upstream mirrors. Also, custom RPMs can be made available to all servers to easily distribute custom or self-maintained software.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
---
date: 2017-08-19
excerpt: Using the right tool for the right job
tags:
- ansible
- puppet
- config
title: Ansible or Puppet? Why not both!
toc: true
---
# Introduction
At the [7th DevOps Camp](https://devops-camp.de/devops-camp-12-14-mai-2017/) in May 2017 I listened to a very interesting talk by [Frank Prechtel](https://twitter.com/frankprechtel) and [Andreas Heidoetting](https://www.xing.com/profile/andreas_heidoetting) called "Welche Software für Infrastructure as Code? --- Puppet vs. Chef vs. Ansible vs. Saltstack - welches Tool für welchen Einsatzzweck?" (Which software for Infrastructure as Code? --- Puppet vs. Chef vs. Ansible vs. Saltstack - which tool for what use case?).
In that talk, they arranged those tools along two axes:
* **Procedural** vs. **Declarative**
* **Client-Server** vs **Client-only**
Ansible and Puppet differ on both of those metrics, which Ansible being a procedular client-only system and puppet being a declarative system following a client-server architecture.
In this post, I will focus on the first point, and how the differences lead to each system being stronger than the other in different problem domains.
First, let's look into the concept of "state" applied to configuration management
# State
Configuration management is all about managing state. The state encompasses everything you can think of on the target system: Contents and permissions of files, what processes are running, what packages are installed, users, groups, network configuration, device management and much more, you get the idea.
Now, you generally have a number of "target" states; there are simply the different server roles you have. You might have web servers, database servers, storage servers, and so on. Every server in one of those roles only differs minimally from other servers of the same class. For example, network configuration might be slightly different (IP addresses).
It is in your interest to always assert which state a system is in. We call the discrepancy between the actual state and the declared state "configuration drift". Configuration drift can generally be intoduced two different ways:
* Your actual state changes, and those changes were not introduced by the management system. This is usually the case when you change something on your systems manually (i.e. SSH into them)
* Your declared state changes, without those changes being applied to the systems. This happens when you neglect to do a configuration run after changing your configuration system.
The first point can be remedied by making the declared states as all-encompassing as possible, and as flexible as possible. When it's easier to go through your configuration mangement system to make changes --- even small ones --- there is not need to SSH into a box.
To remedy the second point, you need to apply all changes in the declared state to your systems as easily as possible, preferably automatically. Setting up a CI pipeline that runs your configuration management on every commit in the configuration repository makes sure that configuration drift does not begin to creep up.
To sum it up, any configuration management system benefits from the following:
* Broad scope, i.e. you can tune every parameter of the managed system
* Flexibility, i.e. you can easily adapt the system to new requirements (new things to manage)
* Speed, i.e. applying the declared state takes as little time as possible
# Procedural vs. Declarative
Now that we have a good idea of what "state" is, we can draw the destinction between the procedural and the declarative approch.
In the context of configuration management systems, "prodecural" vs "declarative" relate to *how* the system brings the managed entity (most often a server) into a new state. So, it's not about what you get in the end (state-wise), but the way there.
The descriptions here are very theoretical, and do not apply cleanly to the real world (spoiler: No configuration management system fits perfectly into one of those categories). Nevertheless, thinking about those two extremes helps with understanding the strenghts for each system (more on that later)
## Declarative
A declarative approach means we have to *declare* (duh) the state we want to have on the system (often in some kind of DSL), and the configuration management system's task is to transition whatever state it finds into the declared state.
We define the target state (green), and do not have to care about whatever state there currently is on the system, nor about state transitions. This is all in the tool's hands.
```viz-dot
rank = same
rankdir = LR
ranksep = 1.5
margin = 0.5
node [
color = black
fontsize = 14
shape = box
fontname = sans
margin = "0.5,0.3"
]
edge [
color = black
fontsize = 14
shape = plaintext
fontname = sans
style = dashed
]
"target state" [
fillcolor = green
style = filled
]
"state 1" -> "target state"
"state 2" -> "target state"
"state 3" -> "target state"
```
The cool thing about the declarative approach is that it scales lineary with the number of states: When you introduce a new system with a new target state, you only have to write one declaration for that system, and you're done. It also gives you a nice sense of confidence: When our declaration is sufficently comprehensive, we can be certain that our system is in line with our configuration.
The big problem with that approach is the complexity it brings to the tool itself. The tool has to ananlyize each resource it is expected to bring into the desired state and figure out the steps it has to take. This might not directly impact you (it's more of a problem for the guys writing the config management tool), but this complexity might (and does, in my experience) leak into the use of that system, through leaky abstractions.
Also, to be really useful, a declarative configuration management system needs to be all-encompassing. Let me tell you why.
You might have experienced the following scenario: Assume you have some kind of `conf.d` directory. This is common to split configuration of a program into several files. Two examples that come to my mind are cron (`/etc/cron.d`) and rsyslog (`/etc/rsyslog.d`). There are usually three ways how configuration files might end up in that directory:
* defaults, installed with the package itself
* files from other packages (this is done extensively with `/etc/logrotate.d`
* files from your configuration management system
To actually be sure that, regardless of what's currently in that directory, you end up with the files you want, your configuration management tool has to "take over" that whole directory.
Similarly, a true delarative tool would remove all packages from a system that it does not know about. To turn it around, this means that you have to *tell* the system about all packages you want to install.
Now, sometimes this is exactly what you want (you actually want those superfluous packages gone and sometimes you can split a `conf.d` directory into multiple ones) but this case nevertheless shows that the default state of a declarative tool it to "own" the system.
Pros:
* Independence of current states makes it suitable for heterogenous environments
* Confidence in the target state
* Scales linearily with the ammout of different states
* Idempotence "built-in"
Cons:
* The tools need to be quite heavy and complex
* To leverage the whole power, you need to delare your whole system and let the configuration management system "own it"
## Procedural
A procedural system simply applies a set of predefined state transitions (green) to reach a new state:
{% digraph some graph title %}
rank = same
rankdir = LR
ranksep = 1.5
margin = 0.5
node [
color = black
fontsize = 14
shape = box
fontname = sans
margin = "0.5,0.3"
]
edge [
color = green
fontsize = 14
shape = plaintext
fontname = sans
style = dashed
]
"state" -> "new state"
{% enddigraph %}
With the state transition being predefined, this means that the new state is dependent on the old state. As long as you can be sure of the state of the system, this is not an issue. But, as soon as you have any configuration drift, for example introduced by manual intervention, your have a new state and therefore need a new state transition:
{% digraph some graph title %}
rank = same
rankdir = LR
ranksep = 1.5
margin = 0.5
node [
color = black
fontsize = 14
shape = box
fontname = sans
margin = "0.5,0.3"
]
edge [
color = green
fontsize = 14
shape = plaintext
fontname = sans
style = dashed
]
"state 1" -> "new state"
"state 2" -> "new state"
{% enddigraph %}
As you can see, the more initial states you have, the more state transitions you have to maintain. Now imagine you have different target states, and watch the complexity exploding:
{% digraph some graph title %}
rank = same
rankdir = LR
ranksep = 1.5
margin = 0.5
node [
color = black
fontsize = 14
shape = box
fontname = sans
margin = "0.5,0.3"
]
edge [
color = green
fontsize = 14
shape = plaintext
fontname = sans
style = dashed
]
"state 1" -> "new state 1" [headport="w"]
"state 1" -> "new state 2" [headport="w"]
"state 1" -> "new state 3" [headport="w"]
"state 1" -> "new state 4" [headport="w"]
"state 2" -> "new state 1" [headport="w"]
"state 2" -> "new state 2" [headport="w"]
"state 2" -> "new state 3" [headport="w"]
"state 2" -> "new state 4" [headport="w"]
"state 3" -> "new state 1" [headport="w"]
"state 3" -> "new state 2" [headport="w"]
"state 3" -> "new state 3" [headport="w"]
"state 3" -> "new state 4" [headport="w"]
{% enddigraph %}
The upside of the procedural approach is that the state transitions are quite simple. When contrasted with the declarative approach, instead of saying "this is how I want the result to look like", one can simply say "do this!"
Pros
* State transitions are relatively simple
Cons
* Does not tolerate any configuration drift
* Care must be taken for the transitions to be idempotent
# Back to our tools
How do Puppet and Ansible fit into those categories? Well, they both have a part of both.
Ansible, with its concepts of "plays" and "tasks", fits better into the procedural approach, even though most modules are declarative and idempotent. Take the core modules as an example: Both the ``file`` and the ``service`` module define what you want the file or service to look like.
On the other hand, Puppet is --- at its core --- fixated on the declarative approach. Colloquially, if your ``puppet agent`` run changes some resource every time it is run, "you are doing something wrong". Almost all modules you encounter, even third-party ones, give you some kind of interface to define what you want their resource to look like, and do some magic in the background to make it so.
So while both tools can fit both styles, they are not equally suitable for the jobs. Puppet is, hands down, the better declarative tool, but you use a needlessly complex tool when you just want to define state transitions. On the other hand, ansible is much more fitting for the procedural approach, and you will have to jump through a lot of hoops an have to be very careful to use it in a proper declarative manner.
# Which tool to chose
TL;DR: If you have a mutable infrastructure, use Puppet. If you have an immutable infrastructure, use Ansible. If you have both, use both.

View File

@@ -0,0 +1,80 @@
---
date: 2017-08-22
excerpt: This is why I love linux!
tags:
- linux
- netcat
title: Cloning a hard disk over the network
toc: false
---
This is going to be short: My old trusty laptop began showing signs of old age. The screen started flickering, a problem I already knew. Last time, I bought a new screen from Alibaba for ~100€, but wasn't going to spend that much on a three year old laptop.
So, new laptop it is. The Lenovo V110-15IAP looked nice, so I ordered it online. It arrived today, but I wasn't going to spend hours to set up a new OS, copy all files over, check if everything is ok --- and I also didn't have an external drive to hold all files during transfer ... there must be an easier way.
Well, there is, and it's called `netcat` and some pipes. Netcat is a nice, simple tool that reads and writes data over the network, using TCP by default (more info [here](http://nc110.sourceforge.net/)).
I connected old and new laptop via ethernet, booted the old laptop into recovery mode, booted the new laptop with a Arch live USB drive I had laying around, and got to work.
First, the two computers need a network connection. On the old laptop:
```shell
[~]$ sudo ip addr add 10.1.1.1/24 dev enp1s0f0
[~]$ sudo ip l set dev enp1s0f0 up
```
and on the new laptop:
```shell
[~]$ sudo ip addr add 10.1.1.2/24 dev enp1s0
[~]$ sudo ip l set dev enp1s0
```
Notice the different interface names. Check connectivity:
```shell
[~]$ ping -c 3 10.1.1.1
PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.
64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=2.33 ms
64 bytes from 10.1.1.1: icmp_seq=2 ttl=64 time=2.34 ms
64 bytes from 10.1.1.1: icmp_seq=3 ttl=64 time=2.33 ms
--- 10.1.1.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 2.335/2.340/2.346/0.004 ms
```
Looks good!
On the "receiving" (new) laptop, I made `netcat` listen on the network, together with `pv` to get an idea of the transmission rate:
```shell
[~]$ nc -l -p 4000 | pv -pterab -s 117g | sudo dd of=/dev/sda
```
The switches for `pv` give us some nice metrics, see [`pv(1)`](https://linux.die.net/man/1/pv) for more info. The `117g` is the size of the disk, this is necessary to get an ETA and a progress meter.
Now, start the transfer on the old laptop:
```shell
[~]$ sudo dd if=/dev/sda | nc 10.1.1.2 4000
```
The transfer took around 20 minutes at about 100 MB/s, which means the Ethernet is actually the bottleneck).
After that, I verified the first few bytes of the disk to make sure they are actually the same:
```shell
[~]$ sudo dd if=/dev/sda bs=1M count=1 status=none | md5sum
d4bf772aa861fef76ff777aa52ec6800 -
```
This output was the same on both machines, which means we are good!
After a reboot, the new machine booted straight into my trusted fedora, and everything worked out of the box (except I had to re-enter the WiFi password, which I guess has something to do with changing MAC addresses). And not even five minutes later, I started writing this post.
I also eperimented a bit with compression (lz4 and lzop), but even though the network was the bottleneck, the transfer didn't complete any faster. Not sure why, but I got what I wanted, so I didn't bother to investigate further.
You could do the same over any network, but with the `netcat` transfer being unencrypted, you'd need some kind of encryption, for example by piping through `ssh`.
Anyway, thank you for reading!

View File

@@ -0,0 +1,75 @@
---
date: 2018-04-22
except: MTU issues with Docker
tags:
- linux
- netcat
title: 'Troubles with Drone on Kubernetes'
toc: false
---
Currently, I am trying out [drone](https://drone.io/) to automate building of docker images for my kubernetes cluster.
I have a Docker-in-Docker (DIND) setup in Kubernetes to enable building docker containers in drone. Yes, those are a lot of Docker layers! Kubernetes, then drone, the whatever Docker containers drone is building ... but it works! At least, now it does. Before, I noticed that I did not have network connectivity inside the build containers. DNS was working, but pinging did not. The `.drone.yml` file looks like this:
```yml
pipeline:
build:
image: docker:17.12.1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk update
- apk add make git
- make
```
I did not want to use DIND containers for every build step, but drone should use a single DIND container for all builds to use. This was done like this in the kubernetes deployment:
```yml
spec:
containers:
- image: docker:17.12.1-dind
name: dind
ports:
- containerPort: 2375
protocol: TCP
securityContext:
privileged: true
- image: drone/agent:0.8
name: drone-agent
args:
- agent
env:
- name: DRONE_SECRET
value: [...]
- name: DRONE_SERVER
value: [...]
- name: DOCKER_HOST
value: tcp://localhost:2375
```
Exept, that did not work, showing the network connectivity issues mentioned above.
In the end, I tracked it down to an MTU issue, also mentioned [here](https://discourse.drone.io/t/docker-mtu-problem/1207). The fix is a bit ugly, because the DIND container does not expose a straight way to set the MTU. The simplest solution (after looking into [the DIND build environment](https://github.com/docker-library/docker/blob/5b158e3ca87bdc20069754a796c00b270e40cfdb/17.12/dind/)) I found was to set the startup arguments for the DIND container explicitly in the kubernetes deployment:
```diff
- image: docker:17.12.1-dind
name: dind
+ command:
+ - dockerd-entrypoint.sh
+ - dockerd
+ - --host=unix:///var/run/docker.sock
+ - --host=tcp://localhost:2375
+ - --mtu=1400
+ - --storage-driver=vfs
ports:
- containerPort: 2375
protocol: TCP
securityContext:
privileged: true
```
With these changes, network connectivity for the build containers is restored, and drone can build docker containers without issues.
Hopefully, in one of my next posts I might describe how this blog is then automatically built using drone ;)

View File

@@ -0,0 +1,244 @@
---
date: 2018-06-03
excerpt: A Review of Ansible in Production
tags:
- ansible
- puppet
- config
title: Ansible Is Not (Yet) Perfect
toc: true
---
# Introduction
I have been using Ansible for over a year now, both at work and at home (for example to configure my Kubernetes cluster using [kubespray](https://github.com/kubernetes-incubator/kubespray).
When I first used Ansible, I was blown away by its power and simplicity. And all that by leveraging the existing SSH server, without a new client setup? Awesome!
But over time, I discovered more and more warts and limitations while using Ansible. In this blog post, I will go over all the cases where it falls short of the promises it make and where you start fighting *against* instead of *together with* Ansible.
This post is in no way meant to put down Ansible. When focusing on the bad parts, one might get the impression that there are no good parts. This is absolutely not the case! But by listing its drawbacks, maybe we can come up with ideas how to fix or work around those, benefitting everyone.
This post assumes some familiarity with Ansible. I can recommend the [Getting Started Documentation](http://docs.ansible.com/ansible/latest/user_guide/index.html) for the first steps with Ansilbe.
# Ansible limitations
## YAML as the configuration language
Ansible uses [YAML](http://yaml.org/) for almost all of its configuration. YAML is an excellent choice when you want to express data, similar to JSON. You have dictionaries, lists, scalars, combinations of them and some syntactic sugar to save typing. Easy.
But YAML is not a good language to express program logic.
It is declarative, but without a strict logic you are only poorly implementing a DSL in a data serialization language.
Let's start with a simple example:
```yml
- shell: echo {{ item }}
with_items:
- "one"
- "two"
- "three"
```
Easy to reason about: This outputs ``one`` ``two`` ``three``. Now, Ansible has a way to express loops using YAML plus Jinja, like this:
```yaml
- shell: echo {{ item }}
when: item != 'two'
with_items:
- "one"
- "two"
- "three"
```
If you know Ansible, you most likely know what is going to happen: If will output `one` and `three`, skipping `two`. This is of course the only way the ordering between `when` and `with_items` makes sense, but this is not at all obvious or deducible by only looking at the code. If this was instead done procedurally, it is immediately obvious:
```python
for item in ['one', 'two', 'three'] {
if item != 'two' {
shell('echo {item}')
}
}
```
A similar problem occurs when using `register` together with a loop, like this:
```yaml
shell: echo {{ item }}
with_items:
- "one"
- "two"
register: out
```
Usually, ``register`` saves the output of a module into the variable given as its key.
But when using a loop, the structure of `out` differs from one you would get without the loop: Instead of `out` being a dictionary containing the return data, `out[results]` is a list of dictionaries with that data for every invocation of `shell`. Now you know that, and it might make sense, but it is so obscure that you will most likely have to look it up next time (I do every time).
My guess is that YAML was chosen because it is declarative. But Ansible is inherently non-declarative, but rather procedular, at least on a high level.
On the module level declarativeness makes a lot of sense. I do not actually care how that file gets its content and permissions, or how that package is installed. I just want to tell Ansible to make it so, and its job is to figure it out. So, YAML might actually be a good decision for module invokation:
```yaml
- copy:
dest: /etc/foo.bar
content: 'Hey!'
owner: foo
group: foo
mode: 0644
```
If is immediately obvious what is going to happen, apart from the not-so-obvious name of `copy` for the module. In the end, I am going to end up with a file that has the exact properties I specified above. Nice.
There is another configuration management system that uses YAML together with Jinja for its syntax: [SaltStack](https://saltstack.com/). The difference is the ordering of the "rendering pipeline": Ansible first parses the files as YAML, and then applies Jinja to certain parts (e.g. the ``when`` key). SaltStack's files are Jinja-templated YAML files, so it first passes the file through the Jinja engine and then parses the output as YAML.
This approach makes for a much more powerful syntax, because you actually have a turing complete language (Jinja) to write your declarations (YAML). It's also less magic: If you know Jinja well enough, it's easy to reason about the code without knowing SaltStack internals.
The problem: You can shoot yourself in the foot, and SaltStack placed your target right next to your foot. There is a thin line between "That makes sense!" and "This is messy!". Take the following code as an example, taken from my salt forumla to set up Nginx together with LetsEncrypt (link [here](https://github.com/hakoerber/salt-nginx-letsencrypt)):
```jinja
{% for domain in params.domains %}
letsencrypt-keydir-{{ domain.name }}:
file.directory:
- name: {{ nginx_map.acme.home }}/{{ domain.name }}
- user: {{ nginx_map.acme.user }}
- group: {{ nginx_map.acme.user }}
- mode: '0750'
- require:
- user: acme
{% endfor %}
```
This is quite easy to understand. But down the rabbithole it goes, and you stumble upon something like this in a different file:
```jinja
{% if params.get('manage_certs', True) %}
{% set no_commoncert = [] %}
{% for domain in params.domains %}
{% if domain.get('ssl_cert', false) %}
{% set main_name = domain.names[0] %}
{% do no_commoncert.append(1) %}
nginx-pkidir-{{ main_name }}:
file.directory:
- name: {{ nginx_map.conf.confdir }}/{{ nginx_map.pki.pkidir }}/{{ main_name }}
- user: root
- group: {{ defaults.rootgroup }}
- mode: 700
- require:
- file: nginx-pkidir
{% endif %}
{% endfor %}
{% endif %}
```
Whatever it does, I think we can agree that this is not nice to read.
In the end, I think that YAML is simply not a good abstraction for configuration management files, and using Jinja as a crutch to get more functionality out of a data description language makes it even worse.
Configuration management needs a touring complete language with a declarative way to use modules. From this, you can generate a declaration of your desired configuration, that can then be used to configure your system. Complete declarativeness for the language, even though it is often touted as the end goal of CMSs, is not possible. Even the Puppet DSL has loops and conditions.
In a way, a strictly functional language might be the best way to go. [NixOS](https://nixos.org/) is a really promising and interesting candidate.
## Release engineering
Ansible moves fast and breaks lots of things. This is simply not a good feature for a configuration management system.
For the ``package`` module, ``state: installed`` is now called ``state: present``
One bug that let to a lot of frustration for me and my team was a ``RecursionError`` caused by too many (>20) ``import_role`` statements in a playbook. It was introduced around version 2.0, fixed in version 2.3, resurfaced in version 2.4, and finally fixed for good (hopefully) in version 2.5.
This does not give me a lot of confidence in the Ansible release engineering. I know that it is a very hard job, and you always have to weigh stability against new features. But it is my impression that the Ansible team leans a bit too much on the latter side, introducting breakage and forcing me to adapt my roles and modules every few releases.
## Inventory and Host Variables
Ansible has a concept of host and group specific variables. There are a lot of places where you can set variables, and their precedence is strictly defined (look at the list in the [official documentation](http://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#variable-precedence-where-should-i-put-a-variable)!).
The problem with that is the merging strategy: Nested values are not merged, but the later ones overwrite the pervious ones. This means that custom roles cannot have a "main" key, e.g. `postgresql_config` for a PostgreSQL role, but have to pollute the top-level variable space with a prefixed list of variables, like this (taken from [here](https://github.com/ANXS/postgresql/blob/master/defaults/main.yml)):
```yaml
postgresql_version: 9.6
postgresql_encoding: "UTF-8"
postgresql_data_checksums: false
postgresql_pwfile: ""
```
This is simply ugly, and not the way YAML is meant to be used. Also, assume you have the following situation: You have a number of servers, and a number of admins that have access to the server, like this:
```yaml
admins:
- name: "hannes"
sudo: true
sshpubkey: ssh-rsa ...
- name: ...
```
Now, you want to add a new guy to your list of users, but only for a few servers (you do not want the new guys to break production!). In a perfect world, you would go to the ``group_vars`` of those servers, and add the new guy:
```yaml
admins:
- name: "newguy"
sudo: false
sshpubkey: ssh-rsa ...
```
This does not work with Ansible, because the second declaration would overwrite the first, and now only your new guy has access to the servers! The only solution to that problem (as far as I can tell), is to use a differently named key:
```yaml
new_admins:
- name: "newguy"
sudo: false
sshpubkey: ssh-rsa ...
```
Then, merge those keys in the role you use to create users:
```yaml
- user:
name: "{{ item.name }}"
state: present
with_items: "{{ admins + new_admins }}"
```
This does not scale: As soon as you need another distinct access rule, you have to add **another** key, and the cycle repeats.
## Speed
Simply put, the execution speed of Ansible playbooks is horrendous. This is due to its architecture, which requires SSH connections to all servers you run playbooks on. It might not be a problem for you, but the workarounds that were created (stuff like [ansible-pull](http://docs.ansible.com/ansible/latest/cli/ansible-pull.html) + cron) show that it is a problem for a significant number of people.
## Dry runs
When running Ansible playbooks, you can pass `--dry-run` to the `ansible-playbook` command, and Ansible will show you what would be done, not actually executing anything.
Except that this does not work reliably. This happens most often when you add a YUM/APT repository, to the install a package from that repository. If the repository is not yet present on the server, the (no-op) package installation will fail with a "package not found" error.
There are workarounds, like using ``when: not ansible_check_mode``, but these are still just that: workarounds.
Ansible does not give me the same sense of reliability as e.g. puppet does.
# My opinion
It might sound weird after the above but I have to say: I really like Ansible. Not so much for configuration, but for orchestration. There is simply nothing better.
I love having repetitive tasks written down as code, having them reviewed before running them. Documentation having copy-paste shell snippets now simply link to an Ansible script that does the same, but repeatably, and without accidentially pasting into the wrong terminal window ;)
Slap something like [Rundeck](https://www.rundeck.com/open-source) or [StackStorm](https://stackstorm.com/) in front of Ansible, and you can give fine-grained access to your playbooks to other people, together with logging, auditing, and integration for your favourite tools.
But, Ansible as configuration management tool has not convinced me yet. As old school as it is, Puppet gives me more confidence while using it. Ansible still has a lot to do in that regard, but with lots and lots of people working on Ansible, together with being backed by RedHat, I hope it will get even better in the future!
<!--
---
But, as far as serverless/self-bootstrapping deploys go, it's less common. Ansible has less of a "culture of dependencies"; the simpler, more approachable-looking nature of the Ansible playbook format seems to lend itself to people one-offing whatever they need rather than looking for best-practices solutions that already exist. Because of this, there's no real Berkshelf equivalent for Ansible. The tooling doesn't exist, outside of Tower (sorta), because nobody wants it, and nobody wants it because the tooling doesn't exist. So the people who are doing with Ansible something similar to the Chef Zero stuff I mentioned above are mostly home-rolling it. (I just use a S3 bucket as a Minimart berkshelf endpoint and move on with my day.)
Last-mile configuration is also tricky. In my Chef Zero stuff, I use CloudFormation metadata to provide Chef attributes. You can do something similar with Ansible...but it's duct-tapey. There are times when simple is better; IMO, Ansible's core tooling errs too far on that side and the ecosystem has not caught up to make more rigorous approaches really viable.
Calling that "serverless" is something of an abuse of the term. You can call it "push-based" rather than "pull-based" (a Chef/Puppet model), but there is definitely a "server" to be had--it's the machine running SSH and with the canonical datastore. It is--and this is one of many reasons I don't like Ansible very much--just a pretty poor server and often the developer's workstation.
"Serverless" would be more like what I described with regards to Chef Zero, where a machine, as it bootstraps, is able to fetch its playbooks from somewhere and self-execute with some sort of sourceable configuration data. The standard Ansible workflow is not only not "serverless", but it is antithetical to cloud-friendly scaling and fault-tolerance practices. (Think about how you're going to manage auto-scaling groups with it. It hurts.)
Sure. But that's pretty awful. ansible-pull relies on a git repository, which relies on key provisioning, which means that you need to configuration-manage your configuration management and you don't have a stump-dumb, easy solution for it in any major cloud. And you have no dependency management (submodules, at best, are not dependency management), so I hope that you've vendored (which is gross) all of your dependencies.
This really is what I do for a living. I'm speaking from a position of entirely too extensive experience when I say that Ansible has no good solution here in common use. If I thought Ansible was good enough for me to be spending a lot of time with (it's not, and I advocate that clients not use it if they have a choice), I'd have probably already had to write it.
As far as machine images go, they are an optimization, not a core system. Your configuration management systems need to be able to bootstrap from either an AMI, to lay on last-mile (configuration, as opposed to setup, stuff) and converge any updates since the last AMI build, or to start from scratch. And that is another weakness of Ansible; writing idempotent Ansible scripts is significantly harder than it needs to be.
-->

View File

@@ -0,0 +1,50 @@
---
date: 2018-07-02
summary: |
The rabbithole that is /proc/self/mountstats
tags:
- linux
- kernel
- prometheus
- golang
- go
title: Prometheus NFS client monitoring for UDP
toc: false
---
Everything started with a simple idea: Get per-client NFS statistics into [Prometheus](https://prometheus.io/).
I found out that the [Node Exporter](https://github.com/prometheus/node_exporter) is already able to export NFS client metrics via the `mountstats` collector. It gathers metrics from `/proc/self/mountstats` and exports them nicely for prometheus to scrape. Nice! Let's try this out:
```bash
./node_exporter --collector.mountstats
failed to parse mountstats: invalid NFS transport stats 1.1 statement: [740 1 881477 875055 5946 2888414103 286261 16 258752 2080886]
```
Hmm, that does not look good. After a lot of digging throught the code, I found out that the collector seems not to support NFS mounts via UDP! This is due to a different format of the `mountstats` file depending on the protocol. [Here](https://utcc.utoronto.ca/%7Ecks/space/blog/linux/NFSMountstatsXprt) is an awesome writeup about the `mountstats` file format, which is seemingly now documented anywhere else. A big thanks to Chris Siebenmann!
The problem lies in the `xprt` line in `/proc/self/mountstats`, which contains transport statistics and looks like this for TCP:
```
xprt: tcp 695 1 1 0 16 96099368 96091328 6383 341933213458 1504192
```
All fields are explained in the link above. The crux is the following part[^quote1]:
> For the udp RPC transport there is no connection count, connect idle time, or idle time (fields #3, #4, and #5); all other fields are the same.
[^quote1]: [Link](https://utcc.utoronto.ca/%7Ecks/space/blog/linux/NFSMountstatsXprt) --- retrieved 2018-12-10
This means that for UDP, the line contains three fewer fields than for TCP. The mountstats exporter always expects the same number of fields and therefore breaks for UDP.
Another tricky thing is the `statvers` variable in `/proc/self/mountpoints` that specifies which version the statistics refer to. `statvers=1.1` added three more fields to the end, 11, 12 and 13 in the link above. I was not sure how this was handled for UDP, but after digging through kernel code and git logs, I found [this commit](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=15a4520621824a3c2eb2de2d1f3984bc1663d3c8), which shows that the three fields were added for both UDP and TCP.
I coded up the changes (learning some golang in the process) and opened a pull request with the procfs project in Prometheus at https://github.com/prometheus/procfs/pull/100, so the component now exports the statistics correctly for TCP and UDP. The fields that are missing in UDP are simply set to zero to ensure the same number of fields for both protocols. I also added a field specifiying the protocol, either "tcp" or "udp".
<!--
TODO
After getting that merged, I opened another pull request with the node exporter to actually export these statistics. Also, the NFS metrics now have a new label indicating the protocol, using the new field mentioned above!
Because these are breaking changes, they will be released with the next version of node exporter. As soon as they do, NFS client metrics will also be available for UDP mounts!
-->

View File

@@ -0,0 +1,56 @@
---
title: "Sphinx: Use CSV macro for simple Markdown-like tables"
date: "2018-12-30T19:39:16+01:00"
summary: |
How to use the "csv-tables" macro to get Markdown-like table syntax in Sphinx
tags:
- sphinx
- documentation
---
I am quite fond of [Sphinx](http://www.sphinx-doc.org/en/master/), a documentation generator using [reStructuredText](http://docutils.sourceforge.net/rst.html) markup syntax. At Tradebyte, we use Sphinx extensively for all kinds of documentation.
One thing I do not like about Sphinx is its table syntax[^1]:
[^1]: Taken from http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#tables --- retrieved 2018-12-30
```rst
+------------------------+------------+----------+----------+
| Header row, column 1 | Header 2 | Header 3 | Header 4 |
| (header rows optional) | | | |
+========================+============+==========+==========+
| body row 1, column 1 | column 2 | column 3 | column 4 |
+------------------------+------------+----------+----------+
| body row 2 | ... | ... | |
+------------------------+------------+----------+----------+
```
In my experience, it is very tedious to write and maintain. The rationale behind the syntax is rooted in the desing of reStructuredText: it is supposed to be readable as text without rendering to another format like HTML.
In contrast to that, Markdown has the following syntax for tables[^2]:
[^2]: Taken from https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet --- retrieved 2018-12-30
```markdown
| Tables | Are | Cool |
| ------------- | ------------- | ----- |
| col 3 is | right-aligned | $1600 |
| col 2 is | centered | $12 |
| zebra stripes | are neat | $1 |
```
CSV tables to the rescue!
You can use a sphinx extension to get Markdown-like syntax for you tables, using ``csv-table`` like this:
```rst
.. csv-table::
:header-rows: 1
:separator: |
Header 1 | Header 2
Cell 1 | Cell 2
```
This gives you (kind of) the same functionality as with Markdown, at least for simple tables (e.g. no joined cells)

View File

@@ -0,0 +1,328 @@
---
title: "Single Sign-On with Keycloak on Kubernetes"
date: "2020-08-30T09:29:26+02:00"
summary: >
How I set up Single Sign-On for a few services (GitLab, Nextcloud, Miniflux)
on Kubernetes with Keycloak
tags:
- keycloak
- openid
- oauth
- go
- docker
- kubernetes
toc: true
---
# Introduction
So I have a few services running in my private "cloud". If I told you that it's just a single VPS at [Hetzner](https://www.hetzner.de/cloud) I would not longer be able to call it "cloud", so please forget what I just said. ;)
Anyway, I currently have a few different "user-facing" services running:
* [Nextcloud](https://nextcloud.com/)
* [Gitea](https://gitea.io/), a git hosting service similar to GitHub
* [Miniflux](https://miniflux.app/), an RSS reader
* [Firefly III](https://www.firefly-iii.org/), a finance manager
* This blog
What annoyed me is the requirement to have a separate login for each service. So save a few minutes I decided to spend a few days to research and set up Single Sign-On for all these services.
Single Sign-On (or SSO for short) means that multiple services are protected behind the same login. Note that this does **not** mean to just have the same password for every service. Instead, logging in to one service means you are effectively logged in to all other services as well, without the need to authenticate again. You most likely know this from Google: When you log in to your GMail account, you are automatically also logged in to Calendar, Youtube, Google Docs etc.
SSO requires a central authentication provider that your services can authenticate against. Often (also in case of Google), this is handled by the [OpenID Connect](https://openid.net/connect/) protocol, which sits on top of OAuth 2.0. I will not go into more detail about this, but instead link to to a an awesome writeup by Micah Silverman from [Okta](https://www.okta.com/) who wrote a detailed explanation of OpenID connect: [Link](https://developer.okta.com/blog/2017/07/25/oidc-primer-part-1). Make sure to read all parts!
At this point, the steps were clear:
* Find an identity provider
* Set it up
* Integrate the services
* Rejoice
# Keycloak
While looking for an identity provider, I was looking for the following:
* Free & Open Source
* Support for OpenID Connect & OAuth 2.0
* Support for two-factor authentication
In the end, I saw that the landscape here is not too crowded and found two solution that fit the bill:
* [Keycloak](https://www.keycloak.org/), which is the upstream base to RedHat's ["Single Sign-On"](https://access.redhat.com/products/red-hat-single-sign-on)
* [Gluu](https://www.gluu.org/)
In the end, I decided on Keycloak. The main reason was that Gluu used MongoDB as its backend database, while Keycloak supports any RDBMS. The reasoning is thin, but I just prefer PostgreSQL to MongoDB.
Keycloak also supports user federation and can be used with any LDAP server. They recommend to use LDAP instead of the built-in RDBMS for scalability, but this is not a problem I am currently facing, so I'll stick to a simple setup. In the future this might be a good starting point to dive into [FreeIPA](https://www.freeipa.org/) ...
## Kubernetes deployment
Keycloak provides ready-made Docker images for the keycloak server, and I set
it
up with PostgreSQL as its backing database. On kubernetes, the setup is really
straight-forward:
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: keycloak
labels:
app: keycloak
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: keycloak
template:
metadata:
labels:
app: keycloak
spec:
containers:
- name: keycloak
# note that v11.0.1 currently has a bug that breaks updating of
# user data, see:
# https://issues.redhat.com/projects/KEYCLOAK/issues/KEYCLOAK-15373
image: quay.io/keycloak/keycloak:11.0.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
protocol: TCP
name: http
env:
- name: KEYCLOAK_USER
value: admin
- name: KEYCLOAK_PASSWORD
value: mysecurepassword
- name: DB_VENDOR
value: postgres
- name: DB_ADDR
value: localhost
- name: DB_PORT
value: "5432"
- name: DB_DATABASE
value: keycloak
- name: DB_USER
value: keycloak
- name: DB_PASSWORD
value: myothersecurepassword
- name: KEYCLOAK_FRONTEND_URL
value: https://keycloak.hkoerber.de/auth/
- name: PROXY_ADDRESS_FORWARDING
value: "true"
- name: keycloak-db
image: 'postgres:12.2'
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5432
volumeMounts:
- mountPath: '/var/lib/postgresql/data'
name: database
env:
- name: POSTGRES_USER
value: keycloak
- name: POSTGRES_DB
value: keycloak
- name: POSTGRES_PASSWORD
value: myothersecurepassword
volumes:
- name: database
persistentVolumeClaim:
claimName: keycloak-db
```
Handling of `VolumeClaims` and setup of services/ingress is left as an exercise to the Kubernetes admin.
## Setup
One `kubectl apply` later (done automatically via [Drone](https://drone.io/) of
course!), I logged into keycloak as the admin and was greeted with the admin
interface:
![Keyclaok admin interface](/assets/images/keycloak/intro.png)
Keycloak has an **excellent**
[documentation](https://www.keycloak.org/documentation.html) that explains all
concepts behind Keycloak and guides you through all menus and settings.
I highly
recommend to read through it (yes, it's a lot).
I will not go into too much detail about the keycloak setup here. Because it's
mostly configured via Web UI, this would just lead to a heap of screenshots.
While I really like graphical configuration for its discoverability, I much
prefer textual config, which can be tracked in git, shared, reviewed, automated
and so on. When my setup is a bit more stable, I plan to migrate the Keycloak
configuration to [Terraform](https://www.terraform.io/) with the [Terraform
provider for Keycloak](https://github.com/mrparkers/terraform-provider-keycloak)
As a brief summary, I did the following in Keycloak:
* Created a new realm for my "cloud"
* Created all users (me)
* Added groups and roles
* I used roles in the format of `<service>:<scope>` for all services. For example, there would be a `nextcloud:admin` role
* I used groups to assign users to roles. So the `/nextcloud/admin` group would get the `nextcloud:admin` role. Quite over-engineered for a single user, but you never know :D
* Added client scopes for the relevant roles and clients
* Added a "confidential" client for every service
That's it for the Keycloak setup! Now it's time to convince some services to authenticate against it ...
# Client configuration
The first clients I migrated were the ones that already have OpenID Connect support built-in, which were Gitea and Miniflux. Because I had to take a few hurdles along the way, I'll describe their setup briefly.
## Gitea
Gitea unfortunately does not offer any means to set the OIDC provider using the configuration file or environment variables. Instead, you have to go the the "Site Administration" menu and create a new provider under "Authentication Sources":
![Gitea setup for OpenID client](/assets/images/keycloak/gitea-oauth-setup.png)
You can see here that I chose `keycloak` for the name of the authentication provider. Gitea will always use `/user/oauth2/<name>/callback` as the callback URL path, so in Keycloak I specified `https://code.hkoerber.de/user/oauth2/keycloak/callback` as the only valid redirect URL.
This is already enough the enable OpenID login in Gitea:
![Gitea Login with OpenID](/assets/images/keycloak/gitea-login.png)
I wanted to manage Gitea users *only* via OpenID. This needed a few settings in Gitea's `app.ini`:
```ini
[service]
; Disable registration
DISABLE_REGISTRATION = false
; ... except via OpenID
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
[openid]
; Do not allow signin to local users via OpenID
ENABLE_OPENID_SIGNIN = false
; Allow creation of new users via OpenID
ENABLE_OPENID_SIGNUP = true
```
That's it for Gitea. The next service is Miniflux, the RSS reader.
## Miniflux
In contrast to Gitea, Miniflux allows setting the OpenID authentication provider via environment variables. This makes it easy to set up in Kubernetes. I set the following environment variables for the Miniflux container in the Kubernetes deployment:
```yaml
env:
- name: OAUTH2_PROVIDER
value: oidc
- name: OAUTH2_CLIENT_ID
value: miniflux
- name: OAUTH2_CLIENT_SECRET
value: [redacted]
- name: OAUTH2_OIDC_DISCOVERY_ENDPOINT
value: https://keycloak.hkoerber.de/auth/realms/mycloud
- name: OAUTH2_REDIRECT_URL
value: https://rss.hkoerber.de/oauth2/oidc/callback
- name: OAUTH2_USER_CREATION
value: "1"
```
According to the Miniflux documentation, "Only google is supported" as an OAuth provider.[^miniflux-man-page]. Fortunately, GitHub user @pmarschik added support for generic OpenID Connect providers in [this pull request](https://github.com/miniflux/miniflux/pull/583).
I struggled a bit with the callback URL: At first, I set the path in `OAUTH2_REDIRECT_URL` to something generic like `/oauth2/keycloak/callback`. This led to a redirect loop after authentication. The browser was redirected to `/oauth2/keycloak/callback`, which started a new authentication flow, which in the end again redirected to `/oauth2/keycloak/callback` and so on. Miniflux did not properly detect that the redirect URL was the redirect URL, and started a new authentication flow every single time. So, what was the correct value to set for `OAUTH2_REDIRECT_URL` to make Miniflux detect the redirect? I had to dive into the source code ...
[^miniflux-man-page]: https://miniflux.app/miniflux.1.html
In the logs, I only got the following message:
```
[ERROR] [OAuth2] oauth2 provider not found
```
This error can only be caused at a single place in the code, at `oauth2/manager.go`[^miniflux-code-manager]
[^miniflux-code-manager]: https://github.com/miniflux/miniflux/blob/3e1e0b604fb42eba4617d77a164cca37d4cae1aa/oauth2/manager.go#L24
```go
// Provider returns the given provider.
func (m *Manager) Provider(name string) (Provider, error) {
if provider, found := m.providers[name]; found {
return provider, nil
}
return nil, errors.New("oauth2 provider not found")
}
```
This method looks for a new provider in the `m.providers` map of a `Manager` object with a certain name. The `Manager` object and its providers are initialized just a few lines further down[^miniflux-code-manager-2]:
[^miniflux-code-manager-2]: https://github.com/miniflux/miniflux/blob/3e1e0b604fb42eba4617d77a164cca37d4cae1aa/oauth2/manager.go#L32
```go
// NewManager returns a new Manager.
func NewManager(ctx context.Context, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint string) *Manager {
m := &Manager{providers: make(map[string]Provider)}
m.AddProvider("google", newGoogleProvider(clientID, clientSecret, redirectURL))
if oidcDiscoveryEndpoint != "" {
if genericOidcProvider, err := newOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil {
logger.Error("[OAuth2] failed to initialize OIDC provider: %v", err)
} else {
m.AddProvider("oidc", genericOidcProvider)
}
}
return m
}
```
The important line is this one:
```go
m.AddProvider("oidc", genericOidcProvider)
```
We see that the key in the `providers` map is `oidc`. So why is it not found? Where does the `name` parmater to `Provider()` actually come from?
It turns out that the name is actually extracted from the URL path. The callback request is handled in a method called `oauth2Redirect()` in `ui/oauth2_callback.go`[^miniflux-code-redirect]
[^miniflux-code-redirect]: https://github.com/miniflux/miniflux/blob/master/ui/oauth2_redirect.go#L27
Here is the call to `Provider()`:
```go
authProvider, err := getOAuth2Manager(r.Context()).Provider(provider)
```
And `provider` is set a bit further above:
```go
provider := request.RouteStringParam(r, "provider")
```
If we look at the method signature, we see that `r` is a pointer to the `http.Request`:
```go
func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
```
The `oauth2Redirect()` method is called by the Go HTTP Router according to the following handler, found in `ui/ui.go`[^miniflux-code-router]:
[^miniflux-code-router]: https://github.com/miniflux/miniflux/blob/master/ui/ui.go#L133
```go
uiRouter.HandleFunc("/oauth2/{provider}/redirect", handler.oauth2Redirect).Name("oauth2Redirect").Methods(http.MethodGet)
```
And there we are. We have an invariant in our OIDC configuration: The second part of the path of `OAUTH2_REDIRECT_URL` has to match the value of `OAUTH2_PROVIDER` (`oidc` in this case). This was of course violated when using `/oauth2/keycloak/callback` as the callback URL's path.
With the correct values set (see above), all is well and authentication works like a charm.
## Wrap up
That's it! Now all the applications are authenticating against the central Keycloak instance. Only one password to remember (I mean, put into the password manager of course). Stuff like two-factor authencation can be managed in Keycloak (it supports TOTP via Google Authenticatior for example).
There will be a follow-up post, because I'm not yet done: What about applications that do not support OpenID Connect themselves?
Stay tuned.

View File

@@ -0,0 +1,498 @@
---
title: "Single Sign-On with Keycloak on Kubernetes — Part 2"
date: "2021-04-18T17:15:28+02:00"
summary: >
How to add Single Sign-On to applications without OIDC support
using OpenResty and some Lua scripting.
tags:
- keycloak
- openid
- oauth
- nginx
- go
- lua
- docker
- kubernetes
toc: true
---
# Introduction
In the [last blog post]({{< relref
"2020-08-30-sso-with-open-id-connect-keycloak.md" >}}), I described how to
configure Keycloak on Kubernetes to enable Single Sign-On for multiple
services. All these services had built in support for authentication and
authorization using OpenID Connect. But what if a service doesn't?
In this part, I'll describe how to use an authentication reverse proxy in front
of applications to secure them properly. A few advantages of OIDC will
of course get lost: For example, fine-grained access control on the application
level is not possible.
# The reverse proxy
I was looking around for a good authenticating reverse proxy and came across
[Pomerium](https://www.pomerium.io/). But to me, this project seemed a bit too
complex for my use case. I guess it's more apt for businesses with lots of
users.
Then I stumbled upon [lua-resty-openidc](https://github.com/zmartzone/lua-resty-openidc).
It's a Lua based plugin to [OpenResty](https://openresty.org), which is a web server
built on Nginx with a lot nice features available.
The functionality is quite straighforward: You place lua-resty-openidc in front
of your application. When a request comes in, the reverse proxy checks if the user
is already authenticated via OpenID connect. If yes, it forwards the request
to the backend application. If no, it redirects the user to the identity provider
(Keycloak in my case) for authentication. Keycloak then redirects back after
successful authentication. The backend application never sees anything of the
authentication flow.
# Building a Container
To deploy the reverse proxy in Kubernetes, I first had to package lua-resty-openidc
as a Docker container. This is actually quite straightforward:
```dockerfile
FROM openresty/openresty:1.19.3.1-centos
# https://luarocks.org/modules/bungle/lua-resty-session
# https://github.com/bungle/lua-resty-session
RUN ["/usr/local/openresty/luajit/bin/luarocks", "--global", "install", "--no-manifest", "lua-resty-session", "3.7"]
# https://luarocks.org/modules/pintsized/lua-resty-http
# https://github.com/ledgetech/lua-resty-http
RUN ["/usr/local/openresty/luajit/bin/luarocks", "--global", "install", "--no-manifest", "lua-resty-http", "0.15"]
# https://luarocks.org/modules/cdbattags/lua-resty-jwt
# https://github.com/cdbattags/lua-resty-jwt
RUN ["/usr/local/openresty/luajit/bin/luarocks", "--global", "install", "--no-manifest", "lua-resty-jwt", "0.2.2"]
# https://luarocks.org/modules/hanszandbelt/lua-resty-openidc
# https://github.com/zmartzone/lua-resty-openidc
RUN ["/usr/local/openresty/luajit/bin/luarocks", "--global", "install", "--no-manifest", "lua-resty-openidc", "1.7.3"]
RUN : \
&& curl -s -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 \
&& chmod +x /usr/local/bin/dumb-init
COPY ./start-nginx.sh /start-nginx
COPY ./nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
CMD ["/usr/local/bin/dumb-init", "--", "/start-nginx"]
EXPOSE 80
```
The module versions are only a snapshot. Make sure to use the latest ones and
to update them regularly (or automatically)!
The nginx configuration is very default-y:
```nginx {linenos=table,hl_lines=["31-32",51]}
# [...]
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
access_log logs/access.log combined;
error_log logs/error.log info;
# See Move default writable paths to a dedicated directory (#119)
# https://github.com/openresty/docker-openresty/issues/119
client_body_temp_path /var/run/openresty/nginx-client-body;
proxy_temp_path /var/run/openresty/nginx-proxy;
fastcgi_temp_path /var/run/openresty/nginx-fastcgi;
uwsgi_temp_path /var/run/openresty/nginx-uwsgi;
scgi_temp_path /var/run/openresty/nginx-scgi;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#gzip on;
# Required because of huge session cookies
large_client_header_buffers 8 64k;
client_header_buffer_size 64k;
lua_shared_dict discovery 10m;
lua_ssl_trusted_certificate /etc/pki/tls/certs/ca-bundle.crt;
lua_ssl_verify_depth 2;
# Include dynamically generated resolver config
include /etc/nginx/resolver.conf;
server {
# Include dynamically generated listener config
include /etc/nginx/listen.conf;
server_name _;
location /health {
return 204;
}
include /etc/nginx/conf.d/oidc.conf;
}
}
```
One interesting point is the `large_client_header_buffers` and `client_header_buffer_size`
setting. More on that later.
As you can see, the configuration imports `/etc/nginx/conf.d/oidc.conf`, which is
not part of the container. This file needs to be mounted into the container at runtime
to configure the access rights.
The start script is straightforward as well:
```bash
#!/usr/bin/env bash
# We need to extract the DNS server from /etc/resolv.conf and put it into the
# `resolver` directive in nginx
nameservers="$(grep ^nameserver /etc/resolv.conf | cut -d ' ' -f 2 | paste -s -d ' ')"
echo "resolver ${nameservers};" > /etc/nginx/resolver.conf
echo "listen ${LISTEN_PORT:-80};" > /etc/nginx/listen.conf
exec /usr/bin/openresty -g "daemon off;"
```
Building the container and making it available to Kubernetes is left as an exercise
to the reader ;)
# Putting it into Kubernetes
Now we can use the container in Kubernetes. The idea is simple: Add it to a pod,
expose only the port of the reverse proxy, and make the service use that port.
Here, I'm using [Firefly III](https://www.firefly-iii.org/) as an example, an
absolutely **awesome** personal finance manager I use to manage all my income
and expenses. It does not support OpenID connect by default. But it supports
"remote user" authentication[^firefly_remote_user]
[^firefly_remote_user]: https://docs.firefly-iii.org/firefly-iii/advanced-installation/authentication/#enable-the-remote-user-option
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: firefly-iii
labels:
app: firefly-iii
spec:
replicas: 1
revisionHistoryLimit: 0
strategy:
type: Recreate
selector:
matchLabels:
app: firefly-iii
template:
metadata:
labels:
app: firefly-iii
spec:
containers:
- name: firefly-iii-db
image: "docker.io/postgres:10.3"
imagePullPolicy: IfNotPresent
volumeMounts:
- mountPath: '/var/lib/postgresql/data'
name: database
env: # snip
- name: firefly-iii
image: "docker.io/jc5x/firefly-iii:version-5.4.6"
imagePullPolicy: IfNotPresent
volumeMounts:
- mountPath: '/var/www/firefly-iii/storage'
name: storage
env: # [...]
- name: AUTHENTICATION_GUARD
value: remote_user_guard
- name: AUTHENTICATION_GUARD_HEADER
# So in PHP, header gets prefixed with HTTP_ and dashes
# are replaced by underscores. In nginx, we set
# X-AUTH-USERNAME and X-AUTH-EMAIL
value: HTTP_X_AUTH_USERNAME
- name: AUTHENTICATION_GUARD_EMAIL
value: HTTP_X_AUTH_EMAIL
- name: CUSTOM_LOGOUT_URI
value: /logout
- name: oidc-proxy
image: registry.hkoerber.de/openresty-oidc:045fc92c5826c766bd087ce51ce3959bc46b93df
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
protocol: TCP
name: http-oidc-proxy
volumeMounts:
- name: nginx-oidc-config
mountPath: /etc/nginx/conf.d/oidc.conf
subPath: conf
livenessProbe:
httpGet:
port: http-oidc-proxy
scheme: HTTP
path: /health
readinessProbe:
httpGet:
port: http-oidc-proxy
scheme: HTTP
path: /health
volumes:
- name: database
persistentVolumeClaim:
claimName: firefly-iii-db
- name: storage
persistentVolumeClaim:
claimName: firefly-iii-storage
- name: nginx-oidc-config
configMap:
name: firefly-iii-nginx-oidc-config-v3
```
First, we need a configmap the contains the nginx configuration for `/etc/nginx/conf.d/oidc.conf` mentioned above:
```yaml
kind: ConfigMap
apiVersion: v1
metadata:
labels:
app: firefly-iii
name: firefly-iii-nginx-oidc-config-v3
data:
conf: |-
set $session_storage cookie;
set $session_cookie_persistent on;
set $session_cookie_secure on;
set $session_cookie_httponly on;
set $session_cookie_samesite Strict;
server_tokens off;
location = /logout_done {
return 200 'Logout done. <a href="/">Login again</a>';
add_header Content-Type text/html;
}
location / {
access_by_lua_block {
local opts = {
redirect_uri = "https://finance.hkoerber.de/oauth2/callback",
discovery = "https://{keycloak}/auth/realms/{realm}/.well-known/openid-configuration",
client_id = "firefly-iii",
client_secret = "{secret}",
token_endpoint_auth_method = "client_secret_post",
scope = "openid email profile firefly-iii",
session_contents = {id_token=true, access_token=true},
ssl_verify = "yes",
accept_unsupported_alg = false,
accept_none_alg = false,
renew_access_token_on_expiry = true,
logout_path = "/logout",
post_logout_redirect_uri = "https://finance.hkoerber.de/logout_done",
revoke_tokens_on_logout = true,
}
local oidc = require("resty.openidc")
-- call authenticate for OpenID Connect user authentication
local res, err = oidc.authenticate(opts)
if err then
ngx.log(ngx.CRIT, tostring(err))
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- get acess token
local parsed_token, token_err = oidc.jwt_verify(res.access_token, opts)
if token_err then
ngx.log(ngx.CRIT, tostring(token_err))
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- get roles from access token
local roles = (parsed_token["realm_access"] or {})["roles"] or {}
local function has_role(role_name)
for _, value in ipairs(roles) do
if value == role_name then
return true
end
end
return false
end
local allow = false
-- all the setup is done. now we can check roles
local email = parsed_token["email"]
if has_role("firefly-iii:user") and email ~= nil then
allow = true
end
if allow == true then
ngx.req.set_header("X-AUTH-USERNAME", email)
ngx.req.set_header("X-AUTH-EMAIL", email)
return
end
ngx.exit(ngx.HTTP_FORBIDDEN)
}
proxy_pass http://127.0.0.1:8080;
}
```
You can see, all the authentication magic is done in the lua block. Actually,
you're completely free to put any logic you want there. Here, I require the
user to have the `firefly-iii:user` role in keycloak. In case of authentication
success, the `X-AUTH-*` headers are set correspondingly. Firefly is configured
to trust those headers.
## The Cookie Problem
Now with 80% of the work done, I could expect to spend 80% of the time on the
remaining 20%. I was not disappointed, because a very hard to debug problem
showed up: Sometimes, the authentication would fail first with a 500 HTTP code,
and then with a 502 on reload.
I checked the logs of nginx, and it complained that it could not decode the
authentication cookie properly. It then started the authentication workflow
again, leading to an infinite loop. This turned out to be quite hard to debug,
mainly because there were so many components:
* Kubernetes ingress
* Nginx
* Keycloak
* The application itself (Firefly-III)
The first step was to reduce the number of components, or eliminate them from
the list of potential sources of errors. The application itself could be the
problem, because I saw the exact same error with other applications as well.
The next step was to strip down the setup to a minimal "proof-of-error". During
that, I noticed that the error vanished as soon as I omitted `access_token
= true` in the nginx lua configuration. I dug into the documentation of `lua-resty-openidc` and
stumpled upon [this FAQ
entry](https://github.com/zmartzone/lua-resty-openidc/wiki#why-does-my-browser-get-in-to-a-redirect-loop):
> **Why does my browser get in to a redirect loop?**
>
> It may be that you are using the (default) cookie-only session storage of lua-resty-session library that lua-resty-openidc depends on and the size of the cookie becomes too large, typically >4096 bytes. See: https://github.com/zmartzone/lua-resty-openidc/issues/32.
>
> Solution: either make the size of the session cookie smaller by having the Provider include less information in the session/claims, or revert to server side session storage such as memcache, see: https://github.com/bungle/lua-resty-session#pluggable-storage-adapters.
That's it: The session cookie gets too large! But at which point are they
dropped? I first suspected nginx to be the problem. So I set the following
configuration in the openresty nginx config, which you already saw above:
```nginx
large_client_header_buffers 8 64k;
client_header_buffer_size 64k;
```
This did not help though. As I saw later, it was one piece of the solution
puzzle. So this setting is required, but it's not enough (yet).
So, to the next suspect: Kubernetes, or more specifically, the nginx ingress. To
pinpoint the exact point, I set up SSH tunnels to different components from my
local machine and checked authentication:
* SSH tunnel to the ingress endpoint: Authentication fails (well, of course)
* SSH tunnel directly to the pod: Authentication works!
* SSH tunnel directly to the service: Authentication works, too!
So the problem was also with the header settings of the Kubernetes ingress. I
first checked the configuration. Effectively, the kubernetes nginx ingress is
just a regular nginx with a dynamically generated configuration. So I used
`kubectl` to take a look at that config:
```bash
kubectl -n ingress-nginx exec \
$(kubectl get -n ingress-nginx pod \
--field-selector=status.phase=Running \
--selector=app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller \\
-o jsonpath='{.items[*].metadata.name}') \
-- cat /etc/nginx/nginx.conf
```
I saw a few concerning defaults:
```nginx
proxy_buffer_size 4k;
proxy_buffers 4 4k;
```
Note that the settings above (`large_client_header_buffers` and
`client_header_buffer_size`) do not apply here, because nginx only acts as
a proxy. Anyway, the values of the settings above are too small for the huge
cookies that we need. So let's increase them!
Fortunately, the nginx ingress exposes these values as
annotations[^ingress_nginx_proxy_buffer_size], which can be set on
a per-ingress basis. So I updated the ingress manifest for the firefly
application:
[^ingress_nginx_proxy_buffer_size]: https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#proxy-buffer-size
```yaml {linenos=table,hl_lines=["12-14"]}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: firefly-iii
labels:
app: firefly-iii
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-production"
# required for big session cookies in oidc authentication
nginx.ingress.kubernetes.io/proxy-buffers-number: "8"
nginx.ingress.kubernetes.io/proxy-buffer-size: "64k"
```
A `kubectl apply` later, authentication worked flawlessly!
# Wrap up
I used the above solution to secure access to multiple other applications, as
well. For example a Prometheus & Alertmanager setup that I use for some
internal monitoring. Prometheus does not have any authentication story anyway,
so this setup allowed me to easily secure access. But as already mentioned,
there are a few drawbacks:
* It's either-or. A user either gets access, or they don't.
* If you use reverse proxy authentication (using headers), you again need
support from the application. At this point, I guess it's easier to add
support for OIDC than for such an antiquated authentication scheme.
Due to that, I much prefer to use applications with proper OIDC integration.
But it's still good as a stopgap measure!

View File

@@ -0,0 +1,262 @@
---
title: "On Dependency Versioning"
date: "2021-09-25T10:58:59+02:00"
summary: "You pin your dependencies, don't you?"
tags:
- development
- devops
- python
- security
---
When I'm talking about "dependencies" here, I'm talking about all the stuff
**around** your code that is **not** your code. Usually, includes stuff like
the following:
* The language. Either your compiler (for languages like Rust or Go) or your
interpreter (Python, PHP, ...).
* Third-party modules. This may include pip modules for Python (more on that
later) or crates for Rust.
* The environment the application is running in. If you're hip and using
container technologies, this includes a complete operating system. This is
mainly whatever you're basing your image off (`FROM` in your `Dockerfile`),
plus each package installed via `apt-get` or similar.
* The platform your application will run on. Maybe Kubernetes or AWS ECS, but
also EC2 would count as a platform dependency.
All these dependencies will be defined somewhere, often in many different
places. In this article, we'll talk about how we handle the *versions* of all
of these dependencies. There are several approaches, each with its
upsides and downsides. The approach you pick will influence the
reproducibility, maintainability, and stability of your deployments. So get it
right!
# Dependency pinning
If you're a Python programmer, you're most likely familiar with the concept of
`requirements.txt` files. For everyone who isn't, here's a short rundown:
When you use third-party libraries in Python, your first destination will most
likely be [https://pypi.org/](PyPI), the Python Package Index. It's a huge
collection of hundreds of thousands of Python libraries. To use one of those,
you'd use the [pip command-line tool](https://pypi.org/project/pip/), which
downloads and installs these libraries to your machine:
```bash
pip install requests
```
This would install the super helpful
[requests](https://docs.python-requests.org/en/latest/) library, which makes
HTTP requests much easier than the Python standard libraries'
[urllib](https://docs.python.org/3/library/urllib.html).
Now imagine the following scenario: You finished developing your application,
using requests as seen above. All is well. Until, a few days/weeks/months
later, you (or someone else) revisit the project. You run the `pip` command
again. But in the meantime, the requests project released a new version that
is incompatible with the version you originally coded against. Boom, something
breaks, and someone is sad and/or angry.
How could we have prevented this? By *pinning* the requests library to
a certain version. This can be done with `pip`:
```bash
pip install requests==2.26.0
```
Now, of course, remembering this version is hard, and it gets even worse when
you use more libraries. But `pip` provides a way to generate a file containing
all libraries and their dependencies: `pip freeze`. The convention is to save
this list into a file called `requirements.txt`:
```bash
pip freeze > ./requirements.txt
```
This is what the `requirements.txt` file looks like:
```
certifi==2021.5.30
charset-normalizer==2.0.6
idna==3.2
requests==2.26.0
urllib3==1.26.7
```
As you can see, apart from the `requests` library, there are also other
libraries. Those are dependencies of "`requests`" itself, so-called "transitive"
dependencies.
So now you commit this `requirements.txt` to your git repo. When you revisit
the project later, you can use `pip` to get the exact versions specified
initially:
```
pip install -r ./requirements.txt
```
And wohooo, no breakage!
This is what dependency pinning means: Specifying the exact version of all
your dependencies. Something similar to Python's `requirements.txt` exists
in almost all languages:
* In NPM, this would be the [`package-lock.json`](https://docs.npmjs.com/cli/v7/configuring-npm/package-lock-json)
* Go calls it `go.sum`
* Rust uses [`Cargo.lock`](https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html)
## Why?
Now that we know what dependency pinning is, the question is: Why? What's the benefit? Or rather: What happens if we don't pin our dependencies?
As said above, without dependency pinning, a later build might break, even
though nothing was changed in the code itself. But even worse, *when* it
breaks, there is no way to go back to a known-good state. The only hope
is that you still have the build artifact laying around (e.g. the old
Docker image).
On a more abstract note, the **time** of the build now is an
implicit **input** of your build. As soon as you've built an artifact,
there is no way to reproduce that exact artifact ever again.
This makes rollbacks impossible, so your only way is to "fail forward",
to adapt your code to the new versions or work around the issue.
If you adhere to the concepts of "everything as code" and "everything
should be tracked in version control", this is not desirable, as there
is a very important aspect of your application that is not tracked in
code or version control.
Dependency pinning solves all of those problems. If a new version of a
dependency breaks, you just go back to a known-good state in git and rebuild.
Solving update issues become a simple `git revert`. You can even go back
in time and recreate your application from one year ago (maybe
to show progress to management, or maybe just for fun).
Additionally, you gain *visibility*: It's always clear which versions of
all of your dependencies you are currently using. This helps with security
analysis and also eases development. You can be sure which version you're
coding against.
It also makes it possible to hold back updates explicitly. Let's say the
`requests` library from above releases a new major version that breaks
compatibility (which it's allowed to do when adhering to
[semver](https://semver.org/lang/de/)!. Without pinning, we'd have to update
our code to use the new API right then and there. If we pin our dependencies,
we can selectively hold back this update until we get around to updating our
code.
## But ...
... dependency pinning comes with a huge drawback: If you don't update the
pinned dependencies, no-one else will. That's kind of the idea of the whole
thing, but it also means that all versions are permanently getting more and
more out of date. It's on you to update those dependencies.
And in my experience, no-one will. As long as updating is a manual process,
it's just not going to happen. I've seen 30-lines `requirements.txt` files
that have not been updated in **years**. You can be sure that at least 50% of
those libraries have released at least one update that you should **urgently**
use, e.g. due to some security issues that have been fixed in the meantime.
So, why does no-one (including myself) update their `requirements.txt` (or all
the other places where you pin your versions)? First, it's just not "fun".
Think about it: At best, you don't immediately break something. At worst, you
break the whole build. There is the benefit of increased security and
bug fixes, but that is not immediately visible.
And that's the second reason: If you have the choice between dependency
updates and working on a new feature, you're automatically inclined to do the
latter because this will sound much better in the next standup. This is
a function of the popular approach to security updates. They do not have an
immediate benefit. The actual benefit is something **not happening**, which is
very hard to sell. And 95% of the time you get lucky, so you don't see the
benefit even in hindsight.
This is not something a single developer can do something about. It's
a cultural thing: Features sell, features are visible. "Security" is something
that just costs time and money, and "nothing will happen to us" anyway.
So what's the remedy? Well, we're all using computers after all, and computers
are very good at doing what we tell them. So let our computers update the pins
for us! I don't mean to open a regular Jira ticket so you don't forget to
update your dependencies manually. I'm talking about complete automation, with
maybe a short, super simple manual review step in the end (pull requests!).
It could be a simple script that runs regularly (via CI) and checks all your
dependencies against the latest upstream version. If there is a new version
available, it updates it in your git repository and creates a pull request.
The big issue here is confidence: How do you know that the new version is not
going to break? Well, the only sane way to handle this is with a comprehensive
test suite. And I'm not just talking about unit tests. What you need is a test
suite that you can hand your build artifact and it's going to tell you "yes"
or "no". If you're confident in that test, you can also be confident that
dependency updates are not going to break your application.
# In Practice
I actually have something like that for my personal "cloud". It's a very ugly,
very bespoke Python script that runs daily via Drone CI and checks all
dependencies against their latest upstream versions. It opens a pull request
in my Gitea instance if there are updates available. This way, I can decide
when and what to update. I don't have a testing suite as it's just for me, and
I don't really care if my Nextcloud is down for a few hours until I get around
to fixing it. But you get the idea.
If you want to take a look, here is the script:
[Link](https://code.hkoerber.de/hannes/_snippets/src/branch/master/autoupdate.py).
As you can see, it's very bespoke. It's also quite horrible code (or rather
"grown organically"). The thing is: **It does not matter**. It's still 100%
better than not doing automated updates at all. And as there is still the manual
step of merging the pull request, I can catch any errors that arise. Here is a
screenshot of a merge request that is produced by that script:
<hr>
{{< figure src=/assets/images/dependency_pinning/merge_request.png caption="An automatically generated merge request in Gitea">}}
<hr>
In a more important environment, you'd of course also have a testing step as
described above. Also, it might be possible to first deploy to a separate
staging environment, so you can be extra sure that nothing will go wrong. The
thing is, you should have all of this *anyway*. A test suite and a testing
environment are crucial for any serious application development, so you might
already have one ;)
# Examples of Dependencies
So, what should be pinned? We already talked about the obvious stuff above:
Programming libraries. But there is more:
* The docker base image, i.e. whatever follows the `FROM` in the `Dockerfile`. You're
hopefully not using the `latest` tag, but a more specific one. This tag needs to
be updated as well!
* Any packages you install. This also means that you have to pin the packages you
install via APT:
```Dockerfile
FROM docker.io/debian:bullseye-20210902
RUN apt-get update && apt-get install -y \
git=1:2.30.2-1 \
python3-django=2:2.2.24-1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
```
* Your configuration management and IaC tooling. Yes, you should track the Ansible
and Terraform versions you're using in git.
* Any software you use in your CI pipeline, e.g. the used Docker containers for
GitLab CI or Drone.
* Any external services you use. Let's say you're on AWS and using ECS Fargate.
You should track (and automatically update) the [Fargate Platform
Version](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/platform_versions.html)!
# Conclusion
Pinning your dependencies is not an easy task. Together with the automation, you'll
have to spend considerable time to get it going. But as soon as everything is
set up, there is not much left to do, as most of the work will be done by your
automation system. You can then reap the benefits that pinned versions bring:
Reproducibility, visibility, stability, and confidence in your application.

View File

@@ -0,0 +1,275 @@
---
title: "Rust vs. Go: Error Handling"
date: "2021-09-26T19:22:05+02:00"
description: ""
tags:
- development
- rust
- golang
- go
---
Recently, I've been diving into the [Go](https://golang.org/) programming
language. I have to say that I'm **very** fond of many of its aspects: The
standard library is plain awesome, especially functionality related to HTTP.
Async via goroutines and channels feels very ... well-thought-out? Static
typing feels like a breath of fresh air. I'd rather the compiler scream at me
during compilation than my Python script breaking during runtime. The
deployment story is very easy due to statically compiled binaries. The
compilation is fast enough, and the runtime speed is amazing (though this might
be unfair when comparing it to Python).
But there is one thing that I'm not too happy about. It's the following, and if
you've ever written Go, you know it all too well most likely:
```go
result, err := somefunc()
if err != nil {
// panic
}
```
This is the extend of Go's error handling: The convention that functions return
a `(value, error)` tuple, with `error` being `nil` if everything went well. So
many function signatures look like this (using [the stdlib function to open a
file](https://pkg.go.dev/os#Open) as an example:
```go
func Open(name string) (*File, error)
```
Now, there are a few drawbacks to that approach. The first one is the
**verbosity**. In any Go codebase, you'll see `err != nil` littered
everywhere. This is, honestly, not too bad. You get used to it. But there is a
bigger problem. Take a look at following snippet:
```go
configFile, err := os.CreateTemp("/tmp", "config")
errorFile, err := os.CreateTemp("/tmp", "error")
if err != nil {
return fmt.Errorf("failed to create temp file, %s", err)
}
```
Looks sane at first glance, but what happens when the first call to
`CreateTemp()` fails? Well, nothing, or at least no error handling for sure.
The `err` variable is redeclared in the next line, effectively overwriting any
error that happened. If the second call succeeds, the program will continue,
even though `configFile` is not safe to use.
So, the verbose ceremony around error handling is not the problem. But you must
never **forget** it. Whenever you get an error variable, you **must** check it.
If you don't, you got a bug!
## How can this be done better?
I've been playing around with [Rust](https://www.rust-lang.org/) on and off for
years now, never really getting around to properly diving into it. It's very
complex and quite unlike all other languages I've worked with so far. By now, I
know enough to at least understand most Rust snippets.
On thing that Rust just gets **right** in my opinion is its approach to error
handling.
### Short Rust Type System Detour!
To lay the groundwork for the dive into error handling, we first have to quickly
talk about a few Rust data types. You have to know that Rust has a very rich
type system. One of the types that you won't have in Python or Go are [Sum
Types](https://en.wikipedia.org/wiki/Tagged_union), called
[Enums](https://doc.rust-lang.org/book/ch06-00-enums.html) in Rust.
Effectively, a sum type is a type that can hold a value that is one, and only
one, of a known list of types.
For example, Rust does not have an "empty" value, e.g. `None` (Python) or `nil`
(Go). Instead, there is an
[`Option`](https://doc.rust-lang.org/std/option/enum.Option.html) Enum, which
is defined like this:
```rust
enum Option<T> {
None,
Some(T),
}
```
`T` is a generic type parameter here, so you can use `Option` with any value.
Effectively, an `Option` can either be "something" (`Some`) or "nothing"
(`None`). And here comes the big advantage over `nil` or `None`: You cannot
accidentally get a `None` when you expect a value. Whenever you have an
`Option`, the compiler forces you to handle both cases: Either you got
something, or you got nothing. The compiles **forces** you to do this, or your
program will not compile. Assume we have some function that returns an option
that can either be nothing or an integer:
```rust
let r: Option<i32> = returns_option();
```
You will not be able to use `r` like an integer:
```rust
let x = r * 2;
```
The compiler complains:
```
error[E0369]: cannot multiply `Option<i32>` by `{integer}`
--> src/main.rs:
|
| let x = r * 2;
| - ^ - {integer}
| |
| Option<i32>
```
It's because you don't have an integer, you have an **Option** that **can** be
an integer. To get the value, we have to match all possible cases:
```rust
let actual_value = match r {
None => {
panic!("Got no value!");
},
Some(i) => i,
};
```
Instead of panicking, you'd most likely have some actual error handling or
course ;) Note that the compiler enforces that you handle any value that your
Enum can have. Let's say you forget to handle the `None` case:
```rust
let actual_value = match r {
Some(i) => i,
};
```
Rust won't like that:
```
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:
|
| let actual_value = match r {
| ^ pattern `None` not covered
```
The compiler even tells you which case you did not handle.
Note that you won't have to use `match` every time. There are a lot of
convenience methods on `Option`, just take a look at [the
documentation](https://doc.rust-lang.org/std/option/enum.Option.html).
For example, our code above that called `panic!()` on a `None` value could be
simply rewritten like this:
```
let actual_value = r.unwrap()
```
`unwrap()` gives you the `Some` value, or panics if there is a `None` value.
Especially `unwrap_or_else()` is quite helpful.
### Rust Error Handling
So, how can sum types help with error handling? Easy: Rust uses the so-called
[`Result`](https://doc.rust-lang.org/std/result/) type whenever a function can
return an error or an actual value.
`Result` is defined like this:
```rust
enum Result<T, E> {
Ok(T),
Err(E),
}
```
This is quite similar to `Option`, right? `Result` can either be the expected
value (`Ok`) or a certain error (`Err`). Conceptually, this is quite close to
Go. The big benefit is that it's enforced **at the type level** that you cannot
forget to handle the error. As we saw above with `Option`, Rust won't even let
you compile your program if you don't handle the `Err` case. And like `Option`,
`Result` has all those convenience functions like `unwrap()`.
`Result` is used *everywhere* in Rust. Remember Go's `Open()` function:
```go
func Open(name string) (*File, error)
```
In Rust, the equivalent function looks [like
this](https://doc.rust-lang.org/std/fs/struct.File.html#method.open)[^1]:
[^1]: This is heavily simplified. Actually, the `Result` type in ``std::io`` is
different from the `Result` type we talked about. Its `Err` variant is not
generic, but always has the `std::io::Error` type. Also, I left out generics.
The actual signature looks like this: `fn open<P: AsRef<Path>>(path: P) ->
Result<File>`
```rust
fn open(path: Path) -> Result<File, std::io::Error>
```
## Error Bubbling
Error Bubbling refers to the practice of throwing an error up the call stack to
the caller function from the perspective of the called function. In other
words, if our function cannot handle the error, we just return it to the
function that called us and hope they know what to do.
In Go, this looks like this:
```go
func ourFunction() (int, error) {
i, err := someFunc()
if err != nil {
return 0, err
}
// do something with i
}
```
The equivalent in Rust might look like this:
```rust
fn our_function() -> Result<i32, String> {
let r = some_function();
match r {
Err(e) => return Err(e),
Ok(i) => {
// do something with i
}
}
}
```
Wow, this is even more boilerplatey than Go! But there is a nice little
operator, the question mark (`?`), that can be used in all functions that
return `Result`. It's similar to `unwrap()`, but instead of panicking on `Err`
values, it bubbles them up to the calling function. So our code could be
rewritten like this:
```rust
fn our_function() -> Result<i32, String> {
let i = some_function()?;
// do something with i
}
```
That is **much** better, don't you think?
## Conclusion
Go gets a lot of things right. But error handling is not one of them, and Rust
shows how it can be done better. Less boilerplate and compiler-time checks for
error handling.
If you want to read more about error handling in Rust, read the chapter
["Recoverable Errors with
`Result`"](https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html)
from the **awesome** Rust Book.

View File

@@ -0,0 +1,131 @@
---
title: "A Git Repository Manager — in Rust"
date: "2021-12-04T12:43:42+01:00"
description: "Managing multiple repositories in a single place &emdash; with Rust"
tags:
- rust
- git
- tools
---
I'm heavily using git both at work at for my personal projects. Over time, I
ended up with a quite substantial amount of git repositories. For now, I've
just been managing them manually. This worked well, but when setting up a new
machine, I'd have to either restore a backup or do a lot of `git clone`s
manually.
I'm also a huge proponent of $whatever-as-code, especially Terraform. I like to
just have a configuration plus a tool that takes that configuration to
configure $whatever.
So, I decided to build a tool to do that: Have a config of my git repositories,
and the tool makes sure that everything is cloned & configured correctly. And
so, the [git repository manager
(GRM)](https://github.com/hakoerber/git-repo-manager) was born.
I chose [Rust](https://www.rust-lang.org/) for this project. I already used
Rust for a few very small projects (<100 LOC), and for the backend of a
[package list application](https://github.com/hakoerber/packager). I'm really
fond of a lot of aspects (see [this blogpost about error handling]({{< relref
"./2021-09-26-error-handling-rust-vs-go.md" >}}) for example).
To be honest, the project is 50% "scratching my own itch" and 50% "I want to
learn Rust and need a project". I think it succeeded for both, as I'm
dogfooding GRM right now, both at home and at work, and I'm also starting to
get a good grasp of Rust.
In the meantime, GRM gained a few additional capabilities. For example, you can
generate a configuration from an existing tree of git repositories (would be
quite a hassle to do this manually). Also, I wrote some code to manage [`git
worktrees`](https://git-scm.com/docs/git-worktree), which makes it much easier
to juggle multiple branches at the same time.
In the following, I'll list the resources I used while learning rust and
writing GRM, and a few lessons about Rust in particular.
# Resources I used
The [rust book](https://doc.rust-lang.org/book/) is absolutely awesome. It's
like a tutorial through the rust language that will also act as a reference for
later when you look up concepts. I read it once from start to finish in the
beginning. Of course, I couldn't remember everything, but it was good to
already have heard of some concepts when I encountered them later on.
I also cross-read the [Rustomonicon](https://doc.rust-lang.org/nomicon/), which
aims to explain in detail how `unsafe` works in rust. I haven't used `unsafe`
at all for GRM, but it was still valuable to know about the concepts.
Additionally, the Rustomonicon is just an exciting read.
There is another book called ["Learn Rust With Entirely Too Many Linked
Lists"](https://rust-unofficial.github.io/too-many-lists/). It hammers home
some concepts like ownership, while giving a thorough introduction into Rust
stdlib components like Iterators, `Rc`, and `Arc`.
Last but not least, I cannot stress how **awesome** the rust reference
documentation is. When starting, you'll most likely work a lot with the
`Option` and `Result` types. Now take a look at the [documentation for
`Option`](https://doc.rust-lang.org/std/option/): It's not just a list of
available methods. No, it also gives an intro about use cases for `Option` and
when you'll encounter it, how to use it with `match`, and groups the methods
into different use cases and describes them
# What I learned about Rust
* [Serde](https://github.com/serde-rs/serde) is everywhere, and it's awesome. I
already used it for [packager](https://github.com/hakoerber/packager) for
JSON (de-)serialization in the REST API. For GRM, I used it to parse the TOML
configuration and for command line parsing with
[clap](https://github.com/clap-rs/clap). In my experience, you'll catch 90%
of logical parsing errors already at compile time, and you're forced to
handle all edge cases during runtime.
* Dependencies grow quite quickly. I'm at ~100 dependencies with 8 direct ones,
this means that >90% of dependencies are transient. I have to say that it
kind of reminds me of the disastrous situation with NPM, albeit not quite as
bad. I wrote a [little python
script](https://github.com/hakoerber/git-repo-manager/blob/4eb88260c8a28f3e2f01ef1fd943d69e2c336f89/depcheck/update-cargo-dependencies.py)
that whether there are new versions available for the direct dependencies,
but this is still not 100% (for example, it currently does not include
transitive dependencies, nor do I pin those anyway). In any case, I also have
to emphasize that almost all crates I encountered are of **very** high
quality and actually provide some real value.
* While the concepts behind error handling are super cool and quite easy to
grasp, applying them in a "real" project needs some experience. I'm not good
at that yet, as I'm mainly just returning error messages directly via
`Result<T, String>`. It works for now, but I'm 100% sure that there are much
better ways. Using the
[`std::error::Error`](https://doc.rust-lang.org/std/error/trait.Error.html)
trait would already be a big upgrade, including the possibility to nest or
wrap errors.
* The crate/module structure just *makes sense*. Every programming language
does this differently. I have to say that Rust's way is the most sensible to
me for now. Maybe my opinion changes when I work with multiple crates via
cargo workspaces, we'll see.
* Productivity was much higher than I expected. I was quite afraid of the
borrow checker at first, but it turned out to not be a problem. The few times
it complained, it turned out that the code was actually written weirdly and
could be improved anyway. Of course, GRM is not making much use of advanced
Rust features, so maybe I'll have more issues in the future.
* Similar to the previous point, Rust was surprisingly well suited for a "high
level" (i.e. abstracted from the hardware) project like GRM, even though it's
mostly popular in the "low level" space (i.e. hardware programming, "close to
the metal") for now (but I feel like this is already rapidly changing). It
was quite easy to build high-level abstractions.
* The Rust compiler is extremely helpful. The error messages are very precise,
and there is often a recommendation how to fix the problem. In 95% of the cases,
this is exactly the way to fix the problem.
* The tooling around cargo is quite intuitive and comprehensive. With `cargo
fmt` and `cargo clippy`, formatting and linting is taken care of. All kinds of
builds and releases are also handled via `cargo`. In short, I'm quite a fan.
---
That's it! In short, Rust is awesome. If you want, check out
[GRM](https://github.com/hakoerber/git-repo-manager) ([Link to the
documentation](https://hakoerber.github.io/git-repo-manager/)).

8
content/blog/_index.md Normal file
View File

@@ -0,0 +1,8 @@
---
title: Blog
infos:
- name: date
type: date
- name: title
defaultlink: true
---

View File

@@ -0,0 +1,5 @@
---
title: All posts
url: /blog/list
layout: postlist
---

View File

@@ -0,0 +1,12 @@
---
title: Global Game Jam 2018
date: 2018-01-26
dateto: 2018-01-28
location: Tradebyte, Ansbach
---
https://globalgamejam.org/2018/jam-sites/ggj-ansbach
Our game:
https://globalgamejam.org/2018/games/lost-son

View File

@@ -0,0 +1,12 @@
---
title: Global Game Jam 2019
date: 2019-01-25
dateto: 2019-01-27
location: Tradebyte, Ansbach
---
https://globalgamejam.org/2019/jam-sites/ggjansbach
Our game:
https://globalgamejam.org/2019/games/claim-your-world-introvert

10
content/events/_index.md Normal file
View File

@@ -0,0 +1,10 @@
---
infos:
- name: date
type: date
- name: event
key: title
defaultlink: true
- name: location
title: Events
---

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #16"
date: 2017-11-14
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Workout for your TDD

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #17"
date: 2017-12-12
location: Tradebyte, Ansbach
titlecase: false
---
Topic: No password no cry

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #18"
date: 2018-01-09
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Global Game Jam - Warmup

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #19"
date: 2018-01-01
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Twisted Game of Life

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #21"
date: 2018-04-10
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Serverless durch die Nacht

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #22"
date: 2018-05-08
location: Tradebyte, Ansbach
titlecase: false
---
Here I gave a [talk about Kubernetes]({{< ref "/talks/kubernetes-deep-dive.md" >}}) together with Vladislav Kuzntsov

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #24"
date: 2018-07-10
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Kafka how deep does the rabbit hole go?

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #26"
date: 2018-09-11
location: Tradebyte, Ansbach
titlecase: false
---
Topic: DDD Strategic Design - Predict the future by designing it

View File

@@ -0,0 +1,10 @@
---
title: "/dev/night #27"
date: 2018-10-16
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Quintet - WebWeek Special
I gave a [talk about unifying application and infrastructure monitoring with Prometheus]({{< ref "/talks/prometheus-unifying-infrastructure-application-monitoring.md" >}})

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #28"
date: 2018-10-16
location: Hetzner, Gunzenhausen
titlecase: false
---
Topic: Control the real world with MicroPython

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #29"
date: 2018-12-11
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Stairway to Service Mesh

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #30"
date: 2019-01-22
location: Tradebyte, Ansbach
titlecase: false
---
Topic: Next Level Jam

View File

@@ -0,0 +1,8 @@
---
title: "/dev/night #22"
date: 2019-03-12
location: Tradebyte, Ansbach
titlecase: false
---
Topic: #WueWW special

View File

@@ -0,0 +1,10 @@
---
title: DevOps Meetup
date: 2018-07-03
tags:
- devops
location: Paessler, Nürnberg
links:
title:
enable: false
---

View File

@@ -0,0 +1,5 @@
---
title: it-sa 2018
date: 2017-10-10
location: Nuremberg
---

View File

@@ -0,0 +1,11 @@
---
title: Kubernetes Meetup Nürnberg
date: 2019-02-13
tags:
- devops
location: Coworking, Nuremberg
links:
title:
enable: true
target: https://www.meetup.com/Kubernetes-Nurnberg/events/258266045/
---

View File

@@ -0,0 +1,8 @@
---
title: OSDC 2018
date: 2018-06-12
dateto: 2018-06-13
location: MOA Berlin
---

291
content/projects/_index.md Normal file
View File

@@ -0,0 +1,291 @@
---
projects:
- name: git-repo-manager
description:
- |
A command-line tool to manage local git repositories
icon:
path: /assets/logos/git.svg
alt: Git
tags:
- type: language
value: rust
- type: tech
value: libgit2
- type: tech
value: toml
links:
github: https://github.com/hakoerber/git-repo-manager
- name: prometheus-restic-backblaze
description:
- |
A prometheus exporter that reports restic backup ages for Backblaze
image:
path: /assets/logos/backblaze.svg
type: picture-padded
alt: Backblaze
tags:
- type: language
value: python
- type: tech
value: prometheus
- type: tech
value: restic
links:
github: https://github.com/hakoerber/prometheus-restic-backblaze
projectpage: x
- name: virt-bootstrap
description:
- |
A script that bootstraps a new libvirt VM using cobbler
tags:
- type: language
value: python
- type: tech
value: libvirt
- type: tech
value: cobbler
links:
github: https://github.com/hakoerber/virt-bootstrap
- name: aws-glacier-backup
description:
- |
A bash script that uploads gzip'ed, gpg encrypted backups to AWS glacier
icon:
path: /assets/logos/aws-s3.svg
alt: AWS S3
tags:
- type: language
value: bash
- type: tech
value: AWS S3
- type: tech
value: GPG
links:
github: https://github.com/hakoerber/aws-glacier-backup
- name: guitar-practice
description:
- |
A simple python script that gives me a series of guitar chords to practice
chord transitions, with customizable rate of change
image:
path: /assets/images/guitar-closeup.jpg
alt: A Guitar
tags:
- type: language
value: python
links:
github: https://github.com/hakoerber/guitar-practice
- name: checkconn
description:
- |
Utiliy that continuously monitors the internet connection and reports downtimes
tags:
- type: language
value: bash
links:
github: https://github.com/hakoerber/checkconn
- name: packager
description:
- |
A learning project that can be used to manage packing lists for trips, considering
duration, weather and other factors.
- |
I mainly wrote this to play around with Flask and Elm
tags:
- type: language
value: python
- type: language
value: elm
- type: language
value: javascript
- type: tech
value: flask
- type: tech
value: SQLite
links:
github: https://github.com/hakoerber/packager
- name: salt-nginx-letsencrypt
description:
- |
A SaltStack nginx formula that also enables automated letsencrypt certificate management
icon:
path: /assets/logos/letsencrypt.svg
alt: Let's Encrypt
tags:
- type: language
value: python
- type: tech
value: SaltStack
- type: tech
value: LetsEncrypt
- type: tech
value: nginx
links:
github: https://github.com/hakoerber/salt-nginx-letsencrypt
- name: ansible-roles
description:
- |
A collection of ansible roles, e.g. for libvirt, networking, OpenVPN
icon:
path: /assets/logos/ansible.svg
alt: Ansible
tags:
- type: language
value: yaml
- type: tech
value: ansible
links:
github: https://github.com/hakoerber/ansible-roles
- name: salt-states
description:
- |
A big collection of saltstack states that I used for my homelab.
- |
It contains configuration for a bunch of different services, e.g. elasticsearch,
dovecot, grafana, influxdb, jenkins, kibana, nginx, owncloud, postgresql, ssh and
a lot of others.
tags:
- type: language
value: YAML
- type: language
value: jinja2
- type: tech
value: saltstack
links:
github: https://github.com/hakoerber/salt-states
- name: wifiqr
description:
- |
A script that generates QR codes for easy WiFi access
image:
path: /assets/images/qrcode-example.png
alt: An example QR code
tags:
- type: language
value: bash
links:
github: https://github.com/hakoerber/wifiqr
- name: syncrepo
description:
- |
A python script to create and maintain a local YUM/DNF package repository
for CentOS. Can be used to keep a mirror up to date with `cron(8)`.
tags:
- type: language
value: python
- type: tech
value: DNF
links:
github: https://github.com/hakoerber/syncrepo
contributions:
- name: Prometheus Node Exporter
changes:
- Add label to NFS metrics containing the NFS protocol (`tcp/udp`)
icon:
path: /assets/logos/prometheus.svg
alt: Prometheus
commits:
- https://github.com/prometheus/node_exporter/commit/14a4f0028e02ba1c21d6833482bd8f7529035b07
tags:
- type: language
value: go
- type: tech
value: prometheus
- type: tech
value: NFS
links:
github: https://github.com/prometheus/node_exporter
- name: Kubespray
changes:
- Fix issues with continuous regeneration of etcd TLS cerificates
- Fix incorrect directory mode for etcd TLS certificates
icon:
path: /assets/logos/kubernetes.svg
alt: Kubernetes
commits:
- TODO
tags:
- type: language
value: go
- type: tech
value: kubernetes
- type: tech
value: ansible
links:
github: https://github.com/kubernetes-sigs/kubespray/
- name: SaltStack
changes:
- Expand the `firewalld` module for interfaces, sources, services and zones
- Fix the reactor engine not being loaded when not explicitly configured
icon:
path: /assets/logos/saltstack.svg
alt: SaltStack
commits:
- https://github.com/saltstack/salt/commit/83aacc3b32be384eb22c514713cf35238dcb98bf
- https://github.com/saltstack/salt/commit/5ad305cedfeda516d900f04ded95c168e6cd1ebb
- https://github.com/saltstack/salt/commit/b8a889497ae557e6e8cc1a0101dc40572c618a5f
- https://github.com/saltstack/salt/commit/f27ac3c1801a6d515a34c9dedabb95488df0e9a7
- https://github.com/saltstack/salt/commit/317b7002bbb248bb5a46c173a1a5d13dfc271b6d
- https://github.com/saltstack/salt/commit/5c1b8fc24611afd8557bcc3b35d5e2523c069408
- https://github.com/saltstack/salt/commit/59d8a3a5a102540384a0561f0ff828dc5eb8cd69
- https://github.com/saltstack/salt/commit/e8347282cd129c6b3b2ba1c6d8292d101fd69d1e
- https://github.com/saltstack/salt/commit/bd49029fe0b312f169443e6086de3b7bbcd1bde7
- https://github.com/saltstack/salt/commit/749b4bc924b3ecdbecd48d70795bdb1a2391f3d3
- https://github.com/saltstack/salt/commit/81961136d5e8c2ccb06af1220a7503cc66255998
tags:
- type: language
value: python
- type: tech
value: saltstack
- type: tech
value: Firewalld
links:
github: https://github.com/saltstack/salt
- name: Vagrant
changes:
- Renew DHCP lease on hostname change for Debian guests
- Fix hostname entry in `/etc/hosts` for Debian guests
icon:
path: /assets/logos/vagrant.svg
alt: Vagrant
commits:
- https://github.com/hashicorp/vagrant/commit/3082ea502e2d7ad314d78cb0af5d71cc36bc42bc
- https://github.com/hashicorp/vagrant/commit/3fa3e995a97d8a2d9705a5b483338009315bfeb0
tags:
- type: language
value: ruby
- type: tech
value: vagrant
links:
github: https://github.com/hashicorp/vagrant
- name: Prometheus procfs
changes:
- Add exporting of a new field containing the NFS protocol (required for the node exporter change)
- Fix parsing of the `xprt` lines in `mountstats` to enable metric exports for UDP mounts
commits:
- https://github.com/prometheus/procfs/commit/ae68e2d4c00fed4943b5f6698d504a5fe083da8a
tags:
- type: language
value: go
- type: tech
value: prometheus
- type: tech
value: NFS
links:
github: https://github.com/prometheus/procfs
- name: The Lost Son
changes:
- Our contribution to the Global Game Jam 2018!
image:
path: /assets/images/lostson.jpg
alt: The game "Lost Son"
tags:
- type: language
value: javascript
- type: tech
value: phaser
links:
github: https://github.com/niklas-heer/the-lost-son
---

1264
content/skills/_index.html Normal file

File diff suppressed because it is too large Load Diff

10
content/talks/_index.md Normal file
View File

@@ -0,0 +1,10 @@
---
infos:
- name: date
type: date
- name: event
key: title
defaultlink: true
- name: location
title: Talks
---

View File

@@ -0,0 +1,27 @@
---
title: "Kubernetes Deep Dive"
date: 2018-05-08
tags:
- kubernetes
- container
location: Tradebyte, Ansbach
links:
title:
enable: false
---
# About
Coming Soon
# Recording
<!-- {{< youtube id="w7Ft2ymGmfc" autoplay="false" >}} -->
# Slides
<!--
{/*
* [PDF]({{< staticRef "assets/presentations/prometheus/slides.pdf" >}})
* [HTML]({{< staticRef "assets/presentations/prometheus/slides.html" >}})
*/}
-->

View File

@@ -0,0 +1,27 @@
---
title: "Prometheus: Unifying Infrastructure and Application Monitoring"
date: 2018-11-01
tags:
- prometheus
- monitoring
location: Tradebyte, Ansbach
links:
title:
enable: false
---
# About
Coming Soon
# Recording
<!-- {{< youtube id="w7Ft2ymGmfc" autoplay="false" >}} -->
# Slides
<!--
{/*
* [PDF]({{< staticRef "assets/presentations/prometheus/slides.pdf" >}})
* [HTML]({{< staticRef "assets/presentations/prometheus/slides.html" >}})
*/}
-->

13
content/work/index.md Normal file
View File

@@ -0,0 +1,13 @@
---
title: "What I am doing for work"
description: ""
---
## Tradebyte GmbH
<div class="image is-128x128 is-pulled-right ml-5 mb-5">
<img class="is-rounded" src="/assets/images/tradebyte_logo.png" alt="The Tradebyte Logo">
</div>
I am currently working at the [Tradebyte GmbH](https://www.tradebyte.com/) which is part of Zalando SE. At Tradebyte, I am part of the System Operations team, making sure all our user-facing applications are stable, secure and scalable.
<br style="clear:both"/>

8
layouts/404.html Normal file
View File

@@ -0,0 +1,8 @@
{{ define "content" }}
<article>
<h1 class="title">
404 &mdash; Not Found
</h1>
<h1 class="title"><a href="{{ "/" | relURL }}">Go Home</a></h1>
</article>
{{ end }}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="{{ $.Site.LanguageCode | default "en" }}">
{{ partial "html-head.html" . }}
<body class="has-navbar-fixed-top">
<div id="footer-fix-body-wrapper">
{{ block "header" . }}{{ partial "header.html" .}}{{ end }}
<main>
<div class="columns is-gapless is-centered is-mobile">
<div class="column is-12 is-11-widescreen is-10-fullhd" style="max-width:80em;">
<div class="content">
{{ block "content" . }}{{ end }}
</div>
</div>
</div>
</main>
</div>
{{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }}
</body>
</html>

View File

@@ -0,0 +1,12 @@
{{ define "content" }}
<div class="section">
{{ if isset .Params "title" }}
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal">{{- title .Title -}}</h1>
{{ end }}
{{- .Content -}}
<div class="mt-6">
{{- partial "pagelist-default.html" . -}}
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "content" }}
<div class="section">
{{ if isset .Params "title" }}
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal">{{- title .Title -}}</h1>
<hr>
{{ end }}
{{- .Content -}}
</div>
{{ end }}

View File

@@ -0,0 +1,22 @@
{{ printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\" ?>" | safeHTML }}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
{{ range .Data.Pages }}{{ if ne .Params.sitemapExclude true }}
<url>
<loc>{{ .Permalink }}</loc>{{ if not .Lastmod.IsZero }}
<lastmod>{{ safeHTML ( .Lastmod.Format "2006-01-02T15:04:05-07:00" ) }}</lastmod>{{ end }}{{ with .Sitemap.ChangeFreq }}
<changefreq>{{ . }}</changefreq>{{ end }}{{ if ge .Sitemap.Priority 0.0 }}
<priority>{{ .Sitemap.Priority }}</priority>{{ end }}{{ if .IsTranslated }}{{ range .Translations }}
<xhtml:link
rel="alternate"
hreflang="{{ .Lang }}"
href="{{ .Permalink }}"
/>{{ end }}
<xhtml:link
rel="alternate"
hreflang="{{ .Lang }}"
href="{{ .Permalink }}"
/>{{ end }}
</url>
{{ end }}{{ end }}
</urlset>

45
layouts/blog/list.html Normal file
View File

@@ -0,0 +1,45 @@
{{ define "content" }}
<div class="section">
<div class="columns is-mobile is-centered">
<div class="column is-8-desktop" style="max-width:45em;">
{{ range (.Paginate (where .Site.RegularPages "Type" "blog") 5).Pages }}
<div class="box">
<div class="content">
<h3><a href="{{ .RelPermalink }}">{{ .Title }}</a></h3>
</div>
<div class="content">
{{ if isset .Params "summary" }}
{{ .Params.summary }}
{{ else }}
{{ .Summary }}
{{ end }}
</div>
<hr>
<div class="columns is-mobile">
<div class="column is-narrow">
<time>{{ .Date.Format "2006-01-02" }}</time>
</div>
<div class="column is-clearfix">
<div class="tags is-pulled-right">
{{ range .Params.tags }}
<span class="tag is-medium"><a href="{{ "/tags/" }}{{ . }}"> {{ . }} </a></span>
{{ end }}
</div>
</div>
</div>
</div>
{{ end }}
<hr>
{{ partial "pagination.html" . }}
<div class="content is-pulled-right">
<a href="/blog/list" class="is-size-7">See list of all posts</a>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,32 @@
{{ define "content" }}
<div class="section">
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal">{{- title .Title -}}</h1>
<div class="content mt-6">
<table class="table is-striped is-hoverable is-bordered">
<thead>
<tr>
{{ range slice "Title" "Date" }}
<th>{{ . }}</th>
{{ end }}
</tr>
</thead>
<tbody>
{{ range ((where .Site.RegularPages "Type" "blog") | complement (slice .)) }}
<tr>
<td>
<a href="{{ .Params.Permalink }}">
{{ .Title }}
</a>
</td>
<td>
<time datetime="{{- dateFormat "2006-01-02" .Date -}}">
{{- dateFormat "2006-01-02" .Date -}}
</time>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}

47
layouts/blog/single.html Normal file
View File

@@ -0,0 +1,47 @@
{{ define "content" }}
<div class="columns is-gapless is-mobile is-centered">
<div class="column is-12 is-8-desktop is-offset-2-desktop" style="max-width:80ch;">
<article class="section">
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal">
{{ if (default true (.Params.titlecase)) }}
{{- title .Title -}}
{{ else }}
{{- .Title -}}
{{ end }}
</h1>
<div class="content has-text-centered is-size-6 mt-4">
<time datetime="{{- .Date.Format "2006-01-02" -}}">
{{- .Date.Format "2006-01-02" -}}
</time>
{{ if not (eq .Lastmod .Date) }}
<time datetime="{{- .Date.Format "2006-01-02" -}}">
(last update:&nbsp{{- .Lastmod.Format "2006-01-02" -}})
</time>
{{ end }}
</div>
<hr class="my-6">
<div class="content is-normal">
{{- .Content -}}
</div>
<div class="columns section">
<div class="column">
{{ if ne .Params.tags nil }}
<div class="content">
<h3>Tags</h3>
<div class="tags">
{{ range .Params.tags }}
<span class="tag has-background-grey-lighter is-medium"><a href="{{ ($.Site.GetPage (printf "/%s" .)).Permalink }}">{{ . }}</a></span>
{{ end }}
</div>
</div>
{{ end }}
</div>
<div class="column">
{{- partial "related.html" . -}}
</div>
</div>
</article>
</div>
</div>
{{ end }}

11
layouts/index.html Normal file
View File

@@ -0,0 +1,11 @@
{{ define "content" }}
<div class="columns is-gapless is-centered is-mobile">
<div class="column" style="max-width:60em;">
<div class="section content">
<article class="is-normal has-text-justified">
{{- .Content -}}
</article>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,69 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Check if there are any navbar burgers
if ($navbarBurgers.length > 0) {
// Add a click event on each of them
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
// Get the target from the "data-target" attribute
const target = el.dataset.target;
const $target = document.getElementById(target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
}
});
</script>
<header>
<nav class="navbar is-fixed-top" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item has-text-weight-normal is-size-4 is-smallcaps" href="{{ .Site.BaseURL }}">
{{ .Site.Title|safeHTML }}
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navMenu" class="navbar-menu">
<div class="navbar-end">
{{ range .Site.Menus.main }}
{{ if .HasChildren }}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link is-smallcaps">
{{ .Name }}
</a>
<div class="navbar-dropdown is-right">
{{ $len := len .Children }}
{{ range $i, $child := .Children }}
<a href="{{ $child.URL }}" title="{{ $child.Name }}" class="navbar-item is-smallcaps">
{{ $child.Name }}
</a>
{{ if not (eq (add $len -1) $i) }}
<hr class="navbar-divider">
{{ end }}
{{ end }}
</div>
</div>
{{ else }}
<a href="{{ .URL }}" title="{{ .Name }}" class="mr-3 navbar-item is-smallcaps">
{{ .Name }}
</a>
{{ end }}
{{ end }}
</div>
</div>
</nav>
</header>

View File

@@ -0,0 +1,28 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.2/css/bulma.min.css">
<meta name="author" content="{{ $.Site.Params.author }}">
<meta name="description" content="{{ $.Site.Params.description }}">
{{ if .Page.Title }}
<title>{{ .Page.Title }} &ndash; {{ .Site.Title | safeHTML }}</title>
{{ else }}
<title>{{ .Site.Title | safeHTML }}</title>
{{ end }}
{{ hugo.Generator }}
{{ if .Site.Params.allow_robots }}
<meta name="robots" content="all">
{{ else }}
<meta name="robots" content="noindex, nofollow">
{{ end }}
<link rel="stylesheet" href="/css/main.css" >
<link rel="stylesheet" href="/css/syntax.css" >
<link rel="stylesheet" href="/fonts/fontawesome/css/all.css">
<link rel="shortcut icon" href="/favicon.svg" type="image/x-icon" />
</head>

View File

@@ -0,0 +1,103 @@
<div class="content">
<table class="table is-striped is-hoverable is-bordered">
{{ if isset .Params "infos" }}
{{ if gt (len .Params.infos) 1 }}
<thead>
<tr>
{{ range .Params.infos }}
<th>{{ title .name }}</th>
{{ end }}
</tr>
</thead>
{{ end }}
{{ end }}
<tbody>
{{ range $page := (.Paginator 15).Pages }}
<tr>
{{ range $info := $.Params.infos }}
{{ $key := "" }}
{{ if eq (index $info "key") nil }}
{{ $key = $info.name }}
{{ else }}
{{ $key = $info.key }}
{{ end }}
{{ $value := (index $page.Params $key) }}
{{ $type := "" }}
{{ if ne (index $info "type") nil }}
{{ $type = $info.type }}
{{ end }}
{{ $link := false }}
{{ $linktarget := "" }}
{{/* get default link */}}
{{ if ne (index $info "defaultlink") nil }}
{{ $link = $info.defaultlink }}
{{ end }}
{{/* look for overwrites */}}
{{ if ne (index $page.Params "links") nil }}
{{ if ne (index $page.Params.links $key) nil }}
{{ $link = default $link (index (index $page.Params.links $key) "enable") }}
{{ end }}
{{ end }}
{{/* get the link target */}}
{{ if ne (index $page.Params "links") nil }}
{{ if ne (index $page.Params.links $key) nil }}
{{ $linktarget = default "" (index (index $page.Params.links $key) "target") }}
{{ end }}
{{ end }}
{{/* fall back to default target if none given */}}
{{ if and ($link) (eq $linktarget "") }}
{{ $linktarget = $page.Permalink }}
{{ end }}
{{ $externallink := "" }}
{{ if ne (index $page.Params "externallink") nil }}
{{ $externallink = (index $page.Params "externallink") }}
{{ else if ne (index $info "externallink") nil }}
{{ $externallink = (index $info "externallink") }}
{{ end }}
{{ $datespan := false }}
{{ $dateto := "" }}
{{ if (eq $type "date") }}
{{ if ne (index $page.Params "dateto") nil }}
{{ $datespan = true }}
{{ $dateto = (index $page.Params "dateto") }}
{{ end }}
<td>
<time class="list-time" datetime="{{- dateFormat "2006-01-02" $value -}}">
{{- dateFormat "2006-01-02" $value -}}
</time>
{{- if $datespan -}}
<br>
<time class="dateto" datetime="{{ dateFormat "2006-01-02" $dateto -}}">
&ndash;
{{ dateFormat "2006-01-02" $dateto -}}
</time>
{{ end }}
</td>
{{ else }}
<td>
{{ if (eq $link true) }}
<a href="{{ $linktarget }}">
{{ $value }}
</a>
{{ else }}
{{ $value }}
{{ end }}
</td>
{{ end }}
{{ end }}
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ partial "pagination.html" . }}

View File

@@ -0,0 +1,74 @@
{{- $pag := $.Paginator -}}
{{- if gt $pag.TotalPages 1 -}}
<nav class="pagination is-centered" aria-label="pagination">
<a
class="pagination-previous{{ if not $pag.HasPrev }} is-invisible {{- end }}"
{{ if $pag.HasPrev -}} href="{{ $pag.Prev.URL }}" {{- end }}
aria-label="Previous page">
<span class="is-hidden-touch">&larr;&nbsp;</span> Previous
</a>
<a
class="pagination-next{{ if not $pag.HasNext }} is-invisible {{- end }}"
{{ if $pag.HasNext -}} href="{{ $pag.Next.URL }}" {{- end }}
aria-label="Next page">
Next<span class="is-hidden-touch">&nbsp;&rarr;</span>
</a>
<ul class="pagination-list">
{{- with $pag.First -}}
<li>
<a
href="{{- .URL -}}"
class="pagination-link{{ if (eq $pag.PageNumber 1) }} is-current {{- end }}"
aria-label="First page">
1
</a>
</li>
{{- end -}}
{{- $ellipse_already_printed_high := false -}}
{{- $ellipse_already_printed_low := false -}}
{{/* all pages but the first and the last */}}
{{- range $pag.Pagers | first (sub (len $pag.Pagers) 1) | last (sub (len $pag.Pagers) 2) -}}
{{- if gt (sub .PageNumber $pag.PageNumber) 1 -}}
{{- if not (eq $ellipse_already_printed_high true) -}}
<li><span class="pagination-ellipsis">&hellip;</span></li>
{{- $ellipse_already_printed_high = true -}}
{{- end -}}
{{- else if gt (sub $pag.PageNumber .PageNumber) 1 -}}
{{- if not (eq $ellipse_already_printed_low true) -}}
<li><span class="pagination-ellipsis">&hellip;</span></li>
{{- $ellipse_already_printed_low = true -}}
{{- end -}}
{{- else -}}
{{- $is_current := eq .PageNumber $pag.PageNumber -}}
<li>
<a
href="{{- .URL -}}"
class="pagination-link{{ if $is_current }} is-current {{- end }}"
aria-label="Page number {{ .PageNumber }}"
{{ if $is_current }}aria-current="page"{{ end }}>
{{- .PageNumber -}}
</a>
</li>
{{- end -}}
{{- end -}}
{{- with $pag.Last -}}
<li>
<a
href="{{- .URL -}}"
class="pagination-link{{ if (eq $pag.PageNumber .PageNumber) }} is-current {{- end }}"
aria-label="Last page">
{{- .PageNumber -}}
</a>
</li>
{{- end -}}
</ul>
</nav>
{{- end -}}

View File

@@ -0,0 +1,18 @@
{{ $related := .Site.RegularPages.Related . | first 3 }}
{{ if gt (len $related) 0 }}
{{ with $related }}
<div class="content">
<h3>See also</h3>
<ul>
{{ range . }}
<li>
<a href="{{ .RelPermalink }}">
{{- .Title -}}
</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,19 @@
<footer class="section has-background-white">
<div class="level">
<div class="level-left">
<div class="level-item">
{{ partial "social-follow.html" . }}
</div>
<a class="level-item px-5" href="https://www.credly.com/badges/870a6345-ed4e-416e-9c46-c9af9c6d2c77/public_url" title="AWS Certified Solutions Architect Associate">
<figure class="image is-48x48">
<img src="/assets/badges/aws-certified-solutions-architect-associate.png">
</figure>
</a>
</div>
<div class="level-right">
<div class="has-text-centered level-item">
<a class="has-text-black" href="https://code.hkoerber.de/hannes/blog"><span class="far fa-copyright"></span>&nbsp;{{ now.Format "2006" }}&nbsp;{{ .Site.Params.author }}</a>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,9 @@
<div class="buttons">
{{ range $social := .Site.Params.social }}
<a href="{{ $social.link }}" class="button" title="{{ $social.description | default (printf "Me on %s" $social.name|title) }}">
<span class="icon is-medium">
<i class="{{ $social.style }} {{ $social.icon }} fa-lg"></i>
</span>
</a>
{{ end }}
</div>

222
layouts/projects/list.html Normal file
View File

@@ -0,0 +1,222 @@
{{ define "content" }}
{{ $column_count := 2 }}
{{ $project_count := len .Params.projects }}
{{ $contribution_count := len .Params.contributions }}
<div class="section">
<div class="columns is-variable is-6">
<div class="column">
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal mb-3">Projects</h1>
<hr>
<div class="tile is-ancestor mt-3">
<div class="tile is-vertical">
{{ range $i := (seq 0 $column_count $project_count) }}
<div class="tile is-block-touch">
{{ $projects_in_row := first $column_count (after $i $.Params.projects) }}
{{ range $projects_in_row }}
<div class="tile is-parent">
<div class="tile is-child card has-background-success-light" style="display: flex;flex-direction: column;">
<div class="card-header">
<div class="card-header-title is-centered">
{{ .name }}
</div>
</div>
{{ with .image }}
{{ $type := .type|default "picture" }}
{{ if (eq $type "picture") }}
<div class="card-image">
<figure class="image">
<img src="{{ .path }}" alt="{{ .alt }}">
</figure>
</div>
{{ else if (eq $type "picture-padded") }}
<div class="card-image">
<figure class="image py-4 px-4">
<img src="{{ .path }}" alt="{{ .alt }}">
</figure>
</div>
{{ end }}
{{ end }}
<div class="card-content" style="display: flex; flex-direction: column; flex-grow: 1;">
{{ with .icon }}
<div class="level">
<div class="level-item">
<figure class="image is-96x96">
<img src="{{ .path }}" alt="{{ .alt }}">
</figure>
</div>
</div>
{{ end }}
<div class="content">
{{ range .description }}
<p>
{{ .|markdownify}}
</p>
{{ end }}
</div>
<div class="block" style="margin-top: auto;">
<div class="field is-grouped is-grouped-multiline">
{{ range .tags }}
<div class="control">
<div class="tags has-addons">
{{ $color := "" }}
{{ if eq .type "language" }}
{{ $color = "info" }}
{{ else if eq .type "tech" }}
{{ $color = "success" }}
{{ else }}
{{ errorf "Unknown tag type \"%s\"" .type }}
{{ end }}
<span class="tag is-dark">{{ .type }}</span>
<span class="tag is-{{ $color }}">{{ .value|title }}</span>
</div>
</div>
{{ end }}
</div>
</div>
</div>
<footer class="card-footer" style="margin-top: auto;">
<p class="card-footer-item" style="margin-bottom: 0;">
<span>
<span class="icon">
<i class="fab fa-github"></i>
</span>
View on <a href="{{ .links.github }}">GitHub</a>
</span>
</p>
{{ if isset .links "projectpage" }}
<p class="card-footer-item">
<span>
<span class="icon">
<i class="fas fa-info-circle"></i>
</span>
See <a href="{{ .links.github }}">Project Page</a>
</span>
</p>
{{ end }}
</footer>
</div>
</div>
{{ end }}
{{/* Pad the last row with empty space */}}
{{ if (lt (len $projects_in_row) $column_count) }}
{{ range (seq 1 (sub $column_count (len $projects_in_row))) }}
<div class="tile is-parent">
<div class="tile is-child">
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
<div class="column">
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal mb-3">Contributions</h1>
<hr>
<div class="tile is-ancestor mt-3">
<div class="tile is-vertical">
{{ range $i := (seq 0 $column_count $contribution_count) }}
<div class="tile is-block-touch">
{{ $contributions_in_row := first $column_count (after $i $.Params.contributions) }}
{{ range $contributions_in_row }}
<div class="tile is-parent">
<div class="tile is-child has-background-info-light card" style="display: flex;flex-direction: column;">
<div class="card-header">
<div class="card-header-title is-centered">
{{ .name }}
</div>
</div>
{{ with .image }}
{{ $type := .type|default "picture" }}
{{ if (eq $type "picture") }}
<div class="card-image">
<figure class="image">
<img src="{{ .path }}" alt="{{ .alt }}">
</figure>
</div>
{{ end }}
{{ end }}
<div class="card-content" style="display: flex; flex-direction: column; flex-grow: 1;">
{{ with .icon }}
<div class="level">
<div class="level-item">
<figure class="image is-96x96">
<img src="{{ .path }}" alt="{{ .alt }}">
</figure>
</div>
</div>
{{ end }}
<div class="content">
{{ if eq (len .changes) 1 }}
<p>
{{ markdownify (index .changes 0) }}
</p>
{{ else }}
<ul>
{{ range .changes }}
<li>
{{ markdownify . }}
</li>
{{ end }}
</ul>
{{ end }}
</div>
<div class="block" style="margin-top: auto;">
<div class="field is-grouped is-grouped-multiline">
{{ range .tags }}
<div class="control">
<div class="tags has-addons">
{{ $color := "" }}
{{ if eq .type "language" }}
{{ $color = "info" }}
{{ else if eq .type "tech" }}
{{ $color = "success" }}
{{ else }}
{{ errorf "Unknown tag type \"%s\"" .type }}
{{ end }}
<span class="tag is-dark">{{ .type }}</span>
<span class="tag is-{{ $color }}">{{ .value|title }}</span>
</div>
</div>
{{ end }}
</div>
</div>
</div>
<footer class="card-footer" style="margin-top: auto;">
<p class="card-footer-item">
<span>
<span class="icon">
<i class="fab fa-github"></i>
</span>
View on <a href="{{ .links.github }}">GitHub</a>
</span>
</p>
</footer>
</div>
</div>
{{ end }}
{{/* Pad the last row with empty space */}}
{{ if (lt (len $contributions_in_row) $column_count) }}
{{ range (seq 1 (sub $column_count (len $contributions_in_row))) }}
<div class="tile is-parent">
<div class="tile is-child">
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
</div>
</div>
{{ end }}

7
layouts/robots.txt Normal file
View File

@@ -0,0 +1,7 @@
User-agent: *
{{ if .Site.Params.allow_robots -}}
Disallow: /assets/
Disallow: /keybase.txt
{{ else -}}
Disallow: /
{{ end }}

View File

@@ -0,0 +1,3 @@
{{- $content := .Inner -}}
<figcaption class="is-size-7 has-text-left has-text-weight-light mt-1">{{ $content }}</figcaption>
{{- /* Do not remove comment, fix for extra whitespace after shortcode */ -}}

View File

@@ -0,0 +1,3 @@
{{- $content := .Inner -}}
<span class="is-smallcaps has-text-weight-medium has-text-danger">{{ $content }}</span>
{{- /* Do not remove comment, fix for extra whitespace after shortcode */ -}}

View File

@@ -0,0 +1,5 @@
{{- .Scratch.Set "path" (.Get 0) -}}
{{- if hasPrefix (.Scratch.Get "path") "/" -}}
{{- .Scratch.Set "path" (slicestr (.Scratch.Get "path") 1) -}}
{{- end -}}
{{- .Scratch.Get "path" | absLangURL -}}

View File

@@ -0,0 +1,20 @@
{{ define "content" }}
<div class="content section">
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal">All posts with the {{ title .Data.Singular }} "{{ .Title }}"</h1>
<table class="table is-striped is-hoverable is-bordered mt-6">
<tbody>
{{ range (index .Site.Taxonomies.tags .Data.Term) }}
<tr>
<td>
<a href="{{ .Page.RelPermalink }}">{{ .Page.Title }}</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<hr>
<div class="section">
<a href={{ printf "/%s" .Data.Plural }}>List of all {{ .Data.Plural }}</a>
</div>
{{ end }}

View File

@@ -0,0 +1,14 @@
{{ define "content" }}
<div class="content section">
<h1 class="subtitle is-3 has-text-centered has-text-weight-normal">List of all {{ title .Data.Plural }}</h1>
<div class="is-flex is-flex-direction-column is-flex-wrap-wrap">
<ul class="mt-6">
{{ range $name, $taxonomy := .Site.Taxonomies.tags }}
{{ with $.Site.GetPage (printf "/%s" $name) }}
<li><span class="tag has-background-grey-lighter is-medium"><a href="{{ .Permalink }}">{{ $name }}</a></span></li>
{{ end }}
{{ end }}
</ul>
</div>
</div>
{{ end }}

12
nginx.server.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
absolute_redirect off;
}

1
static/assets/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/resume/

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
static/assets/images/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Some files were not shown because too many files have changed in this diff Show More