How to deploy Kubernetes meme

Premise

The example in this post is about using a kubernetes CustomResourceDefinition and Operator implemented with ZIO to simplify our lives as someone who made need to run a lot of infrastructure set up (dare I even say Dev/Ops).

The example is complete/functioning, but isn't the most robust solution for what it does. It is meant to be enough to work, and illustrate the concept with a solution to a made-up problem - but not exactly a model code base :angel:

Let's dig in!

Hey, can you set me up a database?

Perhaps you're the one with the password/access to the database, or the only person nearby on the team that "knows SQL", but it's part of your daily life to set up databases for people. In between your coding work, you run a lot of the following type of code for people who need to access their own database from a kubernetes cluster:

CREATE DATABASE stuff;
CREATE USER stuff PASSWORD 'abc123';
GRANT ALL ON DATABASE stuff TO stuff;

Your hard work is then rewarded by remembering to set up a Secret for each database as well, so the user can easily mount it to their pods for access.

But, wait a minute - you've just picked up a nifty framework called ZIO, and have decided to automate a bit of you daily todos.

Enter ZIO

Let's create a SQLService that will set up a matching database and user:

trait SQLService {
  def createDatabaseWithRole(db: String): Task[String]
}

// We're going to be lazy, and not use a Logger
case class SQLServiceLive(cnsl: Console.Service) extends SQLService {
 override def createDatabaseWithRole(db: String): Task[String] = ???
}

We aren't running this so often that we need a dedicated connection pool, so let's just grab a connection from the driver, and use this neat new thing we've learned about called Zmanaged.

private val acquireConnection =
  ZIO.effect {
    val url = {
      sys.env.getOrElse(
        "PG_CONN_URL", // If this environment variable isn't set...
        "jdbc:postgresql://localhost:5432/?user=postgres&password=password" // ... use this default one.
      )
    }
    DriverManager.getConnection(url)
  }

private val managedConnection: ZManaged[Any, Throwable, Connection] =
  ZManaged.fromAutoCloseable(acquireConnection)

// We'll use a ZManaged for Statements too!
private def acquireStatement(conn: Connection): Task[Statement] =
  Task.effect {
    conn.createStatement
  }

def managedStatement(conn: Connection): ZManaged[Any, Throwable, Statement] =
  ZManaged.fromAutoCloseable(acquireStatement(conn))

What's a ZManaged?

ZManaged is a data structure that encapsulates the acquisition and the release of a resource, which may be used by invoking the use method of the resource. The resource will be automatically acquired before the resource is used and automatically released after the resource is used.

So a ZManged is like a try/catch/finally that handles your resources - but you don't have to set up a lot of boilerplate. A common pattern I've used in the past would be to use a thunk to do something similar. The (very unsafe, with no error handling) example below handles the acquisition and release of the connection + statement, and you just need to pass in a function that takes a statement, and produces a result.

def sqlAction[T](thunk: Statement => T): T = {
  val url: String = "jdbc:postgresql://localhost:5432/?user=postgres&password=password"
  val connection = DriverManager.getConnection(url)
  val statement: Statement = connection.createStatement()
  val result: T = thunk(statement)
  statement.close()
  connection.close()
  result
}

def someSql = sqlAction { statement =>
  // do something with statement
  ???
}

In the spirit of our thunk, we'll write a ZIO function that takes a Statement, a String (some SQL), and will execute it. We'll print the SQL we run, or log the error that falls out.

val executeSql: Statement => String => ZIO[Any, Throwable, Unit] =
  st =>
    sql =>
      ZIO
        .effect(st.execute(sql))
        .unit
        .tapBoth(
          err => cnsl.putStrLnErr(err.getMessage),
          _ => cnsl.putStrLn(sql)
        )

Now with all of our pieces in place, we can implement our createDatabaseWithRole that will safely grab a Connection + Statement, run our SQL, and then automatically close those resources when done. It'll even hand back the random password generated:

override def createDatabaseWithRole(db: String): Task[String] = {
    managedConnection.use { conn =>
      managedStatement(conn).use { st =>
        for {
          pw <- ZIO.effect(scala.util.Random.alphanumeric.take(6).mkString)
          _  <- executeSql(st)(s"CREATE DATABASE $db")
          _  <- executeSql(st)(s"CREATE USER $db PASSWORD '$pw'")
          _  <- executeSql(st)(s"GRANT ALL ON DATABASE $db TO $db")
        } yield pw
      }
    }
  }

:heart_eyes: A thing ouf beauty! Now we can just make a simple ZIO program to call our new service, and call it a day!

