diff --git a/charmcraft.yaml b/charmcraft.yaml index 94f4aac..32b92f3 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -83,7 +83,7 @@ actions: - command additionalProperties: false create-backup: - description: Create a Velero backup using the velero-backups relation. + description: Create a Velero backup using the velero-backups or k8s-backup-target relation. params: target: description: | @@ -210,6 +210,9 @@ requires: velero-backups: interface: velero_backup_config optional: true + k8s-backup-target: + interface: k8s_backup_target + optional: true provides: metrics-endpoint: diff --git a/poetry.lock b/poetry.lock index e375410..cf4dea6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -676,6 +676,22 @@ tenacity = "*" [package.extras] test = ["black", "pytest"] +[[package]] +name = "charmlibs-interfaces-k8s-backup-target" +version = "0.1.0.post0" +description = "The charmlibs.interfaces.k8s-backup-target package." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "charmlibs_interfaces_k8s_backup_target-0.1.0.post0-py3-none-any.whl", hash = "sha256:91827aa1e75bbd178dbe6fcf11995f425c5300c5a08d78243563462cb4c8a4c2"}, + {file = "charmlibs_interfaces_k8s_backup_target-0.1.0.post0.tar.gz", hash = "sha256:7452241b5573ad68b5d3d1e781a0858c81f78984c42e68778cdd981ceedcee8b"}, +] + +[package.dependencies] +ops = "*" +pydantic = "*" + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -4335,4 +4351,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "0938271deadef2304aae88fcaf8a18a4142c09383a076370d4e66f8e5d660ec5" +content-hash = "3a142c9cd69c808b11c4b0ac84ebe087d1473884252f213866ae1c8502218e75" diff --git a/pyproject.toml b/pyproject.toml index 536d826..142d0a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ lightkube = "^0.17.1" pydantic = "^2.11.3" tenacity = "^9.0.0" object-storage-charmlib = "^0.1.0" +charmlibs-interfaces-k8s-backup-target = "~=0.1.0" [tool.coverage.run] branch = true diff --git a/requirements.txt b/requirements.txt index 2ffc5a1..2d74acf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0" anyio==4.12.1 ; python_version >= "3.12" and python_version < "4.0" certifi==2026.1.4 ; python_version >= "3.12" and python_version < "4.0" +charmlibs-interfaces-k8s-backup-target==0.1.0.post0 ; python_version >= "3.12" and python_version < "4.0" cosl==1.4.0 ; python_version >= "3.12" and python_version < "4.0" h11==0.16.0 ; python_version >= "3.12" and python_version < "4.0" httpcore==1.0.9 ; python_version >= "3.12" and python_version < "4.0" diff --git a/src/charm.py b/src/charm.py index c918577..0fc71ac 100755 --- a/src/charm.py +++ b/src/charm.py @@ -11,6 +11,7 @@ from typing import Dict, List, Optional, Union, cast import ops +from charmlibs.interfaces.k8s_backup_target import K8sBackupTargetRequirer from charms.data_platform_libs.v0.azure_storage import AzureStorageRequires from charms.data_platform_libs.v0.data_models import TypedCharmBase from charms.data_platform_libs.v0.s3 import S3Requirer @@ -28,6 +29,7 @@ from config import CharmConfig from constants import ( AZURE_SERVICE_PRINCIPAL_RELATION_NAME, + K8S_BACKUP_TARGET_ENDPOINT, VELERO_ALLOWED_SUBCOMMANDS, VELERO_BACKUPS_ENDPOINT, VELERO_BINARY_PATH, @@ -100,6 +102,7 @@ def __init__(self, framework: ops.Framework): self._grafana_dashboard = GrafanaDashboardProvider(self) self._backup_configs = VeleroBackupRequier(self, VELERO_BACKUPS_ENDPOINT) + self._k8s_backup_targets = K8sBackupTargetRequirer(self, K8S_BACKUP_TARGET_ENDPOINT) self.framework.observe(self.on.install, self._reconcile) self.framework.observe(self.on.update_status, self._reconcile) @@ -110,6 +113,7 @@ def __init__(self, framework: ops.Framework): for relation in [r.value for r in StorageRelation] + [ AZURE_SERVICE_PRINCIPAL_RELATION_NAME, VELERO_BACKUPS_ENDPOINT, + K8S_BACKUP_TARGET_ENDPOINT, ]: self.framework.observe(self.on[relation].relation_changed, self._reconcile) self.framework.observe(self.on[relation].relation_broken, self._reconcile) @@ -263,7 +267,12 @@ def _on_create_backup_action(self, event: ops.ActionEvent) -> None: event.fail("Invalid target format. Use 'app:endpoint'") return - backup_spec = self._backup_configs.get_backup_spec(app, endpoint, model) + target_relation = self._find_backup_relation(app) + if not target_relation: + event.fail(f"No relation found for target '{target}'") + return + + backup_spec = self._resolve_backup_spec(target_relation, app, endpoint, model) if not backup_spec: event.fail(f"No backup spec found for target '{target}' in model '{model}'") return @@ -525,6 +534,45 @@ def _backup_list_to_dict(self, backups: List[BackupInfo]) -> dict: } return result + def _find_backup_relation(self, app: str) -> Optional[ops.Relation]: + """Find the backup relation for a target app. + + Args: + app: The remote application name to find. + + Returns: + The matching relation, or None if not found. + """ + for endpoint in (VELERO_BACKUPS_ENDPOINT, K8S_BACKUP_TARGET_ENDPOINT): + for relation in self.model.relations.get(endpoint, []): + if relation.app and relation.app.name == app: + return relation + return None + + def _resolve_backup_spec( + self, relation: ops.Relation, app: str, endpoint: str, model: str + ) -> Optional[VeleroBackupSpec]: + """Resolve a VeleroBackupSpec from the given relation. + + Args: + relation: The relation to read the spec from. + app: The application name. + endpoint: The relation endpoint name. + model: The model name. + + Returns: + The backup specification if found, otherwise None. + """ + if relation.name == VELERO_BACKUPS_ENDPOINT: + return self._backup_configs.get_backup_spec(app, endpoint, model) + + if relation.name == K8S_BACKUP_TARGET_ENDPOINT: + k8s_spec = self._k8s_backup_targets.get_backup_spec(app, endpoint, model) + if k8s_spec: + return VeleroBackupSpec.model_validate(k8s_spec.model_dump()) + + return None + def _cleanup_schedule_for_broken_relation(self, relation: ops.Relation) -> None: """Clean up schedule when a velero-backups relation is broken. diff --git a/src/constants.py b/src/constants.py index fe24e97..216f0f2 100644 --- a/src/constants.py +++ b/src/constants.py @@ -33,6 +33,7 @@ VELERO_ALLOWED_SUBCOMMANDS = {"backup", "restore", "schedule"} VELERO_BACKUPS_ENDPOINT = "velero-backups" +K8S_BACKUP_TARGET_ENDPOINT = "k8s-backup-target" VELERO_BACKUP_LOCATION_RESOURCE = create_namespaced_resource( "velero.io", "v1", "BackupStorageLocation", "backupstoragelocations" diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index aaee63d..105ffeb 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,9 +1,10 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. +import asyncio import subprocess from pathlib import Path -from typing import Dict, Optional, Type +from typing import Dict, Optional, Type, Union import yaml from juju.action import Action @@ -13,8 +14,8 @@ from lightkube import ApiError, Client from lightkube.core.resource import GlobalResource, NamespacedResource from lightkube.generic_resource import create_namespaced_resource -from lightkube.resources.apps_v1 import Deployment -from lightkube.resources.core_v1 import Pod +from lightkube.resources.apps_v1 import DaemonSet, Deployment +from lightkube.resources.core_v1 import Pod, Service, ServiceAccount from pytest_operator.plugin import OpsTest from tenacity import ( Retrying, @@ -36,8 +37,10 @@ "The charm must be deployed with '--trust' flag enabled, run 'juju trust ...'" ) APP_RELATION_NAME = "velero-backups" +K8S_BACKUP_TARGET_RELATION_NAME = "k8s-backup-target" TEST_APP_FIRST_RELATION_NAME = "first-velero-backup-config" TEST_APP_SECOND_RELATION_NAME = "second-velero-backup-config" +TEST_APP_K8S_BACKUP_ENDPOINT = "k8s-backup-endpoint" READY_MESSAGE = "Unit is Ready" BACKUP_STORAGE_LOCALTION_UNAVAILABLE_MESSAGE = ( "Velero Storage location is not ready: BackupStorageLocation is unavailable" @@ -592,3 +595,83 @@ async def get_application_data( relation_data = await get_relation_data(ops_test, application_name, endpoint, related_endpoint) application_data = relation_data[0]["application-data"] return application_data + + +async def deploy_velero_test_charm_and_s3_integrator( + ops_test: OpsTest, + velero_operator_charm_path: Union[str, Path], + test_charm_path: Union[str, Path], +) -> None: + """Deploy velero-operator, test charm, and s3-integrator. + + Args: + ops_test: The ops test framework instance. + velero_operator_charm_path: Path to the velero-operator charm. + test_charm_path: Path to the test charm. + """ + model = get_model(ops_test) + await asyncio.gather( + model.deploy( + velero_operator_charm_path, + application_name=APP_NAME, + trust=True, + config={"use-node-agent": True, "default-volumes-to-fs-backup": True}, + ), + model.deploy( + test_charm_path, + application_name=TEST_APP_NAME, + ), + model.deploy(S3_INTEGRATOR, channel=S3_INTEGRATOR_CHANNEL), + model.wait_for_idle(apps=[APP_NAME], status="blocked", timeout=TIMEOUT), + model.wait_for_idle(apps=[TEST_APP_NAME], status="waiting", timeout=TIMEOUT), + ) + + +async def configure_s3_integrator( + ops_test: OpsTest, + s3_cloud_credentials: dict, + s3_cloud_configs: dict, +) -> None: + """Configure the S3 integrator charm with credentials and configs. + + Args: + ops_test: The ops test framework instance. + s3_cloud_credentials: S3 credential parameters. + s3_cloud_configs: S3 config parameters. + """ + model = get_model(ops_test) + app = model.applications[S3_INTEGRATOR] + await app.set_config(s3_cloud_configs) + action = await app.units[0].run_action("sync-s3-credentials", **s3_cloud_credentials) + result = await action.wait() + assert result.results.get("return-code") == 0 + await model.wait_for_idle(apps=[S3_INTEGRATOR], status="active", timeout=TIMEOUT) + + +async def remove_all_applications(ops_test: OpsTest, lightkube_client: Client) -> None: + """Remove velero-operator, s3-integrator, and test charm. + + Also verifies that the core Velero resources created by velero-operator are + cleaned up from the model namespace after removal. + + Args: + ops_test: The ops test framework instance. + lightkube_client: The lightkube client used to verify resource cleanup. + """ + model = get_model(ops_test) + namespace = model.name + await asyncio.gather( + model.remove_application(APP_NAME, block_until_done=True), + model.remove_application(S3_INTEGRATOR, block_until_done=True), + model.remove_application(TEST_APP_NAME, block_until_done=True), + ) + + for resource_type, name in ( + (Deployment, "velero"), + (DaemonSet, "node-agent"), + (ServiceAccount, "velero"), + (Service, "velero-metrics"), + ): + k8s_assert_resource_not_exists( + lightkube_client, resource_type, name=name, namespace=namespace + ) diff --git a/tests/integration/test_backup_relation.py b/tests/integration/test_backup_relation.py index e0868ca..83dc048 100644 --- a/tests/integration/test_backup_relation.py +++ b/tests/integration/test_backup_relation.py @@ -12,11 +12,12 @@ APP_NAME, APP_RELATION_NAME, S3_INTEGRATOR, - S3_INTEGRATOR_CHANNEL, TEST_APP_FIRST_RELATION_NAME, TEST_APP_NAME, TEST_APP_SECOND_RELATION_NAME, TIMEOUT, + configure_s3_integrator, + deploy_velero_test_charm_and_s3_integrator, get_application_data, get_model, get_relation_data, @@ -26,6 +27,7 @@ k8s_assert_resource_not_exists, k8s_delete_and_wait, k8s_get_velero_backup, + remove_all_applications, run_charm_action, verify_pvc_content, ) @@ -43,23 +45,8 @@ async def test_build_and_deploy( test_charm_path, ): """Build and deploy the velero-operator and test charm.""" - logger.info("Building and deploying velero-operator charm and test charm") - model = get_model(ops_test) - - await asyncio.gather( - model.deploy( - velero_operator_charm_path, - application_name=APP_NAME, - trust=True, - config={"use-node-agent": True, "default-volumes-to-fs-backup": True}, - ), - model.deploy( - test_charm_path, - application_name=TEST_APP_NAME, - ), - model.deploy(S3_INTEGRATOR, channel=S3_INTEGRATOR_CHANNEL), - model.wait_for_idle(apps=[APP_NAME], status="blocked", timeout=TIMEOUT), - model.wait_for_idle(apps=[TEST_APP_NAME], status="waiting", timeout=TIMEOUT), + await deploy_velero_test_charm_and_s3_integrator( + ops_test, velero_operator_charm_path, test_charm_path ) @@ -70,20 +57,7 @@ async def test_configure_s3_integrator( s3_cloud_configs, ): """Configure the integrator charm with the credentials and configs.""" - logger.info("Setting credentials for %s", S3_INTEGRATOR) - model = get_model(ops_test) - app = model.applications[S3_INTEGRATOR] - - await app.set_config(s3_cloud_configs) - action = await app.units[0].run_action("sync-s3-credentials", **s3_cloud_credentials) - result = await action.wait() - assert result.results.get("return-code") == 0 - - await model.wait_for_idle( - apps=[S3_INTEGRATOR], - status="active", - timeout=TIMEOUT, - ) + await configure_s3_integrator(ops_test, s3_cloud_credentials, s3_cloud_configs) @pytest.mark.abort_on_fail @@ -106,18 +80,32 @@ async def integrate_and_check(endpoint_name: str, expected_spec: dict): async with ops_test.fast_forward(fast_interval="30s"): await model.block_until(lambda: is_relation_joined(model, endpoint_name)) await model.wait_for_idle( - apps=[TEST_APP_NAME], + apps=[TEST_APP_NAME, APP_NAME], status="active", timeout=TIMEOUT, ) logger.info("Checking the content of the relation data for %s", endpoint_name) + + async def _wait_for_application_data(): + for attempt in range(30): + application_data = await get_application_data( + ops_test, APP_NAME, APP_RELATION_NAME, endpoint_name + ) + if "app" in application_data: + return application_data + logger.info( + "Attempt %d/30: application data not yet populated for %s, retrying...", + attempt + 1, + endpoint_name, + ) + await asyncio.sleep(10) + return application_data + relation_data = await get_relation_data( ops_test, APP_NAME, APP_RELATION_NAME, endpoint_name ) - application_data = await get_application_data( - ops_test, APP_NAME, APP_RELATION_NAME, endpoint_name - ) + application_data = await _wait_for_application_data() logger.info(relation_data) logger.info(application_data) assert "app" in application_data @@ -269,7 +257,9 @@ async def test_create_restore(ops_test: OpsTest, k8s_test_resources, lightkube_c test_namespace = k8s_test_resources["namespace"].metadata.name test_file = k8s_test_resources["test_file_path"] test_pvc_name = k8s_test_resources["pvc_name"] - k8s_delete_and_wait(lightkube_client, Namespace, test_namespace, grace_period=0) + k8s_delete_and_wait( + lightkube_client, Namespace, test_namespace, grace_period=0, timeout_seconds=300 + ) logger.info("Getting current backups") result = await run_charm_action(unit, "list-backups", app=TEST_APP_NAME) @@ -312,7 +302,9 @@ async def test_create_selective_restore(ops_test: OpsTest, k8s_test_resources, l model = get_model(ops_test) unit = model.applications[APP_NAME].units[0] test_namespace = k8s_test_resources["namespace"].metadata.name - k8s_delete_and_wait(lightkube_client, Namespace, test_namespace, grace_period=0) + k8s_delete_and_wait( + lightkube_client, Namespace, test_namespace, grace_period=0, timeout_seconds=300 + ) logger.info("Getting current backups") result = await run_charm_action(unit, "list-backups", app=TEST_APP_NAME) @@ -362,7 +354,9 @@ async def test_create_or_selector_restore(ops_test: OpsTest, k8s_test_resources, model = get_model(ops_test) unit = model.applications[APP_NAME].units[0] test_namespace = k8s_test_resources["namespace"].metadata.name - k8s_delete_and_wait(lightkube_client, Namespace, test_namespace, grace_period=0) + k8s_delete_and_wait( + lightkube_client, Namespace, test_namespace, grace_period=0, timeout_seconds=300 + ) result = await run_charm_action(unit, "list-backups", app=TEST_APP_NAME) backup_uid = next( @@ -478,13 +472,6 @@ async def test_unrelate(ops_test: OpsTest): @pytest.mark.abort_on_fail -async def test_remove(ops_test: OpsTest): +async def test_remove(ops_test: OpsTest, lightkube_client): """Remove the velero-operator and s3-integrator charms.""" - logger.info("Removing velero-operator and s3-integrator charms") - model = get_model(ops_test) - - await asyncio.gather( - model.remove_application(APP_NAME, block_until_done=True), - model.remove_application(S3_INTEGRATOR, block_until_done=True), - model.remove_application(TEST_APP_NAME, block_until_done=True), - ) + await remove_all_applications(ops_test, lightkube_client) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index c54d3ad..c4c171b 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -108,20 +108,16 @@ async def test_config_use_node_agent(ops_test: OpsTest, lightkube_client): app = model.applications[APP_NAME] logger.info("Setting use-node-agent to false") - await asyncio.gather( - app.set_config({USE_NODE_AGENT_CONFIG_KEY: "false"}), - model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked"), - ) + await app.set_config({USE_NODE_AGENT_CONFIG_KEY: "false"}) + await model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked", idle_period=30) assert_app_status(app, [MISSING_RELATION_MESSAGE]) k8s_assert_resource_not_exists( lightkube_client, DaemonSet, name=VELERO_NODE_AGENT_NAME, namespace=model.name ) logger.info("Setting use-node-agent to true") - await asyncio.gather( - app.set_config({USE_NODE_AGENT_CONFIG_KEY: "true"}), - model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked"), - ) + await app.set_config({USE_NODE_AGENT_CONFIG_KEY: "true"}) + await model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked", idle_period=30) assert_app_status(app, [MISSING_RELATION_MESSAGE]) k8s_assert_resource_exists( lightkube_client, DaemonSet, name=VELERO_NODE_AGENT_NAME, namespace=model.name @@ -136,19 +132,15 @@ async def test_config_default_volumes_to_fs_backup(ops_test: OpsTest, lightkube_ app = model.applications[APP_NAME] logger.info("Setting default-volumes-to-fs-backup to false") - await asyncio.gather( - app.set_config({"default-volumes-to-fs-backup": "false"}), - model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked"), - ) + await app.set_config({"default-volumes-to-fs-backup": "false"}) + await model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked", idle_period=30) assert_app_status(app, [MISSING_RELATION_MESSAGE]) args = k8s_get_velero_deployment_container_args(lightkube_client, model.name) assert "--default-volumes-to-fs-backup=false" in args logger.info("Setting default-volumes-to-fs-backup to true") - await asyncio.gather( - app.set_config({"default-volumes-to-fs-backup": "true"}), - model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked"), - ) + await app.set_config({"default-volumes-to-fs-backup": "true"}) + await model.wait_for_idle(apps=[APP_NAME], timeout=TIMEOUT, status="blocked", idle_period=30) assert_app_status(app, [MISSING_RELATION_MESSAGE]) args = k8s_get_velero_deployment_container_args(lightkube_client, model.name) assert "--default-volumes-to-fs-backup=true" in args diff --git a/tests/integration/test_charm/charmcraft.yaml b/tests/integration/test_charm/charmcraft.yaml index b7950fe..bdad44a 100644 --- a/tests/integration/test_charm/charmcraft.yaml +++ b/tests/integration/test_charm/charmcraft.yaml @@ -34,3 +34,5 @@ provides: interface: velero_backup_config second-velero-backup-config: interface: velero_backup_config + k8s-backup-endpoint: + interface: k8s_backup_target diff --git a/tests/integration/test_charm/requirements.txt b/tests/integration/test_charm/requirements.txt index f529938..0798153 100644 --- a/tests/integration/test_charm/requirements.txt +++ b/tests/integration/test_charm/requirements.txt @@ -1,2 +1,3 @@ ops pydantic +charmlibs-interfaces-k8s-backup-target diff --git a/tests/integration/test_charm/src/charm.py b/tests/integration/test_charm/src/charm.py index d4b0630..59b56d0 100755 --- a/tests/integration/test_charm/src/charm.py +++ b/tests/integration/test_charm/src/charm.py @@ -2,11 +2,15 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Test charm for velero_backup_config library.""" +"""Test charm for velero_backup_config and k8s_backup_target libraries.""" import logging import ops +from charmlibs.interfaces.k8s_backup_target import ( + K8sBackupTargetProvider, + K8sBackupTargetSpec, +) from charms.velero_libs.v0.velero_backup_config import ( VeleroBackupProvider, VeleroBackupSpec, @@ -17,10 +21,11 @@ FIRST_RELATION_NAME = "first-velero-backup-config" SECOND_RELATION_NAME = "second-velero-backup-config" +K8S_BACKUP_ENDPOINT = "k8s-backup-endpoint" class TestCharm(ops.CharmBase): - """Test charm velero_backup_config lib.""" + """Test charm velero_backup_config and k8s_backup_target libs.""" def __init__(self, *args): super().__init__(*args) @@ -66,20 +71,29 @@ def __init__(self, *args): ), ) + self._k8s_backup_config = K8sBackupTargetProvider( + self, + K8S_BACKUP_ENDPOINT, + spec=K8sBackupTargetSpec( + include_namespaces=["velero-integration-tests"], + include_resources=[ + "deployments", + "persistentvolumeclaims", + "pods", + "persistentvolumes", + "services", + ], + label_selector={"app": "dummy"}, + ttl=str(self.config["ttl"]), + ), + refresh_event=[self.on.config_changed], + ) + self.framework.observe(self.on.start, self._on_start) self.framework.observe(self.on.config_changed, self._on_config_changed) - self.framework.observe( - self.on[FIRST_RELATION_NAME].relation_joined, self._on_relation_joined - ) - self.framework.observe( - self.on[FIRST_RELATION_NAME].relation_broken, self._on_relation_broken - ) - self.framework.observe( - self.on[SECOND_RELATION_NAME].relation_joined, self._on_relation_joined - ) - self.framework.observe( - self.on[SECOND_RELATION_NAME].relation_broken, self._on_relation_broken - ) + for endpoint in (FIRST_RELATION_NAME, SECOND_RELATION_NAME, K8S_BACKUP_ENDPOINT): + self.framework.observe(self.on[endpoint].relation_joined, self._on_relation_joined) + self.framework.observe(self.on[endpoint].relation_broken, self._on_relation_broken) def _on_config_changed(self, event: ops.ConfigChangedEvent): """Handle the config changed event.""" diff --git a/tests/integration/test_k8s_backup_target.py b/tests/integration/test_k8s_backup_target.py new file mode 100644 index 0000000..a5ff6dd --- /dev/null +++ b/tests/integration/test_k8s_backup_target.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +import json +import logging +from datetime import datetime + +import pytest +from helpers import ( + APP_NAME, + K8S_BACKUP_TARGET_RELATION_NAME, + S3_INTEGRATOR, + TEST_APP_K8S_BACKUP_ENDPOINT, + TEST_APP_NAME, + TIMEOUT, + configure_s3_integrator, + deploy_velero_test_charm_and_s3_integrator, + get_application_data, + get_model, + get_relation_data, + is_relation_broken, + is_relation_joined, + k8s_assert_resource_exists, + k8s_delete_and_wait, + k8s_get_velero_backup, + remove_all_applications, + run_charm_action, + verify_pvc_content, +) +from lightkube.resources.core_v1 import Namespace +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy( + ops_test: OpsTest, + s3_connection_info, + velero_operator_charm_path, + test_charm_path, +): + """Build and deploy the velero-operator and test charm.""" + await deploy_velero_test_charm_and_s3_integrator( + ops_test, velero_operator_charm_path, test_charm_path + ) + + +@pytest.mark.abort_on_fail +async def test_configure_s3_integrator( + ops_test: OpsTest, + s3_cloud_credentials, + s3_cloud_configs, +): + """Configure the integrator charm with the credentials and configs.""" + await configure_s3_integrator(ops_test, s3_cloud_credentials, s3_cloud_configs) + + +@pytest.mark.abort_on_fail +async def test_relate(ops_test: OpsTest): + """Relate charms using the k8s-backup-target interface.""" + logger.info("Relating velero-operator to %s via k8s-backup-target", TEST_APP_NAME) + model = get_model(ops_test) + + await model.integrate(APP_NAME, S3_INTEGRATOR) + + logger.info( + "Integrating %s:%s with %s:%s", + APP_NAME, + K8S_BACKUP_TARGET_RELATION_NAME, + TEST_APP_NAME, + TEST_APP_K8S_BACKUP_ENDPOINT, + ) + await model.integrate( + f"{APP_NAME}:{K8S_BACKUP_TARGET_RELATION_NAME}", + f"{TEST_APP_NAME}:{TEST_APP_K8S_BACKUP_ENDPOINT}", + ) + async with ops_test.fast_forward(fast_interval="30s"): + await model.block_until(lambda: is_relation_joined(model, TEST_APP_K8S_BACKUP_ENDPOINT)) + await model.wait_for_idle( + apps=[TEST_APP_NAME], + status="active", + timeout=TIMEOUT, + ) + + logger.info("Checking the content of the relation data for k8s-backup-target") + relation_data = await get_relation_data( + ops_test, APP_NAME, K8S_BACKUP_TARGET_RELATION_NAME, TEST_APP_K8S_BACKUP_ENDPOINT + ) + application_data = await get_application_data( + ops_test, APP_NAME, K8S_BACKUP_TARGET_RELATION_NAME, TEST_APP_K8S_BACKUP_ENDPOINT + ) + logger.info(relation_data) + logger.info(application_data) + assert "backup_targets" in application_data + targets = json.loads(application_data["backup_targets"]) + assert len(targets) > 0 + target = targets[0] + assert target["app"] == TEST_APP_NAME + assert target["relation_name"] == TEST_APP_K8S_BACKUP_ENDPOINT + spec = target["spec"] + expected_spec = { + "include_namespaces": ["velero-integration-tests"], + "include_resources": [ + "deployments", + "persistentvolumeclaims", + "pods", + "persistentvolumes", + "services", + ], + "label_selector": {"app": "dummy"}, + "ttl": "24h5m5s", + "exclude_namespaces": None, + "exclude_resources": None, + "include_cluster_resources": None, + } + for key, value in expected_spec.items(): + assert spec.get(key) == value + + async with ops_test.fast_forward(fast_interval="60s"): + await model.wait_for_idle( + apps=[APP_NAME], + status="active", + timeout=TIMEOUT, + ) + + +@pytest.mark.abort_on_fail +async def test_refresh_event(ops_test: OpsTest): + """Test the refresh event for the K8sBackupTargetProvider.""" + logger.info("Testing refresh event for K8sBackupTargetProvider") + model = get_model(ops_test) + app = model.applications[TEST_APP_NAME] + + await app.set_config({"ttl": "48h"}) + async with ops_test.fast_forward(fast_interval="30s"): + await model.wait_for_idle( + apps=[TEST_APP_NAME], + status="active", + timeout=TIMEOUT, + ) + + application_data = await get_application_data( + ops_test, APP_NAME, K8S_BACKUP_TARGET_RELATION_NAME, TEST_APP_K8S_BACKUP_ENDPOINT + ) + assert "backup_targets" in application_data + targets = json.loads(application_data["backup_targets"]) + assert len(targets) > 0 + assert targets[0]["spec"]["ttl"] == "48h" + + +@pytest.mark.abort_on_fail +async def test_create_backup(ops_test: OpsTest, k8s_test_resources, lightkube_client): + """Test create-backup action via k8s-backup-target relation.""" + logger.info("Testing create-backup action via k8s-backup-target") + model = get_model(ops_test) + unit = model.applications[APP_NAME].units[0] + test_namespace = k8s_test_resources["namespace"].metadata.name + test_file = k8s_test_resources["test_file_path"] + test_pvc_name = k8s_test_resources["pvc_name"] + + logger.info("Waiting for the test namespace to be ready") + verify_pvc_content(lightkube_client, test_namespace, test_pvc_name, test_file, 1) + + logger.info("Running the create-backup action with non-existent target") + try: + await run_charm_action( + unit, + "create-backup", + target="app:endpoint", + ) + assert False, "Expected an error when running create-backup with non-existent target" + except AssertionError: + pass + + logger.info("Running the create-backup action with k8s-backup-target") + result = await run_charm_action( + unit, + "create-backup", + target=f"{TEST_APP_NAME}:{TEST_APP_K8S_BACKUP_ENDPOINT}", + ) + backup_name = result["backup-name"] + + logger.info("Verifying the backup") + backup = k8s_get_velero_backup(lightkube_client, backup_name, model.name) + assert backup["status"]["phase"] == "Completed", "K8s backup target backup is not completed" + logger.info("Created backup: %s", backup_name) + + +@pytest.mark.abort_on_fail +async def test_list_backups(ops_test: OpsTest): + """Test the list-backups action for k8s-backup-target backups.""" + logger.info("Testing list-backups action") + model = get_model(ops_test) + unit = model.applications[APP_NAME].units[0] + + result = await run_charm_action( + unit, "list-backups", app=TEST_APP_NAME, endpoint=TEST_APP_K8S_BACKUP_ENDPOINT + ) + backups = result["backups"] + assert len(backups) == 1, "Expected one backup for the k8s-backup-target endpoint" + + +@pytest.mark.abort_on_fail +async def test_create_restore(ops_test: OpsTest, k8s_test_resources, lightkube_client): + """Test the create-restore action for a k8s-backup-target backup. + + The backup spec uses label_selector={"app": "dummy"}, so only resources with + that label are included: PVC, Deployment, and Service (dummy-service). + Resources labelled app=dummy-2 (ConfigMap, dummy-service-2) are not backed up. + """ + logger.info("Testing restore functionality via k8s-backup-target") + model = get_model(ops_test) + unit = model.applications[APP_NAME].units[0] + test_namespace = k8s_test_resources["namespace"].metadata.name + test_file = k8s_test_resources["test_file_path"] + test_pvc_name = k8s_test_resources["pvc_name"] + k8s_delete_and_wait( + lightkube_client, Namespace, test_namespace, grace_period=0, timeout_seconds=300 + ) + + logger.info("Getting current backups") + result = await run_charm_action( + unit, "list-backups", app=TEST_APP_NAME, endpoint=TEST_APP_K8S_BACKUP_ENDPOINT + ) + assert len(result["backups"]) > 0, "No backups found" + logger.info("Backups found: %s", result["backups"]) + + backups = result["backups"] + backup_uids = [ + uid + for uid, _ in sorted( + backups.items(), + key=lambda item: datetime.strptime(item[1]["start-timestamp"], "%Y-%m-%dT%H:%M:%SZ"), + ) + ] + + logger.info("Creating restores for each backup") + for backup_uid in backup_uids: + await run_charm_action( + unit, + "restore", + **{"backup-uid": backup_uid}, + ) + + logger.info("Verifying the restore — only app=dummy resources should be restored") + # The backup only includes resources with label app=dummy: + # PVC (test-pvc), Deployment (dummy-deployment), Service (dummy-service) + expected_resources = [ + r + for r in k8s_test_resources["resources"] + if r.metadata.labels and r.metadata.labels.get("app") == "dummy" + ] + for resource in expected_resources: + k8s_assert_resource_exists( + lightkube_client, type(resource), name=resource.metadata.name, namespace=test_namespace + ) + verify_pvc_content(lightkube_client, test_namespace, test_pvc_name, test_file, 2) + + +@pytest.mark.abort_on_fail +async def test_unrelate(ops_test: OpsTest): + """Unrelate the k8s-backup-target relation and check the status.""" + logger.info("Unrelating velero-operator from %s (k8s-backup-target)", TEST_APP_NAME) + model = get_model(ops_test) + + await ops_test.juju(*["remove-relation", APP_NAME, S3_INTEGRATOR]) + await ops_test.juju( + *[ + "remove-relation", + APP_NAME, + f"{TEST_APP_NAME}:{TEST_APP_K8S_BACKUP_ENDPOINT}", + ] + ) + + async with ops_test.fast_forward(fast_interval="30s"): + await model.block_until(lambda: is_relation_broken(model, TEST_APP_K8S_BACKUP_ENDPOINT)) + await model.wait_for_idle( + apps=[TEST_APP_NAME], + status="waiting", + timeout=TIMEOUT, + ) + await model.wait_for_idle( + apps=[APP_NAME], + status="blocked", + timeout=TIMEOUT, + ) + + +@pytest.mark.abort_on_fail +async def test_remove(ops_test: OpsTest, lightkube_client): + """Remove the velero-operator and s3-integrator charms.""" + await remove_all_applications(ops_test, lightkube_client) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 3ecb267..00d0d6e 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -43,6 +43,7 @@ INVALID_CONFIG_MESSAGE = "Invalid configuration: " UPGRADE_MESSAGE = "Upgrading Velero" VELERO_BACKUP_ENDPOINT = "velero-backups" +K8S_BACKUP_TARGET_ENDPOINT = "k8s-backup-target" @pytest.fixture() @@ -1511,3 +1512,194 @@ def test_run_list_backups_action_with_app_and_endpoint( assert labels["app"] == "test-app" assert labels["endpoint"] == "test-endpoint" assert ctx.action_results.get("status") == "success" + + +# --- k8s-backup-target tests --- + + +K8S_BACKUP_TARGET_APP_DATA = { + "backup_targets": '[{"app": "test-app", "relation_name": "test-endpoint",' + ' "model": "test-model", "spec": {"include_namespaces": ["test-namespace"]}}]', +} + + +def test_create_backup_action_with_k8s_backup_target( + mock_velero, + mock_lightkube_client, +): + """Test create-backup action works with k8s-backup-target relation.""" + target = "test-app:test-endpoint" + model = "test-model" + with ( + patch.object( + VeleroOperatorCharm, "storage_relation", new_callable=PropertyMock + ) as mock_storage_rel, + ): + mock_storage_rel.return_value = StorageRelation.S3 + mock_velero.is_storage_configured.return_value = True + ctx = testing.Context(VeleroOperatorCharm) + relation = testing.Relation( + endpoint=K8S_BACKUP_TARGET_ENDPOINT, + remote_app_name="test-app", + remote_app_data=K8S_BACKUP_TARGET_APP_DATA, + ) + + # Act + ctx.run( + ctx.on.action("create-backup", params={"target": target, "model": model}), + testing.State(relations=[relation]), + ) + + # Assert + mock_velero.create_backup.assert_called_once() + assert ctx.action_results.get("status") == "success" + + +def test_create_backup_action_prefers_velero_backup_config( + mock_velero, + mock_lightkube_client, +): + """Test create-backup prefers velero_backup_config over k8s_backup_target.""" + target = "test-app:test-endpoint" + model = "test-model" + with ( + patch.object( + VeleroOperatorCharm, "storage_relation", new_callable=PropertyMock + ) as mock_storage_rel, + ): + mock_storage_rel.return_value = StorageRelation.S3 + mock_velero.is_storage_configured.return_value = True + ctx = testing.Context(VeleroOperatorCharm) + velero_relation = testing.Relation( + endpoint=VELERO_BACKUP_ENDPOINT, + remote_app_name="test-app", + remote_app_data={ + "app": "test-app", + "model": "test-model", + "relation_name": "test-endpoint", + "spec": '{"include_namespaces": ["velero-ns"], "schedule": "0 2 * * *"}', + }, + ) + k8s_relation = testing.Relation( + endpoint=K8S_BACKUP_TARGET_ENDPOINT, + remote_app_name="test-app", + remote_app_data=K8S_BACKUP_TARGET_APP_DATA, + ) + + # Act + ctx.run( + ctx.on.action("create-backup", params={"target": target, "model": model}), + testing.State(relations=[velero_relation, k8s_relation]), + ) + + # Assert - should use velero_backup_config spec (with velero-ns, not test-namespace) + mock_velero.create_backup.assert_called_once() + call_args = mock_velero.create_backup.call_args + spec = call_args[0][2] + assert spec.include_namespaces == ["velero-ns"] + + +def test_create_backup_action_k8s_backup_target_no_spec( + mock_velero, + mock_lightkube_client, +): + """Test create-backup fails when k8s-backup-target relation has no matching spec.""" + target = "test-app:test-endpoint" + model = "wrong-model" + with patch.object( + VeleroOperatorCharm, "storage_relation", new_callable=PropertyMock + ) as mock_storage_rel: + mock_storage_rel.return_value = StorageRelation.S3 + mock_velero.is_storage_configured.return_value = True + ctx = testing.Context(VeleroOperatorCharm) + relation = testing.Relation( + endpoint=K8S_BACKUP_TARGET_ENDPOINT, + remote_app_name="test-app", + remote_app_data=K8S_BACKUP_TARGET_APP_DATA, + ) + + with pytest.raises(testing.ActionFailed): + ctx.run( + ctx.on.action("create-backup", params={"target": target, "model": model}), + testing.State(relations=[relation]), + ) + + +def test_find_backup_relation_skips_relation_without_app( + mock_velero, + mock_lightkube_client, +): + """Test _find_backup_relation skips relations where relation.app is None.""" + target = "test-app:test-endpoint" + model = "test-model" + with patch.object( + VeleroOperatorCharm, "storage_relation", new_callable=PropertyMock + ) as mock_storage_rel: + mock_storage_rel.return_value = StorageRelation.S3 + mock_velero.is_storage_configured.return_value = True + ctx = testing.Context(VeleroOperatorCharm) + # A relation with no remote_app_name simulates relation.app being None + # during a relation-departed or similar transient state. + # We need a relation that has app=None plus a valid one to find. + relation_no_app = testing.Relation( + endpoint=K8S_BACKUP_TARGET_ENDPOINT, + ) + relation_with_app = testing.Relation( + endpoint=K8S_BACKUP_TARGET_ENDPOINT, + remote_app_name="test-app", + remote_app_data=K8S_BACKUP_TARGET_APP_DATA, + ) + + ctx.run( + ctx.on.action("create-backup", params={"target": target, "model": model}), + testing.State(relations=[relation_no_app, relation_with_app]), + ) + + mock_velero.create_backup.assert_called_once() + assert ctx.action_results.get("status") == "success" + + +def test_resolve_backup_spec_unknown_relation( + mock_velero, + mock_lightkube_client, +): + """Test _resolve_backup_spec returns None for an unknown relation type.""" + ctx = testing.Context(VeleroOperatorCharm) + state = testing.State() + with ctx(ctx.on.start(), state) as mgr: + mock_relation = MagicMock() + mock_relation.name = "unknown-endpoint" + result = mgr.charm._resolve_backup_spec( + mock_relation, "test-app", "test-endpoint", "test-model" + ) + assert result is None + + +def test_k8s_backup_target_relation_changed_triggers_reconcile( + mock_velero, + mock_lightkube_client, +): + """Test that k8s-backup-target relation changed triggers reconcile.""" + with ( + patch.object( + VeleroOperatorCharm, "storage_relation", new_callable=PropertyMock + ) as mock_storage_rel, + ): + mock_storage_rel.return_value = StorageRelation.S3 + mock_velero.is_storage_configured.return_value = True + mock_velero.is_installed.return_value = True + ctx = testing.Context(VeleroOperatorCharm) + relation = testing.Relation( + endpoint=K8S_BACKUP_TARGET_ENDPOINT, + remote_app_name="test-app", + remote_app_data=K8S_BACKUP_TARGET_APP_DATA, + ) + + # Act + state_out = ctx.run( + ctx.on.relation_changed(relation), + testing.State(relations=[relation]), + ) + + # Assert - charm should reach active status (reconcile ran successfully) + assert state_out.unit_status == testing.ActiveStatus(READY_MESSAGE)