diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml deleted file mode 100644 index 727e3f8..0000000 --- a/.github/workflows/integration_test.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: Integration tests - -on: - pull_request: - -jobs: - integration-tests: - strategy: - matrix: - juju-version: [ 3.3/stable, 3.4/stable, 3.5/stable ] - uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main - secrets: inherit - with: - extra-arguments: -x --localstack-address 172.17.0.1 - pre-run-script: localstack-installation.sh - charmcraft-channel: latest/edge - modules: '["test_charm.py", "test_cos.py", "test_database.py", "test_db_migration.py", "test_django.py", "test_django_integrations.py", "test_fastapi.py", "test_go.py", "test_integrations.py", "test_proxy.py", "test_workers.py"]' - rockcraft-channel: latest/edge - juju-channel: ${{ matrix.juju-version }} - channel: 1.29-strict/stable diff --git a/.github/workflows/spread_test.yaml b/.github/workflows/spread_test.yaml new file mode 100644 index 0000000..89e93f9 --- /dev/null +++ b/.github/workflows/spread_test.yaml @@ -0,0 +1,37 @@ +name: e2e smoke 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/.licenserc.yaml b/.licenserc.yaml index 42efa2c..09412ec 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -33,4 +33,5 @@ header: - 'examples/django/charm/lib/charms/redis_k8s/v0/redis.py' - 'examples/flask/lib/charms/redis_k8s/v0/redis.py' - 'examples/go/charm/lib/charms/redis_k8s/v0/redis.py' + - 'tests/spread/**' comment: on-failure diff --git a/pyproject.toml b/pyproject.toml index 2b98188..10fe190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ extend-exclude = ''' | examples/django/charm/lib/.* | examples/fastapi/charm/lib/.* | examples/go/charm/lib/.* + | tests/spread/.* ) ''' @@ -63,7 +64,7 @@ profile = "black" max-line-length = 99 max-doc-length = 99 max-complexity = 10 -exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv", "examples/*/lib/**"] +exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv", "examples/*/lib/**", "tests/spread/**"] select = ["E", "W", "F", "C", "N", "R", "D", "H"] # Ignore W503, E501 because using black creates errors with this # Ignore D107 Missing docstring in __init__ diff --git a/spread.yaml b/spread.yaml new file mode 100644 index 0000000..5d36c00 --- /dev/null +++ b/spread.yaml @@ -0,0 +1,201 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +project: paas-charm + +environment: + PROVIDER: microk8s + CHARMCRAFT_CHANNEL: latest/edge + CHARMCRAFT_REPOSITORY: $(HOST:echo $CHARMCRAFT_REPOSITORY) + # CHARMCRAFT_REPOSITORY: https://github.com/canonical/charmcraft.git + CHARMCRAFT_BRANCH: main + # PAAS_CHARM_BRANCH: $(HOST:echo $GITHUB_SHA) + PAAS_CHARM_BRANCH: 4f05187b15d3e55ad0b401f95af02045aba6d418 + ROCKCRAFT_CHANNEL: latest/edge + JUJU_CHANNEL: 3/stable + LXD_CHANNEL: latest/stable + MICROK8S_CHANNEL: 1.28-strict/stable + MICROK8S_ADDONS: hostpath-storage registry + + 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/tests/spread/lib + + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:$CRAFT_TEST_LIB_PATH/tools + +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 4 --disk 30G --memory 6G --name "${instance_name}" \ + --cloud-init 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' > /etc/sudoers.d/99-spread-users + 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-amd64: + username: ubuntu + password: ubuntu + workers: 1 + + lxd: + systems: + - ubuntu-22.04-64: + image: ubuntu:22.04 + profiles: + - default + # With this profile, it works: https://microk8s.io/docs/install-lxd + - microk8s + +suites: + tests/spread/general/: + summary: Charm functionality tests + + systems: + - ubuntu-22.04* + + environment: + # CHARMCRAFT_CHANNEL/charmcraft_current: latest/stable + # CHARMCRAFT_CHANNEL/charmcraft_next: latest/candidate + CHARMCRAFT_CHANNEL/charmcraft_edge: latest/edge + + 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 git jq + pip3 install tox + + install_lxd + install_charmcraft + install_rockcraft + install_juju + install_microk8s + + bootstrap_juju + + prepare-each: | + set -e + juju add-model testing + + restore: | + set -e + . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh + # rm -f "$PROJECT_PATH"/*.charm + # charmcraft clean -p "$PROJECT_PATH" + + restore_juju + restore_charmcraft + restore_rockcraft + restore_microk8s + restore_lxd + + restore-each: | + set -e + juju destroy-model --no-prompt --destroy-storage --force --no-wait testing + # delete all lxc instances, as they tend to occupy a lot of space + # TODO USE JSON? + lxc list --all-projects | tail -n +3 | grep -v "\-\-\-" | awk ' { print "lxc delete --project " $2 " " $4 " --force" }' | bash -x + + tests/spread/scenario/: + summary: Charm scenario smoke tests without juju/k8s + + 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 git jq + pip3 install tox + + install_lxd + install_charmcraft + + restore: | + set -e + . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh + restore_charmcraft + restore_lxd + +exclude: + - .git + - .tox + +path: /home/spread/proj + +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" + + +kill-timeout: 1h diff --git a/tests/spread/general/django/django_hello_world/django_hello_world/__init__.py b/tests/spread/general/django/django_hello_world/django_hello_world/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/spread/general/django/django_hello_world/django_hello_world/asgi.py b/tests/spread/general/django/django_hello_world/django_hello_world/asgi.py new file mode 100644 index 0000000..86ad1e0 --- /dev/null +++ b/tests/spread/general/django/django_hello_world/django_hello_world/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_hello_world project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_hello_world.settings') + +application = get_asgi_application() diff --git a/tests/spread/general/django/django_hello_world/django_hello_world/settings.py b/tests/spread/general/django/django_hello_world/django_hello_world/settings.py new file mode 100644 index 0000000..b9954c4 --- /dev/null +++ b/tests/spread/general/django/django_hello_world/django_hello_world/settings.py @@ -0,0 +1,129 @@ +""" +Django settings for django_hello_world project. + +Generated by 'django-admin startproject' using Django 5.1.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +import json +import os +import secrets +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', secrets.token_hex(32)) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get('DJANGO_DEBUG', 'false') == 'true' + +ALLOWED_HOSTS = json.loads(os.environ.get('DJANGO_ALLOWED_HOSTS', '[]')) + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'django_hello_world.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'django_hello_world.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('POSTGRESQL_DB_NAME'), + 'USER': os.environ.get('POSTGRESQL_DB_USERNAME'), + 'PASSWORD': os.environ.get('POSTGRESQL_DB_PASSWORD'), + 'HOST': os.environ.get('POSTGRESQL_DB_HOSTNAME'), + 'PORT': os.environ.get('POSTGRESQL_DB_PORT'), + } +} + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/tests/spread/general/django/django_hello_world/django_hello_world/urls.py b/tests/spread/general/django/django_hello_world/django_hello_world/urls.py new file mode 100644 index 0000000..74a1868 --- /dev/null +++ b/tests/spread/general/django/django_hello_world/django_hello_world/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for django_hello_world project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/tests/spread/general/django/django_hello_world/django_hello_world/wsgi.py b/tests/spread/general/django/django_hello_world/django_hello_world/wsgi.py new file mode 100644 index 0000000..bc64d81 --- /dev/null +++ b/tests/spread/general/django/django_hello_world/django_hello_world/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_hello_world project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_hello_world.settings') + +application = get_wsgi_application() diff --git a/tests/spread/general/django/django_hello_world/manage.py b/tests/spread/general/django/django_hello_world/manage.py new file mode 100755 index 0000000..f604d2d --- /dev/null +++ b/tests/spread/general/django/django_hello_world/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_hello_world.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/tests/spread/general/django/requirements.txt b/tests/spread/general/django/requirements.txt new file mode 100644 index 0000000..f003952 --- /dev/null +++ b/tests/spread/general/django/requirements.txt @@ -0,0 +1,2 @@ +django +psycopg2-binary diff --git a/tests/spread/general/django/task.yaml b/tests/spread/general/django/task.yaml new file mode 100644 index 0000000..48dac43 --- /dev/null +++ b/tests/spread/general/django/task.yaml @@ -0,0 +1,39 @@ +summary: Smoke test for django-framework + +execute: | + rockcraft init --profile django-framework --name django-hello-world + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true rockcraft pack + rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \ + oci-archive:django-hello-world_0.1_amd64.rock \ + docker://localhost:32000/django-hello-world:0.1 + + mkdir charm + cd charm + charmcraft init --profile django-framework --name django-hello-world + echo "requires: {postgresql: {interface: postgresql_client, optional: false, limit: 1}}" >> charmcraft.yaml + + if [ ! -z "$PAAS_CHARM_BRANCH" ]; then + echo Using paas_charm $PAAS_CHARM_BRANCH + echo "parts: {0-git: {plugin: nil, build-packages: [git]}}" >> charmcraft.yaml + sed -i "s~paas-charm.*~paas-charm @ git+https://github.com/canonical/paas-charm@${PAAS_CHARM_BRANCH}~" requirements.txt + fi + + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft fetch-libs + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft pack + + juju deploy postgresql-k8s --channel 14/stable --trust + + juju deploy ./django-hello-world_ubuntu-22.04-amd64.charm django-hello-world \ + --resource django-app-image=localhost:32000/django-hello-world:0.1 + + # once the django-allowed-hosts base url is ready, we could use directly something like + # curl --fail django-hello-world.testing:8000 --resolve django-hello-world.testing:8000:${UNIT_IP} + juju config django-hello-world django-allowed-hosts='*' django-debug=true + + juju integrate postgresql-k8s django-hello-world + + juju wait-for application django-hello-world + + UNIT_IP=$(juju status --format json | jq -r '.applications."django-hello-world".address') && echo $UNIT_IP + retry -n 20 --wait 2 curl --fail "${UNIT_IP}:8000" + [[ \"$(curl --fail "${UNIT_IP}:8000")\" =~ "The install worked successfully!" ]] diff --git a/tests/spread/general/fastapi/app.py b/tests/spread/general/fastapi/app.py new file mode 100644 index 0000000..21f80ad --- /dev/null +++ b/tests/spread/general/fastapi/app.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def root(): + return {"message": "Hello, world"} diff --git a/tests/spread/general/fastapi/requirements.txt b/tests/spread/general/fastapi/requirements.txt new file mode 100644 index 0000000..13712cc --- /dev/null +++ b/tests/spread/general/fastapi/requirements.txt @@ -0,0 +1 @@ +fastapi[standard] diff --git a/tests/spread/general/fastapi/task.yaml b/tests/spread/general/fastapi/task.yaml new file mode 100644 index 0000000..be25619 --- /dev/null +++ b/tests/spread/general/fastapi/task.yaml @@ -0,0 +1,30 @@ +summary: Smoke test for fastapi-framework + +execute: | + rockcraft init --profile fastapi-framework --name fastapi-hello-world + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true rockcraft pack + rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \ + oci-archive:fastapi-hello-world_0.1_amd64.rock \ + docker://localhost:32000/fastapi-hello-world:0.1 + + mkdir charm + cd charm + charmcraft init --profile fastapi-framework --name fastapi-hello-world + + if [ ! -z "$PAAS_CHARM_BRANCH" ]; then + echo Using paas_charm $PAAS_CHARM_BRANCH + echo "parts: {0-git: {plugin: nil, build-packages: [git]}}" >> charmcraft.yaml + sed -i "s~paas-charm.*~paas-charm @ git+https://github.com/canonical/paas-charm@${PAAS_CHARM_BRANCH}~" requirements.txt + fi + + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft fetch-libs + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft pack + + juju deploy ./fastapi-hello-world_amd64.charm fastapi-hello-world \ + --resource app-image=localhost:32000/fastapi-hello-world:0.1 + + juju wait-for application fastapi-hello-world + + UNIT_IP=$(juju status --format json | jq -r '.applications."fastapi-hello-world".address') && echo $UNIT_IP + retry -n 20 --wait 2 curl --fail "${UNIT_IP}:8080" + [[ \"$(curl --fail "${UNIT_IP}:8080")\" =~ "Hello, world" ]] diff --git a/tests/spread/general/flask/app.py b/tests/spread/general/flask/app.py new file mode 100644 index 0000000..40230f6 --- /dev/null +++ b/tests/spread/general/flask/app.py @@ -0,0 +1,10 @@ +import flask + +app = flask.Flask(__name__) + +@app.route("/") +def index(): + return "Hello, world!\n" + +if __name__ == "__main__": + app.run() diff --git a/tests/spread/general/flask/requirements.txt b/tests/spread/general/flask/requirements.txt new file mode 100644 index 0000000..7e10602 --- /dev/null +++ b/tests/spread/general/flask/requirements.txt @@ -0,0 +1 @@ +flask diff --git a/tests/spread/general/flask/task.yaml b/tests/spread/general/flask/task.yaml new file mode 100644 index 0000000..8313404 --- /dev/null +++ b/tests/spread/general/flask/task.yaml @@ -0,0 +1,30 @@ +summary: Smoke test for flask-framework + +execute: | + rockcraft init --profile flask-framework --name flask-hello-world + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true rockcraft pack + rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \ + oci-archive:flask-hello-world_0.1_amd64.rock \ + docker://localhost:32000/flask-hello-world:0.1 + + mkdir charm + cd charm + charmcraft init --profile flask-framework --name flask-hello-world + + if [ ! -z "$PAAS_CHARM_BRANCH" ]; then + echo Using paas_charm $PAAS_CHARM_BRANCH + echo "parts: {0-git: {plugin: nil, build-packages: [git]}}" >> charmcraft.yaml + sed -i "s~paas-charm.*~paas-charm @ git+https://github.com/canonical/paas-charm@${PAAS_CHARM_BRANCH}~" requirements.txt + fi + + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft fetch-libs + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft pack + + juju deploy ./flask-hello-world_ubuntu-22.04-amd64.charm flask-hello-world \ + --resource flask-app-image=localhost:32000/flask-hello-world:0.1 + + juju wait-for application flask-hello-world + + UNIT_IP=$(juju status --format json | jq -r '.applications."flask-hello-world".address') && echo $UNIT_IP + retry -n 20 --wait 2 curl --fail "${UNIT_IP}:8000" + [[ \"$(curl --fail "${UNIT_IP}:8000")\" =~ "Hello, world" ]] diff --git a/tests/spread/general/go/go.mod b/tests/spread/general/go/go.mod new file mode 100644 index 0000000..2ae76f6 --- /dev/null +++ b/tests/spread/general/go/go.mod @@ -0,0 +1,3 @@ +module go-hello-world + +go 1.23.2 diff --git a/tests/spread/general/go/main.go b/tests/spread/general/go/main.go new file mode 100644 index 0000000..54889cf --- /dev/null +++ b/tests/spread/general/go/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "log" + "net/http" +) + +func helloWorldHandler(w http.ResponseWriter, req *http.Request) { + log.Printf("new hello world request") + fmt.Fprintln(w, "Hello, world!") +} + +func main() { + log.Printf("starting hello world application") + http.HandleFunc("/", helloWorldHandler) + http.ListenAndServe(":8080", nil) +} + diff --git a/tests/spread/general/go/task.yaml b/tests/spread/general/go/task.yaml new file mode 100644 index 0000000..e80fa32 --- /dev/null +++ b/tests/spread/general/go/task.yaml @@ -0,0 +1,30 @@ +summary: Smoke test for go-framework + +execute: | + rockcraft init --profile go-framework --name go-hello-world + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true rockcraft pack + rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \ + oci-archive:go-hello-world_0.1_amd64.rock \ + docker://localhost:32000/go-hello-world:0.1 + + mkdir charm + cd charm + charmcraft init --profile go-framework --name go-hello-world + + if [ ! -z "$PAAS_CHARM_BRANCH" ]; then + echo Using paas_charm $PAAS_CHARM_BRANCH + echo "parts: {0-git: {plugin: nil, build-packages: [git]}}" >> charmcraft.yaml + sed -i "s~paas-charm.*~paas-charm @ git+https://github.com/canonical/paas-charm@${PAAS_CHARM_BRANCH}~" requirements.txt + fi + + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft fetch-libs + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft pack + + juju deploy ./go-hello-world_amd64.charm go-hello-world \ + --resource app-image=localhost:32000/go-hello-world:0.1 + + juju wait-for application go-hello-world + + UNIT_IP=$(juju status --format json | jq -r '.applications."go-hello-world".address') && echo $UNIT_IP + retry -n 20 --wait 2 curl --fail "${UNIT_IP}:8080" + [[ \"$(curl --fail "${UNIT_IP}:8080")\" =~ "Hello, world" ]] diff --git a/tests/spread/lib/cloud-config.yaml b/tests/spread/lib/cloud-config.yaml new file mode 100644 index 0000000..29618c6 --- /dev/null +++ b/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/tests/spread/lib/test-helpers.sh b/tests/spread/lib/test-helpers.sh new file mode 100644 index 0000000..2e71a73 --- /dev/null +++ b/tests/spread/lib/test-helpers.sh @@ -0,0 +1,109 @@ +# shellcheck disable=all + +export PATH=/snap/bin:$PROJECT_PATH/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() { + if [ ! -z "$CHARMCRAFT_REPOSITORY" ]; then + echo "Compiling charmcraft from source" + snap install snapcraft --classic + # where are we? + git clone "$CHARMCRAFT_REPOSITORY" --branch "$CHARMCRAFT_BRANCH" charmcraftrepo + cd charmcraftrepo + snapcraft --use-lxd -o charmcraft.snap + snap install ./charmcraft.snap --classic --dangerous + cd .. + snap remove --purge snapcraft + rm -rf ./charmcraftrepo + else + snap install charmcraft --classic --channel "$CHARMCRAFT_CHANNEL" + snap refresh charmcraft --classic --channel "$CHARMCRAFT_CHANNEL" + fi +} + +install_rockcraft() { + snap install rockcraft --classic --channel "$ROCKCRAFT_CHANNEL" + snap refresh rockcraft --classic --channel "$ROCKCRAFT_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_rockcraft() { + snap remove --purge rockcraft +} + + +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/tests/spread/lib/tools/retry b/tests/spread/lib/tools/retry new file mode 100755 index 0000000..233ad37 --- /dev/null +++ b/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/tests/spread/scenario/smoke/task.yaml b/tests/spread/scenario/smoke/task.yaml new file mode 100644 index 0000000..0421606 --- /dev/null +++ b/tests/spread/scenario/smoke/task.yaml @@ -0,0 +1,24 @@ +summary: Scenario test for flask-framework + +environment: + PROFILE_NAME/flask: flask + PROFILE_NAME/django: django + PROFILE_NAME/go: go + PROFILE_NAME/fastapi: fastapi + + +execute: | + set -e + charmcraft init --profile ${PROFILE_NAME}-framework --name hello-world --force + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft fetch-libs + + # expand charmcraft.yaml, as it is not expanded by ops testing + CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true charmcraft expand-extensions > charmcraft-expanded.yaml + mv charmcraft-expanded.yaml charmcraft.yaml + + tox -c task_tox.ini -e unit + +restore: | + rm charmcraft.yaml + rm requirements.txt + rm -rf ./src diff --git a/tests/spread/scenario/smoke/task_tox.ini b/tests/spread/scenario/smoke/task_tox.ini new file mode 100644 index 0000000..c5097fe --- /dev/null +++ b/tests/spread/scenario/smoke/task_tox.ini @@ -0,0 +1,42 @@ +# Copyright 2024 Canonical +# See LICENSE file for licensing details. + +[tox] +no_package = True +skip_missing_interpreters = True +env_list = format, lint, static +min_version = 4.0.0 + +[vars] +src_path = {tox_root}/src +tests_path = {tox_root}/tests +lib_path = {tox_root}/lib/charms/operator_name_with_underscores +all_path = {[vars]src_path} + +[testenv] +set_env = + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PYTHONBREAKPOINT=pdb.set_trace + PY_COLORS=1 +pass_env = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:unit] +description = Run unit tests +deps = + pytest + coverage[toml] + ops-scenario + -r {tox_root}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit + coverage report + diff --git a/tests/spread/scenario/smoke/tests/unit/test_charm.py b/tests/spread/scenario/smoke/tests/unit/test_charm.py new file mode 100644 index 0000000..8e59a0d --- /dev/null +++ b/tests/spread/scenario/smoke/tests/unit/test_charm.py @@ -0,0 +1,25 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Smoke scenario test for Flask.""" + + +import scenario +import scenario.errors +from charm import HelloWorldCharm + + +def test_smoke(): + """The only goal of this test is a smoke test, that is, that the charm does not raise.""" + ctx = scenario.Context(HelloWorldCharm) + container_name = next(iter(ctx.charm_spec.meta['containers'].keys())) + container = scenario.Container( + name=container_name, + can_connect=True, + ) + state_in = scenario.State(containers={container}) + out = ctx.run( + ctx.on.pebble_ready(container), + state_in, + ) + assert type(out.unit_status) in (scenario.WaitingStatus, scenario.BlockedStatus)