console_conf/models: add models for recovery systems

Add models for recovery chooser systems, brand information, snapd models and
recovery actions.

Signed-off-by: Maciej Borzecki <maciej.zenon.borzecki@canonical.com>
This commit is contained in:
Maciej Borzecki 2020-03-24 11:15:07 +01:00
parent 5d77d71499
commit c328152fb8
2 changed files with 437 additions and 0 deletions

View File

@ -0,0 +1,213 @@
#!/usr/bin/env python3
# Copyright 2020 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import json
import attr
import jsonschema
log = logging.getLogger("console_conf.models.systems")
# This json schema describes the recovery systems data. Allow additional
# properties at each level so that console-conf does not have to be exactly in
# sync with snapd.
_RECOVERY_SYSTEMS_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "systems",
"type": "object",
"additionalProperties": True,
"required": ["systems"],
"properties": {
"systems": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "brand", "model"],
"properties": {
"label": {"type": "string"},
"actions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": True,
"required": ["title", "mode"],
"properties": {
"title": {"type": "string"},
"mode": {"type": "string"},
},
},
},
"brand": {
"type": "object",
"additionalProperties": True,
"required": ["id", "username", "display-name"],
"properties": {
"id": {"type": "string"},
"username": {"type": "string"},
"display-name": {"type": "string"},
"validation": {"type": "string"},
},
},
"model": {
"type": "object",
"additionalProperties": True,
"required": ["model", "brand-id", "display-name"],
"properties": {
"model": {"type": "string"},
"brand-id": {"type": "string"},
"display-name": {"type": "string"},
},
},
},
},
},
},
}
class RecoverySystemsModel:
"""Recovery chooser data"""
def __init__(self, systems_data):
self.systems = systems_data
# current selection
self._selection = None
def select(self, system, action):
self._selection = SelectedSystemAction(system=system, action=action)
@property
def selection(self):
return self._selection
@staticmethod
def from_systems_stream(chooser_input):
"""Deserialize recovery systems from input JSON stream."""
try:
dec = json.load(chooser_input)
jsonschema.validate(dec, _RECOVERY_SYSTEMS_SCHEMA)
systems = dec.get("systems", [])
except json.JSONDecodeError:
log.exception("cannot decode recovery systems info")
raise
except jsonschema.ValidationError:
log.exception("cannot validate recovery systems data")
raise
systems = []
for sys in dec["systems"]:
m = sys["model"]
b = sys["brand"]
model = SystemModel(
model=m["model"],
brand_id=m["brand-id"],
display_name=m["display-name"]
)
brand = Brand(
ID=b["id"],
username=b["username"],
display_name=b["display-name"],
validation=b.get("validation", "unproven"),
)
actions = []
for a in sys.get("actions", []):
actions.append(SystemAction(title=a["title"], mode=a["mode"]))
s = RecoverySystem(
current=sys.get("current", False),
label=sys["label"],
model=model,
brand=brand,
actions=actions,
)
systems.append(s)
return RecoverySystemsModel(systems)
@staticmethod
def to_response_stream(obj, chooser_output):
"""Serialize an object with selected action as JSON to the given output
stream.
"""
if not isinstance(obj, SelectedSystemAction):
raise TypeError("unexpected type: {}".format(type(obj)))
choice = {
"label": obj.system.label,
"mode": obj.action.mode,
}
return json.dump(choice, fp=chooser_output)
@attr.s
class RecoverySystem:
current = attr.ib()
label = attr.ib()
model = attr.ib()
brand = attr.ib()
actions = attr.ib()
def __eq__(self, other):
return self.current == other.current and \
self.label == other.label and \
self.model == other.model and \
self.brand == other.brand and \
self.actions == other.actions
@attr.s
class Brand:
ID = attr.ib()
username = attr.ib()
display_name = attr.ib()
validation = attr.ib()
def __eq__(self, other):
return self.ID == other.ID and \
self.username == other.username and \
self.display_name == other.display_name and \
self.validation == other.validation
@attr.s
class SystemModel:
model = attr.ib()
brand_id = attr.ib()
display_name = attr.ib()
def __eq__(self, other):
return self.model == other.model and \
self.brand_id == other.brand_id and \
self.display_name == other.display_name
@attr.s
class SystemAction:
title = attr.ib()
mode = attr.ib()
def __eq__(self, other):
return self.title == other.title and \
self.mode == other.mode
@attr.s
class SelectedSystemAction:
system = attr.ib()
action = attr.ib()
def __eq__(self, other):
return self.system == other.system and \
self.action == other.action

View File

