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: