Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/cluster/ClusterEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@
<v-list-item-title>{{ localSpecs.image }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-select
v-if="!existingCluster"
v-model="localSpecs.version"
:items="getPossibleValues('version')"
label="Version"
:rules="[versionRule]"
/>
<v-list-item-content v-else>
<v-list-item-subtitle>Version</v-list-item-subtitle>
<v-list-item-title>{{ localSpecs.version }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-menu :nudge-right="40" transition="scale-transition" offset-y min-width="auto">
<template v-slot:activator="{ on, attrs }">
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -466,6 +489,7 @@ export default {
"cluster_name",
"hieradata_entries",
"image",
"version",
"public_keys",
"guest_passwd",
"instances",
Expand Down Expand Up @@ -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";
},
Expand Down
22 changes: 22 additions & 0 deletions frontend/tests/unit/components/cluster/ClusterEditor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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();

Expand Down
12 changes: 12 additions & 0 deletions mchub/configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions mchub/models/cloud/cloud_manager.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
20 changes: 20 additions & 0 deletions mchub/models/magic_castle/magic_castle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions mchub/models/magic_castle/magic_castle_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions mchub/models/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"cluster_name": "",
"domain": None,
"image": None,
"version": None,
"nb_users": 10,
"instances": {
"mgmt": {
Expand Down
69 changes: 69 additions & 0 deletions mchub/models/version_constraint.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions mchub/resources/magic_castle_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions mchub/services/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions tests/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]},
Expand Down Expand Up @@ -585,4 +586,5 @@
"public_keys": [""],
"hieradata": "",
"image": "Rocky-8.7-x64-2023-02",
"version": "14.1.2",
}
Loading