Skip to content
Open
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
21 changes: 21 additions & 0 deletions sunbeam-python/tests/functional/feature/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
test_config.yaml
features/adminrc
__pycache__/
*.py[cod]
*$py.class
*.so
.Python

venv/
env/
ENV/

.vscode/
.idea/
*.swp
*.swo

.pytest_cache/
.coverage
htmlcov/
*.log
199 changes: 199 additions & 0 deletions sunbeam-python/tests/functional/feature/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Sunbeam Feature Functional Tests

Functional tests for Sunbeam feature enablement/disablement. These tests
connect to an **existing Sunbeam deployment** and run the enable/verify
lifecycle for each feature, with an optional disable phase controlled by
configuration.

The suite is designed to be run via `tox` from the `sunbeam-python` tree.

## Prerequisites

- **Existing Sunbeam deployment** already bootstrapped and reachable
- `sunbeam` CLI on `PATH` and configured to talk to that deployment
- e.g. `sunbeam deployment list` shows your deployment
- `openstack` CLI configured for that cloud
- e.g. `openstack endpoint list` works
- `juju` CLI installed and able to access the controller/model that backs the
Sunbeam deployment

## Configuration

Create a config file from the example:

```bash
cd sunbeam-python
cp tests/functional/feature/test_config.yaml.example tests/functional/feature/test_config.yaml
```

Then edit `tests/functional/feature/test_config.yaml`:

```yaml
sunbeam:
deployment_name: "ps6" # Name shown by `sunbeam deployment list`

juju:
model: "openstack" # Juju model backing the cloud
# controller: "my-controller" # Optional; auto-detected if omitted
```

### Run the full feature functional suite

```bash
tox -e functional-feature
```

### Run a single feature functional test

You can pass standard `pytest` selectors through tox via `posargs`. For example:

- **Instance Recovery**:

```bash
tox -e functional-feature -- tests/functional/feature/test_features.py::test_instance_recovery
```

- **TLS CA**:

```bash
tox -e functional-feature -- tests/functional/feature/test_features.py::test_tls_ca
```

- **Vault**:

```bash
tox -e functional-feature -- tests/functional/feature/test_features.py::test_vault
```

You can also run any single feature test **directly with the virtualenv Python**,
which is handy when you are iterating locally:

```bash
../.venv/bin/python -m pytest \
tests/functional/feature/test_features.py::test_<feature_name> \
--config tests/functional/feature/test_config.yaml
```
Comment on lines +71 to +75
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README examples pass --config tests/functional/feature/test_config.yaml, but conftest.py currently resolves --config relative to tests/functional/feature/, which will duplicate the path and skip. Either update the examples to use --config test_config.yaml (or adjust config path resolution in conftest.py to accept repo-relative paths).

Copilot uses AI. Check for mistakes.

For example:

- TLS CA:

```bash
../.venv/bin/python -m pytest \
tests/functional/feature/test_features.py::test_tls_ca \
--config tests/functional/feature/test_config.yaml
```

- Vault:

```bash
../.venv/bin/python -m pytest \
tests/functional/feature/test_features.py::test_vault \
--config tests/functional/feature/test_config.yaml
```

### Control whether features are disabled after tests

By default, features are **left enabled** after their tests complete. You can
enable the legacy "enable then disable" behaviour via `test_config.yaml`:

```yaml
features:
disable_after: true # disable every feature after its test
```

You can also override this per feature:

```yaml
features:
disable_after: false # default for all features

tls:
disable_after: true # only TLS is disabled after test

vault:
disable_after: false # explicitly keep Vault enabled

You can also override this behaviour **from the command line** without editing
the config file, using the `--features-disable-after` pytest option. When
Comment on lines +114 to +118
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fenced YAML block starting at You can also override this per feature: is missing a closing ``` before the following paragraph, so the rest of the document renders as code. Close the YAML fence after the vault: example.

Copilot uses AI. Check for mistakes.
running via `tox`:

