diff --git a/gdk/static/user_input_recipe_schema.json b/gdk/static/user_input_recipe_schema.json new file mode 100644 index 00000000..70a8d1db --- /dev/null +++ b/gdk/static/user_input_recipe_schema.json @@ -0,0 +1,353 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "recipeformatversion": { + "type": "string", + "enum": ["2020-01-25"] + }, + "componentname": { + "type": "string", + "oneOf": [ + {"pattern": "^[a-zA-Z0-9-_.]+$"}, + {"enum": ["{COMPONENT_NAME}"]} + ], + "maxLength": 128 + }, + "componentversion": { + "type": "string", + "maxLength": 64, + "oneOf": [ + { + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?" + }, + { + "enum": ["NEXT_PATCH", "{COMPONENT_VERSION}"] + } + ] + }, + "componentdescription": { + "type": "string" + }, + "componentpublisher": { + "type": "string" + }, + "componentconfiguration": { + "type": "object", + "properties": { + "defaultconfiguration": {} + } + }, + "componentdependencies": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "versionrequirement": { + "type": "string" + }, + "dependencytype": { + "type": "string", + "enum": ["SOFT", "HARD"] + } + } + } + }, + "componenttype": { + "type": "string" + }, + "componentsource": { + "type": "string", + "pattern": "^arn:aws:lambda:.*$" + }, + "manifests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "platform": { + "type": "object", + "properties": { + "os": { + "type": "string" + }, + "architecture": { + "type": "string" + }, + "architecture.detail": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "additionalProperties": false + }, + "lifecycle": { + "type": "object", + "properties": { + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "install": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "requiresprivilege": { + "type": "boolean" + }, + "skipif": { + "type": "string" + }, + "timeout": { + "type": "integer", + "minimum": 0 + }, + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "run": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "requiresprivilege": { + "type": "boolean" + }, + "skipif": { + "type": "string" + }, + "timeout": { + "type": "integer", + "minimum": 0 + }, + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "startup": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "requiresPrivilege": { + "type": "boolean" + }, + "skipif": { + "type": "string" + }, + "timeout": { + "type": "integer", + "minimum": 0 + }, + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "shutdown": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "requiresprivilege": { + "type": "boolean" + }, + "skipif": { + "type": "string" + }, + "timeout": { + "type": "integer", + "minimum": 0 + }, + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "recover": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "requiresprivilege": { + "type": "boolean" + }, + "skipif": { + "type": "string" + }, + "timeout": { + "type": "integer", + "minimum": 0 + }, + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "bootstrap": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "requiresprivilege": { + "type": "boolean" + }, + "timeout": { + "type": "integer", + "minimum": 0 + }, + "setenv": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + }, + "selections": { + "type": "array", + "items": { + "type": "string" + } + }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "unarchive": { + "type": "string", + "enum": [ + "NONE", + "ZIP" + ] + }, + "permission": { + "type": "object", + "properties": { + "read": { + "type": "string", + "enum": [ + "NONE", + "OWNER", + "ALL" + ] + }, + "execute": { + "type": "string", + "enum": [ + "NONE", + "OWNER", + "ALL" + ] + } + }, + "additionalProperties": false + }, + "digest": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "lifecycle": { + "type": "object" + } + }, + "required": ["recipeformatversion", "componentname"], + "additionalProperties": false +} \ No newline at end of file diff --git a/tests/gdk/common/test_RecipeValidator.py b/tests/gdk/common/test_RecipeValidator.py index 8a14a459..35d82a5b 100644 --- a/tests/gdk/common/test_RecipeValidator.py +++ b/tests/gdk/common/test_RecipeValidator.py @@ -2,6 +2,11 @@ from unittest import TestCase import pytest +import yaml +import json +import os + +from jsonschema import validate, exceptions from gdk.common.CaseInsensitive import CaseInsensitiveDict from gdk.common.RecipeValidator import RecipeValidator @@ -17,7 +22,6 @@ def test_load_from_file_json(self): "valid_component_recipe.json").resolve() validator = RecipeValidator(json_file) recipe_data = validator._load_recipe() - print(recipe_data) assert isinstance(recipe_data, CaseInsensitiveDict) assert "manifests" in recipe_data assert "artifacts" in recipe_data["manifests"][0] @@ -53,3 +57,61 @@ def test_load_from_invalid_type(self): with pytest.raises(ValueError) as e: validator._load_recipe() assert "Invalid recipe source type" in e.value.args[0] + + +# =========================== Tests for recipe schema =========================== + +# Load the JSON schema +with open('gdk/static/user_input_recipe_schema.json', 'r') as schema_file: + schema = json.load(schema_file) + +# Get the list of valid recipe files +valid_recipe_files_json = [ + os.path.join('tests/gdk/static/sample_recipes', filename) + for filename in os.listdir('tests/gdk/static/sample_recipes') + if filename.startswith('recipe') and filename.endswith('.json') +] +valid_recipe_files_yaml = [ + os.path.join('tests/gdk/static/sample_recipes', filename) + for filename in os.listdir('tests/gdk/static/sample_recipes') + if filename.startswith('recipe') and filename.endswith('.yaml') +] +# Get the list of invalid recipe files +invalid_recipe_files = [ + os.path.join('tests/gdk/static/sample_recipes', filename) + for filename in os.listdir('tests/gdk/static/sample_recipes') + if filename.startswith('invalid') and filename.endswith('.json') +] + + +# Function to recursively convert keys to lowercase +def convert_keys_to_lowercase(input_dict): + if isinstance(input_dict, dict): + return {key.lower(): convert_keys_to_lowercase(value) for key, value in input_dict.items()} + elif isinstance(input_dict, list): + return [convert_keys_to_lowercase(item) for item in input_dict] + else: + return input_dict + + +# Define the test function +@pytest.mark.parametrize("recipe_file", valid_recipe_files_json) +def test_valid_recipes_json(recipe_file): + with open(recipe_file, 'r') as recipe_file: + recipe_data = json.load(recipe_file) + validate(instance=convert_keys_to_lowercase(recipe_data), schema=schema) + + +@pytest.mark.parametrize("recipe_file", valid_recipe_files_yaml) +def test_valid_recipes_yaml(recipe_file): + with open(recipe_file, 'r') as recipe_file: + recipe_data = yaml.safe_load(recipe_file) + validate(instance=convert_keys_to_lowercase(recipe_data), schema=schema) + + +@pytest.mark.parametrize("recipe_file", invalid_recipe_files) +def test_invalid_recipes_raise_error(recipe_file): + with open(recipe_file, 'r') as recipe_file: + recipe_data = json.load(recipe_file) + with pytest.raises(exceptions.ValidationError): + validate(instance=convert_keys_to_lowercase(recipe_data), schema=schema) diff --git a/tests/gdk/static/sample_recipes/invalid_recipe_extra_property.json b/tests/gdk/static/sample_recipes/invalid_recipe_extra_property.json new file mode 100644 index 00000000..c4fde962 --- /dev/null +++ b/tests/gdk/static/sample_recipes/invalid_recipe_extra_property.json @@ -0,0 +1,29 @@ +{ + "RecipeFormatVersion": "2020-01-25", + "ComponentName": "ggV2HelloWorld", + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentDescription": "This is simple Hello World component written in Python.", + "ComponentPublisher": "{COMPONENT_AUTHOR}", + "ComponentConfiguration": { + "DefaultConfiguration": { + "Message": "World" + } + }, + "Manifests": [ + { + "Platform": { + "os": "all" + }, + "Artifacts": [ + { + "URI": "s3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip", + "Unarchive": "ZIP" + } + ], + "Lifecycle": { + "run": "python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py {configuration:/Message}" + } + } + ], + "NewField": "extra data" +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/invalid_recipe_incorrect_value.json b/tests/gdk/static/sample_recipes/invalid_recipe_incorrect_value.json new file mode 100644 index 00000000..a04a71e6 --- /dev/null +++ b/tests/gdk/static/sample_recipes/invalid_recipe_incorrect_value.json @@ -0,0 +1,28 @@ +{ + "RecipeFormatVersion": "2020-01-25", + "ComponentName": "ggV2HelloWorld", + "ComponentVersion": "32.7", + "ComponentDescription": "This is simple Hello World component written in Python.", + "ComponentPublisher": "{COMPONENT_AUTHOR}", + "ComponentConfiguration": { + "DefaultConfiguration": { + "Message": "World" + } + }, + "Manifests": [ + { + "Platform": { + "os": "all" + }, + "Artifacts": [ + { + "URI": "s3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip", + "Unarchive": "ZYP" + } + ], + "Lifecycle": { + "run": "python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py {configuration:/Message}" + } + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/invalid_recipe_mismatched_type.json b/tests/gdk/static/sample_recipes/invalid_recipe_mismatched_type.json new file mode 100644 index 00000000..6e8d3010 --- /dev/null +++ b/tests/gdk/static/sample_recipes/invalid_recipe_mismatched_type.json @@ -0,0 +1,32 @@ +{ + "RecipeFormatVersion": "2020-01-25", + "ComponentName": 27, + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentDescription": "This is simple Hello World component written in Python.", + "ComponentPublisher": "{COMPONENT_AUTHOR}", + "ComponentConfiguration": { + "DefaultConfiguration": { + "Message": "World" + } + }, + "Manifests": [ + { + "Platform": { + "os": "all" + }, + "Artifacts": [ + { + "URI": "s3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip", + "Unarchive": "ZIP", + "Permission": { + "read": "OWNER", + "execute": "ALL" + } + } + ], + "Lifecycle": { + "run": "python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py {configuration:/Message}" + } + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/invalid_recipe_missing_required_property.json b/tests/gdk/static/sample_recipes/invalid_recipe_missing_required_property.json new file mode 100644 index 00000000..d6455067 --- /dev/null +++ b/tests/gdk/static/sample_recipes/invalid_recipe_missing_required_property.json @@ -0,0 +1,27 @@ +{ + "ComponentName": "ggV2HelloWorld", + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentDescription": "This is simple Hello World component written in Python.", + "ComponentPublisher": "{COMPONENT_AUTHOR}", + "ComponentConfiguration": { + "DefaultConfiguration": { + "Message": "World" + } + }, + "Manifests": [ + { + "Platform": { + "os": "all" + }, + "Artifacts": [ + { + "URI": "s3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip", + "Unarchive": "ZIP" + } + ], + "Lifecycle": { + "run": "python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py {configuration:/Message}" + } + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/invalid_recipe_misspelling_property.json b/tests/gdk/static/sample_recipes/invalid_recipe_misspelling_property.json new file mode 100644 index 00000000..ac2256a9 --- /dev/null +++ b/tests/gdk/static/sample_recipes/invalid_recipe_misspelling_property.json @@ -0,0 +1,28 @@ +{ + "RecipeFormatVersion": "2020-01-25", + "ComponentMeme": "ggV2HelloWorld", + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentDescription": "This is simple Hello World component written in Python.", + "ComponentPublisher": "{COMPONENT_AUTHOR}", + "ComponentConfiguration": { + "DefaultConfiguration": { + "Message": "World" + } + }, + "Manifests": [ + { + "Platform": { + "os": "all" + }, + "Artifacts": [ + { + "URI": "s3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip", + "Unarchive": "ZIP" + } + ], + "Lifecycle": { + "run": "python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py {configuration:/Message}" + } + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/recipe_case_insensitive.json b/tests/gdk/static/sample_recipes/recipe_case_insensitive.json new file mode 100644 index 00000000..8e9a2bcc --- /dev/null +++ b/tests/gdk/static/sample_recipes/recipe_case_insensitive.json @@ -0,0 +1,32 @@ +{ + "recipeFormatVersion": "2020-01-25", + "cOmPonentName": "ggV2HelloWorld", + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentDescription": "This is simple Hello World component written in Python.", + "ComponentPublisher": "{COMPONENT_AUTHOR}", + "ComponentConfiguration": { + "DefaultCONfiguration": { + "MessaGe": "World" + } + }, + "ManifestS": [ + { + "PLATFORM": { + "os": "all" + }, + "ArtIfacts": [ + { + "uRi": "s3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip", + "uNarchive": "ZIP", + "Permission": { + "read": "OWNER", + "execute": "ALL" + } + } + ], + "LifeCycle": { + "run": "python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py {configuration:/Message}" + } + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/recipe_case_insensitive.yaml b/tests/gdk/static/sample_recipes/recipe_case_insensitive.yaml new file mode 100644 index 00000000..723ffd64 --- /dev/null +++ b/tests/gdk/static/sample_recipes/recipe_case_insensitive.yaml @@ -0,0 +1,21 @@ +recipeFormatVersion: '2020-01-25' +cOmPonentName: ggV2HelloWorld +ComponentVersion: '{COMPONENT_VERSION}' +ComponentDesCription: This is simple Hello World component written in Python. +ComponentPubLISher: '{COMPONENT_AUTHOR}' +ComponentConFIGuration: + DefaultCONfiguration: + MessaGe: World +ManifestS: + - PLATFORM: + oS: all + ArtIfacts: + - uRi: 's3://BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION/ggV2HelloWorld.zip' + uNarchive: ZIP + PermisSion: + rEad: OWNER + execuTe: ALL + LifeCycle: + ruN: >- + python3 -u {artifacts:decompressedPath}/ggV2HelloWorld/main.py + {configuration:/Message} diff --git a/tests/gdk/static/sample_recipes/recipe_gg_client_device_auth.json b/tests/gdk/static/sample_recipes/recipe_gg_client_device_auth.json new file mode 100644 index 00000000..2a3f8913 --- /dev/null +++ b/tests/gdk/static/sample_recipes/recipe_gg_client_device_auth.json @@ -0,0 +1,27 @@ +{ + "RecipeFormatVersion": "2020-01-25", + "ComponentName": "{COMPONENT_NAME}", + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentType": "aws.greengrass.plugin", + "ComponentDescription": "The client device auth component authenticates client devices and authorizes client device actions, so client devices can connect to your Greengrass core device. This component creates the certificate vended by the local MQTT broker.", + "ComponentPublisher": "{COMPONENT_PUBLISHER}", + "ComponentDependencies": { + "aws.greengrass.Nucleus": { + "VersionRequirement": ">=2.2.0 <2.10.0", + "DependencyType": "SOFT" + } + }, + "Manifests": [ + { + "Platform": { + "os": "*" + }, + "Lifecycle": {}, + "Artifacts": [ + { + "URI": "s3://aws.greengrass.clientdevices.Auth.jar" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/recipe_gg_disk_spooler.json b/tests/gdk/static/sample_recipes/recipe_gg_disk_spooler.json new file mode 100644 index 00000000..c48dbd1c --- /dev/null +++ b/tests/gdk/static/sample_recipes/recipe_gg_disk_spooler.json @@ -0,0 +1,27 @@ +{ + "RecipeFormatVersion": "2020-01-25", + "ComponentName": "{COMPONENT_NAME}", + "ComponentVersion": "{COMPONENT_VERSION}", + "ComponentType": "aws.greengrass.plugin", + "ComponentDescription": "The Disk Spooler component stores MQTT messages destined for AWS IoT Core in a database, as opposed to in memory, when offline.", + "ComponentPublisher": "{COMPONENT_PUBLISHER}", + "ComponentDependencies": { + "aws.greengrass.Nucleus": { + "VersionRequirement": ">=2.2.0 <2.11.0", + "DependencyType": "SOFT" + } + }, + "Manifests": [ + { + "Platform": { + "os": "*" + }, + "Lifecycle": {}, + "Artifacts": [ + { + "URI": "s3://aws.greengrass.DiskSpooler.jar" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/recipe_gg_nucleus.yaml b/tests/gdk/static/sample_recipes/recipe_gg_nucleus.yaml new file mode 100644 index 00000000..e6b1084d --- /dev/null +++ b/tests/gdk/static/sample_recipes/recipe_gg_nucleus.yaml @@ -0,0 +1,62 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +--- +RecipeFormatVersion: '2020-01-25' +ComponentName: aws.greengrass.Nucleus +ComponentType: aws.greengrass.nucleus +ComponentDescription: Core functionality for device side orchestration of deployments and lifecycle management for execution of Greengrass components and applications. This includes features such as starting, stopping, and monitoring execution of components and apps, inter-process communication server for communication between components, component installation and configuration management. This is a fundamental cornerstone of open-sourcing Greengrass, providing documentation and ability to debug Greengrass Core. +ComponentPublisher: AWS +ComponentVersion: '2.12.0' +ComponentConfiguration: + DefaultConfiguration: + iotDataEndpoint: "" + iotCredEndpoint: "" + greengrassDataPlanePort: 8443 + awsRegion: "" + iotRoleAlias: "" + mqtt: {} + networkProxy: {} + runWithDefault: {} + deploymentPollingFrequencySeconds: 15 + componentStoreMaxSizeBytes: 10000000000 + platformOverride: {} +Manifests: + - Platform: + os: darwin + Lifecycle: + bootstrap: + RequiresPrivilege: true + script: |- + + set -eu + KERNEL_ROOT="{kernel:rootPath}" + UNPACK_DIR="{artifacts:decompressedPath}/aws.greengrass.nucleus" + rm -r "$KERNEL_ROOT"/alts/current/* + echo "{configuration:/jvmOptions}" > "$KERNEL_ROOT/alts/current/launch.params" + ln -sf "$UNPACK_DIR" "$KERNEL_ROOT/alts/current/distro" + exit 100 + + - Platform: + os: linux + Lifecycle: + bootstrap: + RequiresPrivilege: true + script: |- + + set -eu + KERNEL_ROOT="{kernel:rootPath}" + UNPACK_DIR="{artifacts:decompressedPath}/aws.greengrass.nucleus" + rm -r "$KERNEL_ROOT"/alts/current/* + echo "{configuration:/jvmOptions}" > "$KERNEL_ROOT/alts/current/launch.params" + ln -sf "$UNPACK_DIR" "$KERNEL_ROOT/alts/current/distro" + exit 100 + - Platform: + os: windows + Lifecycle: + bootstrap: + RequiresPrivilege: true + script: >- + copy "{kernel:rootPath}\alts\current\distro\bin\greengrass.xml" "{artifacts:decompressedPath}\aws.greengrass.nucleus\bin\greengrass.xml"& del /q "{kernel:rootPath}\alts\current\*"&& for /d %x in ("{kernel:rootPath}\alts\current\*") do @rd /s /q "%x"&& echo {configuration:/jvmOptions} > "{kernel:rootPath}\alts\current\launch.params"&& mklink /d "{kernel:rootPath}\alts\current\distro" "{artifacts:decompressedPath}\aws.greengrass.nucleus"&& exit 100 \ No newline at end of file diff --git a/tests/gdk/static/sample_recipes/recipe_gg_secret_manager.yaml b/tests/gdk/static/sample_recipes/recipe_gg_secret_manager.yaml new file mode 100644 index 00000000..b6657a17 --- /dev/null +++ b/tests/gdk/static/sample_recipes/recipe_gg_secret_manager.yaml @@ -0,0 +1,13 @@ +--- +RecipeFormatVersion: '2020-01-25' +ComponentName: aws.greengrass.SecretManager +ComponentDescription: AWS Greengrass Secret Manager +ComponentPublisher: AWS +ComponentVersion: '0.0.0' +ComponentType: 'aws.greengrass.plugin' +ComponentConfiguration: + DefaultConfiguration: + cloudSecrets: [] +Manifests: + - Artifacts: + - URI: s3://gg-dev-artifacts-$stage/$componentName/$version/aws.greengrass.SecretManager.jar \ No newline at end of file