Skip to content

Commit

Permalink
feat: Self scheduling
Browse files Browse the repository at this point in the history
related: #487
Change-Id: I9fab08d06b94ed0d6cbd494d4c8049b7d1bba5de
  • Loading branch information
grafuls committed Dec 11, 2024
1 parent 2287196 commit 59f221e
Show file tree
Hide file tree
Showing 45 changed files with 1,115 additions and 241 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,5 @@ wiki_db/
wordpress_data/
codecov
tests/cli/artifacts/*
tests/artifacts/*
tests/tools/artifacts/*
8 changes: 8 additions & 0 deletions conf/selfservice.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Turn on or off ss globally
ssm_enable: true
# Set max limit per self-schedule
ssm_host_limit: 10
# Set default lifetime in days for self-schedules
ssm_default_lifetime: 5
# How many clouds (and auth tokens, one per cloud) a unique user ID can have
ssm_user_cloud_limit: 2
2 changes: 1 addition & 1 deletion container/server/container-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ services:
environment:
SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@quads_db:5432/quads"
volumes:
- /var/lib/jenkins/workspace/QUADS-2.1-Latest:/opt/quads:z
- /var/lib/jenkins/workspace/QUADS-2.1-Development:/opt/quads:z
networks:
podman:
ipv4_address: 10.88.0.11
1 change: 1 addition & 0 deletions rpm/quads.spec
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ rm -rf %{buildroot}
/opt/quads/conf/quads.cron.example
/usr/bin/quads
%config(noreplace) /opt/quads/conf/quads.yml
%config(noreplace) /opt/quads/conf/selfservice.yml
%config(noreplace) /opt/quads/conf/vlans.yml
%config(noreplace) /opt/quads/conf/hosts_metadata.yml
%config(noreplace) /opt/quads/conf/idrac_interfaces.yml
Expand Down
41 changes: 40 additions & 1 deletion src/quads/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ def action_modcloud(self):
try:
clean_data["vlan"] = int(self.cli_args.get("vlan"))
except (TypeError, ValueError): # pragma: no cover
clean_data["vlan"] = None
clean_data["vlan"] = "None"

if "wipe" in self.cli_args:
clean_data["wipe"] = self.cli_args.get("wipe")
Expand Down Expand Up @@ -968,6 +968,45 @@ def action_hostresource(self):
raise CliException(str(ex))
self.logger.info(f"{_host.name}")

def action_modhost(self):
data = {}
hostname = self.cli_args.get("host")
if not hostname:
raise CliException("Missing parameter --host")
try:
self.quads.get_host(hostname)
except (APIServerException, APIBadRequest) as ex: # pragma: no cover
raise CliException(str(ex))

if self.cli_args.get("cloud"):
try:
cloud = self.quads.get_cloud(self.cli_args.get("cloud"))
except (APIServerException, APIBadRequest) as ex: # pragma: no cover
raise CliException(str(ex))
data["cloud"] = cloud.name

if self.cli_args.get("defaultcloud"):
try:
cloud = self.quads.get_cloud(self.cli_args.get("defaultcloud"))
except (APIServerException, APIBadRequest) as ex: # pragma: no cover
raise CliException(str(ex))
data["default_cloud"] = cloud.name

data = {
"name": hostname,
"model": self.cli_args.get("model"),
"host_type": self.cli_args.get("hosttype"),
"build": self.cli_args.get("build"),
"validated": self.cli_args.get("validated"),
"switch_config_applied": self.cli_args.get("switchconfigapplied"),
"can_self_schedule": self.cli_args.get("canselfschedule"),
}

try:
self.quads.update_host(hostname, data)
except (APIServerException, APIBadRequest) as ex: # pragma: no cover
raise CliException(str(ex))

def prepare_host_data(self, metadata) -> dict:
data = {}
for key, value in metadata.items():
Expand Down
39 changes: 39 additions & 0 deletions src/quads/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@
const="host_metadata_export",
help="Path to QUADS log file",
)
action_group.add_argument(
"--mod-host",
dest="action",
action="store_const",
const="modhost",
help="Modify a host",
)
action_group.add_argument(
"--define-cloud",
dest="action",
Expand Down Expand Up @@ -623,6 +630,38 @@
default=None,
help="Open-ended identifier for host: util, baremetal, aws, openstack, libvirt, etc.",
)
parser.add_argument(
"--build",
dest="build",
type=str,
choices=["true", "false"],
default=None,
help="Whether the host has been built (true/false)",
)
parser.add_argument(
"--validated",
dest="validated",
type=str,
choices=["true", "false"],
default=None,
help="Whether the host has been validated (true/false)",
)
parser.add_argument(
"--switch-config-applied",
dest="switchconfigapplied",
type=str,
choices=["true", "false"],
default=None,
help="Whether the switch config has been applied (true/false)",
)
parser.add_argument(
"--can-self-schedule",
dest="canselfschedule",
type=str,
choices=["true", "false"],
default=None,
help="Whether the host can self-schedule (true/false)",
)
parser.add_argument(
"--vlan",
dest="vlan",
Expand Down
3 changes: 3 additions & 0 deletions src/quads/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
logger = logging.getLogger(__name__)

DEFAULT_CONF_PATH = "/opt/quads/conf/quads.yml"
SS_CONF_PATH = "/opt/quads/conf/selfservice.yml"


class _ConfigBase:
def __init__(self):
self.loaded = False
self.load_from_yaml(DEFAULT_CONF_PATH)
self.load_from_yaml(SS_CONF_PATH)

def load_from_yaml(self, filepath: str = DEFAULT_CONF_PATH):
"""
Expand Down Expand Up @@ -127,3 +129,4 @@ def API_URL(self):
if __name__ == "__main__":
if not Config.loaded:
Config.load_from_yaml(DEFAULT_CONF_PATH)
Config.load_from_yaml(SS_CONF_PATH)
6 changes: 4 additions & 2 deletions src/quads/server/blueprints/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
from functools import wraps
from flask import request, Response
from quads.server.models import User, db, Role

from flask import Response, request

from quads.server.models import Role, User, db


def check_access(roles):
Expand Down
175 changes: 172 additions & 3 deletions src/quads/server/blueprints/assignments.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import re
from datetime import datetime

from flask import Blueprint, Response, jsonify, make_response, request
from sqlalchemy import inspect

from quads.config import Config
from quads.server.blueprints import check_access
from quads.server.dao.assignment import AssignmentDao
from quads.server.dao.baseDao import BaseDao, EntryNotFound, InvalidArgument
from quads.server.dao.cloud import CloudDao
from quads.server.dao.schedule import ScheduleDao
from quads.server.dao.vlan import VlanDao
from quads.server.models import Assignment

Expand Down Expand Up @@ -130,6 +133,7 @@ def create_assignment() -> Response:
qinq = data.get("qinq")
wipe = data.get("wipe")
cc_user = data.get("ccuser")
is_self_schedule = data.get("is_self_schedule")

required_fields = [
"description",
Expand Down Expand Up @@ -185,14 +189,130 @@ def create_assignment() -> Response:
"wipe": wipe,
"ccuser": cc_user,
"cloud": cloud_name,
"is_self_schedule": is_self_schedule,
}
if _vlan:
kwargs["vlan_id"] = int(vlan)
_assignment_obj = AssignmentDao.create_assignment(**kwargs)
return jsonify(_assignment_obj.as_dict())


@assignment_bp.route("/<assignment_id>", methods=["PATCH"])
@assignment_bp.route("/self/", methods=["POST"])
@check_access(["user"])
def create_self_assignment() -> Response:
"""
Creates a new self assignment in the database.
---
tags:
- API
:return: The created object as a json
"""
data = request.get_json()

enabled = Config.get("ssm_enable", False)
if not enabled:
response = {
"status_code": 403,
"error": "Forbidden",
"message": "Service not enabled",
}
return make_response(jsonify(response), 403)

active_ass = AssignmentDao.filter_assignments(
{"active": True, "is_self_schedule": True, "owner": data.get("owner")}
)
if len(active_ass) >= Config.get("ssm_user_cloud_limit", 1):
response = {
"status_code": 403,
"error": "Forbidden",
"message": "Self scheduling limit reached",
}
return make_response(jsonify(response), 403)

_cloud = None
_vlan = None
cloud_name = data.get("cloud")
vlan = data.get("vlan")
description = data.get("description")
owner = data.get("owner")
ticket = data.get("ticket")
qinq = data.get("qinq")
wipe = data.get("wipe")
cc_user = data.get("cc_user")

required_fields = [
"description",
"owner",
]

for field in required_fields:
if not data.get(field):
response = {
"status_code": 400,
"error": "Bad Request",
"message": f"Missing argument: {field}",
}
return make_response(jsonify(response), 400)

if cc_user:
cc_user = re.split(r"[, ]+", cc_user)

if cloud_name:
_cloud = CloudDao.get_cloud(cloud_name)
if not _cloud:
response = {
"status_code": 400,
"error": "Bad Request",
"message": f"Cloud not found: {cloud_name}",
}
return make_response(jsonify(response), 400)
_assignment = AssignmentDao.get_active_cloud_assignment(_cloud)
if _assignment:
response = {
"status_code": 400,
"error": "Bad Request",
"message": f"There is an already active assignment for {cloud_name}",
}
return make_response(jsonify(response), 400)
else:
_free_clouds = CloudDao.get_free_clouds()
if not _free_clouds:
response = {
"status_code": 400,
"error": "Bad Request",
"message": "No free clouds available",
}
return make_response(jsonify(response), 400)
_cloud = _free_clouds[0]

if vlan:
_vlan = VlanDao.get_vlan(int(vlan))
if not _vlan:
response = {
"status_code": 400,
"error": "Bad Request",
"message": f"Vlan not found: {vlan}",
}
return make_response(jsonify(response), 400)

kwargs = {
"description": description,
"owner": owner,
"ticket": ticket,
"qinq": qinq,
"wipe": wipe,
"ccuser": cc_user,
"is_self_schedule": True,
"cloud": _cloud.name,
}
if _vlan:
kwargs["vlan_id"] = int(vlan)
_assignment_obj = AssignmentDao.create_assignment(**kwargs)
return jsonify(_assignment_obj.as_dict())


@assignment_bp.route("/<assignment_id>/", methods=["PATCH"])
@check_access(["admin"])
def update_assignment(assignment_id: str) -> Response:
"""
Expand All @@ -203,8 +323,6 @@ def update_assignment(assignment_id: str) -> Response:
- in: path
name: assignment_id # The id of the assignment to update. This is a required parameter.
It must be passed as part of the URL path, not as a query string or request body parameter.
Example usage would be /api/v3/assignments/&lt;assignment_id&gt; where &lt;assignment_id&gt;
is replaced with the actual value for that field (e.g., /api/v3/assignments/12345). Note that
:param assignment_id: str: Identify which assignment to update
:return: A json object containing the updated assignment
Expand Down Expand Up @@ -268,6 +386,57 @@ def update_assignment(assignment_id: str) -> Response:
return jsonify(assignment_obj.as_dict())


@assignment_bp.route("/terminate/<assignment_id>/", methods=["POST"])
@check_access(["user"])
def terminate_assignment(assignment_id) -> Response:
"""
Terminates an existing assignment.
---
tags: API
parameters:
- in: path
name: assignment_id
"""
_assignment = AssignmentDao.get_assignment(int(assignment_id))
if not _assignment:
response = {
"status_code": 400,
"error": "Bad Request",
"message": f"Assignment not found: {assignment_id}",
}
return make_response(jsonify(response), 400)

auth_value = request.headers["Authorization"].split(" ")
user = auth_value[1].split("@")[0]
if user != _assignment.owner:
response = {
"status_code": 403,
"error": "Forbidden",
"message": "You don't have permission to terminate this assignment",
}
return make_response(jsonify(response), 403)

_schedules = ScheduleDao.get_current_schedule(cloud=_assignment.cloud)
if not _schedules:
response = {
"status_code": 400,
"error": "Bad Request",
"message": f"No active schedule for {assignment_id}",
}
return make_response(jsonify(response), 400)

for sched in _schedules:
sched.end = datetime.now()

BaseDao.safe_commit()

response = {
"status_code": 200,
"message": "Assignment terminated",
}
return jsonify(response)


@assignment_bp.route("/", methods=["DELETE"])
@check_access(["admin"])
def delete_assignment() -> Response:
Expand Down
Loading

0 comments on commit 59f221e

Please sign in to comment.