```bash
tox -e functional-feature -- --features-disable-after true # force disable
tox -e functional-feature -- --features-disable-after false # force keep enabled
```

Or directly with the virtualenv Python:

```bash
../.venv/bin/python -m pytest \
tests/functional/feature/test_features.py::test_<feature_name> \
--config tests/functional/feature/test_config.yaml \
--features-disable-after true # or false
Comment on lines +128 to +132
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fenced bash block starting at Or directly with the virtualenv Python: isn't closed until much later, which pulls headings and prose into the code block. Add a closing ``` right after the command example (after the --features-disable-after ... line).

Copilot uses AI. Check for mistakes.

Concrete examples:

- **Run TLS CA test and disable TLS after it completes**:

```bash
tox -e functional-feature -- \
tests/functional/feature/test_features.py::test_tls_ca \
--features-disable-after true
```

- **Run Vault test and keep Vault enabled afterwards** (even if config sets
`disable_after: true`):

```bash
tox -e functional-feature -- \
tests/functional/feature/test_features.py::test_vault \
--features-disable-after false
```
```

## Feature coverage and dependencies

### Features in this suite

- **Enabled in current flow**
- `instance-recovery`
- `caas` (Containers as a Service)
- `dns`
- `images-sync`
- `loadbalancer`
- `resource-optimization`
- `shared-filesystem`
- `telemetry`
- `observability`
- `tls` (CA mode)
- `vault`
- `validation`
- `secrets`

- **Present but intentionally disabled for now**
- `baremetal`
- `ldap`
- `maintenance`
- `pro`

### Feature dependencies

Some features have explicit dependencies:

- **CaaS (`caas`)**
- Depends on: **`secrets`**, **`loadbalancer`**
- The CaaS test ensures these dependencies are enabled before running.

- **Secrets as a Service (`secrets`)**
- Depends on: **`vault`**
- The Secrets test ensures the Vault feature is enabled before running.

- **TLS (Vault-backed)**
- TLS can also be deployed in a Vault-backed mode which implicitly depends on
the **`vault`** feature. This suite currently exercises only the TLS CA
mode (`test_tls_ca`).

## Notes

- When `disable_after` is enabled (globally or per-feature), disable failures
are **logged and ignored** so that the suite continues to the next feature.
8 changes: 8 additions & 0 deletions sunbeam-python/tests/functional/feature/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: 2024 - Canonical Ltd
# SPDX-License-Identifier: Apache-2.0

"""Sunbeam feature functional test suite.

These tests exercise `sunbeam enable/disable` for individual features
against an existing Sunbeam deployment.
"""
88 changes: 88 additions & 0 deletions sunbeam-python/tests/functional/feature/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2024 - Canonical Ltd
# SPDX-License-Identifier: Apache-2.0

"""Pytest configuration and fixtures for Sunbeam feature functional tests."""

from pathlib import Path

import pytest
import yaml

from .utils.juju import JujuClient
from .utils.sunbeam import SunbeamClient


def pytest_addoption(parser):
"""Add custom command-line options."""
parser.addoption(
"--config",
action="store",
default="test_config.yaml",
help="Path to test configuration file",
)
parser.addoption(
"--features-disable-after",
action="store",
choices=["true", "false"],
default=None,
help=(
"Override features.disable_after (true/false) from test_config.yaml "
"without editing the file."
),
)


@pytest.fixture(scope="session")
def test_config(request):
"""Load test configuration from YAML file."""
config_path = request.config.getoption("--config")
# Resolve relative to this feature functional directory
config_file = Path(__file__).parent / config_path

Comment on lines +38 to +41
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config_file = Path(__file__).parent / config_path breaks when --config is given as a repo-relative path (it gets duplicated under tests/functional/feature/). Consider resolving config_path as-is first (absolute or relative to CWD), and only falling back to Path(__file__).parent / config_path when that doesn't exist.

Suggested change
config_path = request.config.getoption("--config")
# Resolve relative to this feature functional directory
config_file = Path(__file__).parent / config_path
raw_config_path = request.config.getoption("--config")
config_path = Path(raw_config_path)
# First, try the path as provided (absolute or relative to CWD).
if config_path.exists():
config_file = config_path
else:
# Fallback: resolve relative to this feature functional directory.
config_file = Path(__file__).parent / raw_config_path

Copilot uses AI. Check for mistakes.
if not config_file.exists():
msg = (
f"Configuration file not found: {config_file}. "
"Copy tests/functional/feature/test_config.yaml.example to "
"tests/functional/feature/test_config.yaml and set sunbeam.deployment_name, juju.model."
)
pytest.skip(msg)

with open(config_file, "r") as f:
config = yaml.safe_load(f)

Comment on lines +51 to +52
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yaml.safe_load(f) can return None for an empty config file, but later code assumes a dict (e.g., config.setdefault(...)). Default config to {} when the YAML is empty/invalid to avoid an AttributeError and produce a clearer skip/error message.

Suggested change
config = yaml.safe_load(f)
try:
config = yaml.safe_load(f)
except yaml.YAMLError as exc:
pytest.skip(
f"Invalid YAML in configuration file {config_file}: {exc}"
)
if config is None or not isinstance(config, dict):
config = {}

Copilot uses AI. Check for mistakes.
# Optional CLI override for disable-after behaviour.
cli_disable_after = request.config.getoption("--features-disable-after")
if cli_disable_after is not None:
features_cfg = config.setdefault("features", {})
features_cfg["disable_after"] = cli_disable_after == "true"

return config


@pytest.fixture(scope="session")
def sunbeam_client(test_config):
"""Create Sunbeam client for test session."""
deployment_name = test_config.get("sunbeam", {}).get("deployment_name")
if not deployment_name:
pytest.skip("deployment_name not configured in test_config.yaml")

client = SunbeamClient(deployment_name)

if not client.is_connected():
pytest.skip(f"Cannot connect to Sunbeam deployment '{deployment_name}'.")

return client


@pytest.fixture(scope="session")
def juju_client(test_config):
"""Create Juju client for test session."""
model = test_config.get("juju", {}).get("model", "openstack")
controller = test_config.get("juju", {}).get("controller")

client = JujuClient(model=model, controller=controller)

if not client.is_connected():
pytest.skip(f"Cannot connect to Juju model '{model}'.")

return client
4 changes: 4 additions & 0 deletions sunbeam-python/tests/functional/feature/features/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2024 - Canonical Ltd
# SPDX-License-Identifier: Apache-2.0

"""Feature test classes for Sunbeam feature functional tests."""
42 changes: 42 additions & 0 deletions sunbeam-python/tests/functional/feature/features/baremetal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2024 - Canonical Ltd
# SPDX-License-Identifier: Apache-2.0

"""Test for baremetal feature.

Baremetal provides Ironic-based bare metal provisioning.
Functionality is validated via the Ironic (baremetal) API.
"""

import logging
import subprocess

from .base import BaseFeatureTest

logger = logging.getLogger(__name__)


class BaremetalTest(BaseFeatureTest):
"""Test baremetal feature enablement/disablement."""

feature_name = "baremetal"
expected_applications: list[str] = []
timeout_seconds = 600

def verify_validate_feature_behavior(self) -> None:
"""Validate that the Baremetal (Ironic) API is reachable."""
logger.info("Verifying Baremetal (Ironic) service is available...")
try:
subprocess.run(
["openstack", "baremetal", "driver", "list"],
capture_output=True,
text=True,
timeout=30,
check=True,
)
except Exception as exc: # noqa: BLE001
logger.warning("Error while verifying Baremetal service: %s", exc)
raise AssertionError(
f"Baremetal service verification failed: {exc}"
) from exc

logger.info("Baremetal service verified via `openstack baremetal driver list`")
Loading