autoinstall: Query cloud-init for schema failures
Users attempting to do autoinstall may incorrectly send autoinstall directives as cloud-config, which will result in cloud-init schema validation errors. When loading autoinstall from cloud-config, we now check to see if there are any cloud-init schema validation errors and warn the user. Additionally, if the source of the error is from a known autoinstall error, we inform the user and halt the installation with a nonreportable AutoinstallError.
This commit is contained in:
parent
f307b87426
commit
8a31c7e72f
|
@ -4,13 +4,32 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Awaitable
|
||||
from subprocess import CompletedProcess
|
||||
from typing import Optional
|
||||
|
||||
from subiquity.server.nonreportable import NonReportableException
|
||||
from subiquitycore.utils import arun_command, run_command
|
||||
|
||||
log = logging.getLogger("subiquity.cloudinit")
|
||||
|
||||
|
||||
class CloudInitSchemaValidationError(NonReportableException):
|
||||
"""Exception for cloud config schema validation failure.
|
||||
|
||||
Attributes:
|
||||
keys -- List of keys which are the cause of the failure
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
keys: list[str],
|
||||
message: str = "Cloud config schema failed to validate.",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.keys = keys
|
||||
|
||||
|
||||
def get_host_combined_cloud_config() -> dict:
|
||||
"""Return the host system /run/cloud-init/combined-cloud-config.json"""
|
||||
try:
|
||||
|
@ -67,6 +86,34 @@ def read_legacy_status(stream):
|
|||
return None
|
||||
|
||||
|
||||
async def get_schema_failure_keys() -> list[str]:
|
||||
"""Retrieve the keys causing schema failure."""
|
||||
|
||||
cmd: list[str] = ["cloud-init", "schema", "--system"]
|
||||
status_coro: Awaitable = arun_command(cmd, clean_locale=True)
|
||||
try:
|
||||
sp: CompletedProcess = await asyncio.wait_for(status_coro, 10)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("cloud-init schema --system timed out")
|
||||
return []
|
||||
|
||||
error: str = sp.stderr # Relies on arun_command decoding to utf-8 str by default
|
||||
|
||||
# Matches:
|
||||
# ('some-key' was unexpected)
|
||||
# ('some-key', 'another-key' were unexpected)
|
||||
pattern = r"\((?P<args>'[^']+'(,\s'[^']+')*) (?:was|were) unexpected\)"
|
||||
search_result = re.search(pattern, error)
|
||||
|
||||
if search_result is None:
|
||||
return []
|
||||
|
||||
args_list: list[str] = search_result.group("args").split(", ")
|
||||
no_quotes: list[str] = [arg.strip("'") for arg in args_list]
|
||||
|
||||
return no_quotes
|
||||
|
||||
|
||||
async def cloud_init_status_wait() -> (bool, Optional[str]):
|
||||
"""Wait for cloud-init completion, and return if timeout ocurred and best
|
||||
available status information.
|
||||
|
@ -86,3 +133,23 @@ async def cloud_init_status_wait() -> (bool, Optional[str]):
|
|||
else:
|
||||
status = read_legacy_status(sp.stdout)
|
||||
return (True, status)
|
||||
|
||||
|
||||
async def validate_cloud_init_schema() -> None:
|
||||
"""Check for cloud-init schema errors.
|
||||
Returns (None) if the cloud-config schmea validated OK according to
|
||||
cloud-init. Otherwise, a CloudInitSchemaValidationError is thrown
|
||||
which contains a list of the keys which failed to validate.
|
||||
Requires cloud-init supporting recoverable errors and extended status.
|
||||
|
||||
:return: None if cloud-init schema validated succesfully.
|
||||
:rtype: None
|
||||
:raises CloudInitSchemaValidationError: If cloud-init schema did not validate
|
||||
succesfully.
|
||||
"""
|
||||
causes: list[str] = await get_schema_failure_keys()
|
||||
|
||||
if causes:
|
||||
raise CloudInitSchemaValidationError(keys=causes)
|
||||
|
||||
return None
|
||||
|
|
|
@ -28,7 +28,12 @@ from cloudinit.config.cc_set_passwords import rand_user_password
|
|||
from jsonschema.exceptions import ValidationError
|
||||
from systemd import journal
|
||||
|
||||
from subiquity.cloudinit import cloud_init_status_wait, get_host_combined_cloud_config
|
||||
from subiquity.cloudinit import (
|
||||
CloudInitSchemaValidationError,
|
||||
cloud_init_status_wait,
|
||||
get_host_combined_cloud_config,
|
||||
validate_cloud_init_schema,
|
||||
)
|
||||
from subiquity.common.api.server import bind, controller_for_request
|
||||
from subiquity.common.apidef import API
|
||||
from subiquity.common.errorreport import ErrorReport, ErrorReporter, ErrorReportKind
|
||||
|
@ -43,7 +48,7 @@ from subiquity.common.types import (
|
|||
PasswordKind,
|
||||
)
|
||||
from subiquity.models.subiquity import ModelNames, SubiquityModel
|
||||
from subiquity.server.autoinstall import AutoinstallValidationError
|
||||
from subiquity.server.autoinstall import AutoinstallError, AutoinstallValidationError
|
||||
from subiquity.server.controller import SubiquityController
|
||||
from subiquity.server.dryrun import DRConfig
|
||||
from subiquity.server.errors import ErrorController
|
||||
|
@ -723,7 +728,66 @@ class SubiquityServer(Application):
|
|||
def base_relative(self, path):
|
||||
return os.path.join(self.base_model.root, path)
|
||||
|
||||
def load_cloud_config(self):
|
||||
@with_context(name="extract_autoinstall")
|
||||
async def _extract_autoinstall_from_cloud_config(
|
||||
self,
|
||||
*,
|
||||
cloud_cfg: dict[str, Any],
|
||||
context: Context,
|
||||
) -> dict[str, Any]:
|
||||
"""Extract autoinstall passed via cloud config."""
|
||||
|
||||
# Not really is-install-context but set to force event reporting
|
||||
context.set("is-install-context", True)
|
||||
context.enter() # publish start event
|
||||
|
||||
try:
|
||||
await validate_cloud_init_schema()
|
||||
except CloudInitSchemaValidationError as exc:
|
||||
bad_keys: list[str] = exc.keys
|
||||
raw_keys: list[str] = [f"{key!r}" for key in bad_keys]
|
||||
context.warning(
|
||||
f"cloud-init schema validation failure for: {', '.join(raw_keys)}",
|
||||
log=log,
|
||||
)
|
||||
|
||||
# Raise AutoinstallError if we found any autoinstall as a cause
|
||||
# of the schema validation error, otherwise continue
|
||||
|
||||
# Filter only the bad keys
|
||||
potential_autoinstall: dict[str, Any] = dict(
|
||||
((key, cloud_cfg[key]) for key in bad_keys)
|
||||
)
|
||||
autoinstall, other = self.filter_autoinstall(potential_autoinstall)
|
||||
|
||||
if len(autoinstall) != 0:
|
||||
for key in autoinstall:
|
||||
context.error(
|
||||
message=(
|
||||
f"{key!r} is valid autoinstall but not "
|
||||
"found under 'autoinstall'."
|
||||
),
|
||||
log=log,
|
||||
)
|
||||
|
||||
raise AutoinstallError(
|
||||
(
|
||||
"Misplaced autoinstall directives resulted in a cloud-init "
|
||||
"schema validation failure."
|
||||
)
|
||||
) from exc
|
||||
|
||||
else:
|
||||
log.debug(
|
||||
"No autoinstall keys found among bad cloud config. Continuing."
|
||||
)
|
||||
|
||||
cfg: dict[str, Any] = cloud_cfg.get("autoinstall", {})
|
||||
|
||||
return cfg
|
||||
|
||||
@with_context()
|
||||
async def load_cloud_config(self, *, context: Context):
|
||||
# cloud-init 23.3 introduced combined-cloud-config, which helps to
|
||||
# prevent subiquity from having to go load cloudinit modules.
|
||||
# This matters because a downgrade pickle deserialization issue may
|
||||
|
@ -755,13 +819,16 @@ class SubiquityServer(Application):
|
|||
users = ug_util.normalize_users_groups(cloud_cfg, cloud.distro)[0]
|
||||
self.installer_user_name = ug_util.extract_default(users)[0]
|
||||
|
||||
if "autoinstall" in cloud_cfg:
|
||||
autoinstall = await self._extract_autoinstall_from_cloud_config(
|
||||
cloud_cfg=cloud_cfg, context=context
|
||||
)
|
||||
|
||||
if autoinstall != {}:
|
||||
log.debug("autoinstall found in cloud-config")
|
||||
cfg = cloud_cfg["autoinstall"]
|
||||
target = self.base_relative(cloud_autoinstall_path)
|
||||
from cloudinit import safeyaml
|
||||
|
||||
write_file(target, safeyaml.dumps(cfg))
|
||||
write_file(target, safeyaml.dumps(autoinstall))
|
||||
else:
|
||||
log.debug("no autoinstall found in cloud-config")
|
||||
|
||||
|
@ -778,7 +845,7 @@ class SubiquityServer(Application):
|
|||
if "disabled" in status:
|
||||
log.debug("Skip cloud-init autoinstall, cloud-init is disabled")
|
||||
else:
|
||||
self.load_cloud_config()
|
||||
await self.load_cloud_config()
|
||||
|
||||
def select_autoinstall(self):
|
||||
# precedence
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
# 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 copy
|
||||
import os
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
@ -21,8 +22,9 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||
import jsonschema
|
||||
from jsonschema.validators import validator_for
|
||||
|
||||
from subiquity.cloudinit import CloudInitSchemaValidationError
|
||||
from subiquity.common.types import NonReportableError, PasswordKind
|
||||
from subiquity.server.autoinstall import AutoinstallValidationError
|
||||
from subiquity.server.autoinstall import AutoinstallError, AutoinstallValidationError
|
||||
from subiquity.server.nonreportable import NonReportableException
|
||||
from subiquity.server.server import (
|
||||
NOPROBERARG,
|
||||
|
@ -357,6 +359,92 @@ class TestAutoinstallValidation(SubiTestCase):
|
|||
self.assertEqual(valid, good)
|
||||
self.assertEqual(invalid, bad)
|
||||
|
||||
@parameterized.expand(
|
||||
(
|
||||
# Has valid cloud config, no autoinstall
|
||||
({"valid-cloud": "data"}, {}, False),
|
||||
# Has valid cloud config and autoinstall, no valid ai in cloud cfg
|
||||
(
|
||||
{
|
||||
"valid-cloud": "data",
|
||||
"autoinstall": {
|
||||
"version": 1,
|
||||
"interactive-sections": ["identity"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"interactive-sections": ["identity"],
|
||||
},
|
||||
False,
|
||||
),
|
||||
# Has valid autoinstall directive in cloud config
|
||||
(
|
||||
{
|
||||
"interactive-sections": "data",
|
||||
"autoinstall": {
|
||||
"version": 1,
|
||||
"interactive-sections": ["identity"],
|
||||
},
|
||||
},
|
||||
None, # Doesn't return
|
||||
True,
|
||||
),
|
||||
# Invalid cloud config key is autoinstall and no autoinstall
|
||||
(
|
||||
{
|
||||
"interactive-sections": ["identity"],
|
||||
},
|
||||
None, # Doesn't return
|
||||
True,
|
||||
),
|
||||
# Has invalid cloud config key but is not valid autoinstall either
|
||||
(
|
||||
{
|
||||
"something-else": "data",
|
||||
"autoinstall": {
|
||||
"version": 1,
|
||||
"interactive-sections": ["identity"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"interactive-sections": ["identity"],
|
||||
},
|
||||
False,
|
||||
),
|
||||
)
|
||||
)
|
||||
async def test_autoinstall_from_cloud_config(self, cloud_cfg, expected, throws):
|
||||
"""Test autoinstall extract from cloud config."""
|
||||
|
||||
self.server.base_schema = SubiquityServer.base_schema
|
||||
self.pseudo_load_controllers()
|
||||
|
||||
cloud_data = copy.copy(cloud_cfg)
|
||||
cloud_data.pop("valid-cloud", None)
|
||||
cloud_data.pop("autoinstall", None)
|
||||
|
||||
with patch("subiquity.server.server.validate_cloud_init_schema") as val_mock:
|
||||
if len(cloud_data) == 0:
|
||||
val_mock.return_value = True
|
||||
else:
|
||||
val_mock.side_effect = CloudInitSchemaValidationError(
|
||||
keys=list(cloud_data.keys())
|
||||
)
|
||||
|
||||
if throws:
|
||||
with self.assertRaises(AutoinstallError):
|
||||
cfg = await self.server._extract_autoinstall_from_cloud_config(
|
||||
cloud_cfg=cloud_cfg
|
||||
)
|
||||
else:
|
||||
cfg = await self.server._extract_autoinstall_from_cloud_config(
|
||||
cloud_cfg=cloud_cfg
|
||||
)
|
||||
|
||||
self.assertEqual(cfg, expected)
|
||||
|
||||
|
||||
class TestMetaController(SubiTestCase):
|
||||
async def test_interactive_sections_not_present(self):
|
||||
|
|
|
@ -19,12 +19,15 @@ from unittest import skipIf
|
|||
from unittest.mock import Mock, patch
|
||||
|
||||
from subiquity.cloudinit import (
|
||||
CloudInitSchemaValidationError,
|
||||
cloud_init_status_wait,
|
||||
cloud_init_version,
|
||||
get_schema_failure_keys,
|
||||
read_json_extended_status,
|
||||
read_legacy_status,
|
||||
supports_format_json,
|
||||
supports_recoverable_errors,
|
||||
validate_cloud_init_schema,
|
||||
)
|
||||
from subiquitycore.tests import SubiTestCase
|
||||
from subiquitycore.tests.parameterized import parameterized
|
||||
|
@ -132,3 +135,83 @@ class TestCloudInitVersion(SubiTestCase):
|
|||
args=[], returncode=0, stdout="status: done\n"
|
||||
)
|
||||
self.assertEqual((True, "done"), await cloud_init_status_wait())
|
||||
|
||||
|
||||
class TestCloudInitSchemaValidation(SubiTestCase):
|
||||
@parameterized.expand(
|
||||
(
|
||||
(
|
||||
(
|
||||
" Error: Cloud config schema errors: : Additional "
|
||||
"properties are not allowed ('bad-key', 'late-commands' "
|
||||
"were unexpected)\n\nError: Invalid schema: user-data\n\n"
|
||||
),
|
||||
["bad-key", "late-commands"],
|
||||
),
|
||||
(
|
||||
(
|
||||
" Error: Cloud config schema errors: : Additional "
|
||||
"properties are not allowed ('bad-key' "
|
||||
"was unexpected)\n\nError: Invalid schema: user-data\n\n"
|
||||
),
|
||||
["bad-key"],
|
||||
),
|
||||
("('key_1', 'key-2', 'KEY3' were unexpected)", ["key_1", "key-2", "KEY3"]),
|
||||
("('key_.-;!)1!' was unexpected)", ["key_.-;!)1!"]),
|
||||
)
|
||||
)
|
||||
async def test_get_schema_failure_keys(self, msg, expected):
|
||||
"""Test 1 or more keys are extracted correctly."""
|
||||
|
||||
with (
|
||||
patch("subiquity.cloudinit.arun_command", new=Mock()),
|
||||
patch("subiquity.cloudinit.asyncio.wait_for") as wait_for_mock,
|
||||
):
|
||||
wait_for_mock.return_value = CompletedProcess(
|
||||
args=[], returncode=1, stderr=msg
|
||||
)
|
||||
|
||||
bad_keys = await get_schema_failure_keys()
|
||||
|
||||
self.assertEqual(bad_keys, expected)
|
||||
|
||||
@patch("subiquity.cloudinit.arun_command", new=Mock())
|
||||
@patch("subiquity.cloudinit.asyncio.wait_for")
|
||||
async def test_get_schema_failure_malformed(self, wait_for_mock):
|
||||
"""Test graceful failure if output changes."""
|
||||
|
||||
error_msg = "('key_1', 'key-2', 'KEY3', were unexpected)"
|
||||
|
||||
wait_for_mock.return_value = CompletedProcess(
|
||||
args=[], returncode=1, stderr=error_msg
|
||||
)
|
||||
|
||||
bad_keys = await get_schema_failure_keys()
|
||||
|
||||
self.assertEqual(bad_keys, [])
|
||||
|
||||
@patch("subiquity.cloudinit.arun_command", new=Mock())
|
||||
@patch("subiquity.cloudinit.asyncio.wait_for")
|
||||
async def test_no_schema_errors(self, wait_for_mock):
|
||||
wait_for_mock.return_value = CompletedProcess(args=[], returncode=0, stderr="")
|
||||
|
||||
self.assertEqual(None, await validate_cloud_init_schema())
|
||||
|
||||
@patch("subiquity.cloudinit.get_schema_failure_keys")
|
||||
async def test_validate_cloud_init_schema(self, sources_mock):
|
||||
mock_keys = ["key1", "key2"]
|
||||
sources_mock.return_value = mock_keys
|
||||
|
||||
with self.assertRaises(CloudInitSchemaValidationError) as ctx:
|
||||
await validate_cloud_init_schema()
|
||||
|
||||
self.assertEqual(mock_keys, ctx.exception.keys)
|
||||
|
||||
@patch("subiquity.cloudinit.arun_command", new=Mock())
|
||||
@patch("subiquity.cloudinit.asyncio.wait_for")
|
||||
@patch("subiquity.cloudinit.log")
|
||||
async def test_get_schema_warn_on_timeout(self, log_mock, wait_for_mock):
|
||||
wait_for_mock.side_effect = asyncio.TimeoutError()
|
||||
sources = await get_schema_failure_keys()
|
||||
log_mock.warning.assert_called()
|
||||
self.assertEqual([], sources)
|
||||
|
|
Loading…
Reference in New Issue