Skip to content

1. Write a charm for myapp

We will write a Kubernetes charm for an application named myapp. This simple web application requires a configuration file that contains the port on which it listens.

1.1. Write the Go charm using goops

Create a new directory for your charm project and initialize a Go module:

mkdir myapp-k8s-operator
cd myapp-k8s-operator
go mod init myapp-k8s-operator

Create a cmd/myapp-k8s-operator/main.go file with the following content:

package main

import (
    "myapp-k8s-operator/internal/charm"
    "os"

    "github.com/gruyaume/goops"
)

func main() {
    err := charm.Configure()
    if err != nil {
        goops.LogErrorf("Failed to configure charm: %v", err)
        os.Exit(1)
    }
}

Create a internal/charm/charm.go file with the following content:

package charm

import (
    "bytes"
    "fmt"
    "strings"

    "github.com/canonical/pebble/client"
    "github.com/gruyaume/goops"
    "gopkg.in/yaml.v3"
)

const (
    Port       = 8080
    ConfigPath = "/etc/myapp/config.yaml"
)

type ServiceConfig struct {
    Override string `yaml:"override"`
    Summary  string `yaml:"summary"`
    Command  string `yaml:"command"`
    Startup  string `yaml:"startup"`
}

type PebbleLayer struct {
    Summary     string                   `yaml:"summary"`
    Description string                   `yaml:"description"`
    Services    map[string]ServiceConfig `yaml:"services"`
}

type PebblePlan struct {
    Services map[string]ServiceConfig `yaml:"services"`
}

func Configure() error {
    err := goops.SetPorts([]*goops.Port{
        {Port: Port, Protocol: goops.ProtocolTCP},
    })
    if err != nil {
        return fmt.Errorf("could not set ports: %w", err)
    }

    pebble := goops.Pebble("myapp")

    _, err = pebble.SysInfo()
    if err != nil {
        _ = goops.SetUnitStatus(goops.StatusWaiting, "waiting for pebble to be ready")
        return nil
    }

    configFileChanged, err := syncConfig(pebble, Port)
    if err != nil {
        return fmt.Errorf("could not sync config: %w", err)
    }

    err = syncPebbleService(pebble)
    if err != nil {
        return fmt.Errorf("could not sync pebble service: %w", err)
    }

    if configFileChanged {
        _, err = pebble.Restart(
            &client.ServiceOptions{
                Names: []string{"myapp"},
            },
        )
        if err != nil {
            return fmt.Errorf("could not start pebble service: %w", err)
        }

        goops.LogInfof("Pebble service restarted")
    }

    _ = goops.SetUnitStatus(goops.StatusActive, "service is running on port", fmt.Sprintf("%d", Port))

    return nil
}

type MyAppConfig struct {
    Port int `yaml:"port"`
}

func getExpectedConfig(port int) ([]byte, error) {
    myappConfig := MyAppConfig{
        Port: port,
    }

    b, err := yaml.Marshal(myappConfig)
    if err != nil {
        return nil, fmt.Errorf("could not marshal config to YAML: %w", err)
    }

    return b, nil
}

func syncConfig(pebble goops.PebbleClient, port int) (bool, error) {
    expectedContent, err := getExpectedConfig(port)
    if err != nil {
        return false, fmt.Errorf("could not get expected config: %w", err)
    }

    target := &bytes.Buffer{}

    err = pebble.Pull(&client.PullOptions{
        Path:   ConfigPath,
        Target: target,
    })
    if err != nil {
        goops.LogInfof("could not pull existing config from pebble: %v", err)
    }

    if target.String() == string(expectedContent) {
        goops.LogInfof("Config file is already up to date at %s", ConfigPath)
        return false, nil
    }

    err = pebble.Push(&client.PushOptions{
        Source: strings.NewReader(string(expectedContent)),
        Path:   ConfigPath,
    })
    if err != nil {
        return false, fmt.Errorf("could not push config to pebble: %w", err)
    }

    goops.LogInfof("Config file pushed to %s", ConfigPath)

    return true, nil
}

func syncPebbleService(pebble goops.PebbleClient) error {
    err := addPebbleLayer(pebble)
    if err != nil {
        return fmt.Errorf("could not add pebble layer: %w", err)
    }

    goops.LogInfof("Pebble layer created")

    _, err = pebble.Start(&client.ServiceOptions{
        Names: []string{"myapp"},
    })
    if err != nil {
        return fmt.Errorf("could not start pebble service: %w", err)
    }

    goops.LogInfof("Pebble service started")

    return nil
}

func addPebbleLayer(pebble goops.PebbleClient) error {
    layerData, err := yaml.Marshal(PebbleLayer{
        Summary:     "MyApp layer",
        Description: "pebble config layer for MyApp",
        Services: map[string]ServiceConfig{
            "myapp": {
                Override: "replace",
                Summary:  "My App Service",
                Command:  "myapp -config /etc/myapp/config.yaml",
                Startup:  "enabled",
            },
        },
    })
    if err != nil {
        return fmt.Errorf("could not marshal layer data to YAML: %w", err)
    }

    err = pebble.AddLayer(&client.AddLayerOptions{
        Combine:   true,
        Label:     "myapp",
        LayerData: layerData,
    })
    if err != nil {
        return fmt.Errorf("could not add pebble layer: %w", err)
    }

    return nil
}

Install the go dependencies:

go mod tidy

1.2. Add the charm definition

Create a charmcraft.yaml file in the root of your project with the following content:

name: myapp-k8s
summary: A Kubernetes charm for `myapp`
description: |
  A Kubernetes charm for `myapp`.

type: charm
base: ubuntu@24.04
build-base: ubuntu@24.04
platforms:
  amd64:

parts:
  charm:
    source: .
    plugin: go
    build-snaps:
      - go
    organize:
      bin/myapp-k8s-operator: dispatch

containers:
  myapp:
    resource: myapp-image
    mounts:
    - storage: config
      location: /etc/myapp

storage:
  config:
    type: filesystem
    minimum-size: 5M

resources:
  myapp-image:
    type: oci-image
    description: OCI image for myapp
    upstream-source: ghcr.io/gruyaume/myapp:v0.0.1

1.3. Build the charm

Build the charm using charmcraft:

charmcraft pack --verbose

This will create a myapp-k8s_amd64.charm file in the current directory.

1.4. Deploy the charm

Create a new Juju model:

juju add-model demo

Deploy the charm to the model:

juju deploy ./myapp-k8s_amd64.charm --resource myapp-image=ghcr.io/gruyaume/myapp:latest

Verify that the charm is running:

juju status

You should see the myapp-k8s application in the status output, indicating that it is active and running.

Model  Controller  Cloud/Region  Version  SLA          Timestamp
demo   k8s-jul1    k8s-jul1      3.6.7    unsupported  09:47:22-04:00

App        Version  Status  Scale  Charm      Channel  Rev  Address         Exposed  Message
myapp-k8s           active      1  myapp-k8s             2  10.152.183.113  no       service is running

Unit          Workload  Agent  Address    Ports  Message
myapp-k8s/0*  active    idle   10.1.0.95         service is running

1.5. Access the application

Open a web browser and navigate to the address of the myapp-k8s application. Here this address is http://10.1.0.95:8080, replace the IP address with the one shown in the unit address in the juju status output. You should see a page displaying MyApp, "/".