Перейти к основному содержимому

Пробуем новый ЯП Pkl для создание манифестов Kubernetes

· 9 мин. чтения
Иван Давыдов

Не так давно Apple выпустили раннюю версию своего нового языка программирования специального назначения – Pkl, предназначенного для написания конфигураций. Несмотря на то, что этот язык, по сути, только родился, он уже способен на некоторые интересные вещи. В этой статье опробуем Pkl путём написания YAML манифестов для Kubernetes.

Pkl

Что такое Pkl?

Pkl (читается, как Pickle) – это, как я уже выше написал, ЯП специального назначения для создания конфигураций. Синтаксис напоминает HCL и Nginx, только с кучей вспомогательных функций.

Pkl поддерживает валидацию полей через проставленые типов, проверки типа if, циклы, функции, наследование и GET http запросы. Всё это в настолько примитивном виде, на сколько это возможно.

Перевести код на Pkl вы можете в JSON, YAML, XML и другие форматы. Он также может генерировать код на Go, Java, Kotlin и Swift, но в статьей это рассматривать не будем.

Пример
example.pkl
name = "Swallow"

job {
title = "Sr. Nest Maker"
company = "Nests R Us"
yearsOfExperience = 2
}
example-output.json
{
"name": "Swallow",
"job": {
"title": "Sr. Nest Maker",
"company": "Nests R Us",
"yearsOfExperience": 2
}
}

Манифесты Kubernetes на Pkl

Для примера я взял манифесты своего pet-проекта, которые включают в себя Deployment, Service и Ingress ресурсы, и всё переписал на Pkl.

Deployment манифест
deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: davy-page-blog
namespace: davy
spec:
selector:
matchLabels:
app: davy-page-blog
template:
metadata:
labels:
app: davy-page-blog
spec:
containers:
- name: davy-page
image: image:v0.0.81
imagePullPolicy: IfNotPresent
resources:
...
readinessProbe:
...
livenessProbe:
...
imagePullSecrets:
- name: vault-registry-secret
Service манифест
service.yml
apiVersion: v1
kind: Service
metadata:
name: davy-page-blog
namespace: davy
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app: davy-page-blog
Ingress манифест
ingress.yml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: davy-page-blog
namespace: davy
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- davy.page
secretName: davy-tls
rules:
- host: davy.page
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: davy-page-blog
port:
number: 80

Основная задача заключалась в том, чтобы взять за основу текущие манифесты, которые у меня использовались для поднятия одного стенда, и создать конфигурацию на Pkl, с помощью которой, я смогу легко генерировать манифесты для трех разных окружений: prod-type-1, prod-type-2 и beta-(0..999).

Для валидации конфигов уже есть готовый модуль pkl-k8s. Он содержит в себе типизированные объекты для ресурсов Kubernetes. Если вы будете добавлять в будущий k8s ресурс поля, которых там не должно быть, или будете корректным полям выставлять некорректные значения, то при сборке конфига получите ошибку.

По итогу у меня получился вот такой главный файл, который создаёт необходимые манифесты для стендов любого типа:

import "@k8s/K8sResource.pkl"
import "./DavyPageStand.pkl"

stand = new DavyPageStand.Stand {
args = new {
// Все аргументы, которые нужны берутся из ENV переменных.
// Если их вдруг нет, то выставится либо дефолтное значение, либо null.
type = read?("env:TYPE") ?? "blog"
version = read?("env:VERSION") ?? "v0.0.81"
id = read?("env:ID")
}
}

output {
value = new Listing<K8sResource> {
stand.deployment
stand.service
stand.ingress
}
renderer = (K8sResource.output.renderer as YamlRenderer) {
isStream = true
}
}

Для гибкости, если вдруг нужно подредактировать, в полученных объектах, конкретные поля, можно сделать так (в моём понимаение это костыль, и так смысл есть делать, только если нужно прям супер срочно что-то починить и нет желания смотреть другие Pkl файлы):

...
value = new Listing<K8sResource> {
(stand.deployment) {
metadata {
name = "test"
}
spec {
template {
spec {
containers {
// меняем образ только у контейнера с таким именем
[[name == "davy-page"]] {
image = "bruh"
}
}
}
}
}
}
stand.service
stand.ingress
}
...

Конструкция (object) { ... } обнозначает наследование. Так можно накладывать на какой-то существующий объект свои новые значения.

