Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions charmcraft/application/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions charmcraft/templates/init-kubernetes/workshop.yaml.j2
Original file line number Diff line number Diff line change
@@ -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 <action>`.
#
# 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 "$@"
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions charmcraft/templates/init-machine/workshop.yaml.j2
Original file line number Diff line number Diff line change
@@ -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 <action>`.
#
# 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 "$@"
45 changes: 45 additions & 0 deletions tests/integration/commands/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -55,6 +62,7 @@
"tests/unit/test_charm.py",
"tox.ini",
"uv.lock",
"workshop.yaml",
)
)
UNKNOWN_AUTHOR_REGEX = re.compile(
Expand Down Expand Up @@ -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:
Expand Down