Feature image for edge computing

Red Hat OpenShift includes an abundance of technologies out of the box that are necessary for effectively managing a fleet of devices at the edge. One of those components, the scheduler, enables these services to be efficiently co-located onto a single platform.

In addition, OpenShift manages many of these services via an Operator, meaning a non-technical team doesn’t need to understand all the specific details about the service. OpenShift, in a sense, helps make managing devices at the edge simpler and cost-effective.

This article details how to go about building this configuration on a single-node OpenShift cluster. Keep in mind that you can apply these same concepts to a 3-node compact and full OpenShift cluster as well.

Why use OpenShift on a single node?

Before we begin, let us step back and ask: Why do this? Well, there are a variety of reasons:

  • Having all the components in a single-node OpenShift (SNO) cluster makes it a great way to have a one-stop experience.
  • Having all the components in a single OpenShift node provides a quick and easy way to prove out a concept.
  • Since it's OpenShift, the SNO concept can be graduated to a large cluster to meet the capacity needs of a production environment.
  • Device Edge images are really just YAML files that should be maintained in Git, which gives us a clear path to infrastructure as code (IaC) and proper continuous integration/continuous deployment (CI/CD) all within OpenShift Container Platform.
  • Operators are genuinely a great way to reduce the barrier of entry when it comes to installing services and components in OpenShift.

Components

Now that we understand some of the why, let's move forward and lay out what components we will be using in this single-node OpenShift "edge manager in a box." The core set of services we’ll be consuming are:

  • An image registry to store our edge images as we compose them.
  • Management of local storage for retaining our composed images, databases, etc.
  • An instance of Ansible automation controller to drive our automation and leverage existing automation.
  • A pipeline technology; we’ll be using Red Hat OpenShift Pipelines.
  • A virtualization platform such as Red Hat OpenShift Virtualization (formerly container-native virtualization).
  • A virtual machine template to deploy virtual machines from which we can build our images.

These core services, when integrated together, offer the functionality necessary for managing our fleet of device edge devices.

There are different ways to deploy workloads on OpenShift. However, because we’ll be consuming a handful of Operators, we find it's useful to leverage automation to get everything deployed. Ansible has a module in the kubernetes.core collection (k8s) that can be leveraged to talk directly to the Kubernetes API. We’ll use it here to push k8s objects related to installing Operators and creating instances from those Operators.

The wrapper playbook

The first playbook we need to create on our quest for edge device management is a wrapper playbook that will ultimately call all the playbooks to build out our environment. The playbook will look like the following:

---
- name: import playbook to configure the local registry
  ansible.builtin.import_playbook: configure-registry.yml

- name: import playbook to setup local storage
  ansible.builtin.import_playbook: configure-storage.yml

- name: import playbook to setup controller
  ansible.builtin.import_playbook: install-ansible.yml
     
- name: import playbook to setup pipelines
  ansible.builtin.import_playbook: configure-pipelines.yml

- name: import playbook to setup virtualization
  ansible.builtin.import_playbook: configure-virtualization.yml

- name: import playbook to setup image builder virtual machine template
  ansible.builtin.import_playbook: setup-image-builder-vm-template.yml

This playbook simply imports other playbooks that contain the actual steps necessary to get an Operator installed: create an OperatorGroup, deploy an instance, and more. We won’t go through all of these playbooks, but let’s take a deep dive on the playbook to set up Red Hat Ansible Automation Platform:

---
- name: install controller
  hosts:
    - sno_clusters
  gather_facts: false
  module_defaults:
    kubernetes.core.k8s:
      kubeconfig: "{{ tmpdir.path }}/ocp/auth/kubeconfig"
  tasks:
    - name: configure storage
      delegate_to: localhost
      block:
        - name: create namespace
          kubernetes.core.k8s:
            definition: "{{ lookup('file', 'files/namespaces/ansible-automation-platform.yaml') | from_yaml }}"
        - name: create operator group
          kubernetes.core.k8s:
            definition: "{{ lookup('file', 'files/operator-groups/ansible-automation-platform.yaml') | from_yaml }}"
        - name: install operator
          kubernetes.core.k8s:
            definition: "{{ lookup('file', 'files/operators/ansible-automation-platform.yaml') | from_yaml }}"
          register: operator_install
          until:
            - operator_install.result.status.state is defined
            - operator_install.result.status.state == 'AtLatestKnown'
          retries: 100
          delay: 10
        - name: create instance of controller
          kubernetes.core.k8s:
            definition: "{{ lookup('file', 'files/instances/controller.yaml') | from_yaml }}"  

The playbook above is grabbing files that contain k8s objects and pushing them into the Kubernetes API. For Ansible Automation Platform specifically, we have a namespace, an Operator group, a subscription, and then an instance of Controller. First, the namespace custom resource YAML:

---
apiVersion: v1
kind: Namespace
metadata:
  labels:
    openshift.io/cluster-monitoring: "true"
  name: ansible-automation-platform

Next, we have the Operator group custom resource YAML:

---
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
  name: ansible-automation-platform-operator
  namespace: ansible-automation-platform
spec:
  targetNamespaces:
    - ansible-automation-platform

Then comes the subscription custom resource YAML:

---
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
  name: ansible-automation-platform
  namespace: ansible-automation-platform
spec:
  channel: 'stable-2.3'
  installPlanApproval: Automatic
  name: ansible-automation-platform-operator
  source: redhat-operators
  sourceNamespace: openshift-marketplace

Finally, once the Operator finishes deploying, an instance of the controller custom resource YAML:

---
apiVersion: automationcontroller.ansible.com/v1beta1
kind: AutomationController
metadata:
  name: controller
  namespace: ansible-automation-platform
spec:
  replicas: 1

After a few minutes, we’ll have a running instance of the Ansible automation controller on our OpenShift cluster.

Configuring Ansible automation controller

If we already have an instance of Controller set up and configured, then this part isn’t necessary. However, if we're starting from a completely empty instance of Controller, then we need to apply some base configuration to it so it can start driving automation.

Note: A best practice with automation controller is to store the configuration in code, then leverage automation to deploy the configuration to Controller. Here, we’ll leverage the redhat_cop.controller_configuration collection.  First, we’ll need some specific credential types:

  • OpenShift kubeconfig: A credential type to inject a kubeconfig into the execution environment of our automation.
  • Red Hat Subscription Management credentials: A credential type for storing authentication details for Red Hat Customer Portal.
  • Image credentials: A credential type for securely storing the user account credentials we want in our composed images, as opposed to storing these in plain text.
  • Ansible controller API credentials: A set of credentials to authenticate to automation controller’s API.
  • Kube credentials: A set of credentials that will be used to authenticate to our registry. I’m using OpenShift’s internal registry and the kubeadmin account, but you can substitute a properly scoped account and use a registry of your choosing.

The YAML definitions of these custom credential types:

controller_credential_types:
  - name: Openshift Kubeconfig
    kind: cloud
    inputs:
      fields:
        - id: kubeconfig
          type: string
          label: Kubeconfig
          #secret: true
          multiline: true
    injectors:
      env:
        K8S_AUTH_KUBECONFIG: "{  { tower.filename.kubeconfig }}"
        KUBECONFIG: "{  { tower.filename.kubeconfig }}"
      file:
        template.kubeconfig: "{  { kubeconfig }}"
  - name: RHSM Credentials
    kind: cloud
    inputs:
      fields:
        - id: rhsm_username
          type: string
          label: RHSM Hostname
        - id: rhsm_password
          type: string
          label: RHSM Username
          secret: true
    injectors:
      extra_vars:
        rhsm_username: "{  { rhsm_username }}"
        rhsm_password: "{  { rhsm_password }}"
  - name: Image Credentials
    kind: cloud
    inputs:
      fields:
        - id: image_username
          type: string
          label: Image Hostname
        - id: image_password
          type: string
          label: Image Username
          secret: true
    injectors:
      extra_vars:
        image_username: "{  { image_username }}"
        image_password: "{  { image_password }}"
  - name: Ansible Controller API Credentials
    kind: cloud
    inputs:
      fields:
        - id: controller_hostname
          type: string
          label: Controller Hostname
        - id: controller_username
          type: string
          label: Controller Username
        - id: controller_password
          type: string
          label: Controller Password
          secret: yes
    injectors:
      extra_vars:
        controller_hostname: "{  { controller_hostname }}"
        controller_username: "{  { controller_username }}"
        controller_password: "{  { controller_password }}"
        controller_validate_certs: "no"
  - name: Kubeadmin Credentials
    kind: cloud
    inputs:
      fields:
        - id: kubeadmin_username
          type: string
          label: Kubeadmin username
        - id: kubeadmin_password
          type: string
          label: Kubeadmin password
          secret: true
    injectors:
      extra_vars:
        kubeadmin_username: "{  { kubeadmin_username }}"
        kubeadmin_password: "{  { kubeadmin_password }}"

