120
.drone.yml
Normal 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
@@ -0,0 +1,3 @@
|
||||
/public
|
||||
/themes
|
||||
/resources
|
||||
0
.gitmodules
vendored
Normal file
4
Dockerfile.nginx
Normal 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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
url: "https://blog.hkoerber.de"
|
||||
1
_config.staging.yml
Normal file
@@ -0,0 +1 @@
|
||||
url: "https://staging.blog.hkoerber.de"
|
||||
31
_data/social.yml
Normal 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
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title = "{{ replace .TranslationBaseName "-" " " | title }}"
|
||||
date = "{{ .Date }}"
|
||||
description = "About the page"
|
||||
draft = true
|
||||
---
|
||||
119
config/_default/config.yaml
Normal 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
|
||||
2
config/development/config.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
params:
|
||||
allow_robots: false
|
||||
2
config/preview/config.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
params:
|
||||
allow_robots: false
|
||||
2
config/production/config.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
params:
|
||||
allow_robots: true
|
||||
127
content/_index.html
Normal 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
@@ -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—the creative one—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>
|
||||
145
content/blog/2015-09-27-about-this-blog.md
Normal 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.
|
||||
260
content/blog/2015-09-27-elk-stack-with-rsyslog.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
328
content/blog/2015-12-29-homelab-centos-package-management.md
Normal 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.
|
||||
1054
content/blog/2016-09-08-ceph-single-node-deployment.md
Normal file
244
content/blog/2017-08-19-ansible-puppet-why-not-both.md
Normal 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.
|
||||
80
content/blog/2017-08-22-clone-disk-over-network.md
Normal 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!
|
||||
@@ -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 ;)
|
||||
244
content/blog/2018-06-03-ansible-is-not-yet-perfect.md
Normal 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.
|
||||
-->
|
||||
50
content/blog/2018-07-01-rabbithole-proc-self-mountstats.md
Normal 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!
|
||||
-->
|
||||
56
content/blog/2018-12-30-sphinx-csv-tables.md
Normal 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)
|
||||
328
content/blog/2020-08-30-sso-with-open-id-connect-keycloak.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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":
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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!
|
||||
|
||||
262
content/blog/2021-09-24-dependency-versioning.md
Normal 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.
|
||||
275
content/blog/2021-09-26-error-handling-rust-vs-go.md
Normal 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.
|
||||
|
||||
131
content/blog/2021-11-18-a-git-repository-manager.md
Normal 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
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Blog
|
||||
infos:
|
||||
- name: date
|
||||
type: date
|
||||
- name: title
|
||||
defaultlink: true
|
||||
---
|
||||
5
content/blog/post_list/_index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: All posts
|
||||
url: /blog/list
|
||||
layout: postlist
|
||||
---
|
||||
12
content/events/2018-global-game-jam.md
Normal 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
|
||||
12
content/events/2019-global-game-jam.md
Normal 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
@@ -0,0 +1,10 @@
|
||||
---
|
||||
infos:
|
||||
- name: date
|
||||
type: date
|
||||
- name: event
|
||||
key: title
|
||||
defaultlink: true
|
||||
- name: location
|
||||
title: Events
|
||||
---
|
||||
8
content/events/dev-night-16.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #16"
|
||||
date: 2017-11-14
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: Workout for your TDD
|
||||
8
content/events/dev-night-17.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #17"
|
||||
date: 2017-12-12
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: No password no cry
|
||||
8
content/events/dev-night-18.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #18"
|
||||
date: 2018-01-09
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: Global Game Jam - Warmup
|
||||
8
content/events/dev-night-19.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #19"
|
||||
date: 2018-01-01
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: Twisted Game of Life
|
||||
8
content/events/dev-night-21.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #21"
|
||||
date: 2018-04-10
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: Serverless durch die Nacht
|
||||
8
content/events/dev-night-22.md
Normal 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
|
||||
8
content/events/dev-night-24.md
Normal 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?
|
||||
8
content/events/dev-night-26.md
Normal 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
|
||||
10
content/events/dev-night-27.md
Normal 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" >}})
|
||||
8
content/events/dev-night-28.md
Normal 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
|
||||
8
content/events/dev-night-29.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #29"
|
||||
date: 2018-12-11
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: Stairway to Service Mesh
|
||||
8
content/events/dev-night-30.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #30"
|
||||
date: 2019-01-22
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: Next Level Jam
|
||||
8
content/events/dev-night-33.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "/dev/night #22"
|
||||
date: 2019-03-12
|
||||
location: Tradebyte, Ansbach
|
||||
titlecase: false
|
||||
---
|
||||
|
||||
Topic: #WueWW special
|
||||
10
content/events/devops-meetup-paessler.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: DevOps Meetup
|
||||
date: 2018-07-03
|
||||
tags:
|
||||
- devops
|
||||
location: Paessler, Nürnberg
|
||||
links:
|
||||
title:
|
||||
enable: false
|
||||
---
|
||||
5
content/events/itsa-2017.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: it-sa 2018
|
||||
date: 2017-10-10
|
||||
location: Nuremberg
|
||||
---
|
||||
11
content/events/kubernetes-meetup-nuremberg-01.md
Normal 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/
|
||||
---
|
||||
8
content/events/osdc-2018.md
Normal 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
@@ -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
10
content/talks/_index.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
infos:
|
||||
- name: date
|
||||
type: date
|
||||
- name: event
|
||||
key: title
|
||||
defaultlink: true
|
||||
- name: location
|
||||
title: Talks
|
||||
---
|
||||
27
content/talks/kubernetes-deep-dive.md
Normal 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" >}})
|
||||
*/}
|
||||
-->
|
||||
@@ -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
@@ -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
@@ -0,0 +1,8 @@
|
||||
{{ define "content" }}
|
||||
<article>
|
||||
<h1 class="title">
|
||||
404 — Not Found
|
||||
</h1>
|
||||
<h1 class="title"><a href="{{ "/" | relURL }}">Go Home</a></h1>
|
||||
</article>
|
||||
{{ end }}
|
||||
20
layouts/_default/baseof.html
Normal 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>
|
||||
12
layouts/_default/list.html
Normal 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 }}
|
||||
9
layouts/_default/single.html
Normal 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 }}
|
||||
22
layouts/_default/sitemap.xml
Normal 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
@@ -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 }}
|
||||
32
layouts/blog/postlist.html
Normal 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
@@ -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: {{- .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
@@ -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 }}
|
||||
69
layouts/partials/header.html
Normal 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>
|
||||
28
layouts/partials/html-head.html
Normal 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 }} – {{ .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>
|
||||
103
layouts/partials/pagelist-default.html
Normal 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 -}}">
|
||||
–
|
||||
{{ 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" . }}
|
||||
74
layouts/partials/pagination.html
Normal 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">← </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"> →</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">…</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">…</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 -}}
|
||||
18
layouts/partials/related.html
Normal 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 }}
|
||||
19
layouts/partials/site-footer.html
Normal 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> {{ now.Format "2006" }} {{ .Site.Params.author }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
9
layouts/partials/social-follow.html
Normal 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
@@ -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
@@ -0,0 +1,7 @@
|
||||
User-agent: *
|
||||
{{ if .Site.Params.allow_robots -}}
|
||||
Disallow: /assets/
|
||||
Disallow: /keybase.txt
|
||||
{{ else -}}
|
||||
Disallow: /
|
||||
{{ end }}
|
||||
3
layouts/shortcodes/figcaption.html
Normal 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 */ -}}
|
||||
3
layouts/shortcodes/myhighlight.html
Normal 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 */ -}}
|
||||
5
layouts/shortcodes/staticRef.html
Normal 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 -}}
|
||||
20
layouts/taxonomy/list.html
Normal 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 }}
|
||||
14
layouts/taxonomy/terms.html
Normal 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
@@ -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
@@ -0,0 +1 @@
|
||||
/resume/
|
||||
|
After Width: | Height: | Size: 77 KiB |
BIN
static/assets/images/chess.jpg
Normal file
|
After Width: | Height: | Size: 438 KiB |
BIN
static/assets/images/dependency_pinning/merge_request.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
static/assets/images/guitar-closeup.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
static/assets/images/guitar.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
static/assets/images/kayak-naab.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
static/assets/images/keycloak/gitea-login.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
static/assets/images/keycloak/gitea-oauth-setup.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
static/assets/images/keycloak/intro.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
static/assets/images/kibana.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
static/assets/images/lostson.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
static/assets/images/me.jpg
Normal file
|
After Width: | Height: | Size: 387 KiB |
BIN
static/assets/images/nebelhorn.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/assets/images/qrcode-example.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
static/assets/images/skeen-trail-al-2020.jpg
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
static/assets/images/tradebyte_logo.png
Normal file
|
After Width: | Height: | Size: 69 KiB |