Shipping Hilla apps to production. Part 3: CI/CD

René Wilby | Sep 30, 2024 min read

Article series

This is the third of four articles describing different aspects of shipping Hilla apps to production:

Automation

CI/CD systems are an important part of modern software development. The continuous integration of code changes into the main branch is often used to build, statically analyse, automatically test, search for vulnerabilities and tag the current state of an application on the main branch. In this way, developers receive constant feedback on the status of the code on the main branch. The continuous delivery of the application to different environments, such as test or production, extends the feedback cycle with additional test scenarios and feedback from end users. Many activities along the continuous integration and continuous delivery process can be automated by CI/CD systems so they can be repeated as often and quickly as needed.

These advantages of CI/CD systems naturally also apply to the development of Hilla apps. It is therefore advisable to implement a suitable CI/CD system for the professional development of Hilla apps. There are many different ways to realize a CI/CD system, and the selection of the right tool depends on many different factors. For example, GitHub Actions is a very popular CI/CD system for organizations that have heavily focus their development on GitHub. Another example of a CD/CD system is Jenkins.

The following sections show a simple CI/CD system for a Hilla app based on OpenShift and Tekton.

CI/CD using OpenShift and Tekton

OpenShift is a platform for the operation of container-based applications. It is based on Kubernetes and is developed by Red Hat. Tekton is an Open Source framework for creating CI/CD systems. Tekton can be used as a CI/CD system in Kubernetes environments. In OpenShift, Tekton is used in the form of the OpenShift Pipelines Operator.

Tekton provides various resources, such as Pipelines, Tasks, Triggers and EventListener. The combination of these resources and their configuration takes place via Custom Resource Definitions (CRD) for Kubernetes in the form of YAML files.

A simple Tekton pipeline

A simple Tekton pipeline for a Hilla app could consist of the following tasks:

  • Check out code
  • Compile app
  • Execute tests

Each task can be implemented individually. Alternatively, existing tasks from the Tekton Hub or so-called Cluster Resolver can be used. The tasks to be used are executed in a pipeline. The following graphic shows the execution of 3 tasks in sequence in a pipeline:

Pipeline

The corresponding YAML file for this pipeline can look like this, for example:

apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: hilla-production-build-pipeline
  namespace: hilla
spec:
  params:
    - default: 'git@scm.example.com:projects/hilla-production-build.git'
      name: repo
      type: string
    - default: master
      name: branch
      type: string
  tasks:
    - name: git-checkout
      params:
        - name: url
          value: $(params.repo)
        - name: revision
          value: $(params.branch)
        - name: subdirectory
          value: $(context.pipelineRun.name)
      taskRef:
        kind: Task
        name: git-clone
      workspaces:
        - name: output
          workspace: shared-workspace
    - name: maven-compile
      params:
        - name: MAVEN_IMAGE
          value: 'maven:3-eclipse-temurin-21-alpine'
        - name: CONTEXT_DIR
          value: $(context.pipelineRun.name)
        - name: GOALS
          value:
            - clean
            - compile
      runAfter:
        - git-checkout
      taskRef:
        kind: Task
        name: maven
      workspaces:
        - name: maven-settings
          workspace: maven-settings
        - name: source
          workspace: shared-workspace
        - name: maven-local-repo
          workspace: maven-local-m2
    - name: maven-test
      params:
        - name: MAVEN_IMAGE
          value: 'maven:3-eclipse-temurin-21-alpine'
        - name: CONTEXT_DIR
          value: $(context.pipelineRun.name)
        - name: GOALS
          value:
            - test
      runAfter:
        - maven-compile
      taskRef:
        kind: Task
        name: maven
      workspaces:
        - name: maven-settings
          workspace: maven-settings
        - name: source
          workspace: shared-workspace
        - name: maven-local-repo
          workspace: maven-local-m2
  workspaces:
    - name: shared-workspace
    - name: maven-settings
    - name: maven-local-m2

The git-checkout task uses git-clone. It clones a Git repo and checks out the desired branch into a workspace directory. This directory is available to all tasks within a pipeline execution (PipelineRun). After the checkout, tasks are executed with maven. This task provides an execution environment for Maven. First, the task is executed as maven-compile with the Maven goals clean compile in order to compile the application. Then the task is executed as maven-test to run the automated tests. Both tasks use the same workspace directory and therefore have access to the checked-out code.

Extension for production build

