diff --git a/cloudinit/config/cc_apk_configure.py b/cloudinit/config/cc_apk_configure.py index a615c814b58..2cb2dad188c 100644 --- a/cloudinit/config/cc_apk_configure.py +++ b/cloudinit/config/cc_apk_configure.py @@ -10,7 +10,7 @@ from cloudinit import log as logging from cloudinit import temp_utils, templater, util -from cloudinit.config.schema import get_meta_doc, validate_cloudconfig_schema +from cloudinit.config.schema import get_meta_doc from cloudinit.settings import PER_INSTANCE LOG = logging.getLogger(__name__) @@ -102,104 +102,7 @@ "frequency": frequency, } -schema = { - "type": "object", - "properties": { - "apk_repos": { - "type": "object", - "properties": { - "preserve_repositories": { - "type": "boolean", - "default": False, - "description": dedent( - """\ - By default, cloud-init will generate a new repositories - file ``/etc/apk/repositories`` based on any valid - configuration settings specified within a apk_repos - section of cloud config. To disable this behavior and - preserve the repositories file from the pristine image, - set ``preserve_repositories`` to ``true``. - - The ``preserve_repositories`` option overrides - all other config keys that would alter - ``/etc/apk/repositories``. - """ - ), - }, - "alpine_repo": { - "type": ["object", "null"], - "properties": { - "base_url": { - "type": "string", - "default": DEFAULT_MIRROR, - "description": dedent( - """\ - The base URL of an Alpine repository, or - mirror, to download official packages from. - If not specified then it defaults to ``{}`` - """.format( - DEFAULT_MIRROR - ) - ), - }, - "community_enabled": { - "type": "boolean", - "default": False, - "description": dedent( - """\ - Whether to add the Community repo to the - repositories file. By default the Community - repo is not included. - """ - ), - }, - "testing_enabled": { - "type": "boolean", - "default": False, - "description": dedent( - """\ - Whether to add the Testing repo to the - repositories file. By default the Testing - repo is not included. It is only recommended - to use the Testing repo on a machine running - the ``Edge`` version of Alpine as packages - installed from Testing may have dependancies - that conflict with those in non-Edge Main or - Community repos." - """ - ), - }, - "version": { - "type": "string", - "description": dedent( - """\ - The Alpine version to use (e.g. ``v3.12`` or - ``edge``) - """ - ), - }, - }, - "required": ["version"], - "minProperties": 1, - "additionalProperties": False, - }, - "local_repo_base_url": { - "type": "string", - "description": dedent( - """\ - The base URL of an Alpine repository containing - unofficial packages - """ - ), - }, - }, - "minProperties": 1, # Either preserve_repositories or alpine_repo - "additionalProperties": False, - } - }, -} - -__doc__ = get_meta_doc(meta, schema) +__doc__ = get_meta_doc(meta) def handle(name, cfg, cloud, log, _args): @@ -222,8 +125,6 @@ def handle(name, cfg, cloud, log, _args): ) return - validate_cloudconfig_schema(cfg, schema) - # If "preserve_repositories" is explicitly set to True in # the configuration do nothing. if util.get_cfg_option_bool(apk_section, "preserve_repositories", False): diff --git a/config/cloud-init-schema-1.0.json b/config/cloud-init-schema-1.0.json index b2e8bd21ff9..afaed285ece 100644 --- a/config/cloud-init-schema-1.0.json +++ b/config/cloud-init-schema-1.0.json @@ -1,6 +1,54 @@ { "$schema": "http://json-schema.org/draft-04/schema#", "$defs": { + "cc_apk_configure": { + "type": "object", + "properties": { + "apk_repos": { + "type": "object", + "properties": { + "preserve_repositories": { + "type": "boolean", + "default": false, + "description": "By default, cloud-init will generate a new repositories file ``/etc/apk/repositories`` based on any valid configuration settings specified within a apk_repos section of cloud config. To disable this behavior and preserve the repositories file from the pristine image, set ``preserve_repositories`` to ``true``.\n\n The ``preserve_repositories`` option overrides all other config keys that would alter ``/etc/apk/repositories``." + }, + "alpine_repo": { + "type": ["object", "null"], + "properties": { + "base_url": { + "type": "string", + "default": "https://alpine.global.ssl.fastly.net/alpine", + "description": "The base URL of an Alpine repository, or mirror, to download official packages from. If not specified then it defaults to ``https://alpine.global.ssl.fastly.net/alpine``" + }, + "community_enabled": { + "type": "boolean", + "default": false, + "description": "Whether to add the Community repo to the repositories file. By default the Community repo is not included." + }, + "testing_enabled": { + "type": "boolean", + "default": false, + "description": "Whether to add the Testing repo to the repositories file. By default the Testing repo is not included. It is only recommended to use the Testing repo on a machine running the ``Edge`` version of Alpine as packages installed from Testing may have dependancies that conflict with those in non-Edge Main or Community repos." + }, + "version": { + "type": "string", + "description": "The Alpine version to use (e.g. ``v3.12`` or ``edge``)" + } + }, + "required": ["version"], + "minProperties": 1, + "additionalProperties": false + }, + "local_repo_base_url": { + "type": "string", + "description": "The base URL of an Alpine repository containing unofficial packages" + } + }, + "minProperties": 1, + "additionalProperties": false + } + } + }, "cc_apt_pipelining": { "type": "object", "properties": { @@ -15,6 +63,7 @@ } }, "allOf": [ + { "$ref": "#/$defs/cc_apk_configure" }, { "$ref": "#/$defs/cc_apt_pipelining" } ] } diff --git a/tests/unittests/config/test_cc_apk_configure.py b/tests/unittests/config/test_cc_apk_configure.py index 6fbc3dec7e3..c422f80fcf7 100644 --- a/tests/unittests/config/test_cc_apk_configure.py +++ b/tests/unittests/config/test_cc_apk_configure.py @@ -6,11 +6,25 @@ import logging import os +import re import textwrap +from pathlib import Path + +import pytest from cloudinit import cloud, helpers, util from cloudinit.config import cc_apk_configure -from tests.unittests.helpers import FilesystemMockingTestCase, mock +from cloudinit.config.schema import ( + SchemaValidationError, + get_schema, + validate_cloudconfig_schema, +) +from tests.unittests.helpers import ( + FilesystemMockingTestCase, + cloud_init_project_dir, + mock, + skipUnlessJsonSchema, +) REPO_FILE = "/etc/apk/repositories" DEFAULT_MIRROR_URL = "https://alpine.global.ssl.fastly.net/alpine" @@ -322,4 +336,84 @@ def test_edge_main_community_testing_local_repos(self): self.assertEqual(expected_content, util.load_file(REPO_FILE)) +class TestApkConfigureSchema: + @pytest.mark.parametrize( + "config, error_msg", + ( + # Valid schemas + ({"apk_repos": {"preserve_repositories": True}}, None), + ({"apk_repos": {"alpine_repo": None}}, None), + ({"apk_repos": {"alpine_repo": {"version": "v3.21"}}}, None), + ( + { + "apk_repos": { + "alpine_repo": { + "base_url": "http://yep", + "community_enabled": True, + "testing_enabled": True, + "version": "v3.21", + } + } + }, + None, + ), + ({"apk_repos": {"local_repo_base_url": "http://some"}}, None), + # Invalid schemas + ( + {"apk_repos": {"alpine_repo": {"version": False}}}, + "apk_repos.alpine_repo.version: False is not of type" + " 'string'", + ), + ( + { + "apk_repos": { + "alpine_repo": {"version": "v3.12", "bogus": 1} + } + }, + re.escape( + "apk_repos.alpine_repo: Additional properties are not" + " allowed ('bogus' was unexpected)" + ), + ), + ( + {"apk_repos": {"alpine_repo": {}}}, + "apk_repos.alpine_repo: 'version' is a required property," + " apk_repos.alpine_repo: {} does not have enough properties", + ), + ( + {"apk_repos": {"alpine_repo": True}}, + "apk_repos.alpine_repo: True is not of type 'object', 'null'", + ), + ( + {"apk_repos": {"preserve_repositories": "wrongtype"}}, + "apk_repos.preserve_repositories: 'wrongtype' is not of type" + " 'boolean'", + ), + ( + {"apk_repos": {}}, + "apk_repos: {} does not have enough properties", + ), + ( + {"apk_repos": {"local_repo_base_url": None}}, + "apk_repos.local_repo_base_url: None is not of type 'string'", + ), + ), + ) + @skipUnlessJsonSchema() + @mock.patch("cloudinit.config.schema.read_cfg_paths") + def test_schema_validation(self, read_cfg_paths, config, error_msg, paths): + read_cfg_paths.return_value = paths + # New-style schema $defs exist in config/cloud-init-schema*.json + for schema_file in Path(cloud_init_project_dir("config/")).glob( + "cloud-init-schema*.json" + ): + util.copy(schema_file, paths.schema_dir) + schema = get_schema() + if error_msg is None: + validate_cloudconfig_schema(config, schema, strict=True) + else: + with pytest.raises(SchemaValidationError, match=error_msg): + validate_cloudconfig_schema(config, schema, strict=True) + + # vi: ts=4 expandtab diff --git a/tests/unittests/config/test_schema.py b/tests/unittests/config/test_schema.py index 9937e3e29e2..fb4d3d997c0 100644 --- a/tests/unittests/config/test_schema.py +++ b/tests/unittests/config/test_schema.py @@ -143,6 +143,7 @@ def test_get_schema_coalesces_known_schema(self, read_cfg_paths, paths): assert ["$defs", "$schema", "allOf"] == sorted(list(schema.keys())) # New style schema should be defined in static schema file in $defs expected_subschema_defs = [ + {"$ref": "#/$defs/cc_apk_configure"}, {"$ref": "#/$defs/cc_apt_pipelining"}, ] found_subschema_defs = [] @@ -156,7 +157,6 @@ def test_get_schema_coalesces_known_schema(self, read_cfg_paths, paths): assert expected_subschema_defs == found_subschema_defs # This list will dwindle as we move legacy schema to new $defs assert [ - "apk_repos", "apt", "bootcmd", "chef",