Most of these credential types are straightforward; however, the kubeconfig credential type has some additional injectors in the form of a file and an environment variable of the path to that file. In addition, the leading two brackets in the injector configurations are how we tell the collection to send an “unsafe” string to the API without attempting to render it locally. Leveraging our new credential types, we can create the set of credentials we’ll need for our automation:

controller_credentials:
  - name: kubeconfig
    organization: Default
    credential_type: Openshift Kubeconfig
    inputs:
      kubeconfig: "{{ lookup('file', (tmpdir.path + '/ocp/auth/kubeconfig')) | from_yaml | string }}"
  - name: Machine Credentials
    organization: Default
    credential_type: Machine
    inputs:
      username: cloud-user
      password: "{{ vm_template_password }}"
      become_password: "{{ vm_template_password }}"
  - name: Ansible Controller API Credentials
    credential_type: Ansible Controller API Credentials
    organization: Default
    inputs:
      controller_hostname: "{{ controller_hostname }}"
      controller_username: admin
      controller_password: "{{ controller_password }}"
  - name: RHSM Credentials
    credential_type: RHSM Credentials
    organization: Default
    inputs:
      rhsm_username: "{{ rhsm_username }}"
      rhsm_password: "{{ rhsm_password }}"
  - name: Image Credentials
    credential_type: Image Credentials
    organization: Default
    inputs:
      image_username: "{{ image_username }}"
      image_password: "{{ image_password }}"
  - name: Kubeadmin Credentials
    credential_type: Kubeadmin Credentials
    organization: Default
    inputs:
      kubeadmin_username: kubeadmin
      kubeadmin_password: "{{ lookup('file', (tmpdir.path + '/ocp/auth/kubeadmin-password')) }}"

Next, we’ll need an execution environment that contains the appropriate collections and Python libraries. We’ll discuss the building of this execution environment later, but for now, this is the definition:

controller_execution_environments:
  - name: Image Builder Execution Environment
    image: quay.io/device-edge-workshops/helper-ee:latest
    pull: always

After our execution environment, we’ll set up two inventories: one scoped for performing “local actions,” where the execution node performs the work without needing to connect to a remote system, and another to contain our image builder system:

controller_inventories:
  - name: Image Builder Servers
    organization: Default
    variables:
      k8s_api_address: "api.{{ inventory_hostname }}"
      k8s_api_int_address: "api-int.{{ inventory_hostname }}:6443"
      ocp_namespace: image-builder
      image_registry: 'image-registry.openshift-image-registry.svc.cluster.local:5000'
  - name: Local Actions
    organization: Default
    variables:
      k8s_api_address: "api.{{ inventory_hostname }}"
      k8s_api_int_address: "api-int.{{ inventory_hostname }}:6443"
      ocp_namespace: image-builder
      image_registry: 'image-registry.openshift-image-registry.svc.cluster.local:5000'

Be sure to define the inventory variables to correspond to your OpenShift cluster environment. Next, a simple host to use for local actions:

controller_hosts:
  - name: localhost
    inventory: Local Actions
    variables:
      ansible_connection: local
      ansible_python_interpreter: "{  { ansible_playbook_python }}"

Note: This has the same double spacing as above, meaning we’re sending a variable that will be resolved by Controller when it runs the automation, and not by the playbook configuring Controller right now. After that, a project containing our code:

controller_projects:
  - name: Image Builder Codebase
    organization: Default
    scm_type: git
    scm_url: https://github.com/redhat-manufacturing/device-edge-demos.git

Finally, we define our job templates:

