Merge "bgpwtf: add static v6 routes via bird"
diff --git a/app/covid-formity/prod.jsonnet b/app/covid-formity/prod.jsonnet
index a6ca8ab..18fb845 100644
--- a/app/covid-formity/prod.jsonnet
+++ b/app/covid-formity/prod.jsonnet
@@ -3,6 +3,7 @@
 #    kubectl -n covid-formity create secret generic covid-formity --from-literal=postgres_password=$(pwgen 24 1) --from-literal=secret_key=$(pwgen 24 1) --from-literal=oauth2_secret=...
 
 local kube = import "../../kube/kube.libsonnet";
+local redis = import "../../kube/redis.libsonnet";
 local postgres = import "../../kube/postgres.libsonnet";
 
 {
@@ -10,9 +11,9 @@
     local cfg = app.cfg,
     cfg:: {
         namespace: "covid-formity",
-        image: "registry.k0.hswaw.net/informatic/covid-formity@sha256:8295f5b6d71266fb758c103210f12380f15903ba2467ead0e48ae0df16b6d608",
+        image: "registry.k0.hswaw.net/informatic/covid-formity@sha256:53c5fb0dbc4a6660ab47e39869a516f1e3f833dee5a03867386771bd9ffaf7b8",
         domain: "covid19.hackerspace.pl",
-        altDomains: ["covid.hackerspace.pl"],
+        altDomains: ["covid.hackerspace.pl", "www.covid.hackerspace.pl"],
     },
 
     metadata(component):: {
@@ -36,6 +37,15 @@
         },
     },
 
+    redis: redis {
+        cfg+: {
+            namespace: cfg.namespace,
+            appName: "covid-formity",
+            password: { secretKeyRef: { name: "covid-formity", key: "redis_password" } },
+            storageClassName: app.postgres.cfg.storageClassName,
+        },
+    },
+
     deployment: kube.Deployment("covid-formity") {
         metadata+: app.metadata("covid-formity"),
         spec+: {
@@ -52,10 +62,14 @@
                                 DATABASE_HOSTNAME: "postgres",
                                 DATABASE_USERNAME: app.postgres.cfg.username,
                                 DATABASE_PASSWORD: app.postgres.cfg.password,
+                                CACHE_REDIS_PASSWORD: app.redis.cfg.password,
+                                CACHE_REDIS_URL: "redis://default:$(CACHE_REDIS_PASSWORD)@redis",
                                 DATABASE_NAME: app.postgres.cfg.appName,
                                 SPACEAUTH_CONSUMER_KEY: "covid-formity",
                                 SPACEAUTH_CONSUMER_SECRET: { secretKeyRef: { name: "covid-formity", key: "oauth2_secret" } },
                                 SECRET_KEY: { secretKeyRef: { name: "covid-formity", key: "secret_key" } },
+                                SHIPPING_KURJERZY_EMAIL: "qrde@hackerspace.pl",
+                                SHIPPING_KURJERZY_PASSWORD: { secretKeyRef: { name: "covid-formity-shipping", key: "kurjerzy_password" } },
                             },
                         },
                     },
@@ -81,6 +95,11 @@
                 "kubernetes.io/tls-acme": "true",
                 "certmanager.k8s.io/cluster-issuer": "letsencrypt-prod",
                 "nginx.ingress.kubernetes.io/proxy-body-size": "0",
+                "nginx.ingress.kubernetes.io/configuration-snippet": "
+                    location /qr1 { rewrite ^/qr1(.*)$ https://covid.hackerspace.pl$1 redirect; }
+                    location /video { return 302 https://youtu.be/eC19w2NFO0E; }
+                    location /manual { return 302 https://wiki.hackerspace.pl/_media/projects:covid-19:przylbica-instrukcja-v1.0.pdf; }
+                ",
             },
         },
         spec+: {
diff --git a/kube/kube.upstream.libsonnet b/kube/kube.upstream.libsonnet
index e20d872..d12a786 100644
--- a/kube/kube.upstream.libsonnet
+++ b/kube/kube.upstream.libsonnet
@@ -53,6 +53,13 @@
 // (client-side, and gives better line information).
 
 {
+  // resource contructors will use kinds/versions/fields compatible at least with version:
+  minKubeVersion: {
+    major: 1,
+    minor: 9,
+    version: "%s.%s" % [self.major, self.minor],
+  },
+
   // Returns array of values from given object.  Does not include hidden fields.
   objectValues(o):: [o[field] for field in std.objectFields(o)],
 
@@ -111,6 +118,8 @@
     std.join("", [remapChar(c, "a", "z", "A") for c in std.stringChars(s)])
   ),
 
+  boolXor(x, y):: ((if x then 1 else 0) + (if y then 1 else 0) == 1),
+
   _Object(apiVersion, kind, name):: {
     local this = self,
     apiVersion: apiVersion,
@@ -164,6 +173,7 @@
       ports: [
         {
           port: service.port,
+          name: service.target_pod.spec.containers[0].ports[0].name,
           targetPort: service.target_pod.spec.containers[0].ports[0].containerPort,
         },
       ],
@@ -203,6 +213,7 @@
         },
       },
       accessModes: ["ReadWriteOnce"],
+      [if pvc.storageClass != null then "storageClassName"]: pvc.storageClass,
     },
   },
 