val simpleProgram: ZIO[Has[SQLService], Nothing, Unit] =
    SQLService(_.createDatabaseWithRole("someUser"))
      .unit
      .catchAll(_ => ZIO.unit)

Automate the Automation

j/k you still have to stop what you're doing to run this for people, and you still need to make the Secret! Wouldn't it be neat if we could have some sort of Kubernetes resource that allowed anyone to just update a straightforward file? What if we had something like:

apiVersion: alterationx10.com/v1
kind: Database
metadata:
  name: databases
spec:
  databases:
    - mark
    - joanie
    - oliver

Well, it turns out we can have nice things! We can create a CustomResourceDefinition that will use that exact file as shown above! The following yaml sets up our own Kind called Database that has a spec of databases, which is just an array of String.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  # name must match the spec fields below, and be in the form: <plural>.<group>
  name: databases.alterationx10.com
spec:
  # group name to use for REST API: /apis/<group>/<version>
  group: alterationx10.com
  # list of versions supported by this CustomResourceDefinition
  versions:
    - name: v1
      # Each version can be enabled/disabled by Served flag.
      served: true
      # One and only one version must be marked as the storage version.
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                databases:
                  type: array
                  items:
                    type: string
  # either Namespaced or Cluster
  scope: Namespaced
  names:
    # plural name to be used in the URL: /apis/<group>/<version>/<plural>
    plural: databases
    # singular name to be used as an alias on the CLI and for display
    singular: database
    # kind is normally the CamelCased singular type. Your resource manifests use this.
    kind: Database
    # shortNames allow shorter string to match your resource on the CLI
    shortNames:
      - db

Since we don't want to run jobs manually, we can create an Operator that will watch for our CustomResourceDefinition , and take action automatically! With the zio-k8s library, these can be fairly straightforward to implement.

  val eventProcessor: EventProcessor[Clock, Throwable, Database] =
    (ctx, event) =>
      event match {
        case Reseted() =>
          cnsl.putStrLn(s"Reseted - will (re) add any existing").ignore
        case Added(item) =>
          processItem(item)
        case Modified(item) =>
          processItem(item)
        case Deleted(item) =>
          cnsl.putStrLn(s"Deleted - but not performing action").ignore
      }

For our example program, we will always try and create the databases listed in the resources, and log/ignore the error if a database already exists on Added and Modified. We will also take the auto-generated password, and create a secret for it as well! We won't tear anything down on Deleted.

def processItem(item: Database): URIO[Clock, Unit] =
    (for {
      // Get all of our databases
      dbs <- ZIO.fromOption(item.spec.flatMap(_.databases).toOption)
      // For each database
      _ <- ZIO.foreach(dbs) { db =>
        (for {
          _ <- cnsl.putStrLn(s"Processing $db...")
          // Create things
          pw <- sqlService.createDatabaseWithRole(db)
          _  <- cnsl.putStrLn(s"... $db created ...")
          // Put the generated PW in a k8s secret
          _ <- upsertSecret(
            Secret(
              metadata = Some(
                ObjectMeta(
                  name = Option(db),
                  namespace = item.metadata
                    .map(_.namespace)
                    .getOrElse(Option("default"))
                )
              ),
              data = Map(
                "POSTGRES_PASSWORD" -> Chunk.fromArray(
                  pw.getBytes()
                )
              )
            )
          ).tapError(e => cnsl.putStrLnErr(s"Couldn't make secret:\n $e"))
          _ <- cnsl.putStrLn(s"... Secret created for $db")
        } yield ()).ignore
      }
    } yield ()).ignore

def upsertSecret(
      secret: Secret
  ): ZIO[Clock, K8sFailure, Secret] = {
    for {
      nm       <- secret.getName
      ns       <- secret.getMetadata.flatMap(_.getNamespace)
      existing <- secrets.get(nm, K8sNamespace(ns)).option
      sec <- existing match {
        case Some(_) => secrets.replace(nm, secret, K8sNamespace(ns))
        case None    => secrets.create(secret, K8sNamespace(ns))
      }
    } yield sec
  }

That's about it! We now have the code we need to automate our daily drudgery!

Deploying

This example is targeted at deploying to the instance of Kubernetes provided by Docker, mainly so we can use our locally published docker image.

Auto generation of our CRD client

We will need the zio-k8s-crd SBT plugin to auto generate the client needed to work with our CRD. Once added, we can update our build.sbt file with the following, which points to the new CRD. With this in place, a compile step will generate the code for us.

