Structuring Software with OCM

Introduction

In this specification software products are comprised of logical units called components. A component version consists of a set of technical artifacts, e.g. Docker images, Helm charts, binaries, configuration data etc. Such artifacts are called resources in this specification. Resources are usually built from something, e.g. code in a git repo. Those are named sources in this specification.

OCM introduces a Component Descriptor for every component version, that describes the resources, sources and other component versions belonging to a particular component version and how to access them.

Usually however real-life applications are composed of multiple components. For example an application might consist of a frontend, a backend, a database and a web server. During the software development process new component versions are created and third-party components might be consumed from a public registry and updated from time to time.

Not all component version combinations of frontend, backend, database etc.are compatible and form a valid product version. In order to define reasonable version combinations for the software product, we could use another feature of the Component Descriptor, called component reference (or reference in short) which allows the aggregation of component versions.

For each component and each version in use there is a Component Descriptor. For the entire application we introduce a new component that describes the overall software product referencing all components. This describes the entire application.

A particular version of this application is again described by a Component Descriptor, which contains references to Component Descriptors of its components in their version in use. You are not restricted to this approach. It is e.g. possible to create multi-level hierarchies or you could just maintain a list of component version combinations which build a valid product release.

In a nutshell OCM provides a simple approach to specify what belongs to a product version. Starting with the Component Descriptor for a product version and following the component references, you could collect all artifacts, belonging to this product version.

Example

Let’s illustrate this idea by an example. As base we use the ‘microblog’ application. This application was created for a programming tutorial and is documented in detail here. The source code can be found here.

At the end of the tutorial this application consists of the following components:

  • Microblog application written in Python
  • A database MySQL or MariaDB
  • A full-text search engine elasticsearch
  • Redis to support task queues for background processing
  • For a Kubernetes deployment and nginx ingress controller is needed in addition.

Example Application Architecture

Following the guideline above we will end-up with six component versions: Five are created for the components in this list. And one component is created describing the application deployment and referencing all the sub-components. Looking closer at the components we can encounter three different flavors:

  • The deployment component describing a product release and referencing all used components
  • The main application component consisting of source code and to a container image.
  • Third party components consumed as binaries located in a public repository.

For building composed components the “All-in-one” mechanism becomes handy.

Helm Charts

Kubernetes deployments often use helm charts. The OCM specification supports helm charts as an artifact type. For the microblog application we will create out own helm chart. For the third-party components we will use readily available helm charts from public helm chart repostories.

The OCM CLI supports referencing helm charts being stored in an OCI registry. However most publicly available helm charts currently are available from helm chart repositories and not from OCI registries. Therefore the helm charts are embedded in the component archive. This can easily be achieved with the helm CLI:

helm repo add <repo-name> <helm-chart-repo-url>
helm pull --destination <target-dir> <repo-name/chart-name>

Example:

helm repo add bitnami https://charts.bitnami.com/bitnami
helm pull --destination . bitnami/mariadb

The helm chart for mariadb is then stored in the current working directory as mariadb:11.4.5.tgz and can be referenced as path from there in the components.yaml file (see below).

The helm chart for the microblog application is our own and part of the source code. It is not downloaded from a public repository.

Input Specification

The corresponding input file for building the component version (components.yaml) will then look like this:

components:
# Deployable component: microblog-deployment
# - contains application and references all runtime dependencies
# - used as root component to deploy the complete application
# - version numbering scheme follows the main application
- name: ${COMPONENT_NAME_PREFIX}/microblog-deployment
  version: ${VERSION}
  provider:
    name: ${PROVIDER}
  componentReferences:
  - name: microblog
    componentName: ocm.software/microblog/microblog
    version: ${VERSION}
  - name: nginx-controller
    componentName: ocm.software/microblog/nginx-controller
    version: ${NGINX_VERSION}
  - name: mariadb
    componentName: ocm.software/microblog/mariadb
    version: ${MARIADB_VERSION}
  - name: elasticsearch
    componentName: ocm.software/microblog/elasticsearch
    version: ${ELASTIC_VERSION}