@@ -212,7 +223,15 @@
     imagePullPolicy: if std.endsWith(self.image, ":latest") then "Always" else "IfNotPresent",
 
     envList(map):: [
-      if std.type(map[x]) == "object" then { name: x, valueFrom: map[x] } else { name: x, value: map[x] }
+      if std.type(map[x]) == "object"
+      then {
+        name: x,
+        valueFrom: map[x],
+      } else {
+        // Let `null` value stay as such (vs string-ified)
+        name: x,
+        value: if map[x] == null then null else std.toString(map[x]),
+      }
       for x in std.objectFields(map)
     ],
 
@@ -237,7 +256,10 @@
     local this = self,
     target_pod:: error "target_pod required",
     spec: {
-      minAvailable: 1,
+      assert $.boolXor(
+        std.objectHas(self, "minAvailable"),
+        std.objectHas(self, "maxUnavailable")
+      ) : "PDB '%s': exactly one of minAvailable/maxUnavailable required" % name,
       selector: {
         matchLabels: this.target_pod.metadata.labels,
       },
@@ -255,7 +277,10 @@
     containers_:: {},
 
     local container_names_ordered = [self.default_container] + [n for n in container_names if n != self.default_container],
-    containers: [{ name: $.hyphenate(name) } + self.containers_[name] for name in container_names_ordered if self.containers_[name] != null],
+    containers: (
+      assert std.length(self.containers_) > 0 : "Pod must have at least one container (via containers_ map)";
+      [{ name: $.hyphenate(name) } + self.containers_[name] for name in container_names_ordered if self.containers_[name] != null]
+    ),
 
     // Note initContainers are inherently ordered, and using this
     // named object will lose that ordering.  If order matters, then
@@ -272,7 +297,7 @@
 
     terminationGracePeriodSeconds: 30,
 
-    assert std.length(self.containers) > 0 : "must have at least one container",
+    assert std.length(self.containers) > 0 : "Pod must have at least one container (via containers array)",
 
     // Return an array of pod's ports numbers
     ports(proto):: [
@@ -330,7 +355,7 @@
 
   // subtype of EnvVarSource
   ConfigMapRef(configmap, key): {
-    assert std.objectHas(configmap.data, key) : "%s not in configmap.data" % [key],
+    assert std.objectHas(configmap.data, key) : "ConfigMap '%s' doesn't have '%s' field in configmap.data" % [configmap.metadata.name, key],
     configMapKeyRef: {
       name: configmap.metadata.name,
       key: key,
@@ -347,7 +372,7 @@
 
   // subtype of EnvVarSource
   SecretKeyRef(secret, key): {
-    assert std.objectHas(secret.data, key) : "%s not in secret.data" % [key],
+    assert std.objectHas(secret.data, key) : "Secret '%s' doesn't have '%s' field in secret.data" % [secret.metadata.name, key],
     secretKeyRef: {
       name: secret.metadata.name,
       key: key,
@@ -370,7 +395,7 @@
     },
   },
 
-  Deployment(name): $._Object("apps/v1beta2", "Deployment", name) {
+  Deployment(name): $._Object("apps/v1", "Deployment", name) {
     local deployment = self,
 
     spec: {
@@ -412,8 +437,10 @@
       // NB: Upstream default is 0
       minReadySeconds: 30,
 
+      // NB: Regular k8s default is to keep all revisions
+      revisionHistoryLimit: 10,
+
       replicas: 1,
-      assert self.replicas >= 0,
     },
   },
 
@@ -438,7 +465,7 @@
     },
   },
 
-  StatefulSet(name): $._Object("apps/v1beta2", "StatefulSet", name) {
+  StatefulSet(name): $._Object("apps/v1", "StatefulSet", name) {
     local sset = self,
 
     spec: {
@@ -490,7 +517,6 @@
     },
   },
 
-  // NB: kubernetes >= 1.8.x has batch/v1beta1 (olders were batch/v2alpha1)
   CronJob(name): $._Object("batch/v1beta1", "CronJob", name) {
     local cronjob = self,
 
@@ -504,7 +530,6 @@
           },
         },
       },
-
       schedule: error "Need to provide spec.schedule",
       successfulJobsHistoryLimit: 10,
       failedJobsHistoryLimit: 20,
@@ -521,16 +546,11 @@
         restartPolicy: "OnFailure",
       },
     },
