원클릭으로
custom-charm
End-to-end workflow for building ops-framework charms for custom applications (K8s and machine)
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
End-to-end workflow for building ops-framework charms for custom applications (K8s and machine)
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Known-good Juju bundle shapes (COS Lite, 12-Factor + COS, Identity Platform, Charmed Kubeflow) — canonical app lists and relation edges
Implementing Juju actions for operational tasks in charms. WHEN: add Juju actions to a charm, implement backup / rotate-credentials / restore actions, declare actions in charmcraft.yaml, write action handlers, test actions with Scenario, run actions with juju run.
Adding and validating charm configuration options. WHEN: add charm configuration options, declare config in charmcraft.yaml, validate config values in config-changed, apply config to a Pebble layer, apply config to a machine charm, write Scenario tests for config.
End-to-end workflow for building ops-framework charms for custom applications (K8s and machine). WHEN: build a custom Juju charm, write src/charm.py with Pebble, write a machine charm with systemd, decide K8s vs machine substrate, scaffold a non-paas-charm with charmcraft init, customise the ops framework charm lifecycle.
Workflow for charming infrastructure software (databases, caches, message brokers, proxies, monitoring). WHEN: charm infrastructure software, charm a database / cache / message broker / proxy / monitoring system, implement peer relations, leader election, primary/replica failover, backup and restore actions, clustering.
Expert guidance for developing, building, testing, and publishing Juju charms using charmcraft. WHEN: edit charmcraft.yaml, run charmcraft pack, run charmcraft init, manage charm libraries, publish a charm to Charmhub, set up multi-base builds, configure relations / config options, set up storage or containers.
| name | custom-charm |
| description | End-to-end workflow for building ops-framework charms for custom applications (K8s and machine) |
This skill covers building a Juju charm for an application that does not fit a recognised 12-factor PaaS framework (Flask, Django, FastAPI, Go, Express, Spring Boot). These charms use the full ops framework — you write src/charm.py yourself, managing the workload lifecycle directly via Pebble (K8s) or systemd/apt (machine).
Use this path (Path B) when:
Do not use this path for:
twelve-factor skill insteadChoose the substrate before scaffolding. Ask the user to confirm.
| Signal | Substrate | Reasoning |
|---|---|---|
| Dockerfile or OCI image exists | K8s | Already containerised; natural fit for Pebble |
| Cloud-native / stateless workload | K8s | Designed for orchestration |
| Bare metal / GPU / kernel modules | Machine | Needs direct hardware access |
| systemd service files present | Machine | Already designed for systemd |
| Heavy OS-level integration (PAM, cron, udev) | Machine | Relies on host OS services |
| No strong signal | K8s (default) | Easier to distribute and test |
Run analyse_framework on the source directory. When no PaaS framework is detected, the tool returns workload_hints indicating whether a Dockerfile, docker-compose file, or systemd service files are present, along with a suggested_substrate.
Present the substrate recommendation to the user with reasoning. Wait for confirmation before proceeding — this choice affects everything downstream.
Use charmcraft_init with the appropriate profile:
profile: "kubernetes"profile: "machine"charmcraft init --profile=kubernetes --name=my-app
This creates the base structure: charmcraft.yaml, src/charm.py, tox.ini, and test directories.
charmcraft.yamlAdd containers, resources, and storage:
name: my-app
type: charm
bases:
- build-on:
- name: ubuntu
channel: "22.04"
run-on:
- name: ubuntu
channel: "22.04"
containers:
my-app:
resource: oci-image
resources:
oci-image:
type: oci-image
description: OCI image for the application
storage:
data:
type: filesystem
minimum-size: 1G
location: /data
requires:
tracing:
interface: tracing
limit: 1
Machine charms typically need no containers or OCI resources:
name: my-app
type: charm
bases:
- build-on:
- name: ubuntu
channel: "22.04"
run-on:
- name: ubuntu
channel: "22.04"
storage:
data:
type: filesystem
minimum-size: 1G
location: /data
requires:
tracing:
interface: tracing
limit: 1
src/charm.py#!/usr/bin/env python3
"""Charm for my-app on Kubernetes."""
import ops
import ops_tracing
class MyAppCharm(ops.CharmBase):
"""Charm the application using Pebble."""
def __init__(self, framework: ops.Framework):
super().__init__(framework)
self._tracing = ops_tracing.Tracing(self, "tracing")
self._container = self.unit.get_container("my-app")
framework.observe(self.on["my-app"].pebble_ready, self._on_pebble_ready)
framework.observe(self.on.config_changed, self._on_config_changed)
def _on_pebble_ready(self, event: ops.PebbleReadyEvent) -> None:
"""Start the workload when the container is ready."""
self._container.add_layer("my-app", self._pebble_layer(), combine=True)
self._container.autostart()
self.unit.status = ops.ActiveStatus()
def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None:
"""Update the workload config and restart if running."""
if not self._container.can_connect():
event.defer()
return
self._container.add_layer("my-app", self._pebble_layer(), combine=True)
self._container.replan()
self.unit.status = ops.ActiveStatus()
def _pebble_layer(self) -> ops.pebble.LayerDict:
"""Return the Pebble layer configuration."""
return {
"summary": "my-app layer",
"description": "Pebble layer for my-app",
"services": {
"my-app": {
"override": "replace",
"summary": "my-app service",
"command": "/usr/bin/my-app",
"startup": "enabled",
"environment": {},
},
},
}
if __name__ == "__main__":
ops.main(MyAppCharm)
Key patterns for K8s charms:
self.unit.get_container("container-name")pebble-ready — this fires when the container's Pebble agent becomes reachableadd_layer() + autostart() on first start; add_layer() + replan() on changesself._container.can_connect() before interacting with Pebble; defer if not readyself._container.push() to write config files into the containerself._container.exec() to run commands inside the container#!/usr/bin/env python3
"""Charm for my-app on machines."""
import subprocess
import ops
import ops_tracing
class MyAppCharm(ops.CharmBase):
"""Charm the application using apt and systemd."""
def __init__(self, framework: ops.Framework):
super().__init__(framework)
self._tracing = ops_tracing.Tracing(self, "tracing")
framework.observe(self.on.install, self._on_install)
framework.observe(self.on.start, self._on_start)
framework.observe(self.on.config_changed, self._on_config_changed)
def _on_install(self, event: ops.InstallEvent) -> None:
"""Install packages and prepare the system."""
self.unit.status = ops.MaintenanceStatus("Installing dependencies")
subprocess.check_call(["apt-get", "update", "-y"])
subprocess.check_call(["apt-get", "install", "-y", "my-app"])
def _on_start(self, event: ops.StartEvent) -> None:
"""Start the workload service."""
subprocess.check_call(["systemctl", "enable", "--now", "my-app"])
self.unit.status = ops.ActiveStatus()
def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None:
"""Update configuration and restart the service."""
self._write_config()
subprocess.check_call(["systemctl", "restart", "my-app"])
self.unit.status = ops.ActiveStatus()
def _write_config(self) -> None:
"""Write the application config file from charm config."""
# Read charm config and write to the appropriate location.
# Example: /etc/my-app/config.yaml
pass
if __name__ == "__main__":
ops.main(MyAppCharm)
Key patterns for machine charms:
install event for package installation (apt-get, snap install)start event to enable and start the systemd service/etc/my-app/, etc.)subprocess.check_call() for system commandssystemctl restart after config changesSet status to communicate the charm's state to the operator:
# During long operations.
self.unit.status = ops.MaintenanceStatus("Installing packages")
# When waiting for a relation.
self.unit.status = ops.WaitingStatus("Waiting for database relation")
# When a required config option is missing.
self.unit.status = ops.BlockedStatus("Missing required config: database-url")
# When everything is working.
self.unit.status = ops.ActiveStatus()
Validate config early and set BlockedStatus on invalid input:
def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None:
port = self.config.get("port")
if not isinstance(port, int) or not (1 <= port <= 65535):
self.unit.status = ops.BlockedStatus("Invalid port number")
return
# Apply the valid config...
Read and write relation data in event handlers:
def _on_database_relation_changed(self, event: ops.RelationChangedEvent) -> None:
if not event.unit:
return
host = event.relation.data[event.unit].get("host")
port = event.relation.data[event.unit].get("port")
if not host or not port:
self.unit.status = ops.WaitingStatus("Waiting for database credentials")
return
# Configure the workload with the database connection...
For detailed relation data patterns, load the relation-data-design skill. For OAuth / OIDC / login flows backed by the Canonical Identity Platform, load the identity-platform skill — it covers the oauth, oauth-cli, oidc-info, and hydra-token-introspect relations and how the secret-backed client credentials flow through to the workload.
Access Juju-managed storage in event handlers:
def _on_storage_attached(self, event: ops.StorageAttachedEvent) -> None:
storage_path = event.storage.location
# Use storage_path for persistent data...
Use Juju secrets for sensitive data:
def _on_secret_changed(self, event: ops.SecretChangedEvent) -> None:
secret = event.secret
content = secret.get_content(refresh=True)
password = content.get("password")
# Apply the new secret...
Custom charms do not need rockcraft. The cycle is:
charmcraft pack
juju deploy ./my-app_amd64.charm
For K8s charms with an OCI image resource:
charmcraft pack
juju deploy ./my-app_amd64.charm --resource oci-image=docker.io/library/my-app:latest
Use the charmcraft_pack and juju_deploy tools. For K8s charms, pass the OCI image via resources={"oci-image": "..."}.
K8s charms need an OCI image for the container resource. There are two options:
Option A: Use an existing registry image
registry_search(query="<workload>")registry_image_info(image="<name>", tag="<version>")juju_deploy(..., resources={"oci-image": "docker.io/library/<name>:<tag>"})Option B: Build a rock
When no suitable image exists or the workload needs custom packaging:
rockcraft.yaml for the applicationrockcraft_pack to build the rockskopeo_registry_push to push to the local registryOption A is the common case for custom charms wrapping well-known software. Option B is needed when the application is custom code or requires modifications to the base image.
For Python source changes only, use the fast path:
src/charm.py (or files in src/ and lib/)charm_sync to push changes to the running unitjuju_dispatch to fire an event (e.g. config-changed, update-status)Use the full charmcraft_pack → juju_refresh cycle when:
charmcraft.yaml changed (new relations, config options, resources)requirements.txt)Unit tests: Load the scenario-tests skill. Cover:
install / start → ActiveStatuspebble-ready (K8s) → Pebble layer applied, service startedconfig-changed → config applied, service restartedIntegration tests: Load the jubilant-tests skill. Cover:
ActiveStatusRun charm_validate before declaring the charm complete. This runs unit tests and charmcraft pack, producing a pass/fail checklist.
charmcraft_pack → juju_refresh --path → juju_wait → verify
No rockcraft or registry push needed for custom charms (unlike 12-factor).
Pebble layer hygiene — add_layer() without combine=True, Pebble methods called without a can_connect() guard, or service entries missing override / command / startup are all checked deterministically by charmlint (PEB001 / PEB002 / PEB003). Run charmlint to surface them — the skill body no longer recites the rules.
Blocking the hook with long-running commands — hooks have a timeout (default 5 minutes). For long installations, consider breaking work across events or increasing the timeout.
Writing config directly in the install hook — config values may not be set yet during install. Use config-changed for applying configuration.
Using kubernetes profile for a PaaS framework — if the app is Flask/Django/etc., use the framework profile with the twelve-factor skill instead. The kubernetes profile gives a bare ops charm.
Missing OCI image resource for K8s charms — the containers section in charmcraft.yaml must reference a resource, and that resource must be declared in the resources section.
| Symptom | Likely Cause | Fix |
|---|---|---|
Unit stuck in waiting with "waiting for Pebble" | Container not started or image pull failed | Check juju debug-log; verify OCI image resource is correct |
pebble-ready never fires | Container name mismatch between charmcraft.yaml and charm code | Ensure the container name in self.on["name"] matches charmcraft.yaml |
ConnectionError from Pebble | Calling Pebble methods before can_connect() returns True | Add a can_connect() guard and defer the event |
Service crashes after replan() | Bad command or missing binary in the container | Check the Pebble layer command; use container.exec() to debug |
apt-get fails during install (machine) | Missing universe repo or network issue | Add add-apt-repository or check proxy settings |
systemctl fails (machine) | Service file not installed or unit not enabled | Verify the package installs a .service file; use systemctl status |
Charm goes to error on relation event | Accessing relation data that does not exist yet | Guard with if not event.unit: return and check for required keys |
| Config change has no effect | Forgot to restart the service after writing new config | Call replan() (K8s) or systemctl restart (machine) after applying config |