Let's connect
Let's connect

How to build AI apps with Scala 3 and Besom

Picture of Łukasz Biały, Scala Dev Advocate

Łukasz Biały

Scala Dev Advocate

12 minutes read

K3S Cluster

scala

import com.augustnagro.magnum.*

object Migrations:

// ... migrations skipped for brevity

  private def createEmbeddingsTable(using DbCon): Unit =
    sql"""CREATE TABLE IF NOT EXISTS docs_embeddings AS
          SELECT id, url, content, pgml.embed('intfloat/e5-small', 'passage: ' || content)::vector(384) AS embedding
          FROM docs""".update.run()

scala

import com.augustnagro.magnum.*

class Db(private val ds: javax.sql.DataSource):

  def queryEmbeddings(query: String): Option[Db.QueryResult] =
    connect(ds) {
      sql"""WITH request AS (
              SELECT pgml.embed(
                'intfloat/e5-small',
                'query: ' || $query
              )::vector(384) AS query_embedding
            )
            SELECT
              id,
              url,
              content,
              1 - (
                embedding::vector <=> (SELECT query_embedding FROM request)
              ) AS cosine_similarity
            FROM docs_embeddings
            ORDER BY cosine_similarity DESC
            LIMIT 1""".query[Db.QueryResult].run().headOption
    }
    
object Db:
  case class QueryResult(
	id: Int, 
	url: String, 
	content: String, 
	similarity: Double
  )

scala

import sttp.openai.OpenAISyncClient
import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatResponse
import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
import sttp.openai.requests.completions.chat.message.{Message, Content}

object AI:
  def askDocs(question: String)(using conf: Config, db: Db): String =
    val openAI = OpenAISyncClient(conf.openAIApiKey)

    val contentFromDb = db.queryEmbeddings(question)

    val prompt = contentFromDb match
      case None =>
        s"""You are a programming assistant. User has asked this question:
           |  $question
           |We weren't able to find anything about that in our database. 
           |Please respond politely and explain that you have no information about this subject.
           |""".stripMargin
      case Some(result) =>
        s"""You are a programming assistant. User has asked this question:
           |  $question
           |We were able to find material regarding this topic in our database:
           |
           |${result.content}
           |
           |Please use the document above to formulate an answer for the user. You can use
           |markdown with code snippets in your response. In the end of your response inform
           |the user that more information can be found at this url:
           |
           |${result.url}
           |""".stripMargin

    val bodyMessages: Seq[Message] = Seq(
      Message.UserMessage(
        content = Content.TextContent(prompt)
      )
    )

    val chatRequestBody: ChatBody = ChatBody(
      model = ChatCompletionModel.GPT35Turbo,
      messages = bodyMessages
    )

    Try(openAI.createChatCompletion(chatRequestBody)) match
      case Failure(exception) =>
        scribe.error("Failed to ask OpenAI", exception)
        "Oops, something is not right!"
      case Success(response) =>
        response.choices.headOption match
          case None =>
            scribe.error("OpenAI response is empty")
            "Oops, something is not right!"
          case Some(chatResponse) =>
            chatResponse.message.content

scala

import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet

object MD:
  private val options = MutableDataSet()

  private val parser = Parser.builder(options).build()
  private val renderer = HtmlRenderer.builder(options).build()

  def render(markdown: String): String =
    val document = parser.parse(markdown)

    renderer.render(document)

scala

import sttp.tapir.*
import sttp.tapir.files.*
import sttp.tapir.server.jdkhttp.*
import java.util.concurrent.Executors

object Http:
  private val index =
    endpoint.get
      .out(htmlBodyUtf8)
      .handle(_ => Right(Templates.index()))

  private def inquire(using Config, Db) =
    endpoint.post
      .in("inquire")
      .in(formBody[Map[String, String]])
      .out(htmlBodyUtf8)
      .handle { form =>
        form.get("q").flatMap { s => if s.isBlank() then None else Some(s) } match
          case Some(question) =>
            val response = AI.askDocs(question)
            val rendered = MD.render(response)

            Right(Templates.response(rendered))

          case None => Right(Templates.response("Have nothing to ask?"))
      }

  def startServer()(using cfg: Config, db: Db) =
    JdkHttpServer()
      .executor(Executors.newVirtualThreadPerTaskExecutor())
      .addEndpoint(staticResourcesGetServerEndpoint("static")(classOf[App].getClassLoader, "/"))
      .addEndpoint(inquire)
      .addEndpoint(index)
      .port(cfg.port)
      .start()

bash

docker login -u lbialy -p $(gh auth token) ghcr.io

bash

scala-cli package app -o app.main -f --assembly

console

FROM ghcr.io/graalvm/jdk-community:21

COPY app.main /app/main

ENTRYPOINT java -jar /app/main