externalCustomResourceDefinitions := Seq(
  file("crds/databases.yaml")
)

enablePlugins(K8sCustomResourceCodegenPlugin)

Building a Docker image of our service

We'll use the sbt-native-packager SBT plugin to build the docker image for us. We'll need a more recent version of Java than what is default, so well set dockerBaseImage := "openjdk:17.0.2-slim-buster" and set our project to .enablePlugins(JavaServerAppPackaging). Now, when we run sbt docker:publishLocal, it will build and tag an image with the version specified in our build.sbt file that we can use in our kubernetes deployment yaml.

REPOSITORY      TAG            IMAGE ID       CREATED         SIZE
smooth-operator 0.1.0-SNAPSHOT a4e2c2025cba   2 days ago      447MB

Who doesn't love more YAML?

This section will go over the kubernetes yaml needed to deploy everything we need for our app.

We will create a standard Deployment of postgres, configured to have the super secure password of password :shushing_face:. We will also create a Service to route traffic to it.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  labels:
    app: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres
          env:
            - name: POSTGRES_PASSWORD
              value: "password"
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
      protocol: TCP

For deploying our Operator, we ultimately are going to set up a Deployment for it, but we're going to need a few more bells and whistles first. Our app will need the right permissions to be able to watch our CustomResourceDefinitions, as well as accessing Secrets - these actions are done by the ServiceAccount our pod runs under. We will create a ClusterRole that has the required permissions, and use a ClusterRoleBinding to assign the ClusterRole to our ServiceAccount.

A very useful kubectl command to check and make sure your permissions are correct is kubectl auth can-i ... command.

kubectl auth can-i create secrets --as=system:serviceaccount:default:db-operator-service-account -n default
kubectl auth can-i watch databases --as=system:serviceaccount:default:db-operator-service-account -n default

With all that in mind, we can use the following yaml to get our app up and running.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: db-operator-service-account
automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: db-operator-cluster-role
rules:
  - apiGroups: [ "alterationx10.com" ]
    resources: [ "databases" ]
    verbs: [ "get", "watch", "list" ]
  - apiGroups: [ "" ]
    resources: [ "secrets" ]
    verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: db-operator-cluster-role-binding
subjects:
  - kind: ServiceAccount
    name: db-operator-service-account
    namespace: default
roleRef:
  kind: ClusterRole
  name: db-operator-cluster-role
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: db-operator
  labels:
    app: db-operator
spec:
  selector:
    matchLabels:
      app: db-operator
  template:
    metadata:
      labels:
        app: db-operator
    spec:
      serviceAccountName: db-operator-service-account
      containers:
        - name: db-operator
          image: smooth-operator:0.1.0-SNAPSHOT
          env:
            - name: PG_CONN_URL
              value: "jdbc:postgresql://postgres:5432/?user=postgres&password=password"
---

Note: When deploying an operator "for real", you want to take care that only one instance is running/working at a time. This is not covered here, but you should look into Leader Election

Running the Example

You can view the source code on GitHub, tagged at v0.0.3 at the time of this blog post.

Assuming you have Docker/Kubernetes et up, you should be able to run the following commands to get an example up and running:

# Build/publish our App to the local Docker repo
sbt docker:publishLocal
# Deploy our CustomResourceDefinition
kubectl apply -f crds/databases.yaml
# Deploy postgres
kubectl apply -f yaml/postgres.yaml
# Deploy our app
kubectl apply -f yaml/db_operator.yaml
# Create Database Resource
kubectl apply -f yaml/databases.yaml

If you check the logs of the running pod, you should hopefully see the SQL successfully ran, and can also use kubectl to check for new Secrets!

➜ smooth-operator (main) ✗ kubectl logs db-operator-74f756c89c-x5f5b 
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
Reseted - will (re) add any existing
Processing mark...
CREATE DATABASE mark
CREATE USER mark PASSWORD 'VCaHar'
GRANT ALL ON DATABASE mark TO mark
... mark created ...
... Secret created for mark
Processing joanie...
CREATE DATABASE joanie
CREATE USER joanie PASSWORD 'mdlQKB'
GRANT ALL ON DATABASE joanie TO joanie
... joanie created ...
... Secret created for joanie
Processing oliver...
CREATE DATABASE oliver
CREATE USER oliver PASSWORD 'vYODSt'
GRANT ALL ON DATABASE oliver TO oliver
... oliver created ...
... Secret created for oliver

Nice.

There you have it! After a day or two of set up, now you too can save tens of minutes every day!