#
# Main application component: microblog
# - has a source repository and compiles its own images
- name: ${COMPONENT_NAME_PREFIX}/microblog
  version: ${VERSION}
  provider:
    name: ${PROVIDER}
  sources:
  - name: source
    type: filesytem
    access:
      type: github
      repoUrl: github.com/acme.org^/microblog
      commit: ${COMMIT}
    version: ${VERSION}
  resources:
  - name: microblog-chart
    type: helmChart
    input:
      type: helm
      path: ../microblog-helmchart
  - name: microblog-image
    type: ociImage
    version: ${VERSION}
    input:
      type: dockermulti
      repository: microblog
      variants:
      - microblog:${VERSION}-linux-amd64
      - microblog:${VERSION}-linux-arm64
#
# Nginx-Controller Component
# - runtime dependency, use pre-built images, embeds helm chart
- name: ${COMPONENT_NAME_PREFIX}/nginx-controller
  version: ${NGINX_VERSION}
  provider:
    name: ${PROVIDER}
  resources:
  - name: nginx-controller-chart
    type: helmChart
    input:
      type: helm
      path: nginx/ingress-nginx-${NGINX_CHART_VERSION}.tgz
  - name: nginx-controller-image
    type: ociImage
    version: ${NGINX_VERSION}
    access:
      type: ociArtifact
      imageReference: registry.k8s.io/ingress-nginx/controller:v${NGINX_VERSION}
#
# Maria-DB Component
# - runtime dependency, use pre-built images, embeds helm chart
- name: ${COMPONENT_NAME_PREFIX}/mariadb
  version: ${MARIADB_VERSION}
  provider:
    name: ${PROVIDER}
  resources:
  - name: mariadb-chart
    type: helmChart
    input:
      type: helm
      path: mariadb/mariadb-${MARIADB_CHART_VERSION}.tgz
  - name: mariadb-image
    type: ociImage
    version: ${MARIADB_VERSION}
    access:
      type: ociArtifact
      imageReference: bitnami/mariadb:${MARIADB_VERSION}-debian-11-r12
#
# Elasticsearch Component:
# - runtime dependency, use pre-built images, embeds helm chart
- name: ${COMPONENT_NAME_PREFIX}/elasticsearch
  version: ${ELASTIC_VERSION}
  provider:
    name: ${PROVIDER}
  resources:
  - name: elasticsearch-chart
    type: helmChart
    input:
      type: helm
      path: elastic/elasticsearch-${ELASTIC_VERSION}.tgz
  - name: elasticsearch-image
    type: ociImage
    version: ${ELASTIC_VERSION}
    access:
      type: ociArtifact
      imageReference: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}

Some frequently changing parameters have been extracted as variables. The OCM CLI uses templating to fill them with values. The templating mechanism is described here. For this example we use the simple (default) template engine type subst.

Note the differences between the various components:

  • The microblog-deployment is the root and contains only references to other components
  • The microblog application is the main application, built from sources. It has sources.
  • The microblog application consists of an image and a helm chart. The image is built in previous build step (not described here) and taken from the local docker registry. The two images for different architectures are converted to a multi-arch image.
  • All other components are third-party components and referenced from public registries.

Building the Common Transport Archive

From the input file components.yaml the common transport archive can be created with the OCM CLI. For all variables we need to provide values. Variable values can be passed in the command line or stored in a file. For many variable having a values file is more convenient. The corresponding file settings.yaml may look like this:

VERSION: 0.23.1
COMMIT: 5f03021059c7dbe760ac820a014a8a84166ef8b4
NAME: microblog
COMPONENT_NAME_PREFIX: github.com/acme.org/microblog
PROVIDER: acme.org
ELASTIC_VERSION: 8.5.1
MARIADB_VERSION: 10.6.11
MARIADB_CHART_VERSION: 11.4.2
NGINX_VERSION: 1.5.1
NGINX_CHART_VERSION: 4.4.2

Create the transport archive then with:

ocm add componentversions --create --file <ctf-target-dir> --settings settings.yaml components.yaml

You can view the generated component descriptor using the command:

ocm get component -o yaml <ctf-target-dir>

You can store the transport archive in an OCI registry (this step needs a proper configuration of credentials for the OCM CLI):

ocm transfer ctf -f <ctf-target-dir> <oci-repo-url>