bash

docker buildx build . -f Dockerfile --platform linux/amd64 -t ghcr.io/lbialy/askme:0.1.0

docker push ghcr.io/lbialy/askme:0.1.0

scala

import besom.*
import besom.api.hcloud
import hcloud.inputs.*

@main def main = Pulumi.run {

  val locations = Vector("fsn1", "nbg1", "hel1")

  val sshPublicKey  = config.requireString("ssh_public_key_path").map(os.Path(_)).map(os.read(_))
  val sshPrivateKey = config.requireString("ssh_private_key_path").map(os.Path(_)).map(os.read(_))

  val hcloudProvider = hcloud.Provider(
    "hcloud",
    hcloud.ProviderArgs(
      token = config.requireString("hcloud_token")
    )
  )

  val sshKey = hcloud.SshKey(
    "ssh-key",
    hcloud.SshKeyArgs(
      name = "ssh-key",
      publicKey = sshPublicKey
    ),
    opts(provider = hcloudProvider)
  )

  val serverPool = (1 to 1).map { i =>
    hcloud
      .Server(
        s"k3s-server-$i",
        hcloud.ServerArgs(
          serverType = "cx21",
          name = s"k3s-server-$i",
          image = "ubuntu-22.04",
          location = locations(i % locations.size),
          sshKeys = List(sshKey.name),
          publicNets = List(
            ServerPublicNetArgs(
              ipv4Enabled = true,
              ipv6Enabled = false
            )
          )
        ),
        opts(provider = hcloudProvider)
      )
  }.toVector

  val spawnNodes = serverPool.parSequence

  val nodeIps = serverPool.map(_.ipv4Address).parSequence
  
  Stack(spawnNodes).exports(
    nodeIps = nodeIps
  )
}

scala

// on top
import besom.api.command.*

// inside of Pulumi.run function!

  val clusterName = "askme-dev"

  val ghcrToken = config.requireString("github_docker_token")

  val authFileContents =
    p"""configs:
       |  ghcr.io:
       |    auth:
       |      username: lbialy
       |      password: $ghcrToken""".stripMargin

  val echoFileCommand =
    p"""mkdir -p /etc/rancher/k3s/ && cat << EOF > /etc/rancher/k3s/registries.yaml
       |$authFileContents
       |EOF""".stripMargin

  case class K3S(kubeconfig: Output[String], token: Output[String], nodeIps: Output[Vector[String]])

  val k3s = serverPool.parSequence.flatMap { servers =>
    // split servers into leader and followers group
    val leader    = servers.head
    val followers = servers.tail

    val leaderConn = remote.inputs.ConnectionArgs(
      host = leader.ipv4Address,
      user = "root",
      privateKey = privateKey
    )

    val k3sVersion = "v1.29.1+k3s2"

    val initializeK3sLeader = remote.Command(
      "start-k3s-leader",
      remote.CommandArgs(
        connection = leaderConn,
        create = s"curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=$k3sVersion sh -s - --flannel-backend=wireguard-native",
        delete = "sh /usr/local/bin/k3s-uninstall.sh"
      )
    )

    val token =
      remote
        .Command(
          "get-leader-token",
          remote
            .CommandArgs(
              connection = leaderConn,
              create = "cat /var/lib/rancher/k3s/server/node-token"
            ),
          opts(dependsOn = initializeK3sLeader)
        )
        .stdout

    val insertGhcrToken =
      remote.Command(
        "insert-ghcr-token-leader",
        remote.CommandArgs(
          connection = leaderConn,
          create = echoFileCommand
        ),
        opts(dependsOn = initializeK3sLeader)
      )

    val restartK3sLeader =
      remote.Command(
        "restart-k3s-leader",
        remote.CommandArgs(
          connection = leaderConn,
          create = "sudo systemctl force-reload k3s"
        ),
        opts(dependsOn = insertGhcrToken)
      )

    val kubeconfig =
      remote
        .Command(
          "get-kubeconfig",
          remote.CommandArgs(
            connection = leaderConn,
            create = "cat /etc/rancher/k3s/k3s.yaml"
          ),
          opts(dependsOn = initializeK3sLeader)
        )
        .stdout

    val initializeFollowers = followers.zipWithIndex.map { case (followerServer, idx) =>
      val followerIdx = idx + 1

      val followerConnection = remote.inputs.ConnectionArgs(
        host = followerServer.ipv4Address,
        user = "root",
        privateKey = privateKey
      )

      val installOnFollower = remote.Command(
        s"start-k3s-follower-${followerIdx}",
        remote.CommandArgs(
          connection = followerConnection,
          create =
            p"curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=$k3sVersion K3S_URL=https://${leader.ipv4Address}:6443 K3S_TOKEN=${token} sh -s -"
        ),
        opts(dependsOn = restartK3sLeader)
      )

      val insertGhcrToken = remote.Command(
        s"insert-ghcr-token-${followerIdx}",
        remote.CommandArgs(
          connection = followerConnection,
          create = echoFileCommand
        ),
        opts(dependsOn = installOnFollower)
      )

      val restartK3sFollower = remote.Command(
        s"restart-k3s-follower-${followerIdx}",
        remote.CommandArgs(
          connection = followerConnection,
          create = "sudo systemctl force-reload k3s-agent"
        ),
        opts(dependsOn = insertGhcrToken)
      )

      restartK3sFollower
    }

    val ipAddresses = servers.map(_.ipv4Address).parSequence

    val adjustedKubeconfig =
      for
        _        <- restartK3sLeader
        config   <- kubeconfig
        leaderIp <- serverPool.head.ipv4Address
      yield config.replace("default", clusterName).replace("127.0.0.1", leaderIp)

    initializeFollowers.parSequence.map(_ => K3S(adjustedKubeconfig, token, ipAddresses))
  }