controller_templates:
  - name: Manage Virtual Machine Connectivity
    organization: Default
    inventory: Local Actions
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/manage-vm-connection.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    credentials:
      - kubeconfig
  - name: Manage Host in Controller
    organization: Default
    inventory: Local Actions
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/manage-host-in-controller.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    credentials:
      - kubeconfig
      - Ansible Controller API Credentials
  - name: Preconfigure Virtual Machine
    organization: Default
    inventory: Image Builder Servers
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/preconfigure-virtual-machine.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    become_enabled: true
    credentials:
      - Machine Credentials
      - RHSM Credentials
  - name: Install Image Builder
    organization: Default
    inventory: Image Builder Servers
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/install-image-builder.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    become_enabled: true
    credentials:
      - Machine Credentials
  - name: Manage Image Builder Connectivity
    organization: Default
    inventory: Local Actions
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/manage-ib-connection.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    credentials:
      - kubeconfig
  - name: Compose Image
    organization: Default
    inventory: Image Builder Servers
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/compose-image.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    become_enabled: true
    credentials:
      - Machine Credentials
      - Image Credentials
  - name: Push Image to Registry
    organization: Default
    inventory: Image Builder Servers
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/push-image-to-registry.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    become_enabled: true
    credentials:
      - Machine Credentials
      - Kubeadmin Credentials
  - name: Deploy Edge Container
    organization: Default
    inventory: Local Actions
    project: Image Builder Codebase
    playbook: demos/rhde-pipeline/playbooks/deploy-edge-container.yml
    execution_environment: Image Builder Execution Environment
    ask_variables_on_launch: true
    credentials:
      - kubeconfig

A few things to note here: We’re consuming the credentials, inventories, project, and execution environment we created earlier. We’re also allowing some of these job templates to take additional variables when launched, a feature we’ll leverage later when building out our pipeline. Also, all of the referenced playbooks are available on GitHub as a starting point for building your own edge automation.

Interfacing with automation controller

Automation controller has a fully featured RESTful API that can be leveraged to perform basically every controller function, making it very easy to integrate with. However, we will do something a bit more custom, which will simplify our pipeline tasks and allow individual tasks to wait for the corresponding automation to complete.

A quick refresher: Execution environments are container images with roles, collections, Python libraries, and the Ansible bits pre-installed and ready to roll. Since we’re already operating within a container platform, we can reuse those execution environments within our pipeline tasks.

Because we’re building an execution environment, our collections and Python libraries will be included, meaning if we start the container, we can directly call Ansible. To extend the functionality a bit further, we’ll add a few steps to the build process and insert a playbook directly that we can leverage during our pipeline run.

Here’s an example Containerfile for our execution environment:

ARG EE_BASE_IMAGE=registry.redhat.io/ansible-automation-platform-23/ee-minimal-rhel8:latest
ARG EE_BUILDER_IMAGE=registry.redhat.io/ansible-automation-platform-23/ansible-builder-rhel8

FROM $EE_BASE_IMAGE as galaxy
ARG ANSIBLE_GALAXY_CLI_COLLECTION_OPTS=
ARG ANSIBLE_GALAXY_CLI_ROLE_OPTS=
USER root

ADD _build /build
WORKDIR /build

RUN ansible-galaxy role install $ANSIBLE_GALAXY_CLI_ROLE_OPTS -r requirements.yml --roles-path "/usr/share/ansible/roles"
RUN ANSIBLE_GALAXY_DISABLE_GPG_VERIFY=1 ansible-galaxy collection install $ANSIBLE_GALAXY_CLI_COLLECTION_OPTS -r requirements.yml --collections-path "/usr/share/ansible/collections"

FROM $EE_BUILDER_IMAGE as builder

COPY --from=galaxy /usr/share/ansible /usr/share/ansible

ADD _build/requirements.txt requirements.txt
RUN ansible-builder introspect --sanitize --user-pip=requirements.txt --write-bindep=/tmp/src/bindep.txt --write-pip=/tmp/src/requirements.txt
RUN assemble

FROM $EE_BASE_IMAGE
USER root

# Add our customizations here
RUN mkdir /helper-playbooks
COPY run-job-template.yml /helper-playbooks/

COPY --from=galaxy /usr/share/ansible /usr/share/ansible

COPY --from=builder /output/ /output/
RUN /output/install-from-bindep && rm -rf /output/wheels
LABEL ansible-execution-environment=true

We’ve added two steps: creating a directory and placing a playbook into it. This playbook is very simple and only acts as a “go-between” our pipeline and the Controller API, yet allows us to wait for jobs to complete and do a bit of validation of inputs:

---
- name: trigger job template run
  hosts: localhost
  gather_facts: false
  pre_tasks:
    - name: assert that vars are defined
      ansible.builtin.assert:
        that:
          - controller_hostname is defined
          - controller_username is defined
          - controller_password is defined
          - controller_validate_certs is defined
          - job_template is defined
    - name: set vars for role
      ansible.builtin.set_fact:
        controller_launch_jobs:
          - name: "{{ job_template }}"
            wait: true
            timeout: 14400
            extra_vars:
              virtual_machine_name: "{{ virtual_machine_name | default('rhel9-vm') }}"
              resource_state: "{{ resource_state | default('present') }}"
  roles:
    - redhat_cop.controller_configuration.job_launch
 

Once the build is complete, this execution environment will also be consumable for our Device Edge build pipeline.

Creating a pipeline to build Device Edge images

With the automation pieces in place and an execution environment (container image) we can leverage as a simple interface between a pipeline and automation controller, we can start to build out a pipeline that will let us achieve our best practices for Device Edge images—defining them as code (IaC) and testing them before rolling them out to our fleet of devices (CI/CD).

From this point forward, we’re going to treat automation controller as what it is: a platform we can consume to run automation in the proper context and securely, all via the API.

The goal of our pipeline is to kick off a compose of a Device Edge image anytime we update or change our image definition. We’ll need to take some additional steps to set up for and capture our composed image, which the pipeline will also handle. Once those steps are completed, our pipeline will clean up all of the lingering pieces configured to ensure our compose works.

First, Red Hat OpenShift 4.12 includes a tech preview feature to manage virtual machines with OpenShift Pipelines, which allows us to easily spin up and spin down virtual machines as part of our pipeline.

Leveraging our customized execution environment from before, we’ll set up some tasks that will be strung together to form our pipeline. In addition, I’ve created a secret in the namespace of my virtual machine and pipeline that contains the details of my instance of Automation Controller; however, feel free to replace that with a proper secret storage system.

First, a task to expose the SSH port of the created virtual machine:

---
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: manage-virtual-machine-connectivity
  namespace: image-builder
spec:
  params:
    - name: virtualMachineName
      type: string
      description: The name of the virtual machine to expose
      default: rhel9-vm
    - name: resourceState
      type: string
      description: Creating or cleaning up
      default: present
  steps:
     - name: expose-virtual-machine
       image: quay.io/device-edge-workshops/helper-ee:latest
       env:
         - name: CONTROLLER_HOSTNAME
           valueFrom:
             secretKeyRef:
               name: controller-auth-account
               key: controller_hostname
         - name: CONTROLLER_USERNAME
           valueFrom:
             secretKeyRef:
               name: controller-auth-account
               key: controller_username
         - name: CONTROLLER_PASSWORD
           valueFrom:
             secretKeyRef:
               name: controller-auth-account
               key: controller_password
         - name: CONTROLLER_VALIDATE_CERTS
           valueFrom:
             secretKeyRef:
               name: controller-auth-account
               key: controller_validate_certs
       script: |
         ansible-playbook /helper-playbooks/run-job-template.yml \
         --extra-vars "controller_hostname=$CONTROLLER_HOSTNAME" \
         --extra-vars "controller_username=$CONTROLLER_USERNAME" \
         --extra-vars "controller_password=$CONTROLLER_PASSWORD" \
         --extra-vars "controller_validate_certs=$CONTROLLER_VALIDATE_CERTS" \
         --extra-vars "job_template='Manage Virtual Machine Connectivity'" \
         --extra-vars "virtual_machine_name=$(params.virtualMachineName)" \
         --extra-vars "resource_state=$(params.resourceState)"
 

A good number of our tasks will look similar, so we can go through this task in detail and then simply make tweaks for later tasks.

From top to bottom, we’ve defined the following:

  • A name and namespace for the task.
  • Some parameters the task will take, and default values for them. Note that we’ve defined a parameter of resourceState—this allows us to reuse this same task to both create and destroy resources, simply by feeding in a different value from the pipeline.
  • Inserting the values of our Kubernetes secret into the container environment.
  • Our execution environment we built earlier.
  • A simple script block that calls our helper playbook and feeds in the appropriate variables.

When this task runs, the execution environment is started, ansible-playbook is invoked, and our corresponding variables are fed to the playbook, which communicates with the Controller API.

Our other tasks are similar, with minor tweaks to the job_template variable so a different job template is called and executed by controller. As an added perk, the collection leveraged within our playbook will wait for controller to complete the job, then return success or failure accordingly, giving our pipeline the necessary visibility.