The pipeline shown can be extended flexibly with additional tasks. The creation of the production build of a Hilla app in the form of an executable JAR file can take place after the maven-test task in a new maven-package task:

    - name: maven-package
      taskRef:
        name: maven
      params:
        - name: MAVEN_IMAGE
          value: 'maven:3-eclipse-temurin-21-alpine'
        - name: CONTEXT_DIR
          value: $(context.pipelineRun.name)
        - name: GOALS
            value:
            - -DskipTests
            - package
            - -Pproduction 
      workspaces:
        - name: maven-settings
          workspace: maven-settings
        - name: source
          workspace: shared-workspace
        - name: maven-local-repo
          workspace: maven-local-m2
      runAfter:
        - maven-test

Alternatively, the production build of the Hilla app can also be created as a Native Image. The already known maven task can also be used for this. However, the MAVEN_IMAGE parameter must use an image, which supports the creation of a Native Image using GraalVM:

    - name: maven-package
      taskRef:
        name: maven
      params:
        - name: MAVEN_IMAGE
          value: 'ghcr.io/graalvm/native-image-community:21.0.2'
        - name: CONTEXT_DIR
          value: $(context.pipelineRun.name)
        - name: GOALS
            value:
            - -DskipTests
            - package
            - -Pproduction
            - -Pnative 
            - native:compile
      workspaces:
        - name: maven-settings
          workspace: maven-settings
        - name: source
          workspace: shared-workspace
        - name: maven-local-repo
          workspace: maven-local-m2
      runAfter:
        - maven-test

Extension for creating the Docker image

The next step is to extend the pipeline with a task that creates a Docker image based on the generated production build. OpenShift offers different approaches for this. The approach for building a Docker image based on a Dockerfile will be used here. Creating the required BuildConfig and starting the actual build is done in a separate task:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: build-docker-image
  namespace: hilla
spec:
  params:
    - name: context-dir
      type: string
  steps:
    - env:
        - name: HOME
          value: '/tekton/home'
        - name: CONTEXT_DIR
          value: $(params.context-dir)
        - name: WORKSPACE_SOURCE_PATH
          value: $(workspaces.source.path)
      image: registry.redhat.io/openshift4/ose-cli-rhel9:v4.16
      name:  build-docker-image
      computeResources:
        requests:
          cpu: 25m
          memory: 128Mi
      script: |
        #!/usr/bin/env bash
        
        set -eu

        cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}

        BUILD_NAME="hilla-production-build"

        oc delete buildconfigs --selector=build=${BUILD_NAME}
        oc delete imagestreams --selector=build=${BUILD_NAME}

        oc new-build --binary=true --strategy=docker --name=${BUILD_NAME}

        oc start-build ${BUILD_NAME} --from-dir=. --follow --wait        
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
  workspaces:
    - name: source

The task uses an Image, which contains the command line tool oc. At first, the task creates a new BuildConfig. To do this, the CLI oc is used with the command new-build. The parameter --binary=true results in a build that uses the generated production build as input. The created BuildConfig is then referenced with the command start-build to start the build. All files from the current directory are copied to the build process and are available for the creation of the Docker image.

The new build-docker-image task is added to the existing pipeline after the maven-package task. This ensures that both the required Dockerfile (from the checked-out repository) and the generated production build (from the previous task) are available in the shared workspace:

    - name: build-docker-image
      taskRef:
        name: build-docker-image
      params:
        - name: context-dir
          value: $(context.pipelineRun.name)
      workspaces:
        - name: source
          workspace: shared-workspace
      runAfter:
        - maven-package

The build-docker-image task still has some shortcomings, of course. For example, there is currently no dynamic assignment of the name for a build, including suitable versioning. The version to be used can be read from the pom.xml. This can be done in a separate determine-version task:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: determine-version
  namespace: hilla
spec:
  params:
    - name: context-dir
      type: string
  results:
    - name: version
      description: Value of project.version in pom.xml
  steps:
    - env:
        - name: HOME
          value: '/tekton/home'
        - name: CONTEXT_DIR
          value: $(params.context-dir)
        - name: WORKSPACE_SOURCE_PATH
          value: $(workspaces.source.path)
      image: eclipse-temurin:21-jre
      name:  determine-version
      computeResources:
        requests:
          cpu: 50m
          memory: 256Mi
      script: |
        #!/usr/bin/env bash
        
        set -eu

        cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}

        VERSION=$(./mvnw org.apache.maven.plugins:maven-help-plugin:3.5.0:evaluate -Dexpression=project.version -q -DforceStdout)

        echo ${VERSION} | tee $(results.version.path)        
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
  workspaces:
    - name: source