scala

import besom.api.command.*

case class AuthArgs(
  registry: Input[NonEmptyString], 
  username: Input[NonEmptyString], 
  password: Input[NonEmptyString]
)

case class K3SArgs private (
  clusterName: Output[NonEmptyString],
  servers: Vector[Output[String]],
  privateKey: Output[String],
  k3sVersion: Output[String],
  registryAuth: Output[List[AuthArgs]]
):
  def authFileContents(using Context): Output[String] =
    registryAuth.flatMap { registryCreds =>
      registryCreds.foldLeft(Output("configs:\n")) { case (acc, cred) =>
        acc.flatMap { str =>
          val block =
            p"""  ${cred.registry}:
               |    auth:
               |      username: ${cred.username}
               |      password: ${cred.password}""".stripMargin

          block.map(b => str + b)
        }
      }
    }

object K3SArgs:
  def apply(
    clusterName: Input[NonEmptyString],
    serverIps: Vector[Input[String]],
    privateKey: Input[String],
    k3sVersion: Input[String],
    registryAuth: Input.OneOrList[AuthArgs] = List.empty
  )(using Context): K3SArgs =
    new K3SArgs(
      clusterName.asOutput(),
      serverIps.map(_.asOutput()),
      privateKey.asOutput(),
      k3sVersion.asOutput(),
      registryAuth.asManyOutput()
    )

scala

case class K3S(kubeconfig: Output[String], leaderIp: Output[String], followerIps: Output[Vector[String]])(using ComponentBase)
    extends ComponentResource derives RegistersOutputs

object K3S:
  def apply(name: NonEmptyString, args: K3SArgs, resourceOpts: ComponentResourceOptions)(using Context): Output[K3S] =
    component(name, "user:component:K3S", resourceOpts) {
      val echoFileCommand =
        p"""mkdir -p /etc/rancher/k3s/ && cat << EOF > /etc/rancher/k3s/registries.yaml
           |${args.authFileContents}
           |EOF""".stripMargin

      val k3sVersion = args.k3sVersion

      val leaderIp = args.servers.headOption match
        case Some(ip) => ip
        case None     => Output.fail(Exception("Can't deploy K3S without servers, silly."))

      val followers = if args.servers.isEmpty then Vector.empty else args.servers.tail

      val leaderConn = remote.inputs.ConnectionArgs(
        host = leaderIp,
        user = "root",
        privateKey = args.privateKey
      )

      val initializeK3sLeader = remote.Command(
        "start-k3s-leader",
        remote.CommandArgs(
          connection = leaderConn,
          create = p"curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=$k3sVersion sh -s - --flannel-backend=wireguard-native",
          delete = "sh /usr/local/bin/k3s-uninstall.sh"
        )
      )

      val token =
        remote
          .Command(
            "get-leader-token",
            remote
              .CommandArgs(
                connection = leaderConn,
                create = "cat /var/lib/rancher/k3s/server/node-token"
              ),
            opts(dependsOn = initializeK3sLeader)
          )
          .stdout

      val insertGhcrToken =
        remote.Command(
          "insert-ghcr-token-leader",
          remote.CommandArgs(
            connection = leaderConn,
            create = echoFileCommand
          ),
          opts(dependsOn = initializeK3sLeader)
        )

      val restartK3sLeader =
        remote.Command(
          "restart-k3s-leader",
          remote.CommandArgs(
            connection = leaderConn,
            create = "sudo systemctl force-reload k3s"
          ),
          opts(dependsOn = insertGhcrToken)
        )

      val kubeconfig =
        remote
          .Command(
            "get-kubeconfig",
            remote.CommandArgs(
              connection = leaderConn,
              create = "cat /etc/rancher/k3s/k3s.yaml"
            ),
            opts(dependsOn = initializeK3sLeader)
          )
          .stdout

      val initializeFollowers = followers.zipWithIndex.map { case (followerIpOutput, idx) =>
        val followerIdx = idx + 1

        val followerConnection = remote.inputs.ConnectionArgs(
          host = followerIpOutput,
          user = "root",
          privateKey = args.privateKey
        )

        val installOnFollower = remote.Command(
          s"start-k3s-follower-$followerIdx",
          remote.CommandArgs(
            connection = followerConnection,
            create =
              p"curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=$k3sVersion K3S_URL=https://${leaderIp}:6443 K3S_TOKEN=${token} sh -s -"
          ),
          opts(dependsOn = restartK3sLeader)
        )

        val insertGhcrToken = remote.Command(
          s"insert-ghcr-token-$followerIdx",
          remote.CommandArgs(
            connection = followerConnection,
            create = echoFileCommand
          ),
          opts(dependsOn = installOnFollower)
        )

        val restartK3sFollower = remote.Command(
          s"""restart-k3s-follower-$followerIdx""",
          remote.CommandArgs(
            connection = followerConnection,
            create = "sudo systemctl force-reload k3s-agent"
          ),
          opts(dependsOn = insertGhcrToken)
        )

        restartK3sFollower
      }.parSequence

      val adjustedKubeconfig =
        for
          _           <- restartK3sLeader
          _           <- initializeFollowers
          config      <- kubeconfig
          clusterName <- args.clusterName
          ip          <- leaderIp
        yield config.replace("default", clusterName).replace("127.0.0.1", ip)

      new K3S(adjustedKubeconfig, leaderIp, Output.sequence(followers))
    }

scala

  val clusterName = "askme-dev"

  val ghcrToken = config.requireString("github_docker_token").flatMap(_.toNonEmptyOutput)

  val k3s = K3S(
    clusterName,
    K3SArgs(
      clusterName = clusterName,
      servers = serverPool.map(_.ipv4Address),
      privateKey = sshPrivateKey,
      k3sVersion = "v1.29.2+k3s1",
      registryAuth = AuthArgs("ghcr.io", "lbialy", ghcrToken)
    ),
    ComponentResourceOptions(
      deletedWith = serverPool.headOption.getOrElse(None)
    )
  )

scala

case class PostgresArgs private (
  port: Output[Int],
  dashboardPort: Output[Int]
)
object PostgresArgs:
  def apply(port: Input[Int], dashboardPort: Input[Int])(using Context): PostgresArgs =
    new PostgresArgs(port.asOutput(), dashboardPort.asOutput())

case class AppArgs private (
  name: Output[NonEmptyString],
  replicas: Output[Int],
  containerPort: Output[Int],
  servicePort: Output[Int],
  host: Output[NonEmptyString],
  openAIToken: Output[String],
  docsBaseUrl: Output[String]
)
object AppArgs:
  def apply(
    name: Input[NonEmptyString],
    replicas: Input[Int],
    containerPort: Input[Int],
    servicePort: Input[Int],
    host: Input[NonEmptyString],
    openAIToken: Input[String],
    docsBaseUrl: Input[String]
  )(using Context): AppArgs =
    new AppArgs(
      name.asOutput(),
      replicas.asOutput(),
      containerPort.asOutput(),
      servicePort.asOutput(),
      host.asOutput(),
      openAIToken.asOutput(),
      docsBaseUrl.asOutput()
    )

case class AppDeploymentArgs(
  postgresArgs: PostgresArgs,
  appArgs: AppArgs
)

scala

import scala.concurrent.duration.*

import besom.api.{kubernetes => k8s}
import besom.internal.CustomTimeouts
import k8s.core.v1.enums.*
import k8s.core.v1.inputs.*
import k8s.apps.v1.inputs.*
import k8s.meta.v1.inputs.*
import k8s.apps.v1.{Deployment, DeploymentArgs, StatefulSet, StatefulSetArgs}
import k8s.core.v1.{Namespace, Service, ServiceArgs}
import k8s.networking.v1.{Ingress, IngressArgs}
import k8s.networking.v1.inputs.{
  IngressSpecArgs,
  IngressRuleArgs,
  HttpIngressRuleValueArgs,
  HttpIngressPathArgs,
  IngressBackendArgs,
  IngressServiceBackendArgs,
  ServiceBackendPortArgs
}

scala

case class AppDeployment(
  jdbcUrl: Output[String],
  appUrl: Output[String]
)(using ComponentBase)
    extends ComponentResource
    derives RegistersOutputs

object AppDeployment:
  def apply(name: NonEmptyString, args: AppDeploymentArgs, resourceOpts: ComponentResourceOptions)(using Context): Output[AppDeployment] =
    component(name, "user:component:app-deployment", resourceOpts) {
    
	  AppDeployment(???, ???)
	}

