From 76aaecc7c85db6af610d98643f5ce5d0bd98eb60 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 May 2026 19:34:10 +0000 Subject: [PATCH] feat(templates): add Workshop dev-environment scaffold to machine and kubernetes init profiles Scaffold a Canonical Workshop dev environment into the `machine` and `kubernetes` init profiles: a root `workshop.yaml` and an in-project, hooks-only project SDK at `.workshop/project-dev/` (sdk.yaml plus setup-base, setup-project and check-health hooks). The charm packs inside the workshop via `charmcraft pack --destructive-mode`, so the base is pinned to ubuntu@24.04 to match the charm's build base and the setup-project hook pre-installs the charm's declared build-packages/build-snaps (which the unprivileged workshop user cannot install at pack time). The juju SDK targets an external controller via a `system:` tunnel slot, since bootstrapping a controller / Canonical Kubernetes inside an unprivileged workshop container is blocked today. The init command marks the three hooks executable, and the init tests assert the scaffold renders with the charm name, ships no leftover template syntax, and emits executable hooks. --- charmcraft/application/commands/init.py | 7 +++ .../project-dev/hooks/check-health.j2 | 8 +++ .../.workshop/project-dev/hooks/setup-base.j2 | 23 ++++++++ .../project-dev/hooks/setup-project.j2 | 19 ++++++ .../.workshop/project-dev/sdk.yaml.j2 | 8 +++ .../init-kubernetes/workshop.yaml.j2 | 58 +++++++++++++++++++ .../project-dev/hooks/check-health.j2 | 8 +++ .../.workshop/project-dev/hooks/setup-base.j2 | 23 ++++++++ .../project-dev/hooks/setup-project.j2 | 19 ++++++ .../.workshop/project-dev/sdk.yaml.j2 | 8 +++ .../templates/init-machine/workshop.yaml.j2 | 52 +++++++++++++++++ tests/integration/commands/test_init.py | 45 ++++++++++++++ 12 files changed, 278 insertions(+) create mode 100644 charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/check-health.j2 create mode 100644 charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-base.j2 create mode 100644 charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-project.j2 create mode 100644 charmcraft/templates/init-kubernetes/.workshop/project-dev/sdk.yaml.j2 create mode 100644 charmcraft/templates/init-kubernetes/workshop.yaml.j2 create mode 100644 charmcraft/templates/init-machine/.workshop/project-dev/hooks/check-health.j2 create mode 100644 charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-base.j2 create mode 100644 charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-project.j2 create mode 100644 charmcraft/templates/init-machine/.workshop/project-dev/sdk.yaml.j2 create mode 100644 charmcraft/templates/init-machine/workshop.yaml.j2 diff --git a/charmcraft/application/commands/init.py b/charmcraft/application/commands/init.py index a4cde0de2..0783aea22 100644 --- a/charmcraft/application/commands/init.py +++ b/charmcraft/application/commands/init.py @@ -82,6 +82,10 @@ . ├── charmcraft.yaml - Charm build configuration + ├── workshop.yaml - Canonical Workshop dev environment, created + │ if profile is 'kubernetes' or 'machine' + ├── .workshop - In-project Workshop SDK with setup hooks, + │ created if profile is 'kubernetes' or 'machine' ├── CONTRIBUTING.md - Instructions for how to build and develop │ your charm ├── LICENSE - Your charm license, we recommend Apache 2 @@ -245,6 +249,9 @@ def run(self, parsed_args: argparse.Namespace): "src/charm.py", "tests/spread/lib/tools/retry", "spread/.extension", + ".workshop/project-dev/hooks/setup-base", + ".workshop/project-dev/hooks/setup-project", + ".workshop/project-dev/hooks/check-health", ] src_files = ["src/charm.py"] for template_name in env.list_templates(): diff --git a/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/check-health.j2 b/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/check-health.j2 new file mode 100644 index 000000000..c333d721b --- /dev/null +++ b/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/check-health.j2 @@ -0,0 +1,8 @@ +#!/bin/bash +# Workshop `check-health` hook for the {{ name }} charm dev environment. +# Reports whether the `workshop` user can build and test the charm. +if sudo -H -i -u workshop -- bash -c 'cd /project && charmcraft version >/dev/null 2>&1 && uv run python -c "import ops" >/dev/null 2>&1'; then + workshopctl set-health okay +else + workshopctl set-health --code="project-dev-not-ready" error "charmcraft or the charm's uv environment is not ready for the workshop user" +fi diff --git a/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-base.j2 b/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-base.j2 new file mode 100644 index 000000000..5cc3a1686 --- /dev/null +++ b/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-base.j2 @@ -0,0 +1,23 @@ +#!/bin/bash -e +# Workshop `setup-base` hook for the {{ name }} charm dev environment. +# Runs as root while the workshop base image is being prepared. +# +# `charmcraft pack --destructive-mode` packs the charm in this environment as +# the unprivileged `workshop` user, which cannot install apt or snap packages. +# Install the common charm build tooling here so that packing works without any +# nested provisioning. Dependencies the charm itself declares are installed by +# the setup-project hook. +apt-get -y update +apt-get -y install \ + build-essential \ + findutils \ + git \ + python3-dev \ + python3-pip \ + python3-setuptools \ + python3-venv \ + python3-wheel + +# `yq` is used by the setup-project hook to read the charm's declared +# build-packages and build-snaps out of charmcraft.yaml. +snap install yq diff --git a/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-project.j2 b/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-project.j2 new file mode 100644 index 000000000..644ba64ea --- /dev/null +++ b/charmcraft/templates/init-kubernetes/.workshop/project-dev/hooks/setup-project.j2 @@ -0,0 +1,19 @@ +#!/bin/bash -e +# Workshop `setup-project` hook for the {{ name }} charm dev environment. +# Runs as root after the project is mounted at /project. +# +# Pre-install the build dependencies that the charm declares in charmcraft.yaml. +# `charmcraft pack --destructive-mode` runs as the unprivileged `workshop` user +# and cannot install these itself, so they must already be present. +cd /project + +if [ -f charmcraft.yaml ]; then + # apt build-packages (none in the default template, but charms may add some). + yq '.parts.charm.build-packages[]' charmcraft.yaml | xargs -r apt-get -y install + # classic snaps the charm builds with, e.g. astral-uv for the uv plugin. + yq '.parts.charm.build-snaps[]' charmcraft.yaml | xargs -rn1 snap install --classic +fi + +# Create the charm's Python virtualenv as the workshop user so that the unit and +# integration test actions in workshop.yaml can `uv run` straight away. +sudo -iu workshop -- bash -lc 'cd /project && uv sync' diff --git a/charmcraft/templates/init-kubernetes/.workshop/project-dev/sdk.yaml.j2 b/charmcraft/templates/init-kubernetes/.workshop/project-dev/sdk.yaml.j2 new file mode 100644 index 000000000..65a74b64c --- /dev/null +++ b/charmcraft/templates/init-kubernetes/.workshop/project-dev/sdk.yaml.j2 @@ -0,0 +1,8 @@ +# In-project Workshop SDK for the {{ name }} charm's dev environment. +# +# This is a hooks-only, in-project SDK: it has no build-time fields (no base, +# build-base, platforms or parts) and is configured entirely by the scripts in +# hooks/. It is referenced from workshop.yaml as the `project-dev` SDK. +name: project-dev +version: "0" +summary: In-project dev environment setup for the {{ name }} charm diff --git a/charmcraft/templates/init-kubernetes/workshop.yaml.j2 b/charmcraft/templates/init-kubernetes/workshop.yaml.j2 new file mode 100644 index 000000000..3983ad9f6 --- /dev/null +++ b/charmcraft/templates/init-kubernetes/workshop.yaml.j2 @@ -0,0 +1,58 @@ +# This file configures a Canonical Workshop dev environment for the {{ name }} charm. +# Workshop (https://github.com/canonical/workshop) launches an ephemeral LXD +# container with a reproducible toolchain. Run `workshop launch` from the charm +# project root, then drive the actions below with `workshop run dev `. +# +# The charm is packed *inside* the workshop with `charmcraft pack --destructive-mode` +# (there is no nested build VM), so this `base` must match the charm's build base +# in charmcraft.yaml (ubuntu@24.04). Multi-base charms would need the LXD provider, +# which is out of scope for this scaffold. +name: dev +base: ubuntu@24.04 +sdks: + # The `system` SDK exposes host resources to the workshop. The `juju-api` + # tunnel slot points at a Juju controller running OUTSIDE the workshop. + # + # For a Kubernetes charm this should be an EXTERNAL, Kubernetes-backed Juju + # controller on the host. Running the substrate in-workshop (an in-workshop + # Canonical Kubernetes substrate SDK that juju bootstraps against) is the + # intended future shape, but it is blocked today: Canonical Kubernetes does + # not come up inside an unprivileged workshop container (it needs a more + # privileged container than Workshop currently provides). Until then, point + # juju at an external k8s controller exactly as the machine profile points at + # an external machine controller. + - name: system + slots: + juju-api: + interface: tunnel + endpoint: 17070 + # uv manages the charm's Python dependencies and runs its tests. + - name: uv + # juju drives deployments against the external controller. + - name: juju + channel: 3.6/stable + # charmcraft packs the charm in destructive mode (see the `pack` action). + - name: charmcraft + channel: latest/stable + # The in-project SDK at .workshop/project-dev/ installs the charm's own + # build/test dependencies (see .workshop/project-dev/sdk.yaml and its hooks). + - name: project-dev +# NOTE: there is intentionally no `connections:` block for the controller. +# A `system:` SDK tunnel slot is not auto-connected, so it cannot be listed in +# `connections:`. After `workshop launch`, wire juju to the host controller +# manually: +# +# workshop connect dev/juju:controller dev/system:juju-api +# workshop remount dev/juju:juju-data --host-source ~/.local/share/juju +# +actions: + # Run the unit tests. + unit: | + uv run pytest tests/unit "$@" + # Pack the charm inside the workshop. This produces a .charm in the project + # root, which the integration tests pick up automatically. + pack: | + charmcraft pack --destructive-mode + # Run the integration tests against the external Kubernetes-backed controller. + integration: | + uv run pytest tests/integration "$@" diff --git a/charmcraft/templates/init-machine/.workshop/project-dev/hooks/check-health.j2 b/charmcraft/templates/init-machine/.workshop/project-dev/hooks/check-health.j2 new file mode 100644 index 000000000..c333d721b --- /dev/null +++ b/charmcraft/templates/init-machine/.workshop/project-dev/hooks/check-health.j2 @@ -0,0 +1,8 @@ +#!/bin/bash +# Workshop `check-health` hook for the {{ name }} charm dev environment. +# Reports whether the `workshop` user can build and test the charm. +if sudo -H -i -u workshop -- bash -c 'cd /project && charmcraft version >/dev/null 2>&1 && uv run python -c "import ops" >/dev/null 2>&1'; then + workshopctl set-health okay +else + workshopctl set-health --code="project-dev-not-ready" error "charmcraft or the charm's uv environment is not ready for the workshop user" +fi diff --git a/charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-base.j2 b/charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-base.j2 new file mode 100644 index 000000000..5cc3a1686 --- /dev/null +++ b/charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-base.j2 @@ -0,0 +1,23 @@ +#!/bin/bash -e +# Workshop `setup-base` hook for the {{ name }} charm dev environment. +# Runs as root while the workshop base image is being prepared. +# +# `charmcraft pack --destructive-mode` packs the charm in this environment as +# the unprivileged `workshop` user, which cannot install apt or snap packages. +# Install the common charm build tooling here so that packing works without any +# nested provisioning. Dependencies the charm itself declares are installed by +# the setup-project hook. +apt-get -y update +apt-get -y install \ + build-essential \ + findutils \ + git \ + python3-dev \ + python3-pip \ + python3-setuptools \ + python3-venv \ + python3-wheel + +# `yq` is used by the setup-project hook to read the charm's declared +# build-packages and build-snaps out of charmcraft.yaml. +snap install yq diff --git a/charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-project.j2 b/charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-project.j2 new file mode 100644 index 000000000..644ba64ea --- /dev/null +++ b/charmcraft/templates/init-machine/.workshop/project-dev/hooks/setup-project.j2 @@ -0,0 +1,19 @@ +#!/bin/bash -e +# Workshop `setup-project` hook for the {{ name }} charm dev environment. +# Runs as root after the project is mounted at /project. +# +# Pre-install the build dependencies that the charm declares in charmcraft.yaml. +# `charmcraft pack --destructive-mode` runs as the unprivileged `workshop` user +# and cannot install these itself, so they must already be present. +cd /project + +if [ -f charmcraft.yaml ]; then + # apt build-packages (none in the default template, but charms may add some). + yq '.parts.charm.build-packages[]' charmcraft.yaml | xargs -r apt-get -y install + # classic snaps the charm builds with, e.g. astral-uv for the uv plugin. + yq '.parts.charm.build-snaps[]' charmcraft.yaml | xargs -rn1 snap install --classic +fi + +# Create the charm's Python virtualenv as the workshop user so that the unit and +# integration test actions in workshop.yaml can `uv run` straight away. +sudo -iu workshop -- bash -lc 'cd /project && uv sync' diff --git a/charmcraft/templates/init-machine/.workshop/project-dev/sdk.yaml.j2 b/charmcraft/templates/init-machine/.workshop/project-dev/sdk.yaml.j2 new file mode 100644 index 000000000..65a74b64c --- /dev/null +++ b/charmcraft/templates/init-machine/.workshop/project-dev/sdk.yaml.j2 @@ -0,0 +1,8 @@ +# In-project Workshop SDK for the {{ name }} charm's dev environment. +# +# This is a hooks-only, in-project SDK: it has no build-time fields (no base, +# build-base, platforms or parts) and is configured entirely by the scripts in +# hooks/. It is referenced from workshop.yaml as the `project-dev` SDK. +name: project-dev +version: "0" +summary: In-project dev environment setup for the {{ name }} charm diff --git a/charmcraft/templates/init-machine/workshop.yaml.j2 b/charmcraft/templates/init-machine/workshop.yaml.j2 new file mode 100644 index 000000000..17676dff6 --- /dev/null +++ b/charmcraft/templates/init-machine/workshop.yaml.j2 @@ -0,0 +1,52 @@ +# This file configures a Canonical Workshop dev environment for the {{ name }} charm. +# Workshop (https://github.com/canonical/workshop) launches an ephemeral LXD +# container with a reproducible toolchain. Run `workshop launch` from the charm +# project root, then drive the actions below with `workshop run dev `. +# +# The charm is packed *inside* the workshop with `charmcraft pack --destructive-mode` +# (there is no nested build VM), so this `base` must match the charm's build base +# in charmcraft.yaml (ubuntu@24.04). Multi-base charms would need the LXD provider, +# which is out of scope for this scaffold. +name: dev +base: ubuntu@24.04 +sdks: + # The `system` SDK exposes host resources to the workshop. The `juju-api` + # tunnel slot points at a Juju controller running OUTSIDE the workshop. + # Bootstrapping a controller inside an unprivileged workshop container does + # not work today, so integration tests target an external controller on the + # host (e.g. a `localhost`/LXD controller you bootstrapped yourself). + - name: system + slots: + juju-api: + interface: tunnel + endpoint: 17070 + # uv manages the charm's Python dependencies and runs its tests. + - name: uv + # juju drives deployments against the external controller. + - name: juju + channel: 3.6/stable + # charmcraft packs the charm in destructive mode (see the `pack` action). + - name: charmcraft + channel: latest/stable + # The in-project SDK at .workshop/project-dev/ installs the charm's own + # build/test dependencies (see .workshop/project-dev/sdk.yaml and its hooks). + - name: project-dev +# NOTE: there is intentionally no `connections:` block for the controller. +# A `system:` SDK tunnel slot is not auto-connected, so it cannot be listed in +# `connections:`. After `workshop launch`, wire juju to the host controller +# manually: +# +# workshop connect dev/juju:controller dev/system:juju-api +# workshop remount dev/juju:juju-data --host-source ~/.local/share/juju +# +actions: + # Run the unit tests. + unit: | + uv run pytest tests/unit "$@" + # Pack the charm inside the workshop. This produces a .charm in the project + # root, which the integration tests pick up automatically. + pack: | + charmcraft pack --destructive-mode + # Run the integration tests against the external Juju controller. + integration: | + uv run pytest tests/integration "$@" diff --git a/tests/integration/commands/test_init.py b/tests/integration/commands/test_init.py index 3b10ca16c..0c4bafa5c 100644 --- a/tests/integration/commands/test_init.py +++ b/tests/integration/commands/test_init.py @@ -40,6 +40,13 @@ pathlib.Path(p) for p in ( ".gitignore", + ".workshop", + ".workshop/project-dev", + ".workshop/project-dev/sdk.yaml", + ".workshop/project-dev/hooks", + ".workshop/project-dev/hooks/setup-base", + ".workshop/project-dev/hooks/setup-project", + ".workshop/project-dev/hooks/check-health", "charmcraft.yaml", "CONTRIBUTING.md", "LICENSE", @@ -55,6 +62,7 @@ "tests/unit/test_charm.py", "tox.ini", "uv.lock", + "workshop.yaml", ) ) UNKNOWN_AUTHOR_REGEX = re.compile( @@ -147,6 +155,43 @@ def test_files_created_correct( pytest_check.is_true(re.search(rf"^# Copyright \d+ {author}", tox_ini)) +WORKSHOP_HOOKS = ( + ".workshop/project-dev/hooks/setup-base", + ".workshop/project-dev/hooks/setup-project", + ".workshop/project-dev/hooks/check-health", +) + + +@pytest.mark.parametrize("profile", ["machine", "kubernetes"]) +def test_workshop_scaffold(new_path, init_command, profile): + init_command.run(create_namespace(name="my-charm", profile=profile)) + + workshop_yaml = (new_path / "workshop.yaml").read_text(encoding="utf-8") + sdk_yaml = (new_path / ".workshop/project-dev/sdk.yaml").read_text(encoding="utf-8") + + # The charm name is substituted into the rendered files. + pytest_check.is_in("for the my-charm charm", workshop_yaml) + pytest_check.is_in("for the my-charm charm", sdk_yaml) + + # No template syntax is left behind in any rendered Workshop file. + for relpath in ("workshop.yaml", ".workshop/project-dev/sdk.yaml", *WORKSHOP_HOOKS): + contents = (new_path / relpath).read_text(encoding="utf-8") + pytest_check.is_not_in("{{", contents) + pytest_check.is_not_in("{%", contents) + + # The in-project SDK is referenced from workshop.yaml and is hooks-only + # (no build-time fields). + pytest_check.is_in("name: project-dev", workshop_yaml) + for build_field in ("base:", "build-base:", "platforms:", "parts:"): + pytest_check.is_not_in(build_field, sdk_yaml) + + # The hooks are emitted and executable. + if os.name == "posix": + for hook in WORKSHOP_HOOKS: + mode = (new_path / hook).stat().st_mode + pytest_check.equal(mode & S_IXALL, S_IXALL) + + def test_force(new_path, init_command): tmp_file = new_path / "README.md" with tmp_file.open("w") as f: