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

Get your free consultation

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