Note: Be careful with the -f or --overwrite flag. This will replace existing component versions in the OCI registry. During development it is useful being able to overwrite existing component versions until something is ready for release. For released versions you should never use this flag! Released component versions should be immutable and should never be overwritten. They serve as source of truth for what the release is made of und should never be changed.

Deploying Software

Up to now we have created a transport archive containig all required parts (images, helm charts) for installing the application. This archive is self-contained and can be transferred with a single command from the OCM tooling. After pushing this archive to an OCI-registry we have a shared location that can be used as a source of deployment without any external references. As an alternative you can transport the archive using offline mechanisms (file transfer, USB-stick) and push it on a target location to an OCI registry.

To actually deploy the application we need to get access to the helm charts contained in the archive. We can use the ocm CLI to retrieve their location. See the example below.

Localization

The deployments in a Kubernetes cluster require an image for instantiating a container. With each transport of the archive the image location changes. The image location can be set as a helm value for a helm based installation. The actual value for the current deployment has to be extracted from the component-descriptor and inserted into a helm values file for a helm based installation. In the example below we will do the necessary steps. For real deplyoments you will usually use tools to automate this. The toi installation toolkit is one tool supporting this. The Flux OCM controllers offer this functionality too.

Example

Let’s assume that we have pushed the transport archive to an OCI registry. We need the identity of the component version and the location of the component-descriptors in the OCI registry:

ComponentVersion: name: github.com/jensh007/microblog-deployment version: 0.23.1

URL of OCI registry: ghcr.io/acme.org/microblogapp

It is convenient to put this into an environment variable:

OCMREPO=github.com/acme.org/microblog

Getting all component-versions of the application with the ocm cli:

ocm get component ${OCM_REPO}//github.com/jensh007/microblog-deployment:0.23.1 -o yaml

---
context: []
element:
  component:
    componentReferences:
    - componentName: github.com/jensh007/microblog
      name: microblog
      version: 0.23.1
    - componentName: github.com/jensh007/nginx-controller
      name: nginx-controller
      version: 1.5.1
    - componentName: github.com/jensh007/mariadb
      name: mariadb
      version: 10.11.2
    - componentName: github.com/jensh007/elasticsearch
      name: elasticsearch
      version: 8.5.1
    - componentName: github.com/jensh007/redis
      name: redis
      version: 7.0.9
    name: github.com/jensh007/microblog-deployment
    provider:
      name: ocm.software
    repositoryContexts:
    ...
    resources: []
    sources: []
    version: 0.23.1
  meta:
    ...

With this we can drill down to the installable helm charts and the container images:

ocm get resource ${OCM_REPO}//github.com/jensh007/microblog:0.23.1 -o wide

NAME            VERSION IDENTITY TYPE      RELATION ACCESSTYPE  ACCESSSPEC
microblog-chart 0.23.1           helmChart local    ociArtifact {"imageReference":"ghcr.io/acme.org/microblogapp/github.com/jensh007/microblog/microblog:0.23.1"}
microblog-image 0.23.1           ociImage  local    ociArtifact {"imageReference":"ghcr.io/acme.org/microblogapp/github.com/jensh007/microblog/images/microblog:0.23.1"}

With this information we can create a helm values file with the updated image reference:

microblog_values_localized.yaml:

image:
    repository: ghcr.io/acme.org/microblogapp/github.com/jensh007/microblog/images/microblog
    tag: "0.23.1"
imagePullSecrets:
  - name: gcr-secret

For a private registry you may also need to specify an image pull secret. This secret has to be present on the target cluster before calling helm commands.

image:
    ...
imagePullSecrets:
  - name: gcr-secret

Note: For a real application deployment there will be more localized settings. Number of replicas, domain names in Ingress specs are typical examples.

The following steps will act on a target cluster. For this we assume that your KUBECONFIG enviroment variable is set correctly (or append --kubeconfig option). We will need a namespace in the target cluster. We use the namespace devfor this example:

kubectl create namespace dev

With the localized values we can instruct helm to perform an installation:

helm install -n dev microblog oci://ghcr.io/acme.org/microblogapp/github.com/jensh007/microblog/microblog --version 0.23.1  --values microblog_values_localized.yaml