Этот главный файл ссылается на DavyPageStand.pkl, который объединяет файлы: DavyPageDeployment.pkl, DavyPageService.pkl, DavyPageIngress.pkl, каждый из которых в свою очередь отвечает за создание объектов для своего ресурса (для какого можно понять по названию).

DavyPageStand.pkl
import "@k8s/K8sResource.pkl"

import "./DavyPageDeployment.pkl"
import "./DavyPageService.pkl"
import "./DavyPageIngress.pkl"

const function getHost(type, id) = if (id != null) "pr-\(id).\(type).dev.davy.page" else if (type == "blog") "davy.page" else "p.davy.page"

class Args {
type: String
version: String
id: String?
}

class Stand {
args: Args
_args = args // Костыль (про него будет ниже в статье)
ammend_deploy: Mapping

type_name = if (args.type == "blog") "davy-page-blog" else "davy-page-private"
name = if (args.id != null) "\(type_name)-\(args.id)" else "\(type_name)"

variant = if (args.id != null) "dev" else "prod"

deployment = new DavyPageDeployment.DPL {
args = new {
resource_name = name
version = _args.version
labels {
["app-variant"] = variant
}
}
...ammend_deploy
}

service = new DavyPageService.SVC {
args = new {
resource_name = name
labels {
["app-variant"] = variant
}
}
}

ingress = new DavyPageIngress.ING {
args = new {
resource_name = name
resource_host = getHost(_args.type, _args.id)
with_auth = _args.id != null || _args.type == "private"
labels {
["app-variant"] = variant
}
}
}
}
DavyPageDeployment.pkl
import "@k8s/api/apps/v1/Deployment.pkl"
import "@k8s/api/core/v1/Probe.pkl"
import "@k8s/api/core/v1/ResourceRequirements.pkl"

const defaultProbe = new Probe {
httpGet {
scheme = "HTTP"
path = "/img/logo.svg"
port = 80
}
failureThreshold = 3
timeoutSeconds = 1
}

const params {
readinessProbe = (defaultProbe) {
initialDelaySeconds = 10
periodSeconds = 5
}
livenessProbe = (defaultProbe) {
initialDelaySeconds = 15
periodSeconds = 10
}
resources = new ResourceRequirements {
requests {
["memory"] = "170Mi"
["cpu"] = "10m"
}
limits {
["memory"] = "340Mi"
["cpu"] = "100m"
}
}
image = "my-image"
image_pull_secret = "vault-registry-secret"
}

class Args {
resource_name: String
version: String
labels: Mapping<String, String>?
}

class DPL extends Deployment {
hidden args: Args

metadata {
namespace = "davy"
name = args.resource_name
labels {
["app"] = "\(args.resource_name)"
...args.labels
}
}

spec {
selector {
matchLabels {
["app"] = args.resource_name
}
}
template {
metadata {
labels {
["app"] = args.resource_name
}
}
spec {
containers {
new {
name = "davy-page"
image = "\(params.image):\(args.version)"
imagePullPolicy = "IfNotPresent"
resources = params.resources
readinessProbe = params.readinessProbe
livenessProbe = params.livenessProbe
}
}
imagePullSecrets {
new {
name = params.image_pull_secret
}
}
}
}
}
}
DavyPageService.pkl
import "@k8s/api/core/v1/Service.pkl"

class Args {
resource_name: String
labels: Mapping<String, String>
}

class SVC extends Service {
hidden args = Args

metadata {
namespace = "davy"
name = args.resource_name
labels {
["app"] = "\(args.resource_name)"
...args.labels
}
}
spec {
type = "ClusterIP"
ports {
new {
port = 80
targetPort = 80
name = "http"
}
}
selector {
["app"] = args.resource_name
}
}
}
DavyPageIngress.pkl
import "@k8s/api/networking/v1/Ingress.pkl"

class Args {
resource_name: String
resource_host: String
with_auth: Boolean
labels: Mapping<String, String>?
}

class ING extends Ingress {
hidden args: Args

metadata {
namespace = "davy"
name = args.resource_name
annotations {
["cert-manager.io/cluster-issuer"] = "letsencrypt-prod"
when (args.with_auth) {
["nginx.ingress.kubernetes.io/auth-type"] = "basic"
["nginx.ingress.kubernetes.io/auth-secret"] = "private-basic-auth"
["nginx.ingress.kubernetes.io/auth-realm"] = "private zone"
}
}
labels {
["app"] = "\(args.resource_name)"
...args.labels
}
}
spec {
ingressClassName = "nginx"
tls {
new {
hosts { args.resource_host }
secretName = "\(args.resource_name)-tls"
}
}
rules {
new {
host = args.resource_host
http {
paths {
new {
path = "/"
pathType = "Prefix"
backend {
service {
name = args.resource_name
port {
name = "http"
}
}
}
}
}
}
}
}
}
}