To view all the tasks, check out the tasks directory on GitHub. You can create tasks using Ansible (similar to above, where we were configuring OpenShift) or the oc CLI tool.

With our tasks created, we can build our pipeline:

---
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: build-and-host-device-edge-image
  namespace: image-builder
spec:
  tasks:
    - name: create-vm-from-template
      params:
        - name: templateName
          value: rhel9-image-builder-template
        - name: runStrategy
          value: RerunOnFailure
        - name: startVM
          value: 'true'
      taskRef:
        kind: ClusterTask
        name: create-vm-from-template
    - name: expose-virtual-machine-ssh
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - create-vm-from-template
      taskRef:
        kind: Task
        name: manage-virtual-machine-connectivity
    - name: create-host-in-controller
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - expose-virtual-machine-ssh
      taskRef:
        kind: Task
        name: manage-host-in-controller
    - name: preconfigure-virtual-machine
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - create-host-in-controller
      taskRef:
        kind: Task
        name: preconfigure-virtual-machine
    - name: install-image-builder
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - preconfigure-virtual-machine
      taskRef:
        kind: Task
        name: install-image-builder
    - name: expose-image-builder
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - install-image-builder
      taskRef:
        kind: Task
        name: manage-image-builder-connectivity
    - name: compose-image
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - install-image-builder
        - expose-image-builder
      taskRef:
        kind: Task
        name: compose-image
    - name: push-image-to-registry
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - compose-image
      taskRef:
        kind: Task
        name: push-image-to-registry
    - name: deploy-composed-image
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
      runAfter:
        - push-image-to-registry
      taskRef:
        kind: Task
        name: push-image-to-registry
  finally:
    - name: cleanup-virtual-machine
      params:
        - name: vmName
          value: $(tasks.create-vm-from-template.results.name)
        - name: stop
          value: 'true'
        - name: delete
          value: 'true'
      taskRef:
        kind: ClusterTask
        name: cleanup-vm
    - name: cleanup-vm-connectivity
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
        - name: resourceState
          value: absent
      taskRef:
        kind: Task
        name: manage-virtual-machine-connectivity
    - name: cleanup-image-builder-connectivity
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
        - name: resourceState
          value: absent
      taskRef:
        kind: Task
        name: manage-image-builder-connectivity
    - name: cleanup-host-in-controller
      params:
        - name: virtualMachineName
          value: $(tasks.create-vm-from-template.results.name)
        - name: resourceState
          value: absent
      taskRef:
        kind: Task
        name: manage-host-in-controller
 

Let’s walk through the pipeline step-by-step:

  1. Create a virtual machine on OpenShift and pass the name to later tasks.
  2. Expose SSH to the virtual machine externally (this isn’t necessary, but it was useful while building and testing this process out).
  3. Create a corresponding host entry in automation controller.
  4. Run some preconfiguration steps on the virtual machine, such as registering to Red Hat Subscription Management.
  5. Install image builder.
  6. Compose a Device Edge image.
  7. Push the composed image to an image registry.
  8. Deploy the composed image to OpenShift.
  9. Clean up after ourselves.

With this pipeline in place, we remove the burden of having to constantly run and manage a Red Hat Enterprise Linux image just to run image builder. Instead, all the infrastructure we need is spun up and down on demand, only existing while being consumed, then being destroyed after the work concludes.

Expanding the concepts further

This article is meant to serve as a foundation for building out an "edge manager in a box" capable of best practices for edge device management. As such, there are a few additional things we'd recommend adding to the above, but are out of scope for this specific tutorial:

  • Use a legitimate secret store: There are a few places above where simple secret storage is used, and while functional, it is not at all recommended for production use cases.
  • Extending the pipeline: Currently, the pipeline really only tests if the image will successfully build. Ideally, this would be extended to provision a "test" system using the new image, and test deploying edge applications onto it before declaring the whole process a success.
  • Image builder: Eventually, we want image builder to operate in a container, even a privileged one, which eliminates the need for the virtualization aspects of this workflow.
  • Image Registry: While the internal OpenShift Container Platform registry does work, using a scalable robust registry makes sense in production. For a primer, check out this blog post on getting started with Red Hat Quay.

Links

Last updated: November 28, 2023