Pulled: ghcr.io/acme.org/microblogapp/github.com/jensh007/microblog/microblog:0.23.1
Digest: sha256:2841665cf3f669ed0a45e70c77bbe27a91ae4dde2d119117ae1e7c1f486ce510
NAME: microblog
LAST DEPLOYED: Fri Mar 10 09:28:04 2023
NAMESPACE: dev
STATUS: deployed
REVISION: 1
NOTES:
1. Get the application URL by running these commands:
  https://microblog.ocm2.hubforplay.shoot.canary.k8s-hana.ondemand.com/

This command instructs helm to create a helm release named microblog and use the helm chart from our OCI-registry. The location was grabbed from the command above.

If the command succeeds you can retrieve the status with:

helm list
NAME     	NAMESPACE	REVISION	UPDATED                             	STATUS  	CHART              	APP VERSION
microblog	dev      	1       	2023-03-10 09:28:04.658464 +0100 CET	deployed	microblog-0.23.1   	0.23.1

You can also check the created pod:

kubectl get pods
NAME                                              READY   STATUS             RESTARTS      AGE
microblog-7c65dc4d9d-tvr97                        0/2     CrashLoopBackOff   10 (5s ago)   6m5s

k describe pod microblog-7c65dc4d9d-tvr97

...
Containers:
  microblog:
    ...
    Image:         ghcr.io/acme.org/microblogapp/github.com/jensh007/microblog/images/microblog:0.23.1
...

The pod is not in the status running yet because it is missing the required dependencies. We need to perform the additional steps to install them. The steps are the same so we do not repeat them in detail again:

ocm get resource ${OCM_REPO}//github.com/jensh007/nginx-controller:1.5.1 -o wide
ocm get resource ${OCM_REPO}//github.com/jensh007/mariadb:10.11.2 -o wide
ocm get resource ${OCM_REPO}//github.com/jensh007/elasticsearch:8.5.1 -o wide
ocm get resource ${OCM_REPO}//github.com/jensh007/redis:7.09 -o wide

Create files:

  • nginx_values_localized.yaml
  • mariadb_values_localized.yaml
  • elasticsearch_values_localized.yaml
  • redis_values_localized.yaml

and install with:

helm install -n dev nginx oci://ghcr.io/acme.org/microblogapp/github.com/jensh007/nginx-controller/ingress-nginx --version 4.4.2 --values nginx_values_localized.yaml
helm install -n dev mariadb oci://ghcr.io/acme.org/microblogapp/github.com/jensh007/mariadb/mariadb --version 11.4.2 --values mariadb_values_localized.yaml
helm install -n dev elasticsearch oci://ghcr.io/acme.org/microblogapp/github.com/jensh007/elasticsearch/elasticsearch --version 8.5.1 --values elasticsearch_values_localized.yaml
helm install -n dev redis oci://ghcr.io/acme.org/microblogapp/github.com/jensh007/redis/redis --version 17.6.0 --values redis_values_localized.yaml

Updating Components

Updating components requires two steps:

  • Updating the component version of the used sub-component
  • Updating the root component version to change the reference to the sub-component.

Given the example from above let us assume we will update mariadb from 10.11.2 to 10.11.3 and the microblog application component from 0.23.1 to 0.24.0.

We would create a new root component version:

  component:
    componentReferences:
    - componentName: github.com/jensh007/microblog
      name: microblog
      version: 0.24.0
    - componentName: github.com/jensh007/nginx-controller
      name: nginx-controller
      version: 1.5.1
    - componentName: github.com/jensh007/mariadb
      name: mariadb
      version: 10.11.3
    - componentName: github.com/jensh007/elasticsearch
      name: elasticsearch
      version: 8.5.1
    - componentName: github.com/jensh007/redis
      name: redis
      version: 7.0.9
    name: github.com/jensh007/microblog-deployment
    provider:
      name: ocm.software
    repositoryContexts:
    ...
    resources: []
    sources: []
    version: 0.24.0
  meta:
    ...

For each of the two updated sub-components mariadb and microblog we would also create a new component version.

You should never change versions of sub-components without increasing the version of the root component. Otherwise you will lose the history of your bill-of-delivery.