From 3d20846ab81a147389368c8635e4599c2b5289f2 Mon Sep 17 00:00:00 2001 From: Christine Dodrill Date: Sat, 25 Jan 2020 16:17:37 -0500 Subject: [PATCH] Add blogpost on how I use Dhall for Kubernetes (#110) * blog: add post on Dhall for Kubernetes * go.sum: tidy * Update dhall-kubernetes-2020-01-25.markdown --- blog/dhall-kubernetes-2020-01-25.markdown | 435 ++++++++++++++++++++++ go.sum | 22 +- 2 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 blog/dhall-kubernetes-2020-01-25.markdown diff --git a/blog/dhall-kubernetes-2020-01-25.markdown b/blog/dhall-kubernetes-2020-01-25.markdown new file mode 100644 index 0000000..309abd3 --- /dev/null +++ b/blog/dhall-kubernetes-2020-01-25.markdown @@ -0,0 +1,435 @@ +--- +title: Dhall for Kubernetes +date: 2020-01-25 +tags: + - dhall + - kubernetes + - witchcraft +--- + +# Dhall for Kubernetes + +Kubernetes is a surprisingly complicated software package. Arguably, it has to +be that complicated as a result of the problems it solves being complicated; but +managing yaml configuration files for Kubernetes is a complicated task. [YAML][yaml] +doesn't have support for variables or type metadata. This means that the +validity (or sensibility) of a given Kubernetes configuration file (or files) +isn't easy to figure out without using a Kubernetes server. + +[yaml]: https://yaml.org + +In my [last post][cultk8s] about Kubernetes, I mentioned I had developed a tool +named [dyson][dyson] in order to help me manage Terraform as well as create +Kubernetes manifests from [a template][template]. This works for the majority of +my apps, but it is difficult to extend at this point for a few reasons: + +[cultk8s]: https://christine.website/blog/the-cult-of-kubernetes-2019-09-07 +[dyson]: https://github.com/Xe/within-terraform/tree/master/dyson +[template]: https://github.com/Xe/within-terraform/blob/master/dyson/src/dysonPkg/deployment_with_ingress.yaml + +- It assumes that everything passed to it are already valid yaml terms +- It doesn't assert the type of any values passed to it +- It is difficult to add another container to a given deployment +- Environment variables implicitly depend on the presence of a private git repo +- It depends on the template being correct more than the output being correct + +So, this won't scale. People in the community have created other solutions for +this like [Helm][helm], but a lot of them have some of the same basic problems. +Helm also assumes that your template is correct. [Kustomize][kustomize] does +help with a lot of the type-safe variable replacements, but it doesn't have the +ability to ensure your manifest is valid. + +[helm]: https://helm.sh +[kustomize]: https://kustomize.io + +I looked around for alternate solutions for a while and eventually found +[Dhall][dhall] thanks to a friend. Dhall is a _statically typed_ configuration +language. This means that you can ensure that inputs are _always_ the correct +type or the configuration file won't load. There's also a built-in +[dhall-to-yaml][dhallyaml] tool that can be used with the [Kubernetes +package][dhallk8s] in order to declare Kubernetes manifests in a type-safe way. + +[dhall]: https://dhall-lang.org +[dhallyaml]: https://github.com/dhall-lang/dhall-haskell/tree/master/dhall-yaml#dhall-yaml +[dhallk8s]: https://github.com/dhall-lang/dhall-kubernetes + +Here's a small example of Dhall and the yaml it generates: + +```dhall +-- Mastodon usernames +[ { name = "Cadey", mastodon = "@cadey@mst3k.interlinked.me" } +, { name = "Nicole", mastodon = "@sharkgirl@mst3k.interlinked.me" } +] +``` + +Which produces: + +```yaml +- mastodon: "@cadey@mst3k.interlinked.me" + name: Cadey +- mastodon: "@sharkgirl@mst3k.interlinked.me" + name: Nicole +``` + +Which is fine, but we still have the type-safety problem that you would have in +normal yaml. Dhall lets us define [record types][dhallrecord] for this data like +this: + +[dhallrecord]: http://www.haskellforall.com/2020/01/dhall-year-in-review-2019-2020.html + +```dhall +let User = + { Type = { name : Text, mastodon : Optional Text } + , default = { name = "", mastodon = None } + } + +let users = + [ User::{ name = "Cadey", mastodon = Some "@cadey@mst3k.interlinked.me" } + , User::{ + , name = "Nicole" + , mastodon = Some "@sharkgirl@mst3k.interlinked.me" + } + ] + +in users +``` + +Which produces: + +```yaml +- mastodon: "@cadey@mst3k.interlinked.me" + name: Cadey +- mastodon: "@sharkgirl@mst3k.interlinked.me" + name: Nicole +``` + +This is type-safe because you cannot add arbitrary fields to User instances +without the compiler rejecting it. Let's add an invalid "preferred_language" +field to Cadey's instance: + +``` +-- ... +let users = + [ User::{ + , name = "Cadey" + , mastodon = Some "@cadey@mst3k.interlinked.me" + , preferred_language = "en-US" + } + -- ... + ] +``` + +Which gives us: + +``` +$ dhall-to-yaml --file example.dhall +Error: Expression doesn't match annotation + +{ + preferred_language : … +, … +} + +4│ User::{ name = "Cadey", mastodon = Some "@cadey@mst3k.interlinked.me", +5│ preferred_language = "en-US" } + +example.dhall:4:9 +``` + +Or [this more detailed explanation][explanation] if you add the `--explain` flag +to the `dhall-to-yaml` call. + +[explanation]: https://clbin.com/JtVWT + +We tried to do something that violated the contract that the type specified. +This means that it's an invalid configuration and is therefore rejected and no +yaml file is created. + +The Dhall Kubernetes package specifies record types for _every_ object available +by default in Kubernetes. This does mean that the package is incredibly large, +but it also makes sure that _everything_ you could possibly want to do in +Kubernetes matches what it expects. In the [package +documentation][k8sdhalldocs], they give an example where a +[Deployment][k8sdeployment] is created. + +[k8sdhalldocs]: https://github.com/dhall-lang/dhall-kubernetes/tree/master/1.15#quickstart---a-simple-deployment +[k8sdeployment]: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ + +``` dhall +-- examples/deploymentSimple.dhall + +-- Importing other files is done by specifying the HTTPS URL/disk location of +-- the file. Attaching a sha256 hash (obtained with `dhall freeze`) allows +-- the Dhall compiler to cache these files and speed up configuration loads +-- drastically. +let kubernetes = + https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/1.15/master/package.dhall + sha256:4bd5939adb0a5fc83d76e0d69aa3c5a30bc1a5af8f9df515f44b6fc59a0a4815 + +let deployment = + kubernetes.Deployment::{ + , metadata = kubernetes.ObjectMeta::{ name = "nginx" } + , spec = + Some + kubernetes.DeploymentSpec::{ + , replicas = Some 2 + , template = + kubernetes.PodTemplateSpec::{ + , metadata = kubernetes.ObjectMeta::{ name = "nginx" } + , spec = + Some + kubernetes.PodSpec::{ + , containers = + [ kubernetes.Container::{ + , name = "nginx" + , image = Some "nginx:1.15.3" + , ports = + [ kubernetes.ContainerPort::{ + , containerPort = 80 + } + ] + } + ] + } + } + } + } + +in deployment +``` + +Which creates the following yaml: + +``` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 2 + template: + metadata: + name: nginx + spec: + containers: + - image: nginx:1.15.3 + name: nginx + ports: + - containerPort: 80 +``` + +Dhall's lambda functions can help you break this into manageable chunks. For +example, here's a Dhall function that helps create a docker image reference: + +``` +let formatImage + : Text -> Text -> Text + = \(repository : Text) -> \(tag : Text) -> + "${repository}:${tag}" + +in formatImage "xena/christinewebsite" "latest" +``` + +Which outputs `xena/christinewebsite:latest` when passed to `dhall text`. + +All of this adds up into a powerful toolset that lets you express Kubernetes +configuration in a way that does what you want without as many headaches. + +Most of my apps on Kubernetes need only a few generic bits of configuration: + +- Their name +- What port should be exposed +- The domain that this service should be exposed on +- How many replicas of the service are needed +- Which Let's Encrypt Issuer to use (currently only `"prod"` or `"staging"`) +- The [configuration variables of the service][12factorconfig] +- Any other containers that may be needed for the service + +[12factorconfig]: https://12factor.net/config + +From here, I defined all of the [bits and pieces][kubermemeshttp] for the +Kubernetes manifests that Dyson produces and then created a `Config` type that +helps to template them out. Here's my [`Config` type +definition][configdefinition]: + +[kubermemeshttp]: https://tulpa.dev/cadey/kubermemes/src/branch/master/k8s/http +[configdefinition]: https://tulpa.dev/cadey/kubermemes/src/branch/master/k8s/app/config.dhall + +```dhall +let kubernetes = ../kubernetes.dhall + +in { Type = + { name : Text + , appPort : Natural + , image : Text + , domain : Text + , replicas : Natural + , leIssuer : Text + , envVars : List kubernetes.EnvVar.Type + , otherContainers : List kubernetes.Container.Type + } + , default = + { name = "" + , appPort = 5000 + , image = "" + , domain = "" + , replicas = 1 + , leIssuer = "staging" + , envVars = [] : List kubernetes.EnvVar.Type + , otherContainers = [] : List kubernetes.Container.Type + } + } +``` + +Then I defined a `makeApp` function that creates everything I need to deploy my +stuff on Kubernetes: + +```dhall +let Prelude = ../Prelude.dhall + +let kubernetes = ../kubernetes.dhall + +let typesUnion = ../typesUnion.dhall + +let deployment = ../http/deployment.dhall + +let ingress = ../http/ingress.dhall + +let service = ../http/service.dhall + +let Config = ../app/config.dhall + +let K8sList = ../app/list.dhall + +let buildService = + \(config : Config.Type) + -> let myService = service config + + let myDeployment = deployment config + + let myIngress = ingress config + + in K8sList::{ + , items = + [ typesUnion.Service myService + , typesUnion.Deployment myDeployment + , typesUnion.Ingress myIngress + ] + } + +in buildService +``` + +And used it to deploy the [h language website][hlang]: + +[hlang]: https://h.christine.website + +```dhall +let makeApp = ../app/make.dhall + +let Config = ../app/config.dhall + +let cfg = + Config::{ + , name = "hlang" + , appPort = 5000 + , image = "xena/hlang:latest" + , domain = "h.christine.website" + , leIssuer = "prod" + } + +in makeApp cfg +``` + +Which produces the following Kubernetes config: + +```yaml +apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + annotations: + external-dns.alpha.kubernetes.io/cloudflare-proxied: "false" + external-dns.alpha.kubernetes.io/hostname: h.christine.website + external-dns.alpha.kubernetes.io/ttl: "120" + labels: + app: hlang + name: hlang + namespace: apps + spec: + ports: + - port: 5000 + targetPort: 5000 + selector: + app: hlang + type: ClusterIP + - apiVersion: apps/v1 + kind: Deployment + metadata: + name: hlang + namespace: apps + spec: + replicas: 1 + selector: + matchLabels: + app: hlang + template: + metadata: + labels: + app: hlang + name: hlang + spec: + containers: + - image: xena/hlang:latest + imagePullPolicy: Always + name: web + ports: + - containerPort: 5000 + imagePullSecrets: + - name: regcred + - apiVersion: networking.k8s.io/v1beta1 + kind: Ingress + metadata: + annotations: + certmanager.k8s.io/cluster-issuer: letsencrypt-prod + kubernetes.io/ingress.class: nginx + labels: + app: hlang + name: hlang + namespace: apps + spec: + rules: + - host: h.christine.website + http: + paths: + - backend: + serviceName: hlang + servicePort: 5000 + tls: + - hosts: + - h.christine.website + secretName: prod-certs-hlang +kind: List +``` + +And when I applied it on my Kubernetes cluster, it worked the first time and had +absolutely no effect on the existing configuration. + +In the future, I hope to expand this to allow for multiple deployments (IE: a +chatbot running in a separate deployment than a web API the chatbot depends on +or non-web projects in general) as well as supporting multiple Kubernetes +namespaces. + +Dhall is probably the most viable replacement to Helm or other Kubernetes +templating tools I have found in recent memory. I hope that it will be used by +more people to help with configuration management, but I can understand that +that may not happen. At least it works for me. + +If you want to learn more about Dhall, I suggest checking out the following +links: + +- [The Dhall Language homepage](https://dhall-lang.org) +- [Learn Dhall in Y Minutes](https://learnxinyminutes.com/docs/dhall/) +- [The Dhall Language GitHub Organization](https://github.com/dhall-lang) + +I hope this was helpful and interesting. Be well. diff --git a/go.sum b/go.sum index 07edc15..ece06ca 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/celrenheit/sandflake v0.0.0-20190410195419-50a943690bc2 h1:/BpnZPo/sk1vPlt62dLya5KCn7PN9ZBDrpTGlQzgUZI= github.com/celrenheit/sandflake v0.0.0-20190410195419-50a943690bc2/go.mod h1:7L8gY0+4GYeBc9TvqVuDUq7tXuM6Sj7llnt7HkVwWlQ= -github.com/cespare/xxhash/v2 v2.1.0 h1:yTUvW7Vhb89inJ+8irsUqiWjh8iT6sQPZiQzI6ReGkA= -github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -26,14 +25,12 @@ github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -60,15 +57,12 @@ github.com/povilasv/prommod v0.0.12/go.mod h1:GnuK7wLoVBwZXj8bhbJNx/xFSldy7Q49A4 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0 h1:vrDKnkGzuGvhNAL56c7DBz29ZL+KxnoR0x7enabFceM= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.2.1 h1:JnMpQc6ppsNgw9QPAGF6Dod479itz7lvlsMzzNayLOI= -github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= github.com/prometheus/client_golang v1.3.0 h1:miYCvYqFXtl/J9FIy8eNpBfYthAEFg+Ys0XyUVEcDsc= github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0 h1:ElTg5tNp4DqfV7UQjDqv2+RJlNzsDtvNAWccbItceIE= github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= @@ -77,8 +71,7 @@ github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt2 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.5 h1:3+auTFlqw+ZaQYJARz6ArODtkaIwtvBTx3N2NehQlL8= -github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/russross/blackfriday v2.0.0+incompatible h1:cBXrhZNUf9C+La9/YpS+UHpUT8YD6Td9ZMSU9APFcsk= github.com/russross/blackfriday v2.0.0+incompatible/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -114,8 +107,7 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY= -golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= @@ -125,10 +117,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=