diff --git a/docs/configuration.md b/docs/configuration.md
index 526c4eb9..2af6cdc4 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -44,6 +44,7 @@ An example `configuration.json` is shown below.
"github_token": "EXAMPLE_GITHUB_TOKEN",
"github_organization": "EXAMPLE_GITHUB_ORGANIZATION",
"github_default_template": "owner/repo-template",
+ "magic_castle_version_range": ">= 14.0.0, < 15.0.0",
"tfcloud_api_token": "EXAMPLE_TF_TOKEN",
"tfcloud_organization": "EXAMPLE_TFCLOUD_ORGANIZATION",
"tfcloud_oauth_vcs_token_id": "VCS_OAUTH_ID"
@@ -158,6 +159,12 @@ Two formats are supported:
- **Repository name only** (e.g. `"my-template"`): the template is looked up within `github_organization`. This supports both public and private repositories, as long as `github_token` has access to the organization.
- **Full `owner/repo` reference** (e.g. `"owner/repo-template"`): the template is fetched globally. This is useful for public templates hosted outside of `github_organization`. Private repositories outside the organization are also supported if `github_token` has read access to them.
+### `magic_castle_version_range`
+
+A Terraform-style version constraint describing the Magic Castle versions vetted by the MC Hub operator. MC Hub fetches tags from the `ComputeCanada/magic_castle` GitHub repository and presents matching versions in newest-first order. The newest matching version is selected by default. The selected value is written as `version` in the cluster's `terraform.tfvars.json` and cannot be changed after the initial plan is created.
+
+Multiple constraints separated by commas and the `=`, `!=`, `>`, `>=`, `<`, `<=`, and `~>` operators are supported. For example, `">= 14.0.0, < 15.0.0"` accepts version 14 releases, while `"~> 14.1.0"` accepts patch releases from 14.1.
+
### `tfcloud_api_token`
A [Terraform Cloud API token](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens) used to authenticate with the Terraform Cloud API. You can use either a **user token** or a **team token**, but **organization tokens are not supported** — they lack permissions required for certain operations, such as triggering a destroy run.
diff --git a/frontend/src/components/cluster/ClusterEditor.vue b/frontend/src/components/cluster/ClusterEditor.vue
index f6707f0d..baf401e1 100644
--- a/frontend/src/components/cluster/ClusterEditor.vue
+++ b/frontend/src/components/cluster/ClusterEditor.vue
@@ -42,6 +42,19 @@
{{ localSpecs.image }}
+
+
+
+ Version
+ {{ localSpecs.version }}
+
+
@@ -384,6 +397,16 @@ export default {
}
}
+ // Magic Castle version
+ if (this.localSpecs.version === null) {
+ try {
+ this.localSpecs.version = possibleResources.version[0];
+ this.initialSpecs.version = possibleResources.version[0];
+ } catch (err) {
+ console.log("No Magic Castle version available");
+ }
+ }
+
// Instance type
for (let key in this.localSpecs.instances) {
if (this.localSpecs.instances[key].type === null) {
@@ -466,6 +489,7 @@ export default {
"cluster_name",
"hieradata_entries",
"image",
+ "version",
"public_keys",
"guest_passwd",
"instances",
@@ -497,6 +521,12 @@ export default {
"Invalid domain provided"
);
},
+ versionRule() {
+ return (
+ (this.possibleResources && this.possibleResources.version.includes(this.localSpecs.version)) ||
+ "Invalid Magic Castle version provided"
+ );
+ },
volumeCountRule() {
return this.volumeCountUsed <= this.volumeCountMax || "Volume number quota exceeded";
},
diff --git a/frontend/tests/unit/components/cluster/ClusterEditor.spec.js b/frontend/tests/unit/components/cluster/ClusterEditor.spec.js
index 458c45c2..b7fa7898 100644
--- a/frontend/tests/unit/components/cluster/ClusterEditor.spec.js
+++ b/frontend/tests/unit/components/cluster/ClusterEditor.spec.js
@@ -24,6 +24,7 @@ const DEFAULT_MAGIC_CASTLE = Object.freeze({
cluster_name: "",
domain: "magic-castle.cloud",
image: "Rocky-8.7-x64-2023-02",
+ version: "14.1.2",
nb_users: 10,
instances: {
mgmt: {
@@ -55,6 +56,7 @@ const DEFAULT_MAGIC_CASTLE = Object.freeze({
const DEFAULT_POSSIBLE_RESOURCES = Object.freeze({
image: ["centos7", "centos7-updated", "Rocky-8.7-x64-2023-02", "CentOS-8-x64-2019-11", "CentOS-7-x64-2019-01"],
+ version: ["14.1.2", "14.0.0"],
tag_types: {"mgmt": ["p4-6gb", "c2-7.5gb-31"], "login": ["p2-3gb", "p4-6gb"], "node": ["p2-3gb", "p4-6gb"]},
"types": ["p1-1.5gb", "p2-3gb", "p4-6gb"],
volumes: {},
@@ -138,6 +140,26 @@ describe("ClusterEditor", () => {
expect(clusterEditorWrapperExisting.vm.specs.guest_passwd.length).toBe(0);
});
+ it("defaults to the first vetted Magic Castle version", async () => {
+ const specs = cloneDeep(DEFAULT_MAGIC_CASTLE);
+ specs.version = null;
+ const wrapper = mount(ClusterEditor, {
+ localVue,
+ router,
+ vuetify,
+ propsData: {
+ specs,
+ existingCluster: false,
+ stateful: true,
+ }
+ });
+
+ await wrapper.vm.promise;
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.specs.version).toBe(DEFAULT_POSSIBLE_RESOURCES.version[0]);
+ });
+
it("ramGbUsed", async () => {
const clusterEditorWrapper = await getDefaultClusterEditorWrapper();
diff --git a/mchub/configuration/__init__.py b/mchub/configuration/__init__.py
index ca8f3d3b..b5b68f36 100644
--- a/mchub/configuration/__init__.py
+++ b/mchub/configuration/__init__.py
@@ -9,11 +9,19 @@
from .env import CONFIGURATION_FILE_PATH
from ..models.auth_type import AuthType
+from ..models.version_constraint import parse_terraform_version_constraint
CONFIGURATION_FILENAME = "configuration.json"
DATABASE_FILENAME = "database.db"
+def validate_magic_castle_version_range(value):
+ try:
+ parse_terraform_version_constraint(value)
+ except ValueError as error:
+ raise ValidationError(str(error)) from error
+
+
class ConfigurationSchema(Schema):
auth_type = fields.List(fields.Str(required=True))
admins = fields.List(fields.Str())
@@ -26,6 +34,10 @@ class ConfigurationSchema(Schema):
github_token = fields.Str()
github_organization = fields.Str()
github_default_template = fields.Str()
+ magic_castle_version_range = fields.Str(
+ required=True,
+ validate=validate_magic_castle_version_range,
+ )
tfcloud_api_token = fields.Str()
tfcloud_organization = fields.Str()
tfcloud_oauth_vcs_token_id = fields.Str()
diff --git a/mchub/models/cloud/cloud_manager.py b/mchub/models/cloud/cloud_manager.py
index a8b83a76..dd917e5d 100644
--- a/mchub/models/cloud/cloud_manager.py
+++ b/mchub/models/cloud/cloud_manager.py
@@ -1,5 +1,6 @@
from ..cloud.openstack_manager import OpenStackManager
from ..cloud.dns_manager import DnsManager
+from ...services.github_api import get_github_storage
MANAGER_CLASSES = {
"openstack": OpenStackManager,
@@ -23,4 +24,7 @@ def available_resources(self):
available_resources["possible_resources"][
"domain"
] = DnsManager.get_available_domains()
+ available_resources["possible_resources"][
+ "version"
+ ] = get_github_storage().get_magic_castle_versions()
return available_resources
diff --git a/mchub/models/magic_castle/magic_castle.py b/mchub/models/magic_castle/magic_castle.py
index a6849464..005e5d75 100644
--- a/mchub/models/magic_castle/magic_castle.py
+++ b/mchub/models/magic_castle/magic_castle.py
@@ -486,9 +486,22 @@ def _get_var_tf(self):
var_tf["hieradata"] = f"{existing}\n{proxy_hieradata}" if existing else proxy_hieradata
return var_tf
+ @staticmethod
+ def validate_creation_version(data):
+ if data.get("version") not in get_github_storage().get_magic_castle_versions():
+ raise InvalidUsageException("Invalid Magic Castle version")
+
+ def validate_version_unchanged(self, data):
+ existing_version = self.config.get("version")
+ if data.get("version", existing_version) != existing_version:
+ raise InvalidUsageException(
+ "The Magic Castle version cannot be changed after plan creation"
+ )
+
def plan_creation(self, data, created_by_user_id=None):
logger.debug(f"Call <{type(self).__name__}>:plan_creation")
+ self.validate_creation_version(data)
self.set_configuration(data)
self.orm.created_by_user_id = created_by_user_id
self.orm.status = ClusterStatusCode.PLAN_RUNNING
@@ -558,6 +571,13 @@ def plan_modification(self, data):
if self.is_busy:
raise BusyClusterException
+ self.validate_version_unchanged(data)
+ existing_version = self.config.get("version")
+ if existing_version is None:
+ data.pop("version", None)
+ else:
+ data["version"] = existing_version
+
config_changed = self.set_configuration(data)
# Check if main_file has changed before writing
diff --git a/mchub/models/magic_castle/magic_castle_configuration.py b/mchub/models/magic_castle/magic_castle_configuration.py
index ad05510b..f7605312 100644
--- a/mchub/models/magic_castle/magic_castle_configuration.py
+++ b/mchub/models/magic_castle/magic_castle_configuration.py
@@ -48,6 +48,9 @@ class Schema(marshmallow.Schema):
cluster_name = fields.Str(required=True, validate=validate_cluster_name)
domain = fields.Str(required=True, validate=validate_domain)
image = fields.Str(required=True)
+ # Optional for compatibility with clusters created before version selection
+ # was introduced. New cluster plans require a vetted version.
+ version = fields.Str()
nb_users = fields.Int(required=True)
instances = fields.Dict(
keys=fields.Str(),
@@ -121,4 +124,7 @@ def get_var_tf(self):
"hieradata": self["hieradata"],
}
+ if "version" in self:
+ var_tf_data["version"] = self["version"]
+
return var_tf_data
diff --git a/mchub/models/template.py b/mchub/models/template.py
index 91b9a24d..8eedf2a7 100644
--- a/mchub/models/template.py
+++ b/mchub/models/template.py
@@ -3,6 +3,7 @@
"cluster_name": "",
"domain": None,
"image": None,
+ "version": None,
"nb_users": 10,
"instances": {
"mgmt": {
diff --git a/mchub/models/version_constraint.py b/mchub/models/version_constraint.py
new file mode 100644
index 00000000..fb5f9945
--- /dev/null
+++ b/mchub/models/version_constraint.py
@@ -0,0 +1,69 @@
+import re
+
+from packaging.version import InvalidVersion, Version
+
+
+CONSTRAINT_RE = re.compile(
+ r"^(~>|>=|<=|!=|>|<|=)?\s*"
+ r"(v?\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z.-]+)?)$"
+)
+
+
+def parse_version(value):
+ try:
+ return Version(value)
+ except InvalidVersion as error:
+ raise ValueError(f"Invalid version: {value}") from error
+
+
+def parse_terraform_version_constraint(expression):
+ if not isinstance(expression, str) or not expression.strip():
+ raise ValueError("Version constraint cannot be empty")
+
+ constraints = []
+ for raw_constraint in expression.split(","):
+ match = CONSTRAINT_RE.fullmatch(raw_constraint.strip())
+ if match is None:
+ raise ValueError(f"Invalid Terraform version constraint: {expression}")
+
+ operator = match.group(1) or "="
+ raw_version = match.group(2)
+ version = parse_version(raw_version)
+ upper_bound = None
+
+ if operator == "~>":
+ release = list(version.release)
+ version_core = raw_version.lstrip("v").split("-", 1)[0].split("+", 1)[0]
+ component_count = len(version_core.split("."))
+ if component_count >= 3:
+ upper_bound = Version(f"{release[0]}.{release[1] + 1}.0")
+ else:
+ upper_bound = Version(f"{release[0] + 1}.0.0")
+
+ constraints.append((operator, version, upper_bound))
+
+ return constraints
+
+
+def matches_terraform_version_constraint(version, expression):
+ candidate = parse_version(version)
+
+ for operator, expected, upper_bound in parse_terraform_version_constraint(
+ expression
+ ):
+ if operator == "=" and candidate != expected:
+ return False
+ if operator == "!=" and candidate == expected:
+ return False
+ if operator == ">" and candidate <= expected:
+ return False
+ if operator == ">=" and candidate < expected:
+ return False
+ if operator == "<" and candidate >= expected:
+ return False
+ if operator == "<=" and candidate > expected:
+ return False
+ if operator == "~>" and not (candidate >= expected and candidate < upper_bound):
+ return False
+
+ return True
diff --git a/mchub/resources/magic_castle_api.py b/mchub/resources/magic_castle_api.py
index e23d5db3..5c8e3667 100644
--- a/mchub/resources/magic_castle_api.py
+++ b/mchub/resources/magic_castle_api.py
@@ -145,6 +145,7 @@ def apply_cluster(hostname):
project = db.session.get(Project, cloud["id"])
if project and project not in user.projects:
raise InvalidUsageException("Invalid project id")
+ MagicCastle.validate_creation_version(json_data)
user_id = user.orm.id
self._run_in_background(app, MagicCastle().plan_creation, json_data, user_id)
@@ -161,6 +162,7 @@ def put(self, user: User, hostname):
if not json_data:
raise InvalidUsageException("No json data was provided")
+ MagicCastle(orm).validate_version_unchanged(json_data)
app = current_app._get_current_object()
self._claim_background_task(orm)
diff --git a/mchub/services/github_api.py b/mchub/services/github_api.py
index 3609bef8..42d2123c 100644
--- a/mchub/services/github_api.py
+++ b/mchub/services/github_api.py
@@ -12,6 +12,14 @@
from contextlib import contextmanager
import random
+from ..models.version_constraint import (
+ matches_terraform_version_constraint,
+ parse_version,
+)
+
+
+MAGIC_CASTLE_REPOSITORY = "ComputeCanada/magic_castle"
+
@contextmanager
def retry(max_retry=5, sleep_s=5):
@@ -55,6 +63,7 @@ def __init__(self):
auth = Auth.Token(config["github_token"])
self.github = Github(auth=auth)
+ self._magic_castle_versions_cache = {}
def _get_repo_name(self, hostname):
import hashlib
@@ -90,6 +99,27 @@ def validate_template(self, template_name):
)
raise
+ def get_magic_castle_versions(self):
+ version_range = get_config()["magic_castle_version_range"]
+ cached_versions = self._magic_castle_versions_cache.get(version_range)
+ if cached_versions is not None:
+ return cached_versions
+
+ repository = self.github.get_repo(MAGIC_CASTLE_REPOSITORY)
+ versions = []
+ for tag in repository.get_tags():
+ try:
+ parsed_version = parse_version(tag.name)
+ except ValueError:
+ continue
+ if matches_terraform_version_constraint(tag.name, version_range):
+ versions.append((parsed_version, tag.name))
+
+ versions.sort(key=lambda version: version[0], reverse=True)
+ matching_versions = [tag_name for _, tag_name in versions]
+ self._magic_castle_versions_cache[version_range] = matching_versions
+ return matching_versions
+
def create_repo(self, hostname, template_name):
self.template_repo = self._get_template_repo(template_name)
diff --git a/pyproject.toml b/pyproject.toml
index 6d80f8a4..46813f3c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ dependencies = [
"humanize>=4.16.0,<5",
"marshmallow>=4.3.1,<5",
"openstacksdk>=4.20.0,<5",
+ "packaging>=26.0,<27",
"jsonpath-ng>=1.8.0,<2",
"Flask-Cors>=6.0.5,<7",
"cachetools>=7.1.8,<8",
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
index 41c38c65..0cfa38cf 100644
--- a/tests/data/__init__.py
+++ b/tests/data/__init__.py
@@ -5,6 +5,7 @@
"cluster_name": "",
"domain": None,
"image": None,
+ "version": None,
"nb_users": 10,
"instances": {
"mgmt": {"type": None, "count": 1, "tags": ["mgmt", "nfs", "puppet"]},
@@ -585,4 +586,5 @@
"public_keys": [""],
"hieradata": "",
"image": "Rocky-8.7-x64-2023-02",
+ "version": "14.1.2",
}
diff --git a/tests/mocks/configuration/config_mock.py b/tests/mocks/configuration/config_mock.py
index d3428590..82a3b386 100644
--- a/tests/mocks/configuration/config_mock.py
+++ b/tests/mocks/configuration/config_mock.py
@@ -47,6 +47,7 @@
},
"github_token": "EXAMPLE_TOKEN",
"github_organization": "github_org",
+ "magic_castle_version_range": ">= 14.0.0, < 15.0.0",
"tfcloud_api_token": "EXAMPLE_TOKEN",
"tfcloud_organization": "tfcloud_org",
"tfcloud_oauth_vcs_token_id": "tfcloud_oauth",
@@ -74,4 +75,4 @@ def config_auth_none_mock(mocker):
mocker.patch(
"mchub.configuration._config",
configuration,
- )
\ No newline at end of file
+ )
diff --git a/tests/mocks/github_api_mock.py b/tests/mocks/github_api_mock.py
index 729bcdfd..8d55b1a7 100644
--- a/tests/mocks/github_api_mock.py
+++ b/tests/mocks/github_api_mock.py
@@ -1,4 +1,7 @@
class GithubStorageMock:
+ def get_magic_castle_versions(self):
+ return ["14.1.2", "14.0.0"]
+
def create_repo(self, *args, **kwargs):
return "MOCK_ORG/MOCK_REPO"
diff --git a/tests/unit/magic_castle/test_magic_castle.py b/tests/unit/magic_castle/test_magic_castle.py
index 914b569a..4513f985 100644
--- a/tests/unit/magic_castle/test_magic_castle.py
+++ b/tests/unit/magic_castle/test_magic_castle.py
@@ -23,8 +23,10 @@
def test_create_magic_castle_plan_valid(app, mocker):
from mchub.models.magic_castle.magic_castle import MagicCastle
from mchub.services.terraform_cloud_api import get_terraform_cloud
+ from mchub.services.github_api import get_github_storage
create_workspace = mocker.spy(get_terraform_cloud(), "create_workspace")
+ write_variables = mocker.spy(get_github_storage(), "write")
cluster = MagicCastle()
cluster.plan_creation(deepcopy(VALID_CLUSTER_CONFIGURATION))
@@ -34,6 +36,39 @@ def test_create_magic_castle_plan_valid(app, mocker):
"MOCK_ORG/MOCK_REPO",
"tfcloud_id",
)
+ assert write_variables.call_args.args[0]["version"] == "14.1.2"
+
+
+def test_create_magic_castle_rejects_unvetted_version(app):
+ from mchub.exceptions.invalid_usage_exception import InvalidUsageException
+ from mchub.models.magic_castle.magic_castle import MagicCastle
+
+ configuration = deepcopy(VALID_CLUSTER_CONFIGURATION)
+ configuration["version"] = "unvetted"
+
+ with pytest.raises(InvalidUsageException, match="Invalid Magic Castle version"):
+ MagicCastle().plan_creation(configuration)
+
+
+def test_magic_castle_version_cannot_be_modified(app):
+ from mchub.database import db
+ from mchub.exceptions.invalid_usage_exception import InvalidUsageException
+ from mchub.models.magic_castle.magic_castle import MagicCastle, MagicCastleORM
+ from mchub.models.magic_castle.magic_castle_configuration import (
+ MagicCastleConfiguration,
+ )
+
+ orm = db.session.scalar(
+ db.select(MagicCastleORM).filter_by(hostname="created.magic-castle.cloud")
+ )
+ configuration = dict(orm.config)
+ configuration["version"] = "14.1.2"
+ orm.config = MagicCastleConfiguration("openstack", configuration)
+
+ with pytest.raises(
+ InvalidUsageException, match="cannot be changed after plan creation"
+ ):
+ MagicCastle(orm).plan_modification({"version": "14.0.0"})
def test_planned_status_waits_for_local_plan(app):
diff --git a/tests/unit/magic_castle/test_magic_castle_configuration.py b/tests/unit/magic_castle/test_magic_castle_configuration.py
index c164e0dd..b68a294d 100644
--- a/tests/unit/magic_castle/test_magic_castle_configuration.py
+++ b/tests/unit/magic_castle/test_magic_castle_configuration.py
@@ -68,4 +68,28 @@ def test_properties():
config = MagicCastleConfiguration("openstack", CONFIG_DICT)
assert config.cluster_name == "foo-123"
- assert config.domain == "magic-castle.cloud"
\ No newline at end of file
+ assert config.domain == "magic-castle.cloud"
+
+
+def test_version_is_written_to_terraform_variables():
+ from mchub.models.magic_castle.magic_castle_configuration import (
+ MagicCastleConfiguration,
+ )
+
+ config = deepcopy(CONFIG_DICT)
+ config["version"] = "14.1.2"
+
+ assert (
+ MagicCastleConfiguration("openstack", config).get_var_tf()["version"]
+ == "14.1.2"
+ )
+
+
+def test_legacy_configuration_does_not_write_empty_version():
+ from mchub.models.magic_castle.magic_castle_configuration import (
+ MagicCastleConfiguration,
+ )
+
+ assert "version" not in MagicCastleConfiguration(
+ "openstack", deepcopy(CONFIG_DICT)
+ ).get_var_tf()
diff --git a/tests/unit/magic_castle/test_version_constraint.py b/tests/unit/magic_castle/test_version_constraint.py
new file mode 100644
index 00000000..96b238b0
--- /dev/null
+++ b/tests/unit/magic_castle/test_version_constraint.py
@@ -0,0 +1,30 @@
+import pytest
+
+from mchub.models.version_constraint import (
+ matches_terraform_version_constraint,
+ parse_terraform_version_constraint,
+)
+
+
+@pytest.mark.parametrize(
+ ("version", "constraint", "expected"),
+ [
+ ("14.0.0", ">= 14.0.0, < 15.0.0", True),
+ ("14.9.1", ">= 14.0.0, < 15.0.0", True),
+ ("15.0.0", ">= 14.0.0, < 15.0.0", False),
+ ("14.1.7", "~> 14.1.0", True),
+ ("14.2.0", "~> 14.1.0", False),
+ ("14.9.0", "~> 14.1", True),
+ ("15.0.0", "~> 14.1", False),
+ ("v14.1.2", "= 14.1.2", True),
+ ("14.1.2", "!= 14.1.2", False),
+ ],
+)
+def test_matches_terraform_version_constraint(version, constraint, expected):
+ assert matches_terraform_version_constraint(version, constraint) is expected
+
+
+@pytest.mark.parametrize("constraint", ["", "^14.0.0", ">= nope", ">= 14 || < 15"])
+def test_rejects_invalid_constraint(constraint):
+ with pytest.raises(ValueError):
+ parse_terraform_version_constraint(constraint)
diff --git a/tests/unit/test_magic_castle_versions.py b/tests/unit/test_magic_castle_versions.py
new file mode 100644
index 00000000..be936cac
--- /dev/null
+++ b/tests/unit/test_magic_castle_versions.py
@@ -0,0 +1,29 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+from mchub.services.github_api import GithubStorage, MAGIC_CASTLE_REPOSITORY
+
+
+def test_get_magic_castle_versions_filters_and_sorts_tags(mocker):
+ mocker.patch(
+ "mchub.services.github_api.get_config",
+ return_value={"magic_castle_version_range": ">= 14.0.0, < 15.0.0"},
+ )
+ storage = GithubStorage.__new__(GithubStorage)
+ storage.github = Mock()
+ storage._magic_castle_versions_cache = {}
+ repository = storage.github.get_repo.return_value
+ repository.get_tags.return_value = [
+ SimpleNamespace(name="15.0.0"),
+ SimpleNamespace(name="14.0.0"),
+ SimpleNamespace(name="not-a-version"),
+ SimpleNamespace(name="14.2.0"),
+ SimpleNamespace(name="14.1.3-beta.1"),
+ ]
+
+ assert storage.get_magic_castle_versions() == [
+ "14.2.0",
+ "14.1.3-beta.1",
+ "14.0.0",
+ ]
+ storage.github.get_repo.assert_called_once_with(MAGIC_CASTLE_REPOSITORY)
diff --git a/tests/unit/user/test_local_user.py b/tests/unit/user/test_local_user.py
index 69bd37c5..516e9b36 100644
--- a/tests/unit/user/test_local_user.py
+++ b/tests/unit/user/test_local_user.py
@@ -53,6 +53,7 @@ def test_create_empty_magic_castle(app):
"cluster_name": "anon123",
"domain": "mc.ca",
"image": "Rocky-8.7-x64-2023-02",
+ "version": "14.1.2",
"nb_users": 10,
"instances": {
"mgmt": {
diff --git a/tests/unit/user/test_saml_user.py b/tests/unit/user/test_saml_user.py
index 8a407406..38eda1f9 100644
--- a/tests/unit/user/test_saml_user.py
+++ b/tests/unit/user/test_saml_user.py
@@ -94,6 +94,7 @@ def test_create_empty_magic_castle(alice):
"cluster_name": "alice123",
"domain": "mc.ca",
"image": "Rocky-8.7-x64-2023-02",
+ "version": "14.1.2",
"nb_users": 10,
"instances": {
"mgmt": {
diff --git a/uv.lock b/uv.lock
index fa2741bf..747ab4a7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -494,6 +494,7 @@ dependencies = [
{ name = "jsonpath-ng" },
{ name = "marshmallow" },
{ name = "openstacksdk" },
+ { name = "packaging" },
{ name = "pygithub" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
@@ -519,6 +520,7 @@ requires-dist = [
{ name = "jsonpath-ng", specifier = ">=1.8.0,<2" },
{ name = "marshmallow", specifier = ">=4.3.1,<5" },
{ name = "openstacksdk", specifier = ">=4.20.0,<5" },
+ { name = "packaging", specifier = ">=26.0,<27" },
{ name = "pygithub", specifier = ">=2.10.0,<3" },
{ name = "pyyaml", specifier = ">=6.0.3,<7" },
{ name = "sqlalchemy", specifier = ">=2.0.52,<3" },