blob: e20d872589df2ac678880b714fa291ccdafb14b0 [file] [log] [blame]
Sergiusz Bazanskie31d64f2019-10-02 20:59:26 +02001// Generic library of Kubernetes objects (https://github.com/bitnami-labs/kube-libsonnet)
2//
3// Objects in this file follow the regular Kubernetes API object
4// schema with two exceptions:
5//
6// ## Optional helpers
7//
8// A few objects have defaults or additional "helper" hidden
9// (double-colon) fields that will help with common situations. For
10// example, `Service.target_pod` generates suitable `selector` and
11// `ports` blocks for the common case of a single-pod/single-port
12// service. If for some reason you don't want the helper, just
13// provide explicit values for the regular Kubernetes fields that the
14// helper *would* have generated, and the helper logic will be
15// ignored.
16//
17// ## The Underscore Convention:
18//
19// Various constructs in the Kubernetes API use JSON arrays to
20// represent unordered sets or named key/value maps. This is
21// particularly annoying with jsonnet since we want to use jsonnet's
22// powerful object merge operation with these constructs.
23//
24// To combat this, this library attempts to provide more "jsonnet
25// native" variants of these arrays in alternative hidden fields that
26// end with an underscore. For example, the `env_` block in
27// `Container`:
28// ```
29// kube.Container("foo") {
30// env_: { FOO: "bar" },
31// }
32// ```
33// ... produces the expected `container.env` JSON array:
34// ```
35// {
36// "env": [
37// { "name": "FOO", "value": "bar" }
38// ]
39// }
40// ```
41//
42// If you are confused by the underscore versions, or don't want them
43// in your situation then just ignore them and set the regular
44// non-underscore field as usual.
45//
46//
47// ## TODO
48//
49// TODO: Expand this to include all API objects.
50//
51// Should probably fill out all the defaults here too, so jsonnet can
52// reference them. In addition, jsonnet validation is more useful
53// (client-side, and gives better line information).
54
55{
56 // Returns array of values from given object. Does not include hidden fields.
57 objectValues(o):: [o[field] for field in std.objectFields(o)],
58
59 // Returns array of [key, value] pairs from given object. Does not include hidden fields.
60 objectItems(o):: [[k, o[k]] for k in std.objectFields(o)],
61
62 // Replace all occurrences of `_` with `-`.
63 hyphenate(s):: std.join("-", std.split(s, "_")),
64
65 // Convert an octal (as a string) to number,
66 parseOctal(s):: (
67 local len = std.length(s);
68 local leading = std.substr(s, 0, len - 1);
69 local last = std.parseInt(std.substr(s, len - 1, 1));
70 assert last < 8 : "found '%s' digit >= 8" % [last];
71 last + (if len > 1 then 8 * $.parseOctal(leading) else 0)
72 ),
73
74 // Convert {foo: {a: b}} to [{name: foo, a: b}]
75 mapToNamedList(o):: [{ name: $.hyphenate(n) } + o[n] for n in std.objectFields(o)],
76
77 // Return object containing only these fields elements
78 filterMapByFields(o, fields): { [field]: o[field] for field in std.setInter(std.objectFields(o), fields) },
79
80 // Convert from SI unit suffixes to regular number
81 siToNum(n):: (
82 local convert =
83 if std.endsWith(n, "m") then [1, 0.001]
84 else if std.endsWith(n, "K") then [1, 1e3]
85 else if std.endsWith(n, "M") then [1, 1e6]
86 else if std.endsWith(n, "G") then [1, 1e9]
87 else if std.endsWith(n, "T") then [1, 1e12]
88 else if std.endsWith(n, "P") then [1, 1e15]
89 else if std.endsWith(n, "E") then [1, 1e18]
90 else if std.endsWith(n, "Ki") then [2, std.pow(2, 10)]
91 else if std.endsWith(n, "Mi") then [2, std.pow(2, 20)]
92 else if std.endsWith(n, "Gi") then [2, std.pow(2, 30)]
93 else if std.endsWith(n, "Ti") then [2, std.pow(2, 40)]
94 else if std.endsWith(n, "Pi") then [2, std.pow(2, 50)]
95 else if std.endsWith(n, "Ei") then [2, std.pow(2, 60)]
96 else error "Unknown numerical suffix in " + n;
97 local n_len = std.length(n);
98 std.parseInt(std.substr(n, 0, n_len - convert[0])) * convert[1]
99 ),
100
101 local remap(v, start, end, newstart) =
102 if v >= start && v <= end then v - start + newstart else v,
103 local remapChar(c, start, end, newstart) =
104 std.char(remap(
105 std.codepoint(c), std.codepoint(start), std.codepoint(end), std.codepoint(newstart)
106 )),
107 toLower(s):: (
108 std.join("", [remapChar(c, "A", "Z", "a") for c in std.stringChars(s)])
109 ),
110 toUpper(s):: (
111 std.join("", [remapChar(c, "a", "z", "A") for c in std.stringChars(s)])
112 ),
113
114 _Object(apiVersion, kind, name):: {
115 local this = self,
116 apiVersion: apiVersion,
117 kind: kind,
118 metadata: {
119 name: name,
120 labels: { name: std.join("-", std.split(this.metadata.name, ":")) },
121 annotations: {},
122 },
123 },
124
125 List(): {
126 apiVersion: "v1",
127 kind: "List",
128 items_:: {},
129 items: $.objectValues(self.items_),
130 },
131
132 Namespace(name): $._Object("v1", "Namespace", name) {
133 },
134
135 Endpoints(name): $._Object("v1", "Endpoints", name) {
136 Ip(addr):: { ip: addr },
137 Port(p):: { port: p },
138
139 subsets: [],
140 },
141
142 Service(name): $._Object("v1", "Service", name) {
143 local service = self,
144
145 target_pod:: error "service target_pod required",
146 port:: self.target_pod.spec.containers[0].ports[0].containerPort,
147
148 // Helpers that format host:port in various ways
149 host:: "%s.%s.svc" % [self.metadata.name, self.metadata.namespace],
150 host_colon_port:: "%s:%s" % [self.host, self.spec.ports[0].port],
151 http_url:: "http://%s/" % self.host_colon_port,
152 proxy_urlpath:: "/api/v1/proxy/namespaces/%s/services/%s/" % [
153 self.metadata.namespace,
154 self.metadata.name,
155 ],
156 // Useful in Ingress rules
157 name_port:: {
158 serviceName: service.metadata.name,
159 servicePort: service.spec.ports[0].port,
160 },
161
162 spec: {
163 selector: service.target_pod.metadata.labels,
164 ports: [
165 {
166 port: service.port,
167 targetPort: service.target_pod.spec.containers[0].ports[0].containerPort,
168 },
169 ],
170 type: "ClusterIP",
171 },
172 },
173
174 PersistentVolume(name): $._Object("v1", "PersistentVolume", name) {
175 spec: {},
176 },
177
178 // TODO: This is a terrible name
179 PersistentVolumeClaimVolume(pvc): {
180 persistentVolumeClaim: { claimName: pvc.metadata.name },
181 },
182
183 StorageClass(name): $._Object("storage.k8s.io/v1beta1", "StorageClass", name) {
184 provisioner: error "provisioner required",
185 },
186
187 PersistentVolumeClaim(name): $._Object("v1", "PersistentVolumeClaim", name) {
188 local pvc = self,
189
190 storageClass:: null,
191 storage:: error "storage required",
192
193 metadata+: if pvc.storageClass != null then {
194 annotations+: {
195 "volume.beta.kubernetes.io/storage-class": pvc.storageClass,
196 },
197 } else {},
198
199 spec: {
200 resources: {
201 requests: {
202 storage: pvc.storage,
203 },
204 },
205 accessModes: ["ReadWriteOnce"],
206 },
207 },
208
209 Container(name): {
210 name: name,
211 image: error "container image value required",
212 imagePullPolicy: if std.endsWith(self.image, ":latest") then "Always" else "IfNotPresent",
213
214 envList(map):: [
215 if std.type(map[x]) == "object" then { name: x, valueFrom: map[x] } else { name: x, value: map[x] }
216 for x in std.objectFields(map)
217 ],
218
219 env_:: {},
220 env: self.envList(self.env_),
221
222 args_:: {},
223 args: ["--%s=%s" % kv for kv in $.objectItems(self.args_)],
224
225 ports_:: {},
226 ports: $.mapToNamedList(self.ports_),
227
228 volumeMounts_:: {},
229 volumeMounts: $.mapToNamedList(self.volumeMounts_),
230
231 stdin: false,
232 tty: false,
233 assert !self.tty || self.stdin : "tty=true requires stdin=true",
234 },
235
236 PodDisruptionBudget(name): $._Object("policy/v1beta1", "PodDisruptionBudget", name) {
237 local this = self,
238 target_pod:: error "target_pod required",
239 spec: {
240 minAvailable: 1,
241 selector: {
242 matchLabels: this.target_pod.metadata.labels,
243 },
244 },
245 },
246
247 Pod(name): $._Object("v1", "Pod", name) {
248 spec: $.PodSpec,
249 },
250
251 PodSpec: {
252 // The 'first' container is used in various defaults in k8s.
253 local container_names = std.objectFields(self.containers_),
254 default_container:: if std.length(container_names) > 1 then "default" else container_names[0],
255 containers_:: {},
256
257 local container_names_ordered = [self.default_container] + [n for n in container_names if n != self.default_container],
258 containers: [{ name: $.hyphenate(name) } + self.containers_[name] for name in container_names_ordered if self.containers_[name] != null],
259
260 // Note initContainers are inherently ordered, and using this
261 // named object will lose that ordering. If order matters, then
262 // manipulate `initContainers` directly (perhaps
263 // appending/prepending to `super.initContainers` to mix+match
264 // both approaches)
265 initContainers_:: {},
266 initContainers: [{ name: $.hyphenate(name) } + self.initContainers_[name] for name in std.objectFields(self.initContainers_) if self.initContainers_[name] != null],
267
268 volumes_:: {},
269 volumes: $.mapToNamedList(self.volumes_),
270
271 imagePullSecrets: [],
272
273 terminationGracePeriodSeconds: 30,
274
275 assert std.length(self.containers) > 0 : "must have at least one container",
276
277 // Return an array of pod's ports numbers
278 ports(proto):: [
279 p.containerPort
280 for p in std.flattenArrays([
281 c.ports
282 for c in self.containers
283 ])
284 if (
285 (!(std.objectHas(p, "protocol")) && proto == "TCP")
286 ||
287 ((std.objectHas(p, "protocol")) && p.protocol == proto)
288 )
289 ],
290
291 },
292
293 EmptyDirVolume(): {
294 emptyDir: {},
295 },
296
297 HostPathVolume(path, type=""): {
298 hostPath: { path: path, type: type },
299 },
300
301 GitRepoVolume(repository, revision): {
302 gitRepo: {
303 repository: repository,
304
305 // "master" is possible, but should be avoided for production
306 revision: revision,
307 },
308 },
309
310 SecretVolume(secret): {
311 secret: { secretName: secret.metadata.name },
312 },
313
314 ConfigMapVolume(configmap): {
315 configMap: { name: configmap.metadata.name },
316 },
317
318 ConfigMap(name): $._Object("v1", "ConfigMap", name) {
319 data: {},
320
321 // I keep thinking data values can be any JSON type. This check
322 // will remind me that they must be strings :(
323 local nonstrings = [
324 k
325 for k in std.objectFields(self.data)
326 if std.type(self.data[k]) != "string"
327 ],
328 assert std.length(nonstrings) == 0 : "data contains non-string values: %s" % [nonstrings],
329 },
330
331 // subtype of EnvVarSource
332 ConfigMapRef(configmap, key): {
333 assert std.objectHas(configmap.data, key) : "%s not in configmap.data" % [key],
334 configMapKeyRef: {
335 name: configmap.metadata.name,
336 key: key,
337 },
338 },
339
340 Secret(name): $._Object("v1", "Secret", name) {
341 local secret = self,
342
343 type: "Opaque",
344 data_:: {},
345 data: { [k]: std.base64(secret.data_[k]) for k in std.objectFields(secret.data_) },
346 },
347
348 // subtype of EnvVarSource
349 SecretKeyRef(secret, key): {
350 assert std.objectHas(secret.data, key) : "%s not in secret.data" % [key],
351 secretKeyRef: {
352 name: secret.metadata.name,
353 key: key,
354 },
355 },
356
357 // subtype of EnvVarSource
358 FieldRef(key): {
359 fieldRef: {
360 apiVersion: "v1",
361 fieldPath: key,
362 },
363 },
364
365 // subtype of EnvVarSource
366 ResourceFieldRef(key, divisor="1"): {
367 resourceFieldRef: {
368 resource: key,
369 divisor: std.toString(divisor),
370 },
371 },
372
373 Deployment(name): $._Object("apps/v1beta2", "Deployment", name) {
374 local deployment = self,
375
376 spec: {
377 template: {
378 spec: $.PodSpec,
379 metadata: {
380 labels: deployment.metadata.labels,
381 annotations: {},
382 },
383 },
384
385 selector: {
386 matchLabels: deployment.spec.template.metadata.labels,
387 },
388
389 strategy: {
390 type: "RollingUpdate",
391
392 local pvcs = [
393 v
394 for v in deployment.spec.template.spec.volumes
395 if std.objectHas(v, "persistentVolumeClaim")
396 ],
397 local is_stateless = std.length(pvcs) == 0,
398
399 // Apps trying to maintain a majority quorum or similar will
400 // want to tune these carefully.
401 // NB: Upstream default is surge=1 unavail=1
402 rollingUpdate: if is_stateless then {
403 maxSurge: "25%", // rounds up
404 maxUnavailable: "25%", // rounds down
405 } else {
406 // Poor-man's StatelessSet. Useful mostly with replicas=1.
407 maxSurge: 0,
408 maxUnavailable: 1,
409 },
410 },
411
412 // NB: Upstream default is 0
413 minReadySeconds: 30,
414
415 replicas: 1,
416 assert self.replicas >= 0,
417 },
418 },
419
420 CrossVersionObjectReference(target): {
421 apiVersion: target.apiVersion,
422 kind: target.kind,
423 name: target.metadata.name,
424 },
425
426 HorizontalPodAutoscaler(name): $._Object("autoscaling/v1", "HorizontalPodAutoscaler", name) {
427 local hpa = self,
428
429 target:: error "target required",
430
431 spec: {
432 scaleTargetRef: $.CrossVersionObjectReference(hpa.target),
433
434 minReplicas: hpa.target.spec.replicas,
435 maxReplicas: error "maxReplicas required",
436
437 assert self.maxReplicas >= self.minReplicas,
438 },
439 },
440
441 StatefulSet(name): $._Object("apps/v1beta2", "StatefulSet", name) {
442 local sset = self,
443
444 spec: {
445 serviceName: name,
446
447 updateStrategy: {
448 type: "RollingUpdate",
449 rollingUpdate: {
450 partition: 0,
451 },
452 },
453
454 template: {
455 spec: $.PodSpec,
456 metadata: {
457 labels: sset.metadata.labels,
458 annotations: {},
459 },
460 },
461
462 selector: {
463 matchLabels: sset.spec.template.metadata.labels,
464 },
465
466 volumeClaimTemplates_:: {},
467 volumeClaimTemplates: [
468 // StatefulSet is overly fussy about "changes" (even when
469 // they're no-ops).
470 // In particular annotations={} is apparently a "change",
471 // since the comparison is ignorant of defaults.
472 std.prune($.PersistentVolumeClaim($.hyphenate(kv[0])) + { apiVersion:: null, kind:: null } + kv[1])
473 for kv in $.objectItems(self.volumeClaimTemplates_)
474 ],
475
476 replicas: 1,
477 assert self.replicas >= 1,
478 },
479 },
480
481 Job(name): $._Object("batch/v1", "Job", name) {
482 local job = self,
483
484 spec: $.JobSpec {
485 template+: {
486 metadata+: {
487 labels: job.metadata.labels,
488 },
489 },
490 },
491 },
492
493 // NB: kubernetes >= 1.8.x has batch/v1beta1 (olders were batch/v2alpha1)
494 CronJob(name): $._Object("batch/v1beta1", "CronJob", name) {
495 local cronjob = self,
496
497 spec: {
498 jobTemplate: {
499 spec: $.JobSpec {
500 template+: {
501 metadata+: {
502 labels: cronjob.metadata.labels,
503 },
504 },
505 },
506 },
507
508 schedule: error "Need to provide spec.schedule",
509 successfulJobsHistoryLimit: 10,
510 failedJobsHistoryLimit: 20,
511 // NB: upstream concurrencyPolicy default is "Allow"
512 concurrencyPolicy: "Forbid",
513 },
514 },
515
516 JobSpec: {
517 local this = self,
518
519 template: {
520 spec: $.PodSpec {
521 restartPolicy: "OnFailure",
522 },
523 },
524
525 selector: {
526 matchLabels: this.template.metadata.labels,
527 },
528
529 completions: 1,
530 parallelism: 1,
531 },
532
533 DaemonSet(name): $._Object("apps/v1beta2", "DaemonSet", name) {
534 local ds = self,
535 spec: {
536 updateStrategy: {
537 type: "RollingUpdate",
538 rollingUpdate: {
539 maxUnavailable: 1,
540 },
541 },
542 template: {
543 metadata: {
544 labels: ds.metadata.labels,
545 annotations: {},
546 },
547 spec: $.PodSpec,
548 },
549
550 selector: {
551 matchLabels: ds.spec.template.metadata.labels,
552 },
553 },
554 },
555
556 Ingress(name): $._Object("extensions/v1beta1", "Ingress", name) {
557 spec: {},
558
559 local rel_paths = [
560 p.path
561 for r in self.spec.rules
562 for p in r.http.paths
563 if !std.startsWith(p.path, "/")
564 ],
565 assert std.length(rel_paths) == 0 : "paths must be absolute: " + rel_paths,
566 },
567
568 ThirdPartyResource(name): $._Object("extensions/v1beta1", "ThirdPartyResource", name) {
569 versions_:: [],
570 versions: [{ name: n } for n in self.versions_],
571 },
572
573 CustomResourceDefinition(group, version, kind): {
574 local this = self,
575 apiVersion: "apiextensions.k8s.io/v1beta1",
576 kind: "CustomResourceDefinition",
577 metadata+: {
578 name: this.spec.names.plural + "." + this.spec.group,
579 },
580 spec: {
581 scope: "Namespaced",
582 group: group,
583 version: version,
584 names: {
585 kind: kind,
586 singular: $.toLower(self.kind),
587 plural: self.singular + "s",
588 listKind: self.kind + "List",
589 },
590 },
591 },
592
593 ServiceAccount(name): $._Object("v1", "ServiceAccount", name) {
594 },
595
596 Role(name): $._Object("rbac.authorization.k8s.io/v1beta1", "Role", name) {
597 rules: [],
598 },
599
600 ClusterRole(name): $.Role(name) {
601 kind: "ClusterRole",
602 },
603
604 Group(name): {
605 kind: "Group",
606 name: name,
607 apiGroup: "rbac.authorization.k8s.io",
608 },
609
610 User(name): {
611 kind: "User",
612 name: name,
613 apiGroup: "rbac.authorization.k8s.io",
614 },
615
616 RoleBinding(name): $._Object("rbac.authorization.k8s.io/v1beta1", "RoleBinding", name) {
617 local rb = self,
618
619 subjects_:: [],
620 subjects: [{
621 kind: o.kind,
622 namespace: o.metadata.namespace,
623 name: o.metadata.name,
624 } for o in self.subjects_],
625
626 roleRef_:: error "roleRef is required",
627 roleRef: {
628 apiGroup: "rbac.authorization.k8s.io",
629 kind: rb.roleRef_.kind,
630 name: rb.roleRef_.metadata.name,
631 },
632 },
633
634 ClusterRoleBinding(name): $.RoleBinding(name) {
635 kind: "ClusterRoleBinding",
636 },
637
638 // NB: datalines_ can be used to reduce boilerplate importstr as:
639 // kubectl get secret ... -ojson mysec | kubeseal | jq -r .spec.data > mysec-ssdata.txt
640 // datalines_: importstr "mysec-ssddata.txt"
641 SealedSecret(name): $._Object("bitnami.com/v1alpha1", "SealedSecret", name) {
642 spec: {
643 data:
644 if self.datalines_ != ""
645 then std.join("", std.split(self.datalines_, "\n"))
646 else error "data or datalines_ required (output from: kubeseal | jq -r .spec.data)",
647 datalines_:: "",
648 },
649 assert std.base64Decode(self.spec.data) != "",
650 },
651
652 // NB: helper method to access several Kubernetes objects podRef,
653 // used below to extract its labels
654 podRef(obj):: ({
655 Pod: obj,
656 Deployment: obj.spec.template,
657 StatefulSet: obj.spec.template,
658 DaemonSet: obj.spec.template,
659 Job: obj.spec.template,
660 CronJob: obj.spec.jobTemplate.spec.template,
661 }[obj.kind]),
662
663 // NB: return a { podSelector: ... } ready to use for e.g. NSPs (see below)
664 // pod labels can be optionally filtered by their label name 2nd array arg
665 podLabelsSelector(obj, filter=null):: {
666 podSelector: std.prune({
667 matchLabels:
668 if filter != null then $.filterMapByFields($.podRef(obj).metadata.labels, filter)
669 else $.podRef(obj).metadata.labels,
670 }),
671 },
672
673 // NB: Returns an array as [{ port: num, protocol: "PROTO" }, {...}, ... ]
674 // Need to split TCP, UDP logic to be able to dedup each set of protocol ports
675 podsPorts(obj_list):: std.flattenArrays([
676 [
677 { port: port, protocol: protocol }
678 for port in std.set(
679 std.flattenArrays([$.podRef(obj).spec.ports(protocol) for obj in obj_list])
680 )
681 ]
682 for protocol in ["TCP", "UDP"]
683 ]),
684
685 // NB: most of the "helper" stuff comes from above (podLabelsSelector, podsPorts),
686 // NetworkPolicy returned object will have "Ingress", "Egress" policyTypes auto-set
687 // based on populated spec.ingress or spec.egress
688 // See tests/test-simple-validate.jsonnet for example(s).
689 NetworkPolicy(name): $._Object("networking.k8s.io/v1", "NetworkPolicy", name) {
690 local networkpolicy = self,
691 spec: {
692 policyTypes: std.prune([
693 if networkpolicy.spec.ingress != [] then "Ingress" else null,
694 if networkpolicy.spec.egress != [] then "Egress" else null,
695 ]),
696 ingress: $.objectValues(self.ingress_),
697 ingress_:: {},
698 egress: $.objectValues(self.egress_),
699 egress_:: {},
700 },
701 },
702}