scala

  val labels   = Map("app" -> name)
  val dbLabels = Map("db" -> name)

  val appNamespace = Namespace(name)

  val openAIToken   = args.appArgs.openAIToken
  val postgresPort  = args.postgresArgs.port
  val dashboardPort = args.postgresArgs.dashboardPort
  val containerPort = args.appArgs.containerPort
  val servicePort   = args.appArgs.servicePort
  val ingressHost   = args.appArgs.host
  val docsBaseUrl   = args.appArgs.docsBaseUrl

scala

  val postgresmlStatefulSet = k8s.apps.v1.StatefulSet(
    "postgresml",
    k8s.apps.v1.StatefulSetArgs(
      metadata = ObjectMetaArgs(
        name = "postgresml",
        namespace = appNamespace.metadata.name,
        labels = dbLabels
      ),
      spec = StatefulSetSpecArgs(
        serviceName = "postgresml",
        replicas = 1,
        selector = LabelSelectorArgs(matchLabels = dbLabels),
        template = PodTemplateSpecArgs(
          metadata = ObjectMetaArgs(
            labels = dbLabels
          ),
          spec = PodSpecArgs(
            containers = ContainerArgs(
              name = "postgresml",
              image = "ghcr.io/postgresml/postgresml:2.8.2",
              args = List("tail", "-f", "/dev/null"),
              readinessProbe = ProbeArgs(
                exec = ExecActionArgs(
                  command = List("psql", "-d", "postgresml", "-c", "SELECT 1")
                ),
                initialDelaySeconds = 15,
                timeoutSeconds = 2
              ),
              livenessProbe = ProbeArgs(
                exec = ExecActionArgs(
                  command = List("psql", "-d", "postgresml", "-c", "SELECT 1")
                ),
                initialDelaySeconds = 45,
                timeoutSeconds = 2
              ),
              ports = List(
                ContainerPortArgs(name = "postgres", containerPort = postgresPort),
                ContainerPortArgs(name = "dashboard", containerPort = dashboardPort)
              )
            ) :: Nil
          )
        )
      )
    ),
    opts(customTimeouts = CustomTimeouts(create = 10.minutes))
  )

scala

  val postgresMlService = Service(
    "postgresml-svc",
    ServiceArgs(
      spec = ServiceSpecArgs(
        selector = dbLabels,
        ports = List(
          ServicePortArgs(name = "postgres", port = postgresPort, targetPort = postgresPort),
          ServicePortArgs(name = "dashboard", port = dashboardPort, targetPort = dashboardPort)
        )
      ),
      metadata = ObjectMetaArgs(
        namespace = appNamespace.metadata.name,
        labels = labels
      )
    ),
    opts(
      dependsOn = postgresmlStatefulSet
    )
  )

scala

  val postgresmlHost = postgresMlService.metadata.name
    .getOrFail(Exception("postgresml service name not found!"))
  val jdbcUrl = p"jdbc:postgresql://${postgresmlHost}:${postgresPort}/postgresml"

  // below

  AppDeployment(jdbcUrl, ???)

scala

  val appDeployment =
    Deployment(
      name,
      DeploymentArgs(
        spec = DeploymentSpecArgs(
          selector = LabelSelectorArgs(matchLabels = labels),
          replicas = 1,
          template = PodTemplateSpecArgs(
            metadata = ObjectMetaArgs(
              name = p"$name-deployment",
              labels = labels,
              namespace = appNamespace.metadata.name
            ),
            spec = PodSpecArgs(
              containers = ContainerArgs(
                name = "app",
                image = "ghcr.io/lbialy/askme:0.1.0",
                ports = List(
                  ContainerPortArgs(name = "http", containerPort = containerPort)
                ),
                env = List(
                  EnvVarArgs(
                    name = "OPENAI_API_KEY",
                    value = openAIToken
                  ),
                  EnvVarArgs(
                    name = "JDBC_URL",
                    value = jdbcUrl
                  ),
                  EnvVarArgs(
                    name = "DOCS_BASE_URL",
                    value = docsBaseUrl
                  )
                ),
                readinessProbe = ProbeArgs(
                  httpGet = HttpGetActionArgs(
                    path = "/",
                    port = containerPort
                  ),
                  initialDelaySeconds = 10,
                  periodSeconds = 5
                ),
                livenessProbe = ProbeArgs(
                  httpGet = HttpGetActionArgs(
                    path = "/",
                    port = containerPort
                  ),
                  initialDelaySeconds = 10,
                  periodSeconds = 5
                )
              ) :: Nil
            )
          )
        ),
        metadata = ObjectMetaArgs(
          namespace = appNamespace.metadata.name
        )
      )
    )