@ -0,0 +1,224 @@
#!/usr/bin/env python3
# Copyright 2020 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import unittest
import json
import jsonschema
from io import StringIO
from console_conf.models.systems import (
RecoverySystemsModel,
RecoverySystem,
Brand,
SystemModel,
SystemAction,
SelectedSystemAction,
)
class RecoverySystemsModelTests(unittest.TestCase):
reference = {
"systems": [
{
"current": True,
"label": "1234",
"brand": {
"id": "brand-id",
"username": "brand-username",
"display-name": "this is my brand",
"validation": "verified",
},
"model": {
"model": "core20-amd64",
"brand-id": "brand-id",
"display-name": "Core 20 AMD64 system",
},
"actions": [
{"title": "reinstall", "mode": "install"},
{"title": "recover", "mode": "recover"},
]
},
{
"label": "other",
"brand": {
"id": "other-brand-id",
"username": "other-brand",
"display-name": "my brand",
"validation": "unproven",
},
"model": {
"model": "my-brand-box",
"brand-id": "other-brand-id",
"display-name": "Funky box",
},
"actions": [
{"title": "reinstall", "mode": "install"},
]
}
]
}
def test_from_systems_stream_happy(self):
raw = json.dumps(self.reference)
systems = RecoverySystemsModel.from_systems_stream(StringIO(raw))
exp = RecoverySystemsModel([
RecoverySystem(
current=True,
label="1234",
model=SystemModel(
model="core20-amd64",
brand_id="brand-id",
display_name="Core 20 AMD64 system"),
brand=Brand(
ID="brand-id",
username="brand-username",
display_name="this is my brand",
validation="verified"),
actions=[SystemAction(title="reinstall", mode="install"),
SystemAction(title="recover", mode="recover")]
),
RecoverySystem(
current=False,
label="other",
model=SystemModel(
model="my-brand-box",
brand_id="other-brand-id",
display_name="Funky box"),
brand=Brand(
ID="other-brand-id",
username="other-brand",
display_name="my brand",
validation="unproven"),
actions=[SystemAction(title="reinstall", mode="install")]
),
])
self.assertEqual(systems.systems, exp.systems)
def test_from_systems_stream_invalid_empty(self):
with self.assertRaises(jsonschema.ValidationError):
RecoverySystemsModel.from_systems_stream(StringIO("{}"))
def test_from_systems_stream_invalid_missing_system_label(self):
raw = json.dumps({
"systems": [
{
"brand": {
"id": "brand-id",
"username": "brand-username",
"display-name": "this is my brand",
"validation": "verified",
},
"model": {
"model": "core20-amd64",
"brand-id": "brand-id",
"display-name": "Core 20 AMD64 system",
},
"actions": [
{"title": "reinstall", "mode": "install"},
{"title": "recover", "mode": "recover"},
]
},
]
})
with self.assertRaises(jsonschema.ValidationError):
RecoverySystemsModel.from_systems_stream(StringIO(raw))
def test_from_systems_stream_invalid_missing_brand(self):
raw = json.dumps({
"systems": [
{
"label": "1234",
"model": {
"model": "core20-amd64",
"brand-id": "brand-id",
"display-name": "Core 20 AMD64 system",
},
"actions": [
{"title": "reinstall", "mode": "install"},
{"title": "recover", "mode": "recover"},
]
},
]
})
with self.assertRaises(jsonschema.ValidationError):
RecoverySystemsModel.from_systems_stream(StringIO(raw))
def test_from_systems_stream_invalid_missing_model(self):
raw = json.dumps({
"systems": [
{
"label": "1234",
"brand": {
"id": "brand-id",
"username": "brand-username",
"display-name": "this is my brand",
"validation": "verified",
},
"actions": [
{"title": "reinstall", "mode": "install"},
{"title": "recover", "mode": "recover"},
]
},
]
})
with self.assertRaises(jsonschema.ValidationError):
RecoverySystemsModel.from_systems_stream(StringIO(raw))
def test_from_systems_stream_valid_no_actions(self):
raw = json.dumps({
"systems": [
{
"label": "1234",
"model": {
"model": "core20-amd64",
"brand-id": "brand-id",
"display-name": "Core 20 AMD64 system",
},
"brand": {
"id": "brand-id",
"username": "brand-username",
"display-name": "this is my brand",
"validation": "verified",
},
"actions": [],
},
]
})
RecoverySystemsModel.from_systems_stream(StringIO(raw))
def test_selection(self):
raw = json.dumps(self.reference)
model = RecoverySystemsModel.from_systems_stream(StringIO(raw))
model.select(model.systems[1], model.systems[1].actions[0])
self.assertEquals(model.selection,
SelectedSystemAction(
system=model.systems[1],
action=model.systems[1].actions[0]))
def test_to_response_stream(self):
raw = json.dumps(self.reference)
model = RecoverySystemsModel.from_systems_stream(StringIO(raw))
model.select(model.systems[1], model.systems[1].actions[0])
stream = StringIO()
RecoverySystemsModel.to_response_stream(model.selection, stream)
fromjson = json.loads(stream.getvalue())
self.assertEqual(fromjson, {
"mode": "install",
"label": "other",
})