The determined version is published as a result and can be used in subsequent tasks. The new task determine-version is therefore integrated in the pipeline after the task maven-package and before the task build-docker-image:

    - name: determine-version
      taskRef:
        name: determine-version
      params:
        - name: context-dir
          value: $(context.pipelineRun.name)
      workspaces:
        - name: source
          workspace: shared-workspace
      runAfter:
        - maven-package

The task build-docker-image is then extended by the parameters build-name and build-version:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: build-docker-image
  namespace: hilla
spec:
  params:
    - name: context-dir
      type: string
    - name: build-name
      type: string
    - name: build-version
      type: string
  steps:
    - env:
        - name: HOME
          value: '/tekton/home'
        - name: CONTEXT_DIR
          value: $(params.context-dir)
        - name: BUILD_NAME
          value: $(params.build-name)
        - name: BUILD_VERSION
          value: $(params.build-version)
        - name: WORKSPACE_SOURCE_PATH
          value: $(workspaces.source.path)
      image: registry.redhat.io/openshift4/ose-cli-rhel9:v4.16
      name: build-docker-image
      computeResources:
        requests:
          cpu: 25m
          memory: 128Mi
      script: |
        #!/usr/bin/env bash
        
        set -eu

        cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}

        oc delete buildconfigs --selector=build=${BUILD_NAME}
        oc delete imagestreams --selector=build=${BUILD_NAME}

        oc new-build --binary=true --strategy=docker --name=${BUILD_NAME}

        oc start-build ${BUILD_NAME} --from-dir=. --follow --wait        
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
  workspaces:
    - name: source

When using the build-docker-image task in the pipeline, the two new parameters are set. The parameter build-name receives a project-specific value, and the parameter build-version receives the result of the task determine-version as a value:

    - name: build-docker-image
      taskRef:
        name: build-docker-image
      params:
        - name: context-dir
          value: $(context.pipelineRun.name)
        - name: build-name
          value: hilla-production-build
        - name: build-version
          value: $(tasks.determine-version.results.version)
      workspaces:
        - name: source
          workspace: shared-workspace
      runAfter:
        - determine-version

Extension for publishing the Docker image

The Docker image created has to be published in a suitable container registry, of course. The configuration required for this can be passed to the new-build command. To do this, the task build-docker-image is renamed and extended to build-and-push-docker-image:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: build-and-push-docker-image
  namespace: hilla
spec:
  params:
    - name: context-dir
      type: string
    - name: build-name
      type: string
    - name: build-version
      type: string
  steps:
    - env:
        - name: HOME
          value: '/tekton/home'
        - name: CONTEXT_DIR
          value: $(params.context-dir)
        - name: BUILD_NAME
          value: $(params.build-name)
        - name: BUILD_VERSION
          value: $(params.build-version)
        - name: WORKSPACE_SOURCE_PATH
          value: $(workspaces.source.path)
      image: registry.redhat.io/openshift4/ose-cli-rhel9:v4.16
      name: build-and-push-docker-image
      computeResources:
        requests:
          cpu: 25m
          memory: 128Mi
      script: |
        #!/usr/bin/env bash
        
        set -eu

        cd ${WORKSPACE_SOURCE_PATH}/${CONTEXT_DIR}

        oc delete buildconfigs --selector=build=${BUILD_NAME}
        oc delete imagestreams --selector=build=${BUILD_NAME}

        oc new-build --binary=true --strategy=docker --name=${BUILD_NAME} --to-docker=true --to=corporate.registry.example.com/${BUILD_NAME}:${BUILD_VERSION}

        oc start-build ${BUILD_NAME} --from-dir=. --follow --wait        
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
  workspaces:
    - name: source

The additional parameters --to-docker and --to ensure that the Docker image created is then published with the name ${BUILD_NAME} and the tag ${BUILD_VERSION} to the Docker registry corporate.registry.example.com. The pipeline must now be updated so that it also uses the renamed task build-and-push-docker-image. The pipeline now has the following design:

Erweiterte Pipeline

Summary

Using the CI/CD system Tekton in OpenShift, tasks and a pipeline were used as an example to show how the creation of a production build of a Hilla app and the creation and publishing of a Docker image based on it can take place. The degree of automation achieved allows continuous delivery of the Hilla app in production without manual work steps.

Part 4 of the article series describes how a Hilla app, which is available as a production build in a Docker image, can be run as efficiently and scalably as possible as a serverless deployment in container environments.