scala

  val appService =
    Service(
      s"$name-svc",
      ServiceArgs(
        spec = ServiceSpecArgs(
          selector = labels,
          ports = List(
            ServicePortArgs(name = "http", port = servicePort, targetPort = containerPort)
          ),
          `type` = ServiceSpecType.ClusterIP
        ),
        metadata = ObjectMetaArgs(
          namespace = appNamespace.metadata.name,
          labels = labels
        )
      ),
      opts(deleteBeforeReplace = true)
    )

  val appIngress =
    Ingress(
      s"$name-ingress",
      IngressArgs(
        spec = IngressSpecArgs(
          rules = List(
            IngressRuleArgs(
              host = ingressHost,
              http = HttpIngressRuleValueArgs(
                paths = List(
                  HttpIngressPathArgs(
                    path = "/",
                    pathType = "Prefix",
                    backend = IngressBackendArgs(
                      service = IngressServiceBackendArgs(
                        name = appService.metadata.name.getOrElse(name),
                        port = ServiceBackendPortArgs(
                          number = servicePort
                        )
                      )
                    )
                  )
                )
              )
            )
          )
        ),
        metadata = ObjectMetaArgs(
          namespace = appNamespace.metadata.name,
          labels = labels,
          annotations = Map(
            "kubernetes.io/ingress.class" -> "traefik"
          )
        )
      )
    )

scala

  // use all of the above and return final url
  val appUrl =
    for
      _   <- appNamespace
      _   <- postgresmlStatefulSet
      _   <- postgresMlService
      _   <- appDeployment
      _   <- appService
      _   <- appIngress
      url <- p"http://$ingressHost/"
    yield url

  AppDeployment(jdbcUrl, appUrl)

scala

  val k3sProvider = k8s.Provider(
    "k8s",
    k8s.ProviderArgs(
      kubeconfig = k3s.flatMap(_.kubeconfig)
    )
  )

  val app = AppDeployment(
    "askme",
    AppDeploymentArgs(
      PostgresArgs(
        port = 5432,
        dashboardPort = 8000
      ),
      AppArgs(
        name = "askme",
        replicas = 1,
        containerPort = 8080,
        servicePort = 8080,
        host = "machinespir.it",
        openAIToken = config.requireString("openai_token"),
        docsBaseUrl = "https://virtuslab.github.io/besom/docs/"
      )
    ),
    ComponentResourceOptions(
      providers = k3sProvider,
      deletedWith = k3s
    )
  )

scala

  val cfProvider = cf.Provider(
    "cloudflare-provider",
    cf.ProviderArgs(
      apiToken = config.requireString("cloudflare_token")
    )
  )

  val aRecords = serverPool.zipWithIndex.map { case (server, idx) =>
    val recordIdx = idx + 1
    cf.Record(
      s"askme-a-record-$recordIdx",
      cf.RecordArgs(
        name = "machinespir.it",
        `type` = "A",
        value = server.ipv4Address,
        zoneId = config.requireString("cloudflare_zone_id"),
        ttl = 1,
        proxied = true
      ),
      opts(provider = cfProvider)
    )
  }.parSequence

scala

  // use in bash: KUBECONFIG=~/.kube/config:$(pwd)/kubeconfig.conf
  val writeKubeconfig = k3s.flatMap { k3s =>
    k3s.kubeconfig.map { kubeconfig =>
      os.write.over(os.pwd / "kubeconfig.conf", kubeconfig)
    }
  }

scala

  Stack(spawnNodes, writeKubeconfig, k3s, app, aRecords).exports(
    nodes = nodeIps,
    kubeconfigPath = (os.pwd / "kubeconfig.conf").toString,
    url = app.flatMap(_.appUrl)
  )

scala

env = List(
  EnvVarArgs(
    name = "OPENAI_API_KEY",
    value = openAIToken
  ),
  EnvVarArgs(
    name = "JDBC_URL",
    value = jdbcUrl
  ),
  EnvVarArgs(
    name = "DOCS_BASE_URL",
    value = docsBaseUrl
  )
),

scala

case class Config(
  port: Int, 
  openAIApiKey: String, 
  jdbcUrl: String, 
  docsBaseUrl: String
)

object Config:
  def fromEnv[A](key: String, f: String => A = identity): A =
    val strVal =
      try sys.env(key)
      catch
        case _: NoSuchElementException =>
          throw Exception(s"Required configuration key $key not present among environment variables")
    try f(strVal)
    catch
      case t: Exception =>
        throw Exception(s"Failed to convert value $strVal for key $key", t)
        
  def apply(): Config = 
    new Config(
      fromEnv("PORT", _.toInt),
      fromEnv("OPENAI_API_KEY"),
      fromEnv("JDBC_URL"),
      fromEnv("DOCS_BASE_URL")
    )