-
-    selector: {
-      matchLabels: this.template.metadata.labels,
-    },
-
     completions: 1,
     parallelism: 1,
   },
 
-  DaemonSet(name): $._Object("apps/v1beta2", "DaemonSet", name) {
+  DaemonSet(name): $._Object("apps/v1", "DaemonSet", name) {
     local ds = self,
     spec: {
       updateStrategy: {
@@ -553,7 +573,7 @@
     },
   },
 
-  Ingress(name): $._Object("extensions/v1beta1", "Ingress", name) {
+  Ingress(name): $._Object("networking.k8s.io/v1beta1", "Ingress", name) {
     spec: {},
 
     local rel_paths = [
@@ -635,18 +655,14 @@
     kind: "ClusterRoleBinding",
   },
 
-  // NB: datalines_ can be used to reduce boilerplate importstr as:
-  // kubectl get secret ... -ojson mysec | kubeseal | jq -r .spec.data > mysec-ssdata.txt
-  //   datalines_: importstr "mysec-ssddata.txt"
+  // NB: encryptedData can be imported into a SealedSecret as follows:
+  // kubectl get secret ... -ojson mysec | kubeseal | jq -r .spec.encryptedData > sealedsecret.json
+  //   encryptedData: std.parseJson(importstr "sealedsecret.json")
   SealedSecret(name): $._Object("bitnami.com/v1alpha1", "SealedSecret", name) {
     spec: {
-      data:
-        if self.datalines_ != ""
-        then std.join("", std.split(self.datalines_, "\n"))
-        else error "data or datalines_ required (output from: kubeseal | jq -r .spec.data)",
-      datalines_:: "",
+      encryptedData: {},
     },
-    assert std.base64Decode(self.spec.data) != "",
+    assert std.length(std.objectFields(self.spec.encryptedData)) != 0 : "SealedSecret '%s' has empty encryptedData field" % name,
   },
 
   // NB: helper method to access several Kubernetes objects podRef,
@@ -697,6 +713,36 @@
       ingress_:: {},
       egress: $.objectValues(self.egress_),
       egress_:: {},
+      podSelector: {},
+    },
+  },
+
+  VerticalPodAutoscaler(name):: $._Object("autoscaling.k8s.io/v1beta2", "VerticalPodAutoscaler", name) {
+    local vpa = self,
+
+    target:: error "target required",
+
+    spec: {
+      targetRef: $.CrossVersionObjectReference(vpa.target),
+
+      updatePolicy: {
+        updateMode: "Auto",
+      },
+    },
+  },
+  // Helper function to ease VPA creation as e.g.:
+  // foo_vpa:: kube.createVPAFor($.foo_deploy)
+  createVPAFor(target, mode="Auto"):: $.VerticalPodAutoscaler(target.metadata.name) {
+    target:: target,
+
+    metadata+: {
+      namespace: target.metadata.namespace,
+      labels+: target.metadata.labels,
+    },
+    spec+: {
+      updatePolicy+: {
+        updateMode: mode,
+      },
     },
   },
 }
diff --git a/kube/redis.libsonnet b/kube/redis.libsonnet
index d272227..e596ac2 100644
--- a/kube/redis.libsonnet
+++ b/kube/redis.libsonnet
@@ -12,6 +12,7 @@
         appName: error "app name must be set",
         storageClassName: "waw-hdd-redundant-1",
         prefix: "", # if set, should be 'foo-'
+        password: null,
 
         image: "redis:5.0.4-alpine",
         resources: {
@@ -65,13 +66,16 @@
                             args: [
                                 "redis-server",
                                 "--appendonly", "yes",
-                            ],
+                            ] + (if cfg.password != null then ["--requirepass", "$(REDIS_PASSWORD)"] else []),
                             ports_: {
                                 client: { containerPort: 6379 },
                             },
                             volumeMounts_: {
                                 data: { mountPath: "/data" },
                             },
+                            env_: {
+                                [if cfg.password != null then "REDIS_PASSWORD"]: cfg.password,
+                            },
                             resources: cfg.resources,
                         },
                     },