На выходе я получаю рабочий k8s манифест состоящий из трёх ресурсов:

Пример результата в YAML формате
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: davy-page-blog
app-variant: prod
name: davy-page-blog
namespace: davy
spec:
template:
metadata:
labels:
app: davy-page-blog
spec:
imagePullSecrets:
- name: vault-registry-secret
containers:
- image: my-image:v0.0.81
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 3
periodSeconds: 10
timeoutSeconds: 1
initialDelaySeconds: 15
httpGet:
path: /img/logo.svg
scheme: HTTP
port: 80
resources:
requests:
memory: 170Mi
cpu: 10m
limits:
memory: 340Mi
cpu: 100m
name: davy-page
readinessProbe:
failureThreshold: 3
periodSeconds: 5
timeoutSeconds: 1
initialDelaySeconds: 10
httpGet:
path: /img/logo.svg
scheme: HTTP
port: 80
selector:
matchLabels:
app: davy-page-blog
---
apiVersion: v1
kind: Service
metadata:
labels:
app: davy-page-blog
app-variant: prod
name: davy-page-blog
namespace: davy
spec:
ports:
- port: 80
name: http
targetPort: 80
type: ClusterIP
selector:
app: davy-page-blog
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
labels:
app: davy-page-blog
app-variant: prod
name: davy-page-blog
namespace: davy
spec:
ingressClassName: nginx
rules:
- host: davy.page
http:
paths:
- path: /
backend:
service:
port:
name: http
name: davy-page-blog
pathType: Prefix
tls:
- secretName: davy-page-blog-tls
hosts:
- davy.page

Проблемы

Есть несколько проблем, с которыми лично я столкнулся, во время работы с Pkl.

1. Указание ключей объектам

Начну с самого бесячего. Если вы хотите полю в объекте назначить переменную с таким же названием – вы получите Stack Overflow. Потому что Pkl будет подставлять вашу переменную вместо и ключа, и значения.

Pkl Error – A stack overflow occurred.
arg = "test"

object = new MyObject {
arg = arg
}

Чтобы этого избежать, приходится придумывать костыли:

good
_arg = "test"

object = new MyObject {
arg = _arg
}

2. Расширения для IDE

Лично я пользуюся VSCode и для него пока есть только расширение, раскрашивающее синтаксис, и не более. Никаких подсказок нет. Для IntelliJ IDEA вроде как есть что-то получше, но я этого не проверял.

Расширение для VSCode, к тому же, не устанавливается из вкладки "Extensions", а скачивается отдельным файлом из гитхаба) Это не сильный минус, просто лишний раз показывает на насколько ещё молод Pkl.

3. Типизация

Вы не можете через тип указать, что такое-то поле должно быть конкретной строкой, или одной из строк (Пример из TS: let a: "string" | "string1"). Такого можно добиться, но только через if, что не очень удобно.

4. Глобальные переменные

Их нет. Вернее они есть, но только статичные – поменять их в коде будет нельзя. Поэтому объявить глобальную переменную для всего процесса билда конфига вы не сможете.

Есть module.<var> переменные, но они относятся только к контексту одного файла.

Вы также можете задавать различные properties через cli -p name=value. Они будут доступны всем файлам в рамках одного билда, но это всё равно будет статичная переменная. Тоже самое про ENV.

5. Http запросы

Из HTTP запроов есть только GET, и из него вы можете только raw текст вытащить и в таком виде где-нибудь использовать. То-есть достать с запроса JSON, и обращаться к конкретным полям вы не сможете.

Вывод

C Pkl'ом интересно работать и уже можно, как мне кажется, начинать активно практиковаться в нём в своих pet-проектах. Но завозить его в коммерческую разработку пока всё-таки рановато. Это связано не только с выше перечисленными проблемами, но также с отсутствием стабильной версии: каждое новое минорное обновление может принести существенные изменения в язык, из-за чего потребуется значительная адаптация существующих Pkl-файлов для обновления версии.