scala

import besom.cfg.*

case class Config(
  port: Int, 
  openAIApiKey: String, 
  jdbcUrl: String, 
  docsBaseUrl: String
) derives Configured

@main def main() =
  val config: Config = resolveConfiguration[Config]

scala

import besom.cfg.k8s.ConfiguredContainerArgs
import besom.cfg.Struct

scala

val appDeployment =
    Deployment(
      name,
      DeploymentArgs(
        spec = DeploymentSpecArgs(
          selector = LabelSelectorArgs(matchLabels = labels),
          replicas = 1,
          template = PodTemplateSpecArgs(
            metadata = ObjectMetaArgs(
              name = p"$name-deployment",
              labels = labels,
              namespace = appNamespace.metadata.name
            ),
            spec = PodSpecArgs(
              containers = ConfiguredContainerArgs( // here
                name = "app",
                image = "ghcr.io/lbialy/askme:0.1.0",
                configuration = Struct( // here
		            openAIApiKey = openAIToken,
                    jdbcUrl = jdbcUrl,
                    docsBaseUrl = docsBaseUrl
                ), 
                // env removed
                ports = List(
                  ContainerPortArgs(name = "http", containerPort = containerPort)
                ),
                readinessProbe = ProbeArgs(
                  httpGet = HttpGetActionArgs(
                    path = "/",
                    port = containerPort
                  ),
                  initialDelaySeconds = 10,
                  periodSeconds = 5
                ),
                livenessProbe = ProbeArgs(
                  httpGet = HttpGetActionArgs(
                    path = "/",
                    port = containerPort
                  ),
                  initialDelaySeconds = 10,
                  periodSeconds = 5
                )
              ) :: Nil
            )
          )
        ),
        metadata = ObjectMetaArgs(
          namespace = appNamespace.metadata.name
        )
      )
    )

bash

λ scala-cli compile .
Compiling project (Scala 3.3.1, JVM (17))
[error] ./app.scala:178:21
[error] Configuration provided for container app (ghcr.io/lbialy/askme:0.1.0) is invalid:
[error]
[error] {
[error]   port: Int // missing
[error]   openAIApiKey: String
[error]   jdbcUrl: String
[error]   docsBaseUrl: String
[error] }
Error compiling project (Scala 3.3.1, JVM (17))
Compilation failed

scala

                configuration = Struct(
	                port = "8080",
		            openAIApiKey = openAIToken,
                    jdbcUrl = jdbcUrl,
                    docsBaseUrl = docsBaseUrl
                ),

bash

λ scala-cli compile .
Compiling project (Scala 3.3.1, JVM (17))
[error] ./app.scala:178:21
[error] Configuration provided for container app (ghcr.io/lbialy/askme:0.1.0) is invalid:
[error]
[error] {
[error]   port: got String, expected Int
[error]   openAIApiKey: String
[error]   jdbcUrl: String
[error]   docsBaseUrl: String
[error] }
Error compiling project (Scala 3.3.1, JVM (17))
Compilation failed

bash

λ pulumi up
Previewing update (dev):
     Type                                           Name                      Plan
 +   pulumi:pulumi:Stack                            k3s-on-hetzner-dev        create
 +   ├─ hcloud:index:SshKey                         ssh-key                   create
 +   ├─ hcloud:index:Server                         k3s-server-1              create
 +   ├─ hcloud:index:Server                         k3s-server-3              create
 +   ├─ hcloud:index:Server                         k3s-server-2              create
 +   ├─ user:component:K3S                          askme-dev                 create
 +   │  ├─ command:remote:Command                   start-k3s-leader          create
 +   │  ├─ command:remote:Command                   insert-ghcr-token-leader  create
 +   │  ├─ command:remote:Command                   restart-k3s-leader        create
 +   │  ├─ command:remote:Command                   start-k3s-follower-2      create
 +   │  ├─ command:remote:Command                   start-k3s-follower-1      create
 +   │  ├─ command:remote:Command                   insert-ghcr-token-1       create
 +   │  ├─ command:remote:Command                   insert-ghcr-token-2       create
 +   │  ├─ command:remote:Command                   restart-k3s-follower-2    create
 +   │  ├─ command:remote:Command                   restart-k3s-follower-1    create
 +   │  └─ command:remote:Command                   get-kubeconfig            create
 +   ├─ pulumi:providers:kubernetes                 k8s                       create
 +   ├─ user:component:app-deployment               askme                     create
 +   │  ├─ kubernetes:core/v1:Namespace             askme                     create
 +   │  ├─ kubernetes:apps/v1:StatefulSet           postgresml                create
 +   │  ├─ kubernetes:core/v1:Service               postgresml-svc            create
 +   │  ├─ kubernetes:apps/v1:Deployment            askme                     create
 +   │  ├─ kubernetes:core/v1:Service               askme-svc                 create
 +   │  └─ kubernetes:networking.k8s.io/v1:Ingress  askme-ingress             create
 +   ├─ pulumi:providers:cloudflare                 cloudflare-provider       create
 +   ├─ cloudflare:index:Record                     askme-a-record-3         create
 +   ├─ cloudflare:index:Record                     askme-a-record-1         create
 +   └─ cloudflare:index:Record                     askme-a-record-2         create

Outputs:
    kubeconfigPath: "/Users/lbialy/Projects/foss/pulumi/askme/infra/kubeconfig.conf"
    nodes         : output<string>

Resources:
    + 28 to create

Do you want to perform this update? yes
Updating (dev):
     Type                                           Name                      Status
 +   pulumi:pulumi:Stack                            k3s-on-hetzner-dev        created (440s)
 +   ├─ hcloud:index:SshKey                         ssh-key                   created (0.45s)
 +   ├─ hcloud:index:Server                         k3s-server-3              created (13s)
 +   ├─ hcloud:index:Server                         k3s-server-1              created (13s)
 +   ├─ hcloud:index:Server                         k3s-server-2              created (11s)
 +   ├─ user:component:K3S                          askme-dev                 created (46s)
 +   │  ├─ command:remote:Command                   start-k3s-leader          created (24s)
 +   │  ├─ command:remote:Command                   insert-ghcr-token-leader  created (0.54s)
 +   │  ├─ command:remote:Command                   restart-k3s-leader        created (4s)
 +   │  ├─ command:remote:Command                   get-leader-token          created (0.55s)
 +   │  ├─ command:remote:Command                   start-k3s-follower-1      created (15s)
 +   │  ├─ command:remote:Command                   start-k3s-follower-2      created (12s)
 +   │  ├─ command:remote:Command                   insert-ghcr-token-2       created (0.55s)
 +   │  ├─ command:remote:Command                   restart-k3s-follower-2    created (3s)
 +   │  ├─ command:remote:Command                   insert-ghcr-token-1       created (0.50s)
 +   │  ├─ command:remote:Command                   restart-k3s-follower-1    created (5s)
 +   │  └─ command:remote:Command                   get-kubeconfig            created (0.56s)
 +   ├─ pulumi:providers:kubernetes                 k8s                       created (0.00s)
 +   ├─ user:component:app-deployment               askme                     created (306s)
 +   │  ├─ kubernetes:core/v1:Namespace             askme                     created (0.20s)
 +   │  ├─ kubernetes:apps/v1:StatefulSet           postgresml                created (270s)
 +   │  ├─ kubernetes:core/v1:Service               postgresml-svc            created (10s)
 +   │  ├─ kubernetes:core/v1:Service               askme-svc                 created (23s)
 +   │  ├─ kubernetes:apps/v1:Deployment            askme                     created (95s)
 +   │  └─ kubernetes:networking.k8s.io/v1:Ingress  askme-ingress             created (0.14s)
 +   ├─ pulumi:providers:cloudflare                 cloudflare-provider       created (0.00s)
 +   ├─ cloudflare:index:Record                     askme-a-record-1         created (1s)
 +   ├─ cloudflare:index:Record                     askme-a-record-3         created (1s)
 +   └─ cloudflare:index:Record                     askme-a-record-2         created (1s)

Outputs:
    kubeconfigPath: "/Users/lbialy/Projects/foss/pulumi/askme/infra/kubeconfig.conf"
    nodes         : [
        [0]: "157.90.171.154"
        [1]: "65.109.163.32"
        [2]: "167.235.230.34"
    ]
    url           : "https://machinespir.it"

Resources:
    + 29 created

Duration: 7m22s

Take the first step to a sustained competitive edge for your business

Let's connect

VirtusLab's work has met the mark several times over, and their latest project is no exception. The team is efficient, hard-working, and trustworthy. Customers can expect a proactive team that drives results.

Stephen Rooke
Stephen RookeDirector of Software Development @ Extreme Reach

VirtusLab's engineers are truly Strapi extensions experts. Their knowledge and expertise in the area of Strapi plugins gave us the opportunity to lift our multi-brand CMS implementation to a different level.

facile logo
Leonardo PoddaEngineering Manager @ Facile.it

VirtusLab has been an incredible partner since the early development of Scala 3, essential to a mature and stable Scala 3 ecosystem.

Martin_Odersky
Martin OderskyHead of Programming Research Group @ EPFL

The VirtusLab team's in-depth knowledge, understanding, and experience of technology have been invaluable to us in developing our product. The team is professional and delivers on time – we greatly appreciated this efficiency when working with them.

Michael_Grant
Michael GrantDirector of Development @ Cyber Sec Company