diff --git a/.github/workflows/spread_tests.yaml b/.github/workflows/spread_tests.yaml
new file mode 100644
index 0000000000..e0268bcd83
--- /dev/null
+++ b/.github/workflows/spread_tests.yaml
@@ -0,0 +1,35 @@
+name: Tests with spread
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ run-spread-tests:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ spread_job: ['github-ci:ubuntu-22.04-amd64:tests/spread/', 'github-ci:ubuntu-22.04-amd64:tests/spread/scenario/']
+ steps:
+ - uses: actions/checkout@v2
+
+ - uses: actions/setup-go@v5
+ with:
+ check-latest: true
+ go-version: 'stable'
+
+ - name: Install spread
+ run: |
+ go install github.com/snapcore/spread/cmd/spread@latest
+ - name: Run tests
+ run: |
+ sudo adduser --gecos "" --disabled-password ubuntu
+ echo "ubuntu:ubuntu" | sudo chpasswd
+ spread ${{ matrix.spread_job }}
+ - name: Tmate debugging session
+ if: ${{ failure() }}
+ uses: mxschmitt/action-tmate@v3
+ timeout-minutes: 15
diff --git a/charm/generate-src-docs.sh b/charm/generate-src-docs.sh
deleted file mode 100755
index 23cb00701c..0000000000
--- a/charm/generate-src-docs.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-
-# Copyright 2024 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-rm -rf src-docs
-lazydocs --no-watermark --output-path src-docs src/*
diff --git a/charm/requirements-doc.txt b/charm/requirements-doc.txt
new file mode 100644
index 0000000000..bb452f6bb2
--- /dev/null
+++ b/charm/requirements-doc.txt
@@ -0,0 +1,2 @@
+myst-parser==4.0.0
+sphinx-markdown-builder==0.6.7
diff --git a/charm/src-docs/Makefile b/charm/src-docs/Makefile
new file mode 100644
index 0000000000..d4bb2cbb9e
--- /dev/null
+++ b/charm/src-docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/charm/src-docs/charm.py.md b/charm/src-docs/charm.py.md
deleted file mode 100644
index b4f7f5f24b..0000000000
--- a/charm/src-docs/charm.py.md
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-# module `charm.py`
-Django Charm entrypoint.
-
-
-
----
-
-## class `DjangoCharm`
-Django Charm service.
-
-
----
-
-#### property app
-
-Application that this unit is part of.
-
----
-
-#### property charm_dir
-
-Root directory of the charm as it is running.
-
----
-
-#### property config
-
-A mapping containing the charm's config and current values.
-
----
-
-#### property meta
-
-Metadata of this charm.
-
----
-
-#### property model
-
-Shortcut for more simple access the model.
-
----
-
-#### property unit
-
-Unit that this execution is responsible for.
-
-
-
-
diff --git a/charm/src-docs/conf.py b/charm/src-docs/conf.py
new file mode 100644
index 0000000000..eef57bd477
--- /dev/null
+++ b/charm/src-docs/conf.py
@@ -0,0 +1,30 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = 'NetBox'
+copyright = '2024, Canonical'
+author = 'Canonical'
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+ "myst_parser",
+ "sphinx_markdown_builder",
+]
+
+templates_path = ['_templates']
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = 'alabaster'
+html_static_path = ['_static']
diff --git a/charm/src-docs/index.md b/charm/src-docs/index.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/charm/src-docs/make.bat b/charm/src-docs/make.bat
new file mode 100644
index 0000000000..32bb24529f
--- /dev/null
+++ b/charm/src-docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/charm/src-docs/tutorial/code/getting-started/task.yaml b/charm/src-docs/tutorial/code/getting-started/task.yaml
new file mode 100644
index 0000000000..45a6d8e2d4
--- /dev/null
+++ b/charm/src-docs/tutorial/code/getting-started/task.yaml
@@ -0,0 +1,89 @@
+###########################################
+# IMPORTANT
+# Comments matter!
+# The docs use the wrapping comments as
+# markers for including said instructions
+# as snippets in the docs.
+###########################################
+summary: Getting started with NetBox Tutorial
+
+execute: |
+ . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh
+
+ # configure minio
+ export AWS_ACCESS_KEY_ID=minio
+ export AWS_SECRET_ACCESS_KEY=supersuperkey
+ IPADDR=$(ip -4 -j route get 2.2.2.2 | jq -r '.[] | .prefsrc')
+ export AWS_ENDPOINT_URL="http://${IPADDR}:9000"
+ export AWS_BUCKET=netbox
+ export AWS_REGION=us-west-1
+
+ mkdir -p ${HOME}/minio/data
+ docker run -d -p 9000:9000 -p 9001:9001 --user $(id -u):$(id -g) --name minio -e "MINIO_ROOT_USER=minioadmin" -e "MINIO_ROOT_PASSWORD=minioadmin" -v ${HOME}/minio/data:/data quay.io/minio/minio server /data --console-address ":9001"
+ retry -n 5 --wait 2 docker exec minio mc config host add minio http://127.0.0.1:9000 minioadmin minioadmin
+ docker exec minio mc config host add minio http://127.0.0.1:9000 minioadmin minioadmin
+ docker exec minio mc admin user svcacct add --access-key "${AWS_ACCESS_KEY_ID}" --secret-key "${AWS_SECRET_ACCESS_KEY}" minio minioadmin
+ docker exec minio mc mb "minio/${AWS_BUCKET}"
+ docker exec minio mc ls minio
+
+ # [docs:juju-add-model]
+ juju add-model netbox-tutorial
+ # [docs:juju-add-model-end]
+
+ # [docs:juju-deploy-netbox]
+ juju deploy netbox
+ # [docs:juju-deploy-netbox-end]
+
+ # [docs:netbox-config-allowed-hosts]
+ juju config netbox django-allowed-hosts='*'
+ # [docs:netbox-config-allowed-hosts-end]
+
+ # [docs:juju-deploy-redis]
+ juju deploy redis-k8s --channel=latest/edge
+ # [docs:juju-deploy-redis-end]
+
+ # [docs:juju-integrate-redis-netbox]
+ juju integrate redis-k8s netbox
+ # [docs:juju-integrate-redis-netbox-end]
+
+ # [docs:juju-netbox-postgresql]
+ juju deploy postgresql-k8s --channel 14/stable --trust
+ juju integrate postgresql-k8s netbox
+ # [docs:juju-netbox-postgresql-end]
+
+ # [docs:juju-netbox-s3]
+ juju deploy s3-integrator --channel edge
+ juju config s3-integrator endpoint="${AWS_ENDPOINT_URL}" bucket="${AWS_BUCKET}" path=/ region="${AWS_REGION}" s3-uri-style=path
+ juju wait-for application s3-integrator --query='name=="s3-integrator" && (status=="active" || status=="blocked")'
+ juju run s3-integrator/leader sync-s3-credentials access-key="${AWS_ACCESS_KEY_ID}" secret-key="${AWS_SECRET_ACCESS_KEY}"
+ juju integrate s3-integrator netbox
+ # [docs:juju-netbox-s3-end]
+
+ # [docs:traefik]
+ juju deploy traefik-k8s --channel edge --trust
+ # juju config traefik-k8s external_hostname=
+ juju config traefik-k8s routing_mode=path
+ juju integrate traefik-k8s netbox
+ # [docs:traefik-end]
+
+ juju wait-for application traefik-k8s
+ juju wait-for application netbox
+
+ # [docs:traefik-show-endpoints]
+ juju run traefik-k8s/0 show-proxied-endpoints --format=yaml
+ # [docs:traefik-show-endpoints-end]
+ NETBOX_URL=$( juju run traefik-k8s/0 show-proxied-endpoints --format=json | jq -r '."traefik-k8s/0".results."proxied-endpoints"' | jq -r '.netbox.url' )
+ curl --fail "${NETBOX_URL}" | grep "Home | NetBox"
+
+
+ COMMAND=(
+ # [docs:netbox-create-superuser]
+ juju run netbox/0 create-superuser username=admin email=admin@example.com
+ # [docs:netbox-create-superuser-end]
+ --format=json
+ )
+ echo ${COMMAND[@]}
+ PASSWORD=$( ${COMMAND[@]} | jq -r '."netbox/0".results.password' )
+
+ # just test that we can get a token with the admin user.
+ curl -H "Accept: application/json" -H "Content-Type: application/json" --fail "${NETBOX_URL}/api/users/tokens/provision/" -X POST -d "{\"username\": \"admin\", \"password\": \"${PASSWORD}\"}"
diff --git a/charm/src-docs/tutorial/getting-started.md b/charm/src-docs/tutorial/getting-started.md
new file mode 100644
index 0000000000..1412c30802
--- /dev/null
+++ b/charm/src-docs/tutorial/getting-started.md
@@ -0,0 +1,142 @@
+# Getting Started
+
+## What you’ll do
+- Deploy the NetBox charm.
+- Integrate with Redis using the redis-k8s charm.
+- Integrate with the PostgreSQL K8s charm.
+- Integrate with S3 for storage.
+- Expose the NetBox charm with Traefik k8s.
+- Create a super user.
+
+Through the process, you'll verify the workload state, and log in to
+your NetBox instance.
+
+## Requirements
+- Juju 3 installed.
+- Juju controller that can create a model of type kubernetes.
+- Read/write access to a S3 compatible server with a bucket created.
+- Configuration compatible with the traefik-k8s charms. In the case of MicroK8S this can be achieved with the metallb addon.
+
+For more information about how to install Juju, see [Get started with Juju](https://juju.is/docs/olm/get-started-with-juju).
+
+
+## Setting up a Tutorial Model
+
+To manage resources effectively and to separate this tutorial's workload from
+your usual work, we recommend creating a new model using the following command.
+
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:juju-add-model]
+:end-before: [docs:juju-add-model-end]
+:dedent: 2
+```
+
+## Deploy the NetBox charm
+
+Deploy the NetBox charm, with all its mandatory requirements (PostgreSQL, Redis and S3).
+
+### Deploy the charms:
+
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:juju-deploy-netbox]
+:end-before: [docs:juju-deploy-netbox-end]
+:dedent: 2
+```
+
+At this point NetBox should be blocked as there is no S3 integration for
+storage, Redis or PostgreSQL.
+
+Set the allowed hosts. In this example every host is allowed. For a production environment
+only the used hosts should be allowed.
+
+### Deploy the charms:
+
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:netbox-config-allowed-hosts]
+:end-before: [docs:netbox-config-allowed-hosts-end]
+:dedent: 2
+```
+
+### Redis
+
+NetBox requires Redis to work. You can deploy Redis with redis-k8s:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:juju-deploy-redis]
+:end-before: [docs:juju-deploy-redis-end]
+:dedent: 2
+```
+
+Integrate redis-k8s with NetBox with:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:juju-integrate-redis-netbox]
+:end-before: [docs:juju-integrate-redis-netbox-end]
+:dedent: 2
+```
+
+### Deploy PostgreSQL
+
+NetBox requires PostgreSQL to work. Deploy and integrate with:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:juju-netbox-postgresql]
+:end-before: [docs:juju-netbox-postgresql-end]
+:dedent: 2
+```
+
+### Deploy s3-integrator
+
+NetBox requires an S3 integration for the uploaded files. This is because
+the NetBox charm is designed to work in a high availability (HA) configuration.
+This allows uploaded images to be placed on an S3 compatible server instead of
+the local filesystem.
+
+You can configure it with:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:juju-netbox-s3]
+:end-before: [docs:juju-netbox-s3-end]
+:dedent: 2
+```
+
+See the [s3-integrator charmhub page](https://charmhub.io/s3-integrator) for more information.
+
+### Deploy traefik-k8s
+
+You need to enable MetalLb if using MicroK8s. See the [traefik-k8s charmhub page](https://charmhub.io/traefik-k8s) for more information.
+
+With the next example, you can configure Traefik using path mode routing:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:traefik]
+:end-before: [docs:traefik-end]
+:dedent: 2
+```
+
+If the host `netbox_hostname` can be resolved to the correct IP (the load balancer IP),
+you should be able to browse NetBox in the url http://netbox_hostname/netbox-tutorial-netbox
+
+You can check the proxied endpoints with the command:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:traefik-show-endpoints]
+:end-at: [docs:traefik-show-endpoints-end]
+:dedent: 2
+```
+
+## Create superuser
+To be able to login to NetBox, you can create a super user with the next command:
+```{literalinclude} code/getting-started/task.yaml
+:language: bash
+:start-after: [docs:netbox-create-superuser]
+:end-before: [docs:netbox-create-superuser-end]
+:dedent: 2
+```
+
+Congratulations, With the username created and the password provided in the response,
+you have now full access to your own NetBox!
+
diff --git a/charm/tests/spread/lib/cloud-config.yaml b/charm/tests/spread/lib/cloud-config.yaml
new file mode 100644
index 0000000000..29618c66b6
--- /dev/null
+++ b/charm/tests/spread/lib/cloud-config.yaml
@@ -0,0 +1,10 @@
+#cloud-config
+
+ssh_pwauth: true
+
+users:
+ - default
+ - name: spread
+ plain_text_passwd: spread
+ lock_passwd: false
+ sudo: ALL=(ALL) NOPASSWD:ALL
diff --git a/charm/tests/spread/lib/test-helpers.sh b/charm/tests/spread/lib/test-helpers.sh
new file mode 100644
index 0000000000..90f3ca70f8
--- /dev/null
+++ b/charm/tests/spread/lib/test-helpers.sh
@@ -0,0 +1,86 @@
+
+export PATH=/snap/bin:$PROJECT_PATH/charm/tests/spread/lib/tools:$PATH
+export CONTROLLER_NAME="craft-test-$PROVIDER"
+
+
+install_lxd() {
+ snap install lxd --channel "$LXD_CHANNEL"
+ snap refresh lxd --channel "$LXD_CHANNEL"
+ lxd waitready
+ lxd init --auto
+ chmod a+wr /var/snap/lxd/common/lxd/unix.socket
+ lxc network set lxdbr0 ipv6.address none
+ usermod -a -G lxd "$USER"
+
+ # Work-around clash between docker and lxd on jammy
+ # https://github.com/docker/for-linux/issues/1034
+ iptables -F FORWARD
+ iptables -P FORWARD ACCEPT
+}
+
+
+install_microk8s() {
+ snap install microk8s --channel "$MICROK8S_CHANNEL"
+ snap refresh microk8s --channel "$MICROK8S_CHANNEL"
+ microk8s status --wait-ready
+
+ if [ ! -z "$MICROK8S_ADDONS" ]; then
+ microk8s enable $MICROK8S_ADDONS
+ fi
+
+ local version=$(snap list microk8s | grep microk8s | awk '{ print $2 }')
+
+ # workarounds for https://bugs.launchpad.net/juju/+bug/1937282
+ retry microk8s kubectl -n kube-system rollout status deployment/coredns
+ retry microk8s kubectl -n kube-system rollout status deployment/hostpath-provisioner
+
+ retry microk8s kubectl auth can-i create pods
+}
+
+
+install_charmcraft() {
+ snap install charmcraft --classic --channel "$CHARMCRAFT_CHANNEL"
+ snap refresh charmcraft --classic --channel "$CHARMCRAFT_CHANNEL"
+}
+
+
+install_juju() {
+ snap install juju --classic --channel "$JUJU_CHANNEL"
+ snap refresh juju --classic --channel "$JUJU_CHANNEL"
+ mkdir -p "$HOME"/.local/share/juju
+ snap install juju-crashdump --classic
+}
+
+
+bootstrap_juju() {
+ juju bootstrap --verbose "$PROVIDER" "$CONTROLLER_NAME" \
+ $JUJU_BOOTSTRAP_OPTIONS $JUJU_EXTRA_BOOTSTRAP_OPTIONS \
+ --bootstrap-constraints=$JUJU_BOOTSTRAP_CONSTRAINTS
+}
+
+
+restore_charmcraft() {
+ snap remove --purge charmcraft
+}
+
+
+restore_lxd() {
+ snap stop lxd
+ snap remove --purge lxd
+}
+
+
+restore_microk8s() {
+ snap stop microk8s
+ snap remove --purge microk8s
+}
+
+
+restore_juju() {
+ juju controllers --refresh ||:
+ juju destroy-controller -v --no-prompt --show-log \
+ --destroy-storage --destroy-all-models "$CONTROLLER_NAME"
+ snap stop juju
+ snap remove --purge juju
+ snap remove --purge juju-crashdump
+}
diff --git a/charm/tests/spread/lib/tools/retry b/charm/tests/spread/lib/tools/retry
new file mode 100755
index 0000000000..233ad372a6
--- /dev/null
+++ b/charm/tests/spread/lib/tools/retry
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+import itertools
+import os
+import subprocess
+import sys
+import time
+
+
+def envpair(s: str) -> str:
+ if not "=" in s:
+ raise argparse.ArgumentTypeError(
+ "environment variables expected format is 'KEY=VAL' got '{}'".format(s)
+ )
+ return s
+
+
+def _make_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="""
+Retry executes COMMAND at most N times, waiting for SECONDS between each
+attempt. On failure the exit code from the final attempt is returned.
+"""
+ )
+ parser.add_argument(
+ "-n",
+ "--attempts",
+ metavar="N",
+ type=int,
+ default=10,
+ help="number of attempts (default %(default)s)",
+ )
+ parser.add_argument(
+ "--wait",
+ metavar="SECONDS",
+ type=float,
+ default=5,
+ help="grace period between attempts (default %(default)ss)",
+ )
+ parser.add_argument(
+ "--env",
+ type=envpair,
+ metavar="KEY=VAL",
+ action="append",
+ default=[],
+ help="environment variable to use with format KEY=VALUE (no default)",
+ )
+ parser.add_argument(
+ "--maxmins",
+ metavar="MINUTES",
+ type=float,
+ default=0,
+ help="number of minutes after which to give up (no default, if set attempts is ignored)",
+ )
+ parser.add_argument(
+ "--expect-rc",
+ metavar="RETCODE",
+ type=int,
+ default=0,
+ help="the expected return code to consider the command execution successful (default 0)",
+ )
+ parser.add_argument(
+ "--quiet",
+ dest="verbose",
+ action="store_false",
+ default=True,
+ help="refrain from printing any output",
+ )
+ parser.add_argument("cmd", metavar="COMMAND", nargs="...", help="command to execute")
+ return parser
+
+
+def get_env(env: list[str]) -> dict[str, str]:
+ new_env = os.environ.copy()
+ maxsplit = 1 # no keyword support for str.split() in py2
+ for key, val in [s.split("=", maxsplit) for s in env]:
+ new_env[key] = val
+ return new_env
+
+
+def run_cmd(
+ cmd: list[str],
+ n: int,
+ wait: float,
+ maxmins: float,
+ verbose: bool,
+ env: list[str],
+ expect_rc: bool,
+) -> int:
+ if maxmins != 0:
+ attempts = itertools.count(1)
+ t0 = time.time()
+ after = "{} minutes".format(maxmins)
+ of_attempts_suffix = ""
+ else:
+ attempts = range(1, n + 1)
+ after = "{} attempts".format(n)
+ of_attempts_suffix = " of {}".format(n)
+ retcode = 0
+ i = 0
+ new_env = get_env(env)
+ for i in attempts:
+ retcode = subprocess.call(cmd, env=new_env)
+ if retcode == expect_rc:
+ return 0
+ if verbose:
+ print(
+ f"retry: command {' '.join(cmd)} unexpected code {retcode}",
+ file=sys.stderr,
+ )
+ if maxmins != 0:
+ elapsed = (time.time() - t0) / 60
+ if elapsed > maxmins:
+ break
+ if i < n or maxmins != 0:
+ if verbose:
+ print(
+ f"retry: next attempt in {wait} second(s) (attempt {i}{of_attempts_suffix})",
+ file=sys.stderr,
+ )
+ time.sleep(wait)
+
+ if verbose and i > 1:
+ print(
+ f"retry: command {' '.join(cmd)} keeps failing after {after}",
+ file=sys.stderr,
+ )
+ return retcode
+
+
+def main() -> None:
+ parser = _make_parser()
+ ns = parser.parse_args()
+ # The command cannot be empty but it is difficult to express in argparse itself.
+ if len(ns.cmd) == 0:
+ parser.print_usage()
+ parser.exit(0)
+ # Return the last exit code as the exit code of this process.
+ try:
+ retcode = run_cmd(
+ ns.cmd, ns.attempts, ns.wait, ns.maxmins, ns.verbose, ns.env, ns.expect_rc
+ )
+ except OSError as exc:
+ if ns.verbose:
+ print(
+ "retry: cannot execute command {}: {}".format(" ".join(ns.cmd), exc),
+ file=sys.stderr,
+ )
+ raise SystemExit(1)
+ else:
+ raise SystemExit(retcode)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spread.yaml b/spread.yaml
new file mode 100644
index 0000000000..f8c1939788
--- /dev/null
+++ b/spread.yaml
@@ -0,0 +1,136 @@
+project: netbox-tests
+
+environment:
+ PROVIDER: microk8s
+ CHARMCRAFT_CHANNEL: latest/stable
+ JUJU_CHANNEL: 3/stable
+ LXD_CHANNEL: latest/stable
+ MICROK8S_CHANNEL: 1.28-strict/stable
+ MICROK8S_ADDONS: hostpath-storage
+
+ JUJU_BOOTSTRAP_OPTIONS: --model-default test-mode=true --model-default automatically-retry-hooks=false --model-default
+ JUJU_EXTRA_BOOTSTRAP_OPTIONS: ""
+ JUJU_BOOTSTRAP_CONSTRAINTS: ""
+
+ # important to ensure adhoc and linode/qemu behave the same
+ SUDO_USER: ""
+ SUDO_UID: ""
+
+ LANG: "C.UTF-8"
+ LANGUAGE: "en"
+
+ PROJECT_PATH: /home/spread/proj
+ CRAFT_TEST_LIB_PATH: /home/spread/proj/charm/tests/spread/lib
+
+backends:
+ multipass:
+ type: adhoc
+ allocate: |
+ # Mitigate issues found when launching multiple mutipass instances
+ # concurrently. See https://github.com/canonical/multipass/issues/3336
+ sleep 0.$RANDOM
+ sleep 0.$RANDOM
+ sleep 0.$RANDOM
+
+ mkdir -p "$HOME/.spread"
+ export counter_file="$HOME/.spread/multipass-count"
+
+ # Sequential variable for unique instance names
+ instance_num=$(flock -x $counter_file bash -c '
+ [ -s $counter_file ] || echo 0 > $counter_file
+ num=$(< $counter_file)
+ echo $num
+ echo $(( $num + 1 )) > $counter_file')
+
+ multipass_image=$(echo "${SPREAD_SYSTEM}" | sed -e s/ubuntu-// -e s/-64//)
+
+ system=$(echo "${SPREAD_SYSTEM}" | tr . -)
+ instance_name="spread-${SPREAD_BACKEND}-${instance_num}-${system}"
+
+ multipass launch -vv --cpus 2 --disk 20G --memory 4G --name "${instance_name}" \
+ --cloud-init charm/tests/spread/lib/cloud-config.yaml "${multipass_image}"
+
+ # Get the IP from the instance
+ ip=$(multipass info --format csv "$instance_name" | tail -1 | cut -d\, -f3)
+ ADDRESS "$ip"
+
+ discard: |
+ instance_name=$(multipass list --format csv | grep $SPREAD_SYSTEM_ADDRESS | cut -f1 -d\,)
+ multipass delete --purge "${instance_name}"
+
+ systems:
+ - ubuntu-22.04:
+ username: spread
+ password: spread
+ workers: 1
+
+
+ github-ci:
+ type: adhoc
+
+ allocate: |
+ echo "Allocating ad-hoc $SPREAD_SYSTEM"
+ if [ -z "${GITHUB_RUN_ID:-}" ]; then
+ FATAL "this back-end only works inside GitHub CI"
+ exit 1
+ fi
+ echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/99-spread-users
+ ADDRESS localhost:22
+ discard: |
+ echo "Discarding ad-hoc $SPREAD_SYSTEM"
+ systems:
+ - ubuntu-22.04:
+ username: ubuntu
+ password: ubuntu
+ workers: 1
+
+
+path: /home/spread/proj
+kill-timeout: 1h
+
+prepare: |
+ snap refresh --hold
+
+ if systemctl is-enabled unattended-upgrades.service; then
+ systemctl stop unattended-upgrades.service
+ systemctl mask unattended-upgrades.service
+ fi
+
+restore: |
+ apt autoremove -y --purge
+ rm -Rf "$PROJECT_PATH"
+ mkdir -p "$PROJECT_PATH"
+
+suites:
+ charm/src-docs/tutorial/code/:
+ summary: tests tutorial from the docs
+ systems:
+ - ubuntu-22.04
+
+ prepare: |
+ set -e
+ . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh
+ DEBIAN_FRONTEND=noninteractive apt-get update -y
+ DEBIAN_FRONTEND=noninteractive apt-get install -y python3-pip jq
+ snap install docker
+
+ pip3 install tox
+
+ install_lxd
+ install_juju
+ install_microk8s
+
+ # we need the ip address for the load balancer
+ IPADDR=$(ip -4 -j route get 2.2.2.2 | jq -r '.[] | .prefsrc')
+ microk8s enable metallb:$IPADDR-$IPADDR
+
+ bootstrap_juju
+
+ restore: |
+ set -e
+ . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh
+
+ # restore_juju
+ # restore_microk8s
+ # restore_lxd
+
diff --git a/tox.ini b/tox.ini
index 797ab440ba..c29e249d31 100644
--- a/tox.ini
+++ b/tox.ini
@@ -38,11 +38,10 @@ setenv =
PYTHONPATH = {[vars]charm_path}:{[vars]charm_path}/lib:{[vars]src_path}
description = Generate documentation for src
deps =
- lazydocs
- -r{[vars]charm_path}/requirements.txt
+ -r{[vars]charm_path}/requirements-doc.txt
commands =
; cannot run lazydocs directly due to needing to run it on src/* which produces an invocation error in tox
- sh generate-src-docs.sh
+ sphinx-build -M markdown ./src-docs ./build
[testenv:lint]
description = Check code against coding style standards