diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9ca5a75 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Unit Tests + +on: [push] + +jobs: + run-unit-test: + name: Run Unit Tests + runs-on: ubuntu-latest + container: + image: ghcr.io/truenas/middleware:master + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Install dev-tools + run: | + install-dev-tools + + - name: Install dependencies + run: | + apt install python3-pytest-mock -y + + - name: Run Tests + run: | + PYTHONPATH=$(pwd) pytest apps_validation/pytest/unit/ + PYTHONPATH=$(pwd) pytest catalog_reader/pytest/unit/ + PYTHONPATH=$(pwd) pytest apps_schema/pytest/unit/ diff --git a/.gitignore b/.gitignore index 8a47f80..29361d8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ catalog_reader/__pycache__/* catalog_reader/*/__pycache__/* catalog_templating/__pycache__/* catalog_templating/*/__pycache__/* +__pycache__ +.coverage diff --git a/apps_validation/pytest/unit/test_attr_schema.py b/apps_schema/pytest/unit/test_attr_schema.py similarity index 88% rename from apps_validation/pytest/unit/test_attr_schema.py rename to apps_schema/pytest/unit/test_attr_schema.py index 3d39a44..1d72d87 100644 --- a/apps_validation/pytest/unit/test_attr_schema.py +++ b/apps_schema/pytest/unit/test_attr_schema.py @@ -499,12 +499,56 @@ }, False ), + ( + 'attribute', + False + ), + ( + { + 'type': 'string', + '$ref': [123] + }, + False + ), + ( + { + 'type': 'text', + }, + True + ), + ( + { + 'type': 'string', + 'default': 'test', + 'enum': [{ + 'value': 'test', + 'description': 'test' + }] + }, + True + ), + ( + { + 'type': 'string', + 'default': 'invalid', + 'enum': [{ + 'value': 'test', + 'description': 'test' + }] + }, + False + ), ]) def test_schema_validation(schema, should_work): + schema_object = get_schema(schema) if not should_work: - with pytest.raises(ValidationErrors): - get_schema(schema).validate('') + if schema_object is None: + assert schema_object is None + else: + with pytest.raises(ValidationErrors): + schema_object.validate('') else: - assert get_schema(schema).validate('') is None + assert schema_object.validate('', schema) is None + assert schema_object.get_schema_str(schema) == str(schema)+"." # FIXME: Port validate_question test as well diff --git a/apps_validation/pytest/unit/test_app_validate.py b/apps_validation/pytest/unit/test_app_validate.py new file mode 100644 index 0000000..12b50d4 --- /dev/null +++ b/apps_validation/pytest/unit/test_app_validate.py @@ -0,0 +1,453 @@ +import pytest + +from apps_exceptions import ValidationErrors +from apps_validation.validate_app_version import WANTED_FILES_IN_ITEM_VERSION, validate_catalog_item_version +from apps_validation.validate_app import validate_catalog_item +from apps_validation.validate_questions import validate_questions_yaml, validate_variable_uniqueness +from apps_validation.validate_train import validate_train_structure + + +@pytest.mark.parametrize('train_path, should_work', [ + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts', + True + ), + ( + '/mnt/mypool/ix-applications/catalogs/github com truenas charts git master/charts$', + False + ) +]) +def test_validate_train_path(train_path, should_work): + if should_work: + assert validate_train_structure(train_path, 'test_schema') is None + else: + with pytest.raises(ValidationErrors): + validate_train_structure(train_path, 'test_schema') + + +@pytest.mark.parametrize('test_yaml, should_work', [ + ( + ''' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + portals: + web_portal: + protocols: + - "http" + host: + - "$node_ip" + ports: + - "$variable-machinaris_ui_port" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + ''', + True + ), + ( + ''' + groups + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + portals + web_portal: + protocols: + - "http" + host: + - "$node_ip" + ports: + - "$variable-machinaris_ui_port" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + portals: + web_portal: + - protocols: + - host: + - ports: + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + portals: + 123: + - protocols: + - host: + - ports: + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + groups: + - name + + portals: + web_portal: + protocols: + - "http" + host: + - "$node_ip" + ports: + - "$variable-machinaris_ui_port" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + - groups: "" + - portals: "" + - questions: "" + ''', + False + ), + ( + ''' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + portals: + web_portal: + protocols: + - "http" + host: + - "$node_ip" + ports: + - "$variable-machinaris_ui_port" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Invalid group" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + enableIXPortals: true + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Invalid group" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + enableIXPortals: true + iXPortalsGroupName: 'Plex Configuration' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Invalid group" + description: "Configure timezone for machinaris" + ''', + False + ), + ( + ''' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + - variable: timezone + ''', + False + ), + ( + ''' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + subquestions: + - variable: sub_question + - variable: sub_question + ''', + False + ), + ( + ''' + enableIXPortals: true + iXPortalsGroupName: 'Machinaris Configuration' + groups: + - name: "Machinaris Configuration" + description: "Configure timezone for machinaris" + + questions: + - variable: timezone + label: "Configure timezone" + group: "Machinaris Configuration" + description: "Configure timezone for machinaris" + ''', + True + ) +]) +def test_validate_question_yaml(mocker, test_yaml, should_work): + mock_file = mocker.mock_open(read_data=test_yaml) + mocker.patch('builtins.open', mock_file) + mocker.patch('apps_validation.validate_questions.validate_question', return_value=None) + + if should_work: + assert validate_questions_yaml(test_yaml, 'test_schema') is None + else: + with pytest.raises(ValidationErrors): + validate_questions_yaml(test_yaml, 'test_schema') + + +@pytest.mark.parametrize('catalog_path, test_yaml, train, item_yaml, should_work', [ + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/machinaris', + ''' + categories: + - storage + - crypto + icon_url: https://raw.githubusercontent.com/guydavis/machinaris/main/web/static/machinaris.png + ''', + 'charts', + 'item.yaml', + True + ), + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/machinaris', + ''' + categories: + - storage + - crypto + icon_url: https://raw.githubusercontent.com/guydavis/machinaris/main/web/static/machinaris.png + ''', + 'charts', + '', + False + ), + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/machinaris', + ''' + - icon_url: https://raw.githubusercontent.com/guydavis/machinaris/main/web/static/machinaris.png + ''', + 'charts', + 'item.yaml', + False + ), +]) +def test_validate_catalog_item(mocker, catalog_path, test_yaml, train, item_yaml, should_work): + mocker.patch('os.path.isdir', side_effect=[True, True, False]) + mocker.patch('os.listdir', return_value=['1.1.13', item_yaml]) + mocker.patch('apps_validation.validate_app.validate_catalog_item_version', return_value=None) + mock_file = mocker.mock_open(read_data=test_yaml) + mocker.patch('builtins.open', mock_file) + + if should_work: + assert validate_catalog_item(catalog_path, 'test_schema', train) is None + else: + with pytest.raises(ValidationErrors): + validate_catalog_item(catalog_path, 'test_schema', train) + + +@pytest.mark.parametrize('version_path ,app_yaml, schema, required_files, should_work', [ + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/storj/1.0.4', + ''' + name: storj + version: 1.0.4 + train: stable + app_version: 1.0.0.8395 + title: storj + description: Test description + home: https://storj.com + sources: [https://storj.com] + maintainers: + - email: dev@ixsystems.com + name: truenas + url: https://www.truenas.com/ + run_as_context: [] + capabilities: [] + host_mounts: [] + ''', + 'charts.storj.versions.1.0.4', + WANTED_FILES_IN_ITEM_VERSION, + True + ), + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/storj/latest', + ''' + name: storj + version: 1.0.4 + train: stable + app_version: 1.0.0.8395 + title: storj + description: Test description + home: https://storj.com + sources: [https://storj.com] + maintainers: + - email: dev@ixsystems.com + name: truenas + url: https://www.truenas.com/ + run_as_context: [] + capabilities: [] + host_mounts: [] + ''', + 'charts.storj.versions.1.0.4', + WANTED_FILES_IN_ITEM_VERSION, + False + ), + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/storj/1.0.4', + ''' + name: storj + version: 1.0.4 + train: stable + app_version: 1.0.0.8395 + title: storj + description: Test description + home: https://storj.com + sources: [https://storj.com] + maintainers: + - email: dev@ixsystems.com + name: truenas + url: https://www.truenas.com/ + run_as_context: [] + capabilities: [] + host_mounts: [] + ''', + 'charts.storj.versions.1.0.4', + 'app.yaml', + False + ), +]) +def test_validate_catalog_item_version(mocker, version_path, app_yaml, schema, required_files, should_work): + mock_file = mocker.mock_open(read_data=app_yaml) + mocker.patch('builtins.open', mock_file) + mocker.patch('os.listdir', return_value=required_files) + mocker.patch('os.path.exists', return_value=True) + mocker.patch('apps_validation.validate_app_version.validate_questions_yaml', return_value=None) + mocker.patch('apps_validation.validate_app_version.validate_ix_values_yaml', return_value=None) + mocker.patch('apps_validation.validate_app_version.validate_templates', return_value=None) + mocker.patch('apps_validation.validate_app_version.validate_k8s_to_docker_migrations', return_value=None) + + if should_work: + assert validate_catalog_item_version(version_path, schema) is None + else: + with pytest.raises(ValidationErrors): + validate_catalog_item_version(version_path, schema) + + +@pytest.mark.parametrize('data, schema, should_work', [ + ( + [ + { + 'variable': 'enablePlexPass', + 'label': 'Use PlexPass', + 'group': 'Plex Configuration', + 'schema': { + 'type': 'boolean', + 'default': False + } + }, + { + 'variable': 'dnsConfig', + 'label': 'DNS Configuration', + 'group': 'Advanced DNS Settings', + 'schema': { + 'type': 'dict', + 'attrs': [] + } + }, + ], + 'plex.questions', + True + ), + ( + [ + { + 'variable': 'enablePlexPass', + 'label': 'Use PlexPass', + 'group': 'Plex Configuration', + 'schema': { + 'type': 'boolean', + 'default': False + } + }, + { + 'variable': 'enablePlexPass', + 'label': 'DNS Configuration', + 'group': 'Advanced DNS Settings', + 'schema': { + 'type': 'dict', + 'attrs': [] + } + }, + ], + 'plex.questions', + False + ) +]) +def test_validate_variable_uniqueness(data, schema, should_work): + verrors = ValidationErrors() + if should_work: + assert validate_variable_uniqueness(data, schema, verrors) is None + else: + with pytest.raises(ValidationErrors): + validate_variable_uniqueness(data, schema, verrors) diff --git a/apps_validation/pytest/unit/test_catalog_validate.py b/apps_validation/pytest/unit/test_catalog_validate.py new file mode 100644 index 0000000..8c5c188 --- /dev/null +++ b/apps_validation/pytest/unit/test_catalog_validate.py @@ -0,0 +1,45 @@ +import sys + +import pytest + +from apps_exceptions import CatalogDoesNotExist, ValidationErrors +from apps_validation.scripts.catalog_validate import validate + + +@pytest.mark.parametrize( + "catalog_path, side_effect, expected_output, expected_exit_code", + [ + ( + "/valid/path", + None, + None, + None + ), + ( + "/invalid/path", + CatalogDoesNotExist('/invalid/path'), + "[\x1b[91mFAILED\x1b[0m]\tSpecified '/invalid/path' path does not exist", + 1 + ), + ( + "/valid/path_with_errors", + ValidationErrors([("error_field", "Sample validation error")]), + "[\x1b[91mFAILED\x1b[0m]\tFollowing validation failures were found:\n" + "[\x1b[91m0\x1b[0m]\t('error_field', 'Sample validation error')", + 1 + ), + ] +) +def test_validate(mocker, capsys, catalog_path, side_effect, expected_output, expected_exit_code): + mocker.patch('apps_validation.scripts.catalog_validate.validate_catalog', side_effect=side_effect) + + if expected_exit_code: + with pytest.raises(SystemExit): + validate(catalog_path) + assert mocker.spy(sys, 'exit').called_once_with(expected_exit_code) + else: + validate(catalog_path) + + if expected_output: + captured = capsys.readouterr() + assert expected_output in captured.out diff --git a/apps_validation/pytest/unit/test_validate_ix_values.py b/apps_validation/pytest/unit/test_validate_ix_values.py new file mode 100644 index 0000000..fc0e7c9 --- /dev/null +++ b/apps_validation/pytest/unit/test_validate_ix_values.py @@ -0,0 +1,70 @@ +import pytest + +from apps_exceptions import ValidationErrors +from apps_validation.validate_app_version import validate_ix_values_yaml + + +@pytest.mark.parametrize('schema, test_yaml, should_work', [ + ( + 'charts.chia.versions.1.3.38.ix_values', + ''' + image: + pull_policy: IfNotPresent + repository: ixsystems/chia-docker + tag: v1.6.2 + update_strategy: Recreate + ix_portals: [{portalName: 'web portal', protocol: 'http', useNodeIP: false, host: '192.168.0.18', port: 9898}] + ''', + True + ), + ( + 'charts.chia.versions.1.3.38.ix_values', + ''' + image: + pull_policy: IfNotPresent + repository: ixsystems/chia-docker + tag: v1.6.2 + update_strategy: Recreate + ix_portals: [{portalName: 'web portal', protocol: 'http', useNodeIP: true, port: 9898}] + ''', + True + ), + ( + 'charts.chia.versions.1.3.38.ix_values', + ''' + image + pull_policy: IfNotPresent + repository: ixsystems/chia-docker + tag v1.6.2 + update_strategy: Recreate + ix_portals: [{portalName: 'web portal', protocol: 'http', useNodeIP: true, port: 9898}] + ''', + False + ), + ( + 'charts.chia.versions.1.3.38.ix_values', + '', + False + ), + ( + 'charts.chia.versions.1.3.38.ix_values', + ''' + image: + pull_policy: IfNotPresent + repository: ixsystems/chia-docker + tag: v1.6.2 + update_strategy: Recreate + ix_portals: [{portalName: 'web portal', port: '9898'}] + ''', + False + ), +]) +def test_validate_ix_values_yaml(mocker, schema, test_yaml, should_work): + mock_file = mocker.mock_open(read_data=test_yaml) + mocker.patch('builtins.open', mock_file) + + if should_work: + assert validate_ix_values_yaml('', schema) is None + else: + with pytest.raises(ValidationErrors): + validate_ix_values_yaml('', schema) diff --git a/apps_validation/pytest/unit/test_validate_k8s_to_docker_migration.py b/apps_validation/pytest/unit/test_validate_k8s_to_docker_migration.py new file mode 100644 index 0000000..112dff4 --- /dev/null +++ b/apps_validation/pytest/unit/test_validate_k8s_to_docker_migration.py @@ -0,0 +1,37 @@ +import pytest + +from apps_exceptions import ValidationErrors +from apps_validation.validate_k8s_to_docker_migration import validate_k8s_to_docker_migrations + + +@pytest.mark.parametrize('script, executable, error', [ + ( + '''#!/bin/sh + echo "executable script" + ''', + True, + '' + ), + ( + '''/bin/sh + echo "No shebang line" + ''', + True, + '[EINVAL] test_schema: Migration file should start with shebang line' + ), + ( + '''#!/bin/sh + echo "Not executable" + ''', + False, + '[EINVAL] test_schema: Migration file is not executable' + ) +]) +def test_k8s_to_docker_migration(mocker, script, executable, error): + mock_file = mocker.mock_open(read_data=script) + mocker.patch('builtins.open', mock_file) + mocker.patch('os.access', return_value=executable) + verrors = ValidationErrors() + + validate_k8s_to_docker_migrations(verrors, '', 'test_schema') + assert str(verrors).strip() == error diff --git a/apps_validation/pytest/unit/test_validate_min_max_versions.py b/apps_validation/pytest/unit/test_validate_min_max_versions.py new file mode 100644 index 0000000..64d77ec --- /dev/null +++ b/apps_validation/pytest/unit/test_validate_min_max_versions.py @@ -0,0 +1,64 @@ +import pytest + +from apps_exceptions import ValidationErrors +from apps_validation.scale_version import validate_min_max_version_values + + +@pytest.mark.parametrize('annotations_dict, schema, expected_error', [ + ( + { + 'min_scale_version': '23.04', + 'max_scale_version': '24.04' + }, + 'charts.plex.versions.1.7.56', + None + ), + ( + { + 'min_scale_version': '24.04', + 'max_scale_version': '22.04' + }, + 'charts.plex.versions.1.7.56', + 'Provided min_scale_version is greater than provided max_scale_version' + ), + ( + { + 'min_scale_version': '15', + 'max_scale_version': '22.04' + }, + 'charts.plex.versions.1.7.56', + 'Format of provided min_scale_version value is not correct' + ), + ( + { + 'min_scale_version': 24.04, + }, + 'charts.plex.versions.1.7.56', + '\'min_scale_version\' value should be a \'str\'' + ), + ( + { + 'min_scale_version': '22.04', + 'max_scale_version': '14' + }, + 'charts.plex.versions.1.7.56', + 'Format of provided max_scale_version value is not correct' + ), + ( + { + 'min_scale_version': '22.04', + 'max_scale_version': 24.05 + }, + 'charts.plex.versions.1.7.56', + '\'max_scale_version\' value should be a \'str\'' + ), +]) +def test_validate_min_max_versions(annotations_dict, schema, expected_error): + verrors = ValidationErrors() + if expected_error: + with pytest.raises(ValidationErrors) as ve: + validate_min_max_version_values(annotations_dict, verrors, schema) + verrors.check() + assert ve.value.errors[0].errmsg == expected_error + else: + assert validate_min_max_version_values(annotations_dict, verrors, schema) is None diff --git a/apps_validation/pytest/unit/test_validate_portals_and_notes.py b/apps_validation/pytest/unit/test_validate_portals_and_notes.py new file mode 100644 index 0000000..d2a625a --- /dev/null +++ b/apps_validation/pytest/unit/test_validate_portals_and_notes.py @@ -0,0 +1,48 @@ +import pytest + +from apps_exceptions import ValidationErrors +from apps_validation.portals import validate_portals_and_notes + + +@pytest.mark.parametrize('data, should_work', [ + ( + { + 'x-portals': [{'host': '0.0.0.0', 'name': 'Web UI', 'path': '/web', 'port': 32400, 'scheme': 'http'}], + 'x-notes': '# A test note' + }, + True + ), + ( + { + 'x-portals': [{'host': '0.0.0.0', 'name': 'Web UI', 'path': '/web', 'scheme': 'http'}], + 'x-notes': '# A test note' + }, + False + ), + ( + { + 'x-portals': [{'host': '0.0.0.0', 'name': 'Web UI', 'path': '/web', 'port': 32400, 'scheme': 'http'}] + }, + True + ), + ( + { + 'x-portals': [{'host': '0.0.0.0', 'name': 'Web UI', 'path': '/web', 'port': 32400, 'scheme': 'http'}], + 'x-notes': 123 + }, + False + ), + ( + { + 'x-portals': [{'host': '0.0.0.0', 'name': 'Web UI', 'path': '/web', 'port': '32400', 'scheme': 'http'}], + 'x-notes': '# A test note' + }, + False + ) +]) +def test_validate_portals_and_notes(data, should_work): + if should_work: + assert validate_portals_and_notes('test', data) is None + else: + with pytest.raises(ValidationErrors): + validate_portals_and_notes('test', data) diff --git a/apps_validation/pytest/unit/test_validate_recommended_apps.py b/apps_validation/pytest/unit/test_validate_recommended_apps.py new file mode 100644 index 0000000..b5595a7 --- /dev/null +++ b/apps_validation/pytest/unit/test_validate_recommended_apps.py @@ -0,0 +1,40 @@ +import pytest + +from apps_exceptions import ValidationErrors +from apps_validation.validate_recommended_apps import validate_recommended_apps_file + + +@pytest.mark.parametrize('recommended_apps, should_work', [ + ( + ''' + plex: + - emby + - photoprism + palworld: + - terraria + - minecraft + ''', True + ), + ( + ''' + plex + - emby + - photoprism + minio: + - syncthing + - qbittorent + ''', False + ), + ( + '', False + ), +]) +def test_recommended_apps_file(mocker, recommended_apps, should_work): + mock_file = mocker.mock_open(read_data=recommended_apps) + mocker.patch('builtins.open', mock_file) + + if should_work: + assert validate_recommended_apps_file('') is None + else: + with pytest.raises(ValidationErrors): + validate_recommended_apps_file('') diff --git a/catalog_reader/app.py b/catalog_reader/app.py index 22a2e44..eba8be0 100644 --- a/catalog_reader/app.py +++ b/catalog_reader/app.py @@ -176,7 +176,7 @@ def get_app_version_details( # We will normalise questions now so that if they have any references, we render them accordingly # like a field referring to available interfaces on the system - if options.get('normalize_questions', True): + if options.get('normalize_questions', True) and version_data.get('schema'): normalize_questions(version_data, questions_context or get_default_questions_context()) version_data.update({ @@ -191,6 +191,6 @@ def get_app_version_details( version_data.update({ 'human_version': get_human_version(app_metadata['app_version'], app_metadata['version']), 'version': app_metadata['version'], - }) + }) if app_metadata else {} return version_data diff --git a/catalog_reader/pytest/unit/test_app_details.py b/catalog_reader/pytest/unit/test_app_details.py new file mode 100644 index 0000000..423707b --- /dev/null +++ b/catalog_reader/pytest/unit/test_app_details.py @@ -0,0 +1,127 @@ +import pytest + +from catalog_reader.app import get_app_details, get_app_details_impl + + +QUESTION_CONTEXT = { + 'nic_choices': [], + 'gpus': {}, + 'timezones': {'Asia/Saigon': 'Asia/Saigon', 'Asia/Damascus': 'Asia/Damascus'}, + 'node_ip': '192.168.0.10', + 'certificates': [], + 'certificate_authorities': [], + 'system.general.config': {'timezone': 'America/Los_Angeles'}, + 'schema': {'questions': []} +} + + +@pytest.mark.parametrize('item_path, options, items_data', [ + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/chia', + {'retrieve_versions': True}, + { + 'name': 'chia', + 'categories': [], + 'app_readme': None, + 'location': '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/chia', + 'healthy': True, + 'healthy_error': None, + 'home': None, + 'last_update': None, + 'versions': {}, + 'maintainers': [], + 'latest_version': None, + 'latest_app_version': None, + 'latest_human_version': None, + 'recommended': False, + 'title': 'Chia', + 'description': None, + 'tags': [], + 'screenshots': [], + 'sources': [], + } + ), + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/chia', + {'retrieve_versions': False}, + { + 'name': 'chia', + 'categories': [], + 'app_readme': None, + 'location': '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/chia', + 'healthy': True, + 'healthy_error': None, + 'home': None, + 'last_update': None, + 'maintainers': [], + 'latest_version': None, + 'latest_app_version': None, + 'latest_human_version': None, + 'recommended': False, + 'title': 'Chia', + 'description': None, + 'tags': [], + 'screenshots': [], + 'sources': [], + } + ) +]) +def test_get_app_details(mocker, item_path, options, items_data): + mocker.patch('catalog_reader.app.validate_catalog_item', return_value=None) + mocker.patch('catalog_reader.app.get_app_details_impl', return_value={}) + assert get_app_details(item_path, QUESTION_CONTEXT, options) == items_data + + +@pytest.mark.parametrize('item_path,schema,options,expected_data,open_yaml', [ + ( + '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/chia', + 'charts.chia', + {'retrieve_latest_version': True}, + { + 'categories': ['storage', 'crypto'], + 'icon_url': 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg', + 'screenshots': ['https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg'], + 'tags': ['finance'], + 'versions': { + '1.3.37': { + 'healthy': True, + 'supported': True, + 'healthy_error': None, + 'location': '/mnt/mypool/ix-applications/catalogs/' + 'github_com_truenas_charts_git_master/charts/chia/1.3.37', + 'last_update': None, + 'required_features': [], + 'human_version': '1.3.37', + 'version': '1.3.37', + 'app_metadata': None, + 'schema': None, + 'readme': None, + 'changelog': None + } + }, + 'sources': ['https://hub.docker.com/r/emby/embyserver'] + }, + ''' + screenshots: + - 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg' + tags: + - finance + categories: + - storage + - crypto + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''' + ), +]) +def test_get_item_details_impl( + mocker, item_path, schema, options, expected_data, open_yaml, +): + open_file_data = mocker.mock_open(read_data=open_yaml) + mocker.patch('builtins.open', open_file_data) + mocker.patch('os.path.isdir', return_value=True) + mocker.patch('os.listdir', return_value=['1.3.37']) + mocker.patch('catalog_reader.app.validate_catalog_item_version', return_value=None) + mocker.patch('catalog_reader.app.validate_catalog_item', return_value={}) + assert get_app_details_impl(item_path, schema, QUESTION_CONTEXT, options) == expected_data diff --git a/catalog_reader/pytest/unit/test_normalize_questions.py b/catalog_reader/pytest/unit/test_normalize_questions.py new file mode 100644 index 0000000..4fa011f --- /dev/null +++ b/catalog_reader/pytest/unit/test_normalize_questions.py @@ -0,0 +1,517 @@ +import pytest + +from catalog_reader.questions import normalize_question +from catalog_reader.questions_util import ACL_QUESTION, IX_VOLUMES_ACL_QUESTION + + +VERSION_DATA = { + 'location': '/mnt/mypool/ix-applications/catalogs/github_com_truenas_charts_git_master/charts/syncthing/1.0.14', + 'required_features': { + 'normalize/ixVolume', + 'validations/lockedHostPath', + }, + 'chart_metadata': {}, + 'schema': { + 'variable': 'hostNetwork', + 'label': 'Host Network', + 'group': 'Networking', + }, + 'app_readme': 'there is not any', + 'detailed_readme': 'there is not any', + 'changelog': None, +} + +GPU_CHOICES = [ + { + 'vendor': None, + 'description': 'ASPEED Technology, Inc. ASPEED Graphics Family', + 'error': None, + 'vendor_specific_config': {}, + 'gpu_details': { + 'addr': { + 'pci_slot': '0000:03:00.0', + 'domain': '0000', + 'bus': '03', + 'slot': '00' + }, + 'description': 'ASPEED Technology, Inc. ASPEED Graphics Family', + 'devices': [ + { + 'pci_id': '1A03:2000', + 'pci_slot': '0000:03:00.0', + 'vm_pci_slot': 'pci_0000_03_00_0' + } + ], + 'vendor': None, + 'uses_system_critical_devices': True, + 'critical_reason': 'Critical devices found in same IOMMU group: 0000:03:00.0', + 'available_to_host': True + }, + 'pci_slot': '0000:03:00.0' + }, + { + 'vendor': 'NVIDIA', + 'description': 'NVIDIA T400 4GB', + 'error': None, + 'vendor_specific_config': { + 'uuid': 'GPU-059f2adb-93bf-82f6-9f73-366a21bd00c7' + }, + 'gpu_details': { + 'addr': { + 'pci_slot': '0000:17:00.0', + 'domain': '0000', + 'bus': '17', + 'slot': '00' + }, + 'description': 'NVIDIA Corporation TU117GL [T400 4GB]', + 'devices': [ + { + 'pci_id': '10DE:1FF2', + 'pci_slot': '0000:17:00.0', + 'vm_pci_slot': 'pci_0000_17_00_0' + }, + { + 'pci_id': '10DE:10FA', + 'pci_slot': '0000:17:00.1', + 'vm_pci_slot': 'pci_0000_17_00_1' + } + ], + 'vendor': 'NVIDIA', + 'uses_system_critical_devices': False, + 'critical_reason': None, + 'available_to_host': True + }, + 'pci_slot': '0000:17:00.0' + }, + { + 'vendor': 'NVIDIA', + 'description': 'NVIDIA Corporation TU117GL [T400 4GB]', + 'error': 'Unable to locate GPU details from procfs', + 'vendor_specific_config': {}, + 'gpu_details': { + 'addr': { + 'pci_slot': '0000:65:00.0', + 'domain': '0000', + 'bus': '65', + 'slot': '00' + }, + 'description': 'NVIDIA Corporation TU117GL [T400 4GB]', + 'devices': [ + { + 'pci_id': '10DE:1FF2', + 'pci_slot': '0000:65:00.0', + 'vm_pci_slot': 'pci_0000_65_00_0' + }, + { + 'pci_id': '10DE:10FA', + 'pci_slot': '0000:65:00.1', + 'vm_pci_slot': 'pci_0000_65_00_1' + } + ], + 'vendor': 'NVIDIA', + 'uses_system_critical_devices': False, + 'critical_reason': None, + 'available_to_host': False + }, + 'pci_slot': '0000:65:00.0' + } +] + +GPU_DETAIL = [entry for entry in GPU_CHOICES if entry['gpu_details']['available_to_host']] + + +@pytest.mark.parametrize('question, normalized_data, context', [ + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/interface'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/interface'], + 'enum': [], + } + }, + { + 'nic_choices': [], + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/interface'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/interface'], + 'enum': [ + { + 'value': 'ens0', + 'description': "'ens0' Interface" + } + ], + } + }, + { + 'nic_choices': ['ens0'] + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + } + }, + { + 'nic_choices': [], + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/gpu_configuration'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/gpu_configuration'], + 'attrs': [ + { + 'variable': 'use_all_gpus', + 'label': 'Passthrough available (non-NVIDIA) GPUs', + 'description': 'Please select this option to passthrough all (non-NVIDIA) GPUs to the app', + 'schema': { + 'type': 'boolean', + 'default': False, + 'hidden': False, + } + }, + { + 'variable': 'nvidia_gpu_selection', + 'label': 'Select NVIDIA GPU(s)', + 'description': 'Please select the NVIDIA GPU(s) to passthrough to the app', + 'schema': { + 'type': 'dict', + 'additional_attrs': True, + 'hidden': False, + 'attrs': [ + { + 'variable': gpu['gpu_details']['addr']['pci_slot'], + 'label': gpu['description'], + 'description': f'NVIDIA gpu {gpu["vendor_specific_config"]["uuid"]}', + 'schema': { + 'type': 'dict', + 'attrs': [ + { + 'variable': 'uuid', + 'schema': { + 'type': 'string', + 'default': gpu['vendor_specific_config']['uuid'], + 'hidden': True, + } + }, + { + 'variable': 'use_gpu', + 'label': 'Use this GPU', + 'description': 'Use this GPU for the app', + 'schema': { + 'type': 'boolean', + 'default': False, + } + } + ], + } + } + for gpu in GPU_DETAIL + if gpu['vendor'] == 'NVIDIA' and gpu['vendor_specific_config'].get('uuid') + ] + }, + }, + ], + } + }, + { + 'gpu_choices': GPU_CHOICES + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/timezone'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/timezone'], + 'enum': [ + { + 'value': 'Asia/Damascus', + 'description': "'Asia/Damascus' timezone", + }, + { + 'value': 'Asia/Saigon', + 'description': "'Asia/Saigon' timezone", + } + ], + 'default': 'America/Los_Angeles', + } + }, + { + 'timezones': { + 'Asia/Saigon': 'Asia/Saigon', + 'Asia/Damascus': 'Asia/Damascus', + }, + 'system.general.config': { + 'timezone': 'America/Los_Angeles', + } + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/node_bind_ip'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/node_bind_ip'], + 'default': '0.0.0.0', + 'enum': [ + { + 'value': '192.168.0.10', + 'description': "'192.168.0.10' IP Address" + } + ] + } + }, + { + 'ip_choices': {'192.168.0.10'} + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/certificate'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/certificate'], + 'enum': [{'value': None, 'description': 'No Certificate'}], + 'default': None, + 'null': True + } + }, + { + 'certificates': [], + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/certificate_authority'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/certificate_authority'], + 'enum': [ + {'value': None, 'description': 'No Certificate Authority'}, + {'value': None, 'description': 'No Certificate Authority'} + ], + 'default': None, + 'null': True + } + }, + { + 'certificate_authorities': [], + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/port'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/port'], + 'min': 1, + 'max': 65535 + } + }, + { + 'port': [], + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['normalize/acl'], + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['normalize/acl'], + 'attrs': ACL_QUESTION + } + }, + { + 'acl': [] + } + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'dict', + '$ref': ['normalize/ix_volume'], + 'attrs': [ + { + 'variable': 'acl_entries', + 'label': 'ACL Configuration', + 'schema': { + 'type': 'dict', + 'attrs': [] + } + } + ] + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'dict', + '$ref': ['normalize/ix_volume'], + 'attrs': [ + { + 'variable': 'acl_entries', + 'label': 'ACL Configuration', + 'schema': { + 'type': 'dict', + 'attrs': IX_VOLUMES_ACL_QUESTION + } + } + ] + } + }, + {} + ), + ( + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/certificate'], + 'null': False + } + }, + { + 'variable': 'datasetName', + 'label': 'Plots Volume Name', + 'schema': { + 'type': 'string', + 'hidden': True, + '$ref': ['definitions/certificate'], + 'null': False, + 'enum': [], + 'required': True + } + }, + { + 'certificates': [], + } + ) +]) +def test_normalize_question(question, normalized_data, context): + normalize_question(question, VERSION_DATA, context) + assert question == normalized_data diff --git a/catalog_reader/pytest/unit/test_recommended_apps.py b/catalog_reader/pytest/unit/test_recommended_apps.py new file mode 100644 index 0000000..b6cfc2d --- /dev/null +++ b/catalog_reader/pytest/unit/test_recommended_apps.py @@ -0,0 +1,36 @@ +import pytest + +from catalog_reader.recommended_apps import retrieve_recommended_apps + + +@pytest.mark.parametrize('recommended_apps, data', [ + ( + ''' + plex: + - emby + - photoprism + minio: + - syncthing + - qbittorent + ''', + { + 'plex': ['emby', 'photoprism'], + 'minio': ['syncthing', 'qbittorent'] + }, + ), + ( + ''' + plex: + - [ 1923 ] + - photoprism + minio: + - syncthing + - qbittorent + ''', + {}, + ) +]) +def test_retrieve_recommended_apps(mocker, recommended_apps, data): + mock_file = mocker.mock_open(read_data=recommended_apps) + mocker.patch('builtins.open', mock_file) + assert retrieve_recommended_apps('') == data diff --git a/catalog_reader/pytest/unit/test_update_catalog_hashes.py b/catalog_reader/pytest/unit/test_update_catalog_hashes.py new file mode 100644 index 0000000..5cd8b9d --- /dev/null +++ b/catalog_reader/pytest/unit/test_update_catalog_hashes.py @@ -0,0 +1,128 @@ +import pathlib +import pytest + +from apps_exceptions import CatalogDoesNotExist, ValidationErrors +from catalog_reader.scripts.apps_hashes import update_catalog_hashes + + +@pytest.mark.parametrize('path, dir_exists, lib_exists, is_dir, open_yaml, hash, should_work', [ + ( + '/path/to/catalog', + True, + True, + True, + ''' + screenshots: + - 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg' + tags: + - finance + categories: + - storage + - crypto + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''', + {'lib1': 'hash1'}, + True + ), + ( + '/path/to/catalog', + False, + False, + True, + ''' + screenshots: + - 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg' + tags: + - finance + categories: + - storage + - crypto + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''', + {'lib1': 'hash1'}, + False + ), + ( + '/path/to/catalog', + True, + False, + True, + ''' + screenshots: + - 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg' + tags: + - finance + categories: + - storage + - crypto + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''', + {'lib1': 'hash1'}, + False + ), + ( + '/path/to/catalog', + True, + True, + True, + ''' + screenshots: + - 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg' + tags: + - finance + categories: + - storage + - crypto + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''', + None, + True + ), + ( + '/path/to/catalog', + True, + True, + False, + ''' + screenshots: + - 'https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg' + tags: + - finance + categories: + - storage + - crypto + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''', + {'lib1': 'hash1'}, + True + ), +]) +def test_update_catalog_hashes(mocker, path, dir_exists, lib_exists, is_dir, open_yaml, hash, should_work): + mock_file = mocker.mock_open(read_data=open_yaml) + mocker.patch('os.path.exists', return_value=dir_exists) + mocker.patch('pathlib.Path.exists', return_value=lib_exists) + mocker.patch('pathlib.Path.is_dir', return_value=is_dir) + mocker.patch( + 'pathlib.Path.iterdir', + return_value=[ + pathlib.Path('path/to/file1'), + pathlib.Path('path/to/file2') + ] + ) + mocker.patch('catalog_reader.scripts.apps_hashes.get_hashes_of_base_lib_versions', return_value=hash) + mocker.patch('builtins.open', mock_file) + if should_work: + assert update_catalog_hashes(path) is None + else: + with pytest.raises((CatalogDoesNotExist, ValidationErrors)): + update_catalog_hashes(path)