Skip to content

Commit

Permalink
Merge pull request #384 from brightsparklabs/feature/APPCLI-112-valid…
Browse files Browse the repository at this point in the history
…ate-config-files

Feature/appcli 112 validate config files
  • Loading branch information
benjamin-wiffen authored Oct 16, 2023
2 parents 1ecad4b + de46a73 commit 7d43f04
Show file tree
Hide file tree
Showing 20 changed files with 285 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The changelog is applicable from version `1.0.0` onwards.

- APPCLI-133: Add fix for Git repository ownership issue to the AppCli Dockerfile.
- APED-67: Add support for running `NullOrchestrator` apps on Windows OS.
- APPCLI-112: Autovalidate config file when a `.schema.json` file is provided.
- APPCLI-115: Enable automerge for dependabot PRs.

### Fixed
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,32 @@ def main():
- `resources/templates/baseline` - for templates which the end user **is not** expected to modify.
- `resources/templates/configurable` - for templates which the end user is expected to modify.

#### Schema validation

Configuration files will be automatically validated against provided schema files whenever
`configure apply` is run.
Validation is done with [jsonschema](https://json-schema.org/) and is only available for `yaml/yml`
and `json/jsn` files.
The JSON schema file must match the name of the file to validate with a suffix of `.schema.json.`.
It must be placed in the same directory as the file to validate,
The `settings.yml`, `stack_settings.yml` file, and any files in the `resource/templates` or
`resources/overrides` directory can be validated.

```yaml
# resources/templates/configurable/my-config.yml
foobar: 5
```
```json
# resources/templates/configurable/my-config.yml.schema.json
{
"type": "object",
"properties" : {
"foobar" : {"type": "number"}
}
}
```

#### Application context files

Template files are templated with Jinja2. The 'data' passed into the templating engine
Expand Down
6 changes: 6 additions & 0 deletions appcli/commands/configure_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ def init(ctx):
logger.debug("Running pre-configure init hook")
hooks.pre_configure_init(ctx)

# Validate the configuration schema.
logger.debug("Validating configuration files")
ConfigurationManager(
cli_context, self.cli_configuration
).validate_configuration()

# Initialise configuration directory
logger.debug("Initialising configuration directory")
ConfigurationManager(
Expand Down
84 changes: 79 additions & 5 deletions appcli/configuration_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# # -*- coding: utf-8 -*-

"""
Manages configuration.
Manages the configuration of the application.
________________________________________________________________________________
Created by brightSPARK Labs
Expand All @@ -19,20 +19,22 @@
from pathlib import Path
from typing import Iterable

import jsonschema
import yaml

# vendor libraries
from jinja2 import StrictUndefined, Template

from appcli.crypto import crypto

# local libraries
from appcli.crypto import crypto
from appcli.crypto.crypto import decrypt_values_in_file
from appcli.functions import error_and_exit, print_header
from appcli.git_repositories.git_repositories import (
ConfigurationGitRepository,
GeneratedConfigurationGitRepository,
)
from appcli.logger import logger
from appcli.models.cli_context import CliContext
from appcli.models.cli_context import SCHEMA_SUFFIX, CliContext
from appcli.models.configuration import Configuration
from appcli.variables_manager import VariablesManager

Expand All @@ -43,6 +45,14 @@
METADATA_FILE_NAME = "metadata-configure-apply.json"
""" Name of the file holding metadata from running a configure (relative to the generated configuration directory) """

FILETYPE_LOADERS = {
".json": json.load,
".jsn": json.load,
".yaml": yaml.safe_load,
".yml": yaml.safe_load,
}
""" The supported filetypes for the validator. """

# ------------------------------------------------------------------------------
# PUBLIC CLASSES
# ------------------------------------------------------------------------------
Expand All @@ -59,6 +69,39 @@ def __init__(self, cli_context: CliContext, configuration: Configuration):
self.cli_configuration: Configuration = configuration
self.variables_manager: VariablesManager = cli_context.get_variables_manager()

def validate_configuration(self):
"""Validates all settings files that have associated schema files."""

# Define all the config directories and files.
settings_schema: Path = self.cli_context.get_app_configuration_file_schema()
stack_settings_schema: Path = (
self.cli_context.get_stack_configuration_file_schema()
)
overrides_dir: Path = self.cli_context.get_baseline_template_overrides_dir()
templates_dir: Path = self.cli_context.get_configurable_templates_dir()

# Parse the directories to get the schema files.
schema_files = []
if settings_schema.is_file():
schema_files.append(settings_schema)
if stack_settings_schema.is_file():
schema_files.append(stack_settings_schema)
schema_files.extend(overrides_dir.glob(f"**/*{SCHEMA_SUFFIX}"))
schema_files.extend(templates_dir.glob(f"**/*{SCHEMA_SUFFIX}"))

for schema_file in schema_files:
# Take out the `schema` suffix to get the original config file.
# NOTE: As we only need to remove part of the suffix, it is easier to convert to string
# and just remove part of the substring.
config_file: Path = Path(str(schema_file).removesuffix(SCHEMA_SUFFIX))
if not config_file.exists():
logger.warning(f"Found {schema_file} but no matching config file.")
continue

# Load and validate the config/schema.
logger.debug(f"Found schema for {config_file}. Validating...")
self.__validate_schema(config_file, schema_file)

def initialise_configuration(self):
"""Initialises the configuration repository"""

Expand Down Expand Up @@ -206,6 +249,37 @@ def set_variable(self, variable: str, value: any):
def get_stack_variable(self, variable: str):
return self.variables_manager.get_stack_variable(variable)

def __validate_schema(self, config_file: Path, schema_file: Path) -> None:
"""Attempt to validate a config file against a provided schema file.
The function will try and determine the file content based off the extension.
It will throw if the extension is unknown or the contents do not match the expected format.
Args:
config_file (Path): Path to the config file to validate.
schema_file (Path): Path to the schema file.
"""
# Check we actually have a loader for the filetype.
try:
loader = FILETYPE_LOADERS[config_file.suffix]
except KeyError:
error_and_exit(
f"The `{config_file}` file does not have an associated loader function."
f"Check that the suffix is one of the supported types: {[k for k in FILETYPE_LOADERS.keys()]}"
)

# Load the files.
data = loader(open(config_file, "r"))
schema = json.load(open(schema_file, "r"))

# Validate the config.
try:
jsonschema.validate(instance=data, schema=schema)
logger.debug(
f"The configuration file `{config_file}` matched the provided schema."
)
except jsonschema.exceptions.ValidationError as e:
error_and_exit(f"Validation of `{config_file}` failed at:\n{e}")

def __create_new_configuration_branch_and_files(self):
app_version: str = self.cli_context.app_version
app_version_branch: str = self.config_repo.generate_branch_name(app_version)
Expand Down Expand Up @@ -256,11 +330,11 @@ def __seed_configuration_dir(self):
os.makedirs(target_app_configuration_file.parent, exist_ok=True)
shutil.copy2(seed_app_configuration_file, target_app_configuration_file)

# Copy in the stack configuration file.
stack_configuration_file = self.cli_configuration.stack_configuration_file
target_stack_configuration_file = (
self.cli_context.get_stack_configuration_file()
)
# Copy in the stack configuration file
if stack_configuration_file.is_file():
shutil.copy2(stack_configuration_file, target_stack_configuration_file)

Expand Down
36 changes: 36 additions & 0 deletions appcli/models/cli_context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
#!/usr/bin/env python3
# # -*- coding: utf-8 -*-

"""
Outlines the CLI context.
________________________________________________________________________________
Created by brightSPARK Labs
www.brightsparklabs.com
"""

# standard libraries
from pathlib import Path
from typing import Dict, Iterable, NamedTuple, Tuple
Expand All @@ -13,6 +21,18 @@
from appcli.logger import logger
from appcli.variables_manager import VariablesManager

# ------------------------------------------------------------------------------
# CONSTANTS
# ------------------------------------------------------------------------------

SCHEMA_SUFFIX = ".schema.json"
""" The suffix for the validation schema files. """


# ------------------------------------------------------------------------------
# PUBLIC CLASSES
# ------------------------------------------------------------------------------


class CliContext(NamedTuple):
"""Shared context from a run of the CLI."""
Expand Down Expand Up @@ -131,6 +151,14 @@ def get_app_configuration_file(self) -> Path:
"""
return self.configuration_dir.joinpath("settings.yml")

def get_app_configuration_file_schema(self) -> Path:
"""Get the location of the configuration schema file
Returns:
Path: location of the configuration schema file
"""
return self.configuration_dir.joinpath("settings.yml", SCHEMA_SUFFIX)

def get_stack_configuration_file(self) -> Path:
"""Get the location of the configuration file
Expand All @@ -139,6 +167,14 @@ def get_stack_configuration_file(self) -> Path:
"""
return self.configuration_dir.joinpath("stack-settings.yml")

def get_stack_configuration_file_schema(self) -> Path:
"""Get the location of the configuration schema file
Returns:
Path: location of the configuration schema file
"""
return self.configuration_dir.joinpath("stack-settings.yml", SCHEMA_SUFFIX)

def get_baseline_template_overrides_dir(self) -> Path:
"""Get the directory of the configuration template overrides
Expand Down
8 changes: 8 additions & 0 deletions appcli/models/configuration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
#!/usr/bin/env python3
# # -*- coding: utf-8 -*-

"""
Configuration for building the CLI.
________________________________________________________________________________
Created by brightSPARK Labs
www.brightsparklabs.com
"""

# standard libraries
import inspect
import os
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def get_version():
"dataclasses-json==0.5.7",
"deepdiff==6.5.0",
"GitPython==3.1.35",
"jsonschema==4.19.1",
"jinja2==3.1.2",
"pre-commit==3.3.3",
"pycryptodome==3.18.0",
Expand Down
1 change: 1 addition & 0 deletions tests/configuration_manager/invalid_resources/settings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
---
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
foobar: "5" # <- This should be int.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "object",
"properties" : {
"foobar" : {"type": "number"}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"alphabet": {
"a": 1,
"b": 2
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"type": "object",
"properties" : {
"alphabet" : {
"type": "object",
"properties" : {
"a": {"type": "number"},
"b": {"type": "number"}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
alphabet:
a: 1
b: 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"type": "object",
"properties" : {
"alphabet" : {
"type": "object",
"properties" : {
"a": {"type": "number"},
"b": {"type": "number"}
}
}
}
}
Loading

0 comments on commit 7d43f04

Please sign in to comment.