From 297b713ad4a6c989dc9ebbc3dbea9969bf68e588 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 7 Jun 2025 07:34:18 +0200 Subject: [PATCH 1/3] Init with get method Python TSToy resource --- samples/python/resources/first/.gitignore | 166 +++++++++++++ samples/python/resources/first/README.md | 3 + samples/python/resources/first/build.ps1 | 95 +++++++ .../resources/first/src/commands/get.py | 51 ++++ .../resources/first/src/config/config.py | 233 ++++++++++++++++++ .../resources/first/src/config/manager.py | 171 +++++++++++++ .../resources/first/src/core/console.py | 12 + samples/python/resources/first/src/main.py | 15 ++ .../resources/first/src/models/models.py | 25 ++ .../python/resources/first/src/pyproject.toml | 21 ++ .../resources/first/src/resources/strings.py | 7 + .../resources/first/src/schema/schema.py | 48 ++++ .../resources/first/src/utils/logger.py | 109 ++++++++ .../first/tests/acceptance.tests.ps1 | 81 ++++++ 14 files changed, 1037 insertions(+) create mode 100644 samples/python/resources/first/.gitignore create mode 100644 samples/python/resources/first/README.md create mode 100644 samples/python/resources/first/build.ps1 create mode 100644 samples/python/resources/first/src/commands/get.py create mode 100644 samples/python/resources/first/src/config/config.py create mode 100644 samples/python/resources/first/src/config/manager.py create mode 100644 samples/python/resources/first/src/core/console.py create mode 100644 samples/python/resources/first/src/main.py create mode 100644 samples/python/resources/first/src/models/models.py create mode 100644 samples/python/resources/first/src/pyproject.toml create mode 100644 samples/python/resources/first/src/resources/strings.py create mode 100644 samples/python/resources/first/src/schema/schema.py create mode 100644 samples/python/resources/first/src/utils/logger.py create mode 100644 samples/python/resources/first/tests/acceptance.tests.ps1 diff --git a/samples/python/resources/first/.gitignore b/samples/python/resources/first/.gitignore new file mode 100644 index 0000000..863ea61 --- /dev/null +++ b/samples/python/resources/first/.gitignore @@ -0,0 +1,166 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. For PyCharm +# Community Edition, use 'PyCharm CE' in the configurations. +.idea/ + +# Build output +python.zip + +# uv +.uv/ +uv.lock \ No newline at end of file diff --git a/samples/python/resources/first/README.md b/samples/python/resources/first/README.md new file mode 100644 index 0000000..3b4bcce --- /dev/null +++ b/samples/python/resources/first/README.md @@ -0,0 +1,3 @@ +# Python TSToy resource + +To be filled in diff --git a/samples/python/resources/first/build.ps1 b/samples/python/resources/first/build.ps1 new file mode 100644 index 0000000..44d662f --- /dev/null +++ b/samples/python/resources/first/build.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param ( + [ValidateSet('build', 'test')] + [string]$mode = 'build', + [string]$name = 'pythontstoy' +) + +function Build-PythonProject { + [CmdletBinding()] + param ( + [Parameter()] + [string]$Name + ) + begin { + Write-Verbose -Message "Starting Python project build process" + + $sourceDir = Join-Path -Path $PSScriptRoot -ChildPath 'src' + $outputDir = Join-Path -Path $PSScriptRoot -ChildPath 'dist' + } + + process { + Install-Uv + + Push-Location -Path $sourceDir -ErrorAction Stop + + try { + # Create virtual environment + & uv venv + + # Activate it + & .\.venv\Scripts\activate.ps1 + + # Sync all the dependencies + & uv sync + + # Create executable + $pyInstallerArgs = @( + 'main.py', + '-F', + '--clean', + '--distpath', $outputDir, + '--name', $Name + ) + & pyinstaller.exe @pyInstallerArgs + } + finally { + deactivate + Pop-Location -ErrorAction Ignore + } + } + + end { + Write-Verbose -Message "Python project build process completed" + } +} + +function Install-Uv() { + begin { + Write-Verbose -Message "Installing Uv dependencies" + } + + process { + if ($IsWindows) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + Write-Verbose -Message "Installing uv package manager on Windows" + Invoke-RestMethod https://astral.sh/uv/install.ps1 | Invoke-Expression + + } + $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" + } + elseif ($IsLinux) { + curl -LsSf https://astral.sh/uv/install.sh | sh + } + } + + end { + Write-Verbose -Message "Uv installation process completed" + } +} + +switch ($mode) { + 'build' { + Build-PythonProject -Name $name + } + 'test' { + Build-PythonProject -Name $name + + $testContainer = New-PesterContainer -Path (Join-Path 'tests' 'acceptance.tests.ps1') -Data @{ + Name = $name + } + + Invoke-Pester -Container $testContainer -Output Detailed + } + +} \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/get.py b/samples/python/resources/first/src/commands/get.py new file mode 100644 index 0000000..f66ec61 --- /dev/null +++ b/samples/python/resources/first/src/commands/get.py @@ -0,0 +1,51 @@ +import click +import json +import sys +from utils.logger import Logger +from config.config import Settings + +# Create a logger instance for this module +logger = Logger() + +@click.command('get') +@click.option('--input', 'input_json', + help='JSON input data with required scope field', + required=True, + type=str) +def get_command(input_json): + """Get command that retrieves configuration based on scope from validated JSON input""" + + try: + try: + data = json.loads(input_json) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON format - {e}", "get_command", input_data=input_json) + sys.exit(1) + + if not Settings.validate_input(data, logger): + sys.exit(1) + + settings = Settings.from_dict(data) + + if not settings.validate(): + logger.error(f"Settings validation failed for scope: {settings.scope}", "get_command", scope=settings.scope) + sys.exit(1) + + logger.info(f"Processing settings for scope: {settings.scope}", "get_command", scope=settings.scope) + + result_settings, err = settings.enforce() + if err: + logger.error(f"Failed to enforce settings for scope {settings.scope}", "get_command", + scope=settings.scope, error_details=str(err)) + sys.exit(1) + + result_settings.print_config() + logger.info(f"Settings retrieved successfully for scope: {settings.scope}", "get_command", + scope=settings.scope) + + return result_settings + + except Exception as e: + logger.critical(f"Unexpected error in get_command", "get_command", + error_type=type(e).__name__, error_message=str(e)) + sys.exit(1) \ No newline at end of file diff --git a/samples/python/resources/first/src/config/config.py b/samples/python/resources/first/src/config/config.py new file mode 100644 index 0000000..e015991 --- /dev/null +++ b/samples/python/resources/first/src/config/config.py @@ -0,0 +1,233 @@ +from dataclasses import dataclass, field +from typing import Literal, Optional, Dict, Any, Tuple, Set +from pathlib import Path +from config.manager import ConfigManager +from utils.logger import Logger +from schema.schema import validate_resource, RESOURCE_SCHEMA +import json + +@dataclass +class Settings: + scope: Literal["user", "machine"] + _exist: bool = True + updateAutomatically: Optional[bool] = None + updateFrequency: Optional[int] = None + config_path: str = field(default="", init=False, repr=False) + _provided_properties: Set[str] = field(default_factory=set, init=False, repr=False) + + def __post_init__(self): + """Initialize dependencies after dataclass creation""" + self.config_manager = ConfigManager() + self.logger = Logger() + + @classmethod + def validate_input(cls, data: Dict[str, Any], logger: Logger) -> bool: + is_valid, validation_error = validate_resource(data) + if not is_valid: + logger.error(f"Input validation failed: {validation_error}", "Settings") + return False + + allowed_properties = list(RESOURCE_SCHEMA["properties"].keys()) + logger.info(f"Valid properties per schema: {allowed_properties}", "Settings") + logger.info(f"Provided properties: {list(data.keys())}", "Settings") + + return True + + def get_config_path(self) -> Tuple[str, Optional[Exception]]: + try: + if not self.scope: + return "", ValueError("scope is required") + + if self.scope == "machine": + config_path = self.config_manager.get_machine_config_path() + elif self.scope == "user": + config_path = self.config_manager.get_user_config_path() + else: + return "", ValueError(f"invalid scope: {self.scope}") + + self.config_path = str(config_path) + return self.config_path, None + + except Exception as e: + return "", e + + def get_config_map(self) -> Tuple[Optional[Dict[str, Any]], Optional[Exception]]: + try: + config_path, err = self.get_config_path() + if err: + return None, err + + if not Path(config_path).exists(): + self.logger.info(f"Config file not found: {config_path}", "Settings") + return {}, None # Return empty map if file doesn't exist + + # Load configuration + config_data = self.config_manager.load_config_file(Path(config_path)) + if config_data is None: + self.logger.info(f"Config file loaded but empty: {config_path}", "Settings") + return {}, None + + self.logger.info(f"Config loaded successfully: {json.dumps(config_data)}", "Settings") + return config_data, None + + except Exception as e: + return None, e + + def get_config_settings(self) -> Tuple[Optional['Settings'], Optional[Exception]]: + try: + config_map, err = self.get_config_map() + if err: + return None, err + + if not config_map: + self.logger.info("No configuration found, returning with _exist=False", "Settings") + return Settings(scope=self.scope, _exist=False), None + + settings = Settings(scope=self.scope, _exist=True) + + if 'updates' in config_map: + updates = config_map['updates'] + + if 'updateAutomatically' in updates: + settings.updateAutomatically = bool(updates['updateAutomatically']) + + if 'updateFrequency' in updates: + settings.updateFrequency = int(updates['updateFrequency']) + + self.logger.info(f"Loaded settings - updateAutomatically: {settings.updateAutomatically}, " + + f"updateFrequency: {settings.updateFrequency}", "Settings") + else: + self.logger.info("No 'updates' section found in config file", "Settings") + + return settings, None + + except Exception as e: + self.logger.error(f"Error in get_config_settings: {str(e)}", "Settings") + return None, e + + def enforce(self) -> Tuple[Optional['Settings'], Optional[Exception]]: + try: + current_settings, err = self.get_config_settings() + if err: + return None, err + + if not current_settings: + self.logger.info("Failed to get settings, returning with scope only", "Settings") + return Settings(scope=self.scope, _exist=True), None + + if not Path(self.config_path).exists(): + self.logger.info("Config file doesn't exist, returning scope only with _exist=false", "Settings") + result = Settings(scope=self.scope, _exist=False) + result.updateAutomatically = None + result.updateFrequency = None + return result, None + + self.logger.info("Current settings from config file: " + + f"updateAutomatically={current_settings.updateAutomatically}, " + + f"updateFrequency={current_settings.updateFrequency}", "Settings") + + if self._has_properties_to_validate(): + validated_settings = self._validate_settings(current_settings) + return validated_settings, None + else: + self.logger.info("No properties to validate, returning all: " + + f"updateAutomatically={current_settings.updateAutomatically}, " + + f"updateFrequency={current_settings.updateFrequency}", "Settings") + + return current_settings, None + + except Exception as e: + self.logger.error(f"Error in enforce: {str(e)}", "Settings") + return None, e + + def _has_properties_to_validate(self) -> bool: + properties = set(self._provided_properties) + if 'scope' in properties: + properties.remove('scope') + if '_exist' in properties: + properties.remove('_exist') + + has_properties = len(properties) > 0 + self.logger.info(f"Properties to validate: {properties}, has_properties: {has_properties}", "Settings") + return has_properties + + def _validate_settings(self, current_settings: 'Settings') -> 'Settings': + validated_settings = Settings( + scope=self.scope, + _exist=True, # Start with True, will be set to False if validation fails + updateAutomatically=current_settings.updateAutomatically, + updateFrequency=current_settings.updateFrequency + ) + + validation_failed = False + + if 'updateAutomatically' in self._provided_properties: + if current_settings.updateAutomatically is None: + validation_failed = True + self.logger.info(f"updateAutomatically not found in config (requested: {self.updateAutomatically})", "Settings") + elif current_settings.updateAutomatically != self.updateAutomatically: + validation_failed = True + self.logger.info(f"updateAutomatically mismatch - requested: {self.updateAutomatically}, found: {current_settings.updateAutomatically}", "Settings") + else: + self.logger.info(f"updateAutomatically validation passed: {self.updateAutomatically}", "Settings") + + if 'updateFrequency' in self._provided_properties: + if current_settings.updateFrequency is None: + validation_failed = True + self.logger.info(f"updateFrequency not found in config (requested: {self.updateFrequency})", "Settings") + elif current_settings.updateFrequency != self.updateFrequency: + validation_failed = True + self.logger.info(f"updateFrequency mismatch - requested: {self.updateFrequency}, found: {current_settings.updateFrequency}", "Settings") + else: + self.logger.info(f"updateFrequency validation passed: {self.updateFrequency}", "Settings") + + if validation_failed: + validated_settings._exist = False + self.logger.info("Validation failed, setting _exist to False", "Settings") + else: + self.logger.info("All validations passed, _exist remains True", "Settings") + + return validated_settings + + def to_json(self, exclude_private: bool = False, exclude_none: bool = True) -> str: + data = {} + data["_exist"] = self._exist + + if self.scope is not None: + data["scope"] = self.scope + + if not exclude_none or self.updateAutomatically is not None: + data["updateAutomatically"] = self.updateAutomatically + + if not exclude_none or self.updateFrequency is not None: + data["updateFrequency"] = self.updateFrequency + + if exclude_none: + data = {k: v for k, v in data.items() if v is not None or k == "_exist"} + + self.logger.info(f"Serializing to JSON: {data}", "Settings") + + return json.dumps(data) + + def print_config(self) -> None: + json_output = self.to_json(exclude_private=False, exclude_none=True) + self.logger.info(f"Printing configuration: {json_output}", "Settings") + print(json_output) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Settings': + settings = cls( + scope=data.get('scope'), + _exist=data.get('_exist', True), # Default to True if not specified + updateAutomatically=data.get('updateAutomatically'), + updateFrequency=data.get('updateFrequency') + ) + + settings._provided_properties = set(data.keys()) + + return settings + + def validate(self) -> bool: + if not self.scope or self.scope not in ['user', 'machine']: + return False + return True \ No newline at end of file diff --git a/samples/python/resources/first/src/config/manager.py b/samples/python/resources/first/src/config/manager.py new file mode 100644 index 0000000..25f5d45 --- /dev/null +++ b/samples/python/resources/first/src/config/manager.py @@ -0,0 +1,171 @@ +import os +import json +import pathlib +from typing import Dict, Any +from resources.strings import Strings +from utils.logger import Logger + + +class ConfigSource: + """Represents a configuration source.""" + DEFAULT = "default" + MACHINE = "machine" + USER = "user" + ENV = "environment" + CLI = "cli" + +class ConfigManager: + def __init__(self): + self.default_config = { + "updates": { + "updateAutomatically": False, + "updateFrequency": 180 + } + } + self.machine_config = {} + self.user_config = {} + self.env_config = {} + self.cli_config = {} + self.config = {} + + # Track loaded sources for reporting + self.loaded_sources = [] + + self.logger = Logger() + + def get_machine_config_path(self) -> pathlib.Path: + if os.name == 'nt': # Windows + return pathlib.Path(os.environ.get('PROGRAMDATA', 'C:/ProgramData')) / 'tstoy' / 'config.json' + else: # Unix-like + return pathlib.Path('/etc/tstoy/config.json') + + def get_user_config_path(self) -> pathlib.Path: + if os.name == 'nt': # Windows + config_dir = pathlib.Path(os.environ.get('APPDATA')) + else: # Unix-like + config_dir = pathlib.Path.home() / '.config' + + return config_dir / 'tstoy' / 'config.json' + + def load_config_file(self, path: pathlib.Path) -> Dict[str, Any]: + if not path.exists(): + self.logger.info(Strings.CONFIG_NOT_FOUND.format(path)) + return {} + + try: + with open(path, 'r') as f: + config = json.load(f) + self.logger.info(Strings.CONFIG_LOADED.format(path)) + return config + except json.JSONDecodeError as e: + self.logger.error(Strings.CONFIG_INVALID.format(path, str(e))) + return {} + except IOError as e: + self.logger.error(Strings.CONFIG_INVALID.format(path, str(e))) + return {} + + def save_config_file(self, path: pathlib.Path, config: Dict[str, Any]) -> bool: + try: + # Create parent directories if they don't exist + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, 'w') as f: + json.dump(config, f, indent=2) + self.logger.info(Strings.CONFIG_UPDATED.format(path)) + return True + except Exception as e: + self.logger.error(Strings.ERROR_WRITE_CONFIG.format(path, str(e))) + return False + + def load_default_config(self): + self.config = self.default_config.copy() + self.loaded_sources.append(ConfigSource.DEFAULT) + + def load_machine_config(self): + path = self.get_machine_config_path() + self.machine_config = self.load_config_file(path) + if self.machine_config: + self._merge_config(self.machine_config) + self.loaded_sources.append(ConfigSource.MACHINE) + + def load_user_config(self): + path = self.get_user_config_path() + self.user_config = self.load_config_file(path) + if self.user_config: + self._merge_config(self.user_config) + self.loaded_sources.append(ConfigSource.USER) + + def load_environment_config(self, prefix: str): + env_config = {} + for key, value in os.environ.items(): + if key.startswith(prefix): + # Convert DSCPY_UPDATES_AUTOMATIC to updates.automatic + config_key = key[len(prefix):].lower().replace('_', '.') + + # Convert string value to appropriate type + if value.lower() in ('true', 'yes', '1'): + env_config[config_key] = True + elif value.lower() in ('false', 'no', '0'): + env_config[config_key] = False + elif value.isdigit(): + env_config[config_key] = int(value) + else: + env_config[config_key] = value + + if env_config: + self.env_config = env_config + self._merge_config(env_config) + self.loaded_sources.append(ConfigSource.ENV) + + + def _merge_config(self, source: Dict[str, Any]): + def deep_merge(target, source): + for key, value in source.items(): + if key in target and isinstance(target[key], dict) and isinstance(value, dict): + deep_merge(target[key], value) + else: + target[key] = value + + deep_merge(self.config, source) + + def get_merged_config(self) -> Dict[str, Any]: + return self.config + + def get_config_sources(self) -> list: + return self.loaded_sources + + def get_all_config_files(self) -> list: + try: + user_config_dir = self.get_user_config_path().parent + if user_config_dir.exists(): + # Return a list of all JSON files in the config directory + return list(user_config_dir.glob('*.json')) + else: + self.logger.warning(f"Config directory does not exist: {user_config_dir}") + return [] + except Exception as e: + self.logger.error(f"Error enumerating config files: {str(e)}") + return [] + + def get_config_by_name(self, name: str) -> Dict[str, Any]: + if name == 'default': + return self.default_config.copy() + + # Try to find a specific config file with this name + user_config_dir = self.get_user_config_path().parent + config_path = user_config_dir / f"{name}.json" + + if config_path.exists(): + config = self.load_config_file(config_path) + return config + else: + # If no specific file exists, return the merged config + # This behavior can be changed based on requirements + self.logger.warning(f"No configuration found for name: {name}") + return self.get_merged_config() + + def load_all_configs(self, env_prefix: str = "TSTOY_"): + self.load_default_config() + self.load_machine_config() + self.load_user_config() + self.load_environment_config(env_prefix) diff --git a/samples/python/resources/first/src/core/console.py b/samples/python/resources/first/src/core/console.py new file mode 100644 index 0000000..d5e95ac --- /dev/null +++ b/samples/python/resources/first/src/core/console.py @@ -0,0 +1,12 @@ +class Console: + @staticmethod + def info(message: str): + print(f"INFO: {message}") + + @staticmethod + def error(message: str): + print(f"ERROR: {message}") + + @staticmethod + def warning(message: str): + print(f"WARNING: {message}") diff --git a/samples/python/resources/first/src/main.py b/samples/python/resources/first/src/main.py new file mode 100644 index 0000000..28c2ce3 --- /dev/null +++ b/samples/python/resources/first/src/main.py @@ -0,0 +1,15 @@ +import click +from commands.get import get_command + + +@click.group() +def main(): + """Python TSToy CLI tool.""" + pass + +# TODO: Add more commands and move away in main.py +main.add_command(get_command) + + +if __name__ == '__main__': + main() diff --git a/samples/python/resources/first/src/models/models.py b/samples/python/resources/first/src/models/models.py new file mode 100644 index 0000000..501e57a --- /dev/null +++ b/samples/python/resources/first/src/models/models.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, asdict +from typing import Literal, Optional + +import json + +@dataclass +class TsToy: + scope: Literal["user", "machine"] + _exist: bool = True + updateAutomatically: Optional[bool] = None + updateFrequency: Optional[int] = None + + def to_json(self, include_none: bool = False) -> str: + data = asdict(self) + if not include_none: + data = {k: v for k, v in data.items() if v is not None} + return json.dumps(data) + + def to_dict(self, include_none: bool = False) -> dict: + data = asdict(self) + + if not include_none: + data = {k: v for k, v in data.items() if v is not None} + + return data \ No newline at end of file diff --git a/samples/python/resources/first/src/pyproject.toml b/samples/python/resources/first/src/pyproject.toml new file mode 100644 index 0000000..e3a0914 --- /dev/null +++ b/samples/python/resources/first/src/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tstoy" +version = "0.1.0" +description = "A command-line interface application built with Click" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "click>=8.2.1", + "jsonschema>=4.24.0", + "pyinstaller>=6.13.0", +] + +[project.scripts] +tstoy = "main:main" + +[tool.setuptools] +packages = ["commands", "core", "resources", "utils"] diff --git a/samples/python/resources/first/src/resources/strings.py b/samples/python/resources/first/src/resources/strings.py new file mode 100644 index 0000000..fe7bc6e --- /dev/null +++ b/samples/python/resources/first/src/resources/strings.py @@ -0,0 +1,7 @@ +# TODO: Convert to localization strings +class Strings: + CONFIG_NOT_FOUND = "Configuration file not found: {}" + CONFIG_LOADED = "Configuration loaded from: {}" + CONFIG_INVALID = "Invalid configuration file {}: {}" + CONFIG_UPDATED = "Configuration saved to: {}" + ERROR_WRITE_CONFIG = "Error writing configuration to {}: {}" diff --git a/samples/python/resources/first/src/schema/schema.py b/samples/python/resources/first/src/schema/schema.py new file mode 100644 index 0000000..b0208ce --- /dev/null +++ b/samples/python/resources/first/src/schema/schema.py @@ -0,0 +1,48 @@ +import jsonschema +import json + +RESOURCE_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Python TSToy Resource", + "type": "object", + "required": ["scope"], + "additionalProperties": False, + "properties": { + "scope": { + "title": "Target configuration scope", + "description": "Defines which of TSToy's config files to manage.", + "type": "string", + "enum": ["machine", "user"], + }, + "_exist": { + "title": "Should configuration exist", + "description": "Defines whether the config file should exist.", + "type": "boolean", + "default": True, + }, + "updateAutomatically": { + "title": "Should update automatically", + "description": "Indicates whether TSToy should check for updates when it starts.", + "type": "boolean", + }, + "updateFrequency": { + "title": "Update check frequency", + "description": "Indicates how many days TSToy should wait before checking for updates.", + "type": "integer", + "minimum": 1, + "maximum": 180, + }, + } +} + +def validate_resource(instance): + """Validate resource instance against schema.""" + try: + jsonschema.validate(instance=instance, schema=RESOURCE_SCHEMA) + return True, None + except jsonschema.exceptions.ValidationError as err: + return False, f"Validation error: {err.message}" + +def get_schema(): + """Dump the schema as formatted JSON string.""" + return json.dumps(RESOURCE_SCHEMA, separators=(',', ':')) \ No newline at end of file diff --git a/samples/python/resources/first/src/utils/logger.py b/samples/python/resources/first/src/utils/logger.py new file mode 100644 index 0000000..c158c96 --- /dev/null +++ b/samples/python/resources/first/src/utils/logger.py @@ -0,0 +1,109 @@ +import json +import sys +import datetime +import inspect +from typing import Dict, Any +from enum import Enum + + +class LogLevel(Enum): + """Enumeration for log levels""" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class Logger: + """ + A structured JSON logger class that outputs messages to stderr. + + Features: + - JSON formatted output + - Configurable log levels + - Automatic timestamp generation + - Caller information tracking + - Customizable output stream + """ + + def __init__(self, output_stream=None, include_caller_info: bool = True): + self.output_stream = output_stream or sys.stderr + self.include_caller_info = include_caller_info + + def _get_caller_info(self) -> Dict[str, Any]: + if not self.include_caller_info: + return {} + + try: + # Get the frame of the caller (skip internal methods) + frame = inspect.currentframe() + for _ in range(3): # Skip _get_caller_info, _log, and the log level method + frame = frame.f_back + if frame is None: + break + + if frame: + return { + "file": frame.f_code.co_filename.split('\\')[-1], # Just filename + "line": frame.f_lineno, + "function": frame.f_code.co_name + } + except Exception: + pass + + return {} + + def _log(self, level: LogLevel, message: str, target: str = None, **kwargs): + log_entry = { + "timestamp": datetime.datetime.now().isoformat() + "Z", + "level": level.value, + "fields": {"message": message}, + "target": target or "unknown" + } + + # Add caller information if enabled + caller_info = self._get_caller_info() + if caller_info: + log_entry["line_number"] = caller_info.get("line", "Unknown") + log_entry["file"] = caller_info.get("file", "Unknown") + log_entry["function"] = caller_info.get("function", "Unknown") + + # Add any additional fields to the fields section + if kwargs: + log_entry["fields"].update(kwargs) + + try: + json_output = json.dumps(log_entry, separators=(",", ":")) + self.output_stream.write(json_output + '\n') + self.output_stream.flush() + except Exception as e: + # Fallback to basic error output + fallback_msg = f"[LOG ERROR] Failed to write log: {str(e)}\n" + self.output_stream.write(fallback_msg) + self.output_stream.flush() + + def debug(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.DEBUG, message, target, **kwargs) + + def info(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.INFO, message, target, **kwargs) + + def warning(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.WARNING, message, target, **kwargs) + + def error(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.ERROR, message, target, **kwargs) + + def critical(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.CRITICAL, message, target, **kwargs) + + def log_config_loaded(self, config_path: str, config_type: str, **kwargs): + self.info(f"Loaded {config_type} configuration", "config_manager", + config_path=config_path, **kwargs) + + def log_config_error(self, error_msg: str, config_path: str = None, **kwargs): + self.error(f"Configuration error: {error_msg}", "config_manager", + config_path=config_path, **kwargs) + + diff --git a/samples/python/resources/first/tests/acceptance.tests.ps1 b/samples/python/resources/first/tests/acceptance.tests.ps1 new file mode 100644 index 0000000..54a468d --- /dev/null +++ b/samples/python/resources/first/tests/acceptance.tests.ps1 @@ -0,0 +1,81 @@ +param ( + [string]$Name = 'pythontstoy' +) + +BeforeAll { + $oldPath = $env:Path + $env:Path = [System.IO.Path]::PathSeparator + (Join-Path (Split-Path $PSScriptRoot -Parent) 'dist') + + if ($IsWindows) { + $script:machinePath = Join-Path $env:ProgramData 'tstoy' 'config.json' + $script:userPath = Join-Path $env:APPDATA 'tstoy' 'config.json' + } + else { + $script:machinePath = Join-Path $env:HOME '.config' 'tstoy' 'config.json' + $script:userPath = Join-Path $env:HOME '.config' 'tstoy' 'config.json' + } +} + +Describe 'TSToy acceptance tests' { + Context "Help command" { + It 'Should return help' { + $help = & $Name --help + $help | Should -Not -BeNullOrEmpty + $LASTEXITCODE | Should -Be 0 + } + } + + Context "Input validation" { + It 'Should fail with invalid input' { + $out = & $Name get --input '{}' 2>&1 + $LASTEXITCODE | Should -Be 1 + $out | Should -BeLike '*"level":"ERROR"*"message":"Input validation failed: Validation error: ''scope'' is a required property"*' + } + } + + Context "Scope validation" -ForEach @( @{ scope = 'user' }, @{ scope = 'machine' } ) { + BeforeAll { + if ($IsWindows) { + Remove-Item -Path $script:userPath -ErrorAction Ignore + Remove-Item -Path $script:machinePath -ErrorAction Ignore + } + elseif ($IsLinux) { + Remove-Item -Path $script:userPath -ErrorAction Ignore + Remove-Item -Path $script:machinePath -ErrorAction Ignore + } + } + + It "Should not exist scope: " { + $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out._exist | Should -BeFalse + } + + It 'Should exist when file is present' { + $config = @{ + updates = @{ + updateAutomatically = $false + updateFrequency = 180 + } + } | ConvertTo-Json -Depth 10 + + if ($_.scope -eq 'user') { + $scriptPath = $script:userPath + } + else { + $scriptPath = $script:machinePath + } + + New-Item -Path $scriptPath -ItemType File -Value $config -Force | Out-Null + + $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out._exist | Should -BeTrue + } + } +} + + +AfterAll { + $env:Path = $oldPath +} \ No newline at end of file From c8633a164ae432f8de97dd89c600b390955f0684 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:00:38 +0200 Subject: [PATCH 2/3] Update TsToy Go sample building --- .site/go.mod | 2 +- go.work | 2 +- samples/go/resources/first/README.md | 395 ++---------- samples/go/resources/first/docs/1-create.md | 124 ++-- .../first/docs/2-define-config-settings.md | 504 ++++----------- .../go/resources/first/docs/3-handle-input.md | 395 ++---------- .../resources/first/docs/4-implement-get.md | 347 +++------- .../resources/first/docs/5-implement-set.md | 590 ++++-------------- .../resources/first/docs/6-author-manifest.md | 221 +++---- samples/go/resources/first/docs/7-validate.md | 196 ++---- samples/go/resources/first/docs/_index.md | 61 +- samples/go/resources/first/docs/review.md | 53 +- samples/go/resources/first/go.mod | 17 +- samples/go/resources/first/go.sum | 2 + .../resources/first/gotstoy.dsc.config.yaml | 2 +- .../resources/first/gotstoy.dsc.resource.json | 62 -- samples/go/resources/first/main.go | 58 +- samples/go/resources/first/settings.go | 188 ++++++ .../tstoy.example.gotstoy.dsc.resource.json | 82 +++ tstoy/go.mod | 2 +- 20 files changed, 1038 insertions(+), 2265 deletions(-) delete mode 100644 samples/go/resources/first/gotstoy.dsc.resource.json create mode 100644 samples/go/resources/first/settings.go create mode 100644 samples/go/resources/first/tstoy.example.gotstoy.dsc.resource.json diff --git a/.site/go.mod b/.site/go.mod index b0774c4..d161c76 100644 --- a/.site/go.mod +++ b/.site/go.mod @@ -1,5 +1,5 @@ module github.com/PowerShell/DSC-Samples/_site -go 1.19 +go 1.16 require github.com/platenio/platen/modules/platen v0.0.0-20231124141037-5a875309774c // indirect diff --git a/go.work b/go.work index 740dd74..c48bcde 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.19 +go 1.26 use ( ./tstoy diff --git a/samples/go/resources/first/README.md b/samples/go/resources/first/README.md index 45c86fd..7bff066 100644 --- a/samples/go/resources/first/README.md +++ b/samples/go/resources/first/README.md @@ -1,16 +1,15 @@ # Write your first DSC Resource in Go sample code -This folder contains the sample code for the completed _Write your first DSC Resource_ tutorial -in Go, as well as the tutorial's documentation files. +This folder contains the sample code for the completed _Write your first DSC Resource_ +tutorial in Go, as well as the tutorial's documentation files. The resource is built with +[dsc-go-rdk](https://github.com/LibreDsc/dsc-go-rdk), a Go library that implements the Microsoft DSC +command-based resource protocol: CLI dispatch, JSON input handling, output framing, +schema generation, and manifest generation. As a result, the sample only implements the +`get` and `set` logic for the TSToy application. ## Building the sample -You can build the sample code with the included PowerShell build script, or running the commands -manually. The build script handles building the DSC Resource, adding it to the path, and -registering shell completions. - -To manually build the sample for the current operating system, navigate to this folder. Then, run -the following commands: +Navigate to this folder, then run: - On Linux or macOS: @@ -26,394 +25,88 @@ the following commands: $env:Path = $PWD.Path + ';' + $env:Path ``` -## Getting current state - -You can retrieve the current state of the resource with the `get` command. +The resource shells out to the `tstoy` application, so `tstoy` must also be on your `PATH` +(build it from the repository's `tstoy` folder). -```sh -# Get current state with flags -gotstoy get --scope machine --ensure present --updateAutomatically=false -``` +## Getting current state -```json -{"ensure":"absent","scope":"machine"} -``` +Pass the instance as JSON with `--input` or over stdin. ```sh -# Get with JSON over stdin -' -{ - "scope": "user", - "ensure": "present", - "updateAutomatically": true, - "updateFrequency": 45 -} -' | gotstoy get +gotstoy get --input '{"scope":"machine"}' ``` ```json -{"ensure":"absent","scope":"user"} +{"scope":"machine","ensure":"absent"} ``` ```sh -# Get current state of all scopes: -gotstoy get --all +echo '{"scope":"user"}' | gotstoy get ``` ```json -{"ensure":"absent","scope":"machine"} -{"ensure":"absent","scope":"user"} +{"scope":"user","ensure":"absent"} ``` ## Setting desired state -You can enforce the state of the resource with the `set` command. - ```sh -# Set the state with flags -gotstoy set --scope machine --ensure present --updateAutomatically=false -# Set with JSON over stdin -' -{ - "scope": "user", - "ensure": "present", - "updateAutomatically": true, - "updateFrequency": 45 -} -' | gotstoy set -# Get new state of all scopes: -gotstoy get --all +echo '{ + "scope": "user", + "ensure": "present", + "updateAutomatically": true, + "updateFrequency": 45 +}' | gotstoy set ``` ```json -{"ensure":"present","scope":"machine","updateAutomatically":false} - -{"ensure":"present","scope":"user","updateAutomatically":true,"updateFrequency":45} - -{"ensure":"present","scope":"machine","updateAutomatically":false} -{"ensure":"present","scope":"user","updateAutomatically":true,"updateFrequency":45} +{"scope":"user","ensure":"present","updateAutomatically":true,"updateFrequency":45} ``` -## Verifying state - -After you've enforced state, you should verify the changes with the `tstoy` application itself: +After you've enforced state, you can verify the changes with the `tstoy` application +itself: ```sh tstoy show ``` -```yaml -Default configuration: { - "Updates": { - "Automatic": false, - "CheckFrequency": 90 - } -} -Machine configuration: { - "updates": { - "automatic": false - } -} -User configuration: { - "updates": { - "automatic": true, - "checkfrequency": 45 - } -} -Final configuration: { - "updates": { - "automatic": true, - "checkfrequency": 45 - } -} -``` - -## Using `dsc resource` +## Inspecting the resource -You can list `gotstoy` as a DSC Resource to inspect it: - -```pwsh -dsc resource list TSToy.Example/gotstoy +```sh +gotstoy schema # the generated instance JSON Schema +gotstoy manifest # the generated DSC resource manifest +gotstoy manifest --out-dir . # write resource manifest for discovery ``` -```yaml -type: TSToy.Example/gotstoy -version: '' -path: C:\dsc-samples\samples\go\resources\first\dist\gotstoy_windows_amd64_v1\gotstoy.dsc.resource.json -directory: C:\dsc-samples\samples\go\resources\first\dist\gotstoy_windows_amd64_v1 -implementedAs: Command -author: null -properties: [] -requires: null -manifest: - manifestVersion: '1.0' - type: TSToy.Example/gotstoy - version: 0.1.0 - description: The go implementation of a DSC Resource for the fictional TSToy application. - get: - executable: gotstoy - args: - - get - input: stdin - set: - executable: gotstoy - args: - - set - input: stdin - preTest: true - return: state - schema: - embedded: - $schema: https://json-schema.org/draft/2020-12/schema - title: Golang TSToy Resource - type: object - required: - - scope - properties: - ensure: - title: Ensure configuration file existence - description: Defines whether the application's configuration file for this scope should exist or not. - type: string - enum: - - present - - absent - default: present - scope: - title: Target configuration scope - description: Defines which of the application's configuration files the resource should manage. - type: string - enum: - - machine - - user - updateAutomatically: - title: Should update automatically - description: Indicates whether the application should check for updates when it starts. - type: boolean - updateFrequency: - title: Update check frequency - description: Indicates how many days the application should wait before checking for updates. - type: integer - minimum: 1 - maximum: 90 -``` +## Using `dsc resource` -You can retrieve the current state of the resource: +With this folder on `PATH` (or `DSC_RESOURCE_PATH`) and the resource +manifest written next to the binary: ```powershell $ResourceName = 'TSToy.Example/gotstoy' -$MachineSettings = '{ "scope": "machine", "ensure": "present", "updateAutomatically": false }' $UserSettings = @' -{ - "scope": "user", - "ensure": "present", - "updateAutomatically": true, - "updateFrequency": 45 -} +{"scope":"user","ensure":"present","updateAutomatically":true,"updateFrequency":45} '@ -# Get current state with flags -dsc resource get --resource $ResourceName --input $MachineSettings -# Get with JSON over stdin -$UserSettings | dsc resource get --resource $ResourceName -``` - -```yaml -actual_state: - ensure: absent - scope: machine -``` - -```yaml -actual_state: - ensure: absent - scope: user -``` - -You can test whether the resource is in the desired state: - -```powershell -# Test current state with flags -dsc resource test --resource $ResourceName --input $MachineSettings -# Test with JSON over stdin -$UserSettings | dsc resource test --resource $ResourceName -``` -```yaml -expected_state: - scope: machine - ensure: present - updateAutomatically: false -actual_state: - ensure: absent - scope: machine -diff_properties: -- ensure -- updateAutomatically -``` - -```yaml -expected_state: - scope: user - ensure: present - updateAutomatically: true - updateFrequency: 45 -actual_state: - ensure: absent - scope: user -diff_properties: -- ensure -- updateAutomatically -- updateFrequency -``` - -You can enforce the desired state for the resource: - -```powershell -# Set desired state with flags -dsc resource set --resource $ResourceName --input $MachineSettings -# Set with JSON over stdin -$UserSettings | dsc resource set --resource $ResourceName -``` - -```yaml -before_state: - ensure: absent - scope: machine -after_state: - ensure: present - scope: machine - updateAutomatically: false -changed_properties: -- ensure -- updateAutomatically -``` - -```yaml -before_state: - ensure: absent - scope: user -after_state: - ensure: present - scope: user - updateAutomatically: true - updateFrequency: 45 -changed_properties: -- ensure -- updateAutomatically -- updateFrequency -``` - -And when you call it again, the output shows that the resource didn't change the settings: - -```powershell -# Set desired state with flags -dsc resource set --resource $ResourceName --input $MachineSettings -# Set with JSON over stdin -$UserSettings | dsc resource set --resource $ResourceName -``` - -```yaml -before_state: - ensure: present - scope: machine - updateAutomatically: false -after_state: - ensure: present - scope: machine - updateAutomatically: false -changed_properties: [] -``` - -```yaml -before_state: - ensure: present - scope: user - updateAutomatically: true - updateFrequency: 45 -after_state: - ensure: present - scope: user - updateAutomatically: true - updateFrequency: 45 -changed_properties: [] -``` - -Finally, you can call `get` and `test` again: - -```powershell -# Set desired state with flags -dsc resource get --resource $ResourceName --input $MachineSettings -# Set with JSON over stdin +dsc resource list $ResourceName +dsc resource get --resource $ResourceName --input '{"scope":"machine"}' $UserSettings | dsc resource test --resource $ResourceName +$UserSettings | dsc resource set --resource $ResourceName ``` -```yaml -actual_state: - ensure: present - scope: machine - updateAutomatically: false -``` - -```yaml -expected_state: - scope: user - ensure: present - updateAutomatically: true - updateFrequency: 45 -actual_state: - ensure: present - scope: user - updateAutomatically: true - updateFrequency: 45 -diff_properties: [] -``` +The resource has no `test` method in its manifest, so DSC synthesizes the test operation +from `get` output automatically. ## Using `dsc config` -Save this configuration to a file or variable. It sets the configuration files for the application -using the go implementation. To use a different implementation, replace the value for the `type` -key in the resource entries. - -```yaml -$schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json -resources: -- name: All Users Configuration - type: TSToy.Example/gotstoy - properties: - scope: machine - ensure: present - updateAutomatically: false -- name: Current User Configuration - type: TSToy.Example/gotstoy - properties: - scope: user - ensure: present - updateAutomatically: true - updateFrequency: 45 -``` - -Get the current state of the resources with `dsc config get`: +The included [gotstoy.dsc.config.yaml](gotstoy.dsc.config.yaml) defines an instance for +both configuration scopes: ```powershell -$config | dsc config get +dsc config get --file gotstoy.dsc.config.yaml +dsc config test --file gotstoy.dsc.config.yaml +dsc config set --file gotstoy.dsc.config.yaml ``` -```yaml -results: -- name: All Users Configuration - type: TSToy.Example/gotstoy - result: - actual_state: - ensure: present - scope: machine - updateAutomatically: false -- name: Current User Configuration - type: TSToy.Example/gotstoy - result: - actual_state: - ensure: present - scope: user - updateAutomatically: true - updateFrequency: 45 -messages: [] -hadErrors: false -``` +See the tutorial in [docs](docs/_index.md) for the full step-by-step walkthrough. diff --git a/samples/go/resources/first/docs/1-create.md b/samples/go/resources/first/docs/1-create.md index a51ea2e..6da820f 100644 --- a/samples/go/resources/first/docs/1-create.md +++ b/samples/go/resources/first/docs/1-create.md @@ -5,8 +5,8 @@ dscs: menu_title: 1. Create the resource --- -Create a new folder called `gotstoy` and open it in VS Code. This folder is the root folder for the -project. +Create a new folder called `gotstoy` and open it in VS Code. This folder is the root +folder for the project. ```sh mkdir ./gotstoy @@ -19,68 +19,104 @@ Open the integrated terminal in VS Code. In that terminal, initialize the folder go mod init "github.com//gotstoy" ``` -In this tutorial, you'll be creating a DSC Resource with [Cobra][01]. Cobra helps you create a -command line application in Go. It handles argument parsing, setting flags, shell completions, and -help. +In this tutorial, you'll be creating the DSC Resource with [dsc-go-rdk][01]. dsc-go-rdk implements +the Microsoft DSC command-based resource protocol: argument parsing, JSON input from `--input` +or stdin, output framing, exit codes, schema generation, and manifest generation. The +only code you write is the logic that manages TSToy. -Use the following command to install `cobra-cli`. +Add the library to your module: ```sh -go install github.com/spf13/cobra-cli@latest +go get github.com/LibreDsc/dsc-go-rdk@v0.1.0 ``` -Use `cobra-cli` to scaffold the DSC Resource application and add the `get` and `set` commands. - -```sh -cobra-cli init -cobra-cli add get -cobra-cli add set +> If you're working with a local clone of dsc-go-rdk instead of the published module, point your +> `go.mod` at it with a replace directive: +> +> ```text +> replace github.com/LibreDsc/dsc-go-rdk => ../path/to/dsc-go-rdk +> ``` + +Create `main.go` with a minimal resource: a state struct, a handler with a stub `Get` +method, and a `main` function that hands control to the library. + +```go +package main + +import ( + "context" + + dsc "github.com/LibreDsc/dsc-go-rdk" +) + +// Settings models one TSToy configuration file as DSC state. +// You'll define the real properties in the next step. +type Settings struct { + Scope string `json:"scope"` +} + +// Handler implements the resource's operations. +type Handler struct{} + +// Get is a stub for now: it echoes the input back. +func (Handler) Get(_ context.Context, in Settings) (Settings, error) { + return in, nil +} + +func main() { + r := dsc.MustResource[Settings](Handler{}, dsc.ResourceConfig{ + Type: "TSToy.Example/gotstoy", + Version: "0.1.0", + Description: "A DSC Resource written in Go to manage TSToy.", + Tags: []string{"tstoy", "example", "go"}, + }) + r.Main("gotstoy") +} ``` -Run the following commands to get the Go modules you'll be using outside of the standard library. - -```sh -go get github.com/thediveo/enumflag@v0.10.1 -go get github.com/TylerBrock/colorjson@v0.0.0-20200706003622-8a50f05110d2 -go get github.com/knadh/koanf/maps@v0.1.1 -``` +A few things to notice: -The `enumflag` module simplifies using enumerations as command line flags. The `colorjson` module -enables you to pretty-print output in the console. The `maps` module makes interacting with -arbitrary maps easier. +- `dsc.MustResource[Settings]` binds your handler to its configuration and validates the + configuration at startup. The resource type name must match DSC's + `[.][.]/` format and the version must be a semantic version. +- The handler declares what the resource can do by which interfaces it implements. Right + now it only implements `Gettable[Settings]`, the one mandatory capability. As you add + `Set` in a later step, the resource (and its generated manifest) gain that + capability automatically. +- `r.Main("gotstoy")` runs the full protocol CLI. The string is the executable name that + will be written into the generated manifest. -Verify that the new application can run and has the expected commands. +Verify that the new application runs and has the expected commands. ```sh -go run ./main.go +go run . --help ``` ```text -A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application. +gotstoy - Microsoft DSC command-based resource: A DSC Resource written in Go to manage TSToy. Usage: - gotstoy [command] + gotstoy get|set|test|delete|export [--input ] + gotstoy set --what-if [--input ] + gotstoy schema + gotstoy manifest [--resource ] [--out-dir ] + +Input is read from --input or piped stdin. Output follows the Microsoft DSC JSON protocol. +``` -Available Commands: - completion Generate the autocompletion script for the specified shell - get A brief description of your command - help Help about any command - set A brief description of your command +You already have a working protocol CLI. Try the stubbed get: -Flags: - -h, --help help for gotstoy - -t, --toggle Help message for toggle +```sh +go run . get --input '{ "scope": "machine" }' +``` -Use "gotstoy [command] --help" for more information about a command. +```json +{"scope":"machine"} ``` -With the command scaffolded, you need to understand the application the DSC Resource manages before -you can implement the commands. By now, you should have read [About the TSToy application][02]. +With the application scaffolded, you need to understand the application the DSC Resource +manages before you can implement the operations. By now, you should have read [About the +TSToy application][02]. -[01]: https://cobra.dev/ +[01]: https://github.com/LibreDsc/dsc-go-rdk [02]: /tstoy/about diff --git a/samples/go/resources/first/docs/2-define-config-settings.md b/samples/go/resources/first/docs/2-define-config-settings.md index c04ddd2..3014a84 100644 --- a/samples/go/resources/first/docs/2-define-config-settings.md +++ b/samples/go/resources/first/docs/2-define-config-settings.md @@ -2,403 +2,131 @@ title: Step 2 - Define the configuration settings weight: 2 dscs: - menu_title: 2. Define settings + menu_title: 2. Define the settings --- -## Create the config module +TSToy stores its configuration in JSON files, one per scope. The resource manages whether +each file exists and TSToy's update behavior in it: -Create the `config` folder in the project root. Inside it, create the `config.go` file. This file -defines the configuration types and functions of the DSC Resource. - -```sh -mkdir ./config -touch ./config/config.go -``` - -Set the package name at the top of the file to `config`: - -```go -package config -``` - -Create a new type called `Settings` as a struct. The fields of the struct are the settings the DSC -Resource manages. In the struct, define the `Ensure`, `Scope`, `UpdateAutomatically`, and -`UpdateFrequency` fields. Set their types to `any`. - -```go -type Settings struct { - Ensure any - Scope any - UpdateAutomatically any - UpdateFrequency any -} -``` - -Defining the fields this way is convenient but also inaccurate. To make the DSC Resource more -reliable, you need to define the fields to match their purpose. - -## Define Ensure - -The `Ensure` field follows a common pattern in DSC for managing whether an instance of a DSC -Resource should exist. To use this pattern, the field should be an enumeration with the valid -values `absent` and `present`. - -Create a new type called `Ensure` as an integer. - -```go -type Ensure int -``` - -Define three constants of the `Ensure` type to use as enumerations: `EnsureUndefined`, -`EnsureAbsent`, and `EnsurePresent`. - -```go -const ( - EnsureUndefined Ensure = iota - EnsureAbsent - EnsurePresent -) -``` - -Implement the [fmt.Stringer][01] interface for the `Ensure` type. This interface translates the -values into strings. It should return `"absent"`, `"present"`, or `"undefined"`. - -```go -func (e Ensure) String() string { - switch e { - case EnsureAbsent: - return "absent" - case EnsurePresent: - return "present" - } - - return "undefined" -} -``` - -Implement a function called `ParseEnsure` that converts an input string into an `Ensure` enum and -returns an error if the input can't be parsed as `EnsurePresent` or `EnsureAbsent`. - -```go -func ParseEnsure(s string) (Ensure, error) { - switch strings.ToLower(s) { - case "absent": - return EnsureAbsent, nil - case "present": - return EnsurePresent, nil - } - - return EnsureUndefined, fmt.Errorf( - "unable to convert '%s' to Ensure, must be one of: absent, present", - s, - ) -} -``` - -Implement the `MarshalJSON` and `UnmarshalJSON` methods for `Ensure` that convert to and from JSON -as the enum's label instead of the integer value. - -```go -func (e Ensure) MarshalJSON() ([]byte, error) { - return json.Marshal(e.String()) -} - -func (ensure *Ensure) UnmarshalJSON(data []byte) (err error) { - var e string - if err := json.Unmarshal(data, &e); err != nil { - return err - } - if *ensure, err = ParseEnsure(e); err != nil { - return err - } - - return nil +```json +{ + "updates": { + "automatic": true, + "checkFrequency": 30 + } } ``` -Create a variable called `EnsureMap` to map the enumeration value to its string. This map is used -when you define the command line flags for the DSC Resource. +A DSC Resource describes that as a set of properties. With dsc-go-rdk, the properties are the +fields of your state struct — the struct is the single source of truth for the +resource's data shape, and the library generates the instance JSON Schema from it. -```go -var EnsureMap = map[Ensure][]string{ - EnsureAbsent: {"absent"}, - EnsurePresent: {"present"}, -} -``` - -Create a function called `EnsureFlagCompletion`. This function provides shell completion for the -command-line flags of the DSC Resource. +Create `settings.go` and define the full settings model. Then remove the temporary +`Settings` struct you added to `main.go` in step 1. ```go -func EnsureFlagCompletion( - cmd *cobra.Command, - args []string, - toComplete string, -) ([]string, cobra.ShellCompDirective) { - completions := []string{ - "absent\tThe configuration file shouldn't exist.", - "present\tThe configuration file should exist.", - } - return completions, cobra.ShellCompDirectiveNoFileComp -} -``` - -Update the `Ensure` field of the `Settings` type to use the newly defined `Ensure` value instead of -`any`. +package main -```go +// Settings models one TSToy configuration file as DSC state. The struct is +// the resource's contract: dsc-go-rdk generates the instance JSON Schema from its +// fields and tags, and every operation receives and returns it. type Settings struct { - Ensure Ensure - Scope any - UpdateAutomatically any - UpdateFrequency any -} -``` - -## Define Scope - -The `Scope` field of the `Settings` struct defines which instance of the `tstoy` configuration file -the DSC Resource should manage. Like `Ensure`, it should be an enumeration. - -Define the `Scope` as an integer. Add constant values for the enumeration as `ScopeUndefined`, -`ScopeMachine`, and `ScopeUser`. - -```go -type Scope int - -const ( - ScopeUndefined Scope = iota - ScopeMachine - ScopeUser -) -``` - -`Scope` needs the same functions and methods you defined for `Ensure`, but for its own enumeration -values. - -```go -func (s Scope) String() string { - switch s { - case ScopeMachine: - return "machine" - case ScopeUser: - return "user" - } - - return "undefined" -} - -func ParseScope(s string) (Scope, error) { - switch strings.ToLower(s) { - case "machine": - return ScopeMachine, nil - case "user": - return ScopeUser, nil - } - - return ScopeUndefined, fmt.Errorf( - "unable to convert '%s' to Scope, must be one of: machine, user", - s, - ) -} - -func (s Scope) MarshalJSON() ([]byte, error) { - return json.Marshal(s.String()) -} - -func (scope *Scope) UnmarshalJSON(data []byte) (err error) { - var e string - if err := json.Unmarshal(data, &e); err != nil { - return err - } - if *scope, err = ParseScope(e); err != nil { - return err - } - - return nil -} - -var ScopeMap = map[Scope][]string{ - ScopeMachine: {"machine"}, - ScopeUser: {"user"}, -} + Scope string `json:"scope" enum:"machine,user"` + Ensure string `json:"ensure,omitempty" enum:"present,absent"` + UpdateAutomatically *bool `json:"updateAutomatically,omitempty"` + UpdateFrequency int `json:"updateFrequency,omitempty"` +} +``` + +The struct tags carry the schema information: + +- `json:"scope"` without `omitempty` makes `scope` a **required** property — the + resource can't do anything without knowing which file to manage. The other fields use + `omitempty`, making them optional. +- `enum:"machine,user"` constrains the property to those values in the schema, so DSC + rejects invalid values before your code ever runs. +- `UpdateAutomatically` is a `*bool` rather than `bool`. The pointer distinguishes _not + specified_ (`nil`) from _explicitly false_ — a distinction you'll rely on when + implementing set. + +Three things can't be expressed as struct tags: the property descriptions (they'd make the +field lines unreadably long), the `updateFrequency` range (1–90 days), and the default +value for `ensure`. Add them through the schema options in `main.go`: + +```go + r := dsc.MustResource[Settings](Handler{}, dsc.ResourceConfig{ + Type: "TSToy.Example/gotstoy", + Version: "0.1.0", + Description: "A DSC Resource written in Go to manage TSToy.", + Tags: []string{"tstoy", "example", "go"}, + + SchemaOptions: dsc.SchemaOptions{ + SchemaDescription: "Golang TSToy Resource", + Descriptions: map[string]string{ + "scope": "Defines which of TSToy's config files to manage.", + "ensure": "Defines whether the config file should exist.", + "updateAutomatically": "Indicates whether TSToy should check for" + + " updates when it starts.", + "updateFrequency": "Indicates how many days TSToy should wait" + + " before checking for updates.", + }, + // Constraints the struct tags can't express. + Overrides: func(schema map[string]any) { + props := schema["properties"].(map[string]any) + props["ensure"].(map[string]any)["default"] = "present" + frequency := props["updateFrequency"].(map[string]any) + frequency["minimum"] = 1 + frequency["maximum"] = 90 + }, + }, + }) +``` + +> For short descriptions you can also use a `description:"..."` struct tag directly on a field. +> The tag takes precedence over the `Descriptions` map when both are present. + +Every dsc-go-rdk resource has a `schema` subcommand. Inspect what you've defined: -func ScopeFlagCompletion( - cmd *cobra.Command, - args []string, - toComplete string, -) ([]string, cobra.ShellCompDirective) { - completions := []string{ - "machine\tThe configuration file should exist.", - "user\tThe configuration file shouldn't exist.", - } - return completions, cobra.ShellCompDirectiveNoFileComp -} -``` - -When you've implemented the `Scope` type, enumerations, methods, and functions, update the `Scope` -field of the `Settings` type. - -```go -type Settings struct { - Ensure Ensure - Scope Scope - UpdateAutomatically any - UpdateFrequency any -} -``` - -## Define UpdateAutomatically - -Like `Ensure` and `Scope`, whether the `tstoy` application should be configured for automatic -updates only has two options. Unlike `Ensure` and `Scope`, you can represent those options as a -boolean. - -Update the `UpdateAutomatically` field of the `Settings` type to be a pointer to a boolean value. -Using a pointer for this field allows the value to be `nil`, which enables the DSC Resource to -distinguish between the setting not being specified and being specified as `false`. - -```go -type Settings struct { - Ensure Ensure - Scope Scope - UpdateAutomatically *bool - UpdateFrequency any -} -``` - -If the value wasn't a pointer, the DSC Resource would need extra handling to distinguish between -whether the value is false because the user or configuration file specified the value as `false` or -because it wasn't specified at all. - -## Define UpdateFrequency - -The `UpdateFrequency` field represents a count of days between `1` and `90`, inclusive. To add -validation for the field, define a new type called `Frequency` as an integer. - -```go -type Frequency int -``` - -Next, define the `Validate` method to check whether a `Frequency` value is valid for the setting. -It should return an error when the integer value of the Frequency is out of range. - -```go -func (f Frequency) Validate() error { - v := int(f) - if v < 1 || v > 90 { - return fmt.Errorf( - "invalid value %v; must be an integer between 1 and 90, inclusive", - v, - ) - } - return nil -} -``` - -To make the new type usable as a command line flag, you need to implement the `Set`, `Type`, and -`String` methods of the [pflag.Value interface][02]. - -```go -func (f *Frequency) Set(s string) error { - v, err := strconv.ParseInt(s, 0, 64) - if err != nil { - return err - } - - *f = Frequency(v) - - return f.Validate() -} - -func (f *Frequency) Type() string { - return "int" -} - -func (f *Frequency) String() string { - return strconv.Itoa(int(*f)) -} -``` - -Finally, update the `UpdateFrequency` field of `Settings` to use the defined `Frequency` type. - -```go -type Settings struct { - Ensure Ensure - Scope Scope - UpdateAutomatically *bool - UpdateFrequency Frequency -} -``` - -## Ensure Settings serializes to JSON correctly { toc_text="Serialize Settings to JSON" } - -Now that the `Settings` type is defined and has the correct value types for each field, you need to -add tags to the fields so they can be marshalled to and unmarshalled from JSON correctly. - -```go -type Settings struct { - Ensure Ensure `json:"ensure,omitempty"` - Scope Scope `json:"scope,omitempty"` - UpdateAutomatically *bool `json:"updateAutomatically,omitempty"` - UpdateFrequency Frequency `json:"updateFrequency,omitempty"` -} -``` - -The tags should all be in the format `json:",omitempty"`. The first value defines the -name of the key that the DSC Resource expects from JSON input and uses for returning an instance of -`Settings` to DSC. The `omitempty` value indicates that if the fields value is the same as its zero -value, that key shouldn't be included in the output JSON. - -## Ensure Settings can print as JSON - -Now that an instance of `Settings` can correctly serialize to JSON, define the `Print()` method to -simplify emitting the instance as a single line of JSON. - -```go -func (s *Settings) Print() error { - configJson, err := json.Marshal(s) - if err != nil { - return err - } - - fmt.Println(string(configJson)) - - return nil -} -``` - -## Implement validation for Settings - -The DSC Resource should be able to report whether an instance of Settings is valid and, if it -isn't, how it's invalid. - -Add the `Validate` method to return an error if the instance is invalid. - -```go -func (s *Settings) Validate() error { - if s.Scope == ScopeUndefined { - return fmt.Errorf( - "the Scope setting isn't defined. Must define a Scope for Settings", - ) - } - - if s.Ensure == EnsureAbsent { - return nil - } - - if s.UpdateFrequency != 0 { - return s.UpdateFrequency.Validate() - } - - return nil -} -``` - -The method returns an error if the `Scope` field is undefined because the resource requires a -specific scope to manage a `tstoy` configuration file. It short-circuits the validation if `Ensure` -is set to absent because all other keys are ignored. Finally, if the `UpdateFrequency` field is -invalid, it returns an error message indicating the issue. - -[01]: https://pkg.go.dev/fmt#Stringer -[02]: https://pkg.go.dev/github.com/spf13/pflag#Value +```sh +go run . schema +``` + +The command emits the schema as one compact line; pretty-printed, it looks like this: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Golang TSToy Resource", + "type": "object", + "additionalProperties": false, + "required": ["scope"], + "properties": { + "scope": { + "description": "Defines which of TSToy's config files to manage.", + "enum": ["machine", "user"], + "type": "string" + }, + "ensure": { + "default": "present", + "description": "Defines whether the config file should exist.", + "enum": ["present", "absent"], + "type": "string" + }, + "updateAutomatically": { + "description": "Indicates whether TSToy should check for updates when it starts.", + "type": "boolean" + }, + "updateFrequency": { + "description": "Indicates how many days TSToy should wait before checking for updates.", + "minimum": 1, + "maximum": 90, + "type": "integer" + } + } +} +``` + +This is the same schema DSC uses to validate instances in configuration documents, and the +manifest you generate in step 6 embeds it automatically. Note that `additionalProperties` +is `false` by default: if a user typos a property name in their configuration, DSC rejects +it instead of silently ignoring it. diff --git a/samples/go/resources/first/docs/3-handle-input.md b/samples/go/resources/first/docs/3-handle-input.md index b8d3390..7043f56 100644 --- a/samples/go/resources/first/docs/3-handle-input.md +++ b/samples/go/resources/first/docs/3-handle-input.md @@ -1,379 +1,116 @@ --- -title: Step 3 - Handle input +title: Step 3 - See how input is handled weight: 3 dscs: menu_title: 3. Handle input --- -Now that the valid values for settings are defined, the root command of the DSC Resource needs to -handle when users specify those values. +A command-based DSC Resource receives the instance it should operate on as a JSON blob. +The DSC engine sends it over stdin or as the value of a command-line argument, depending +on what the resource's manifest declares. The resource must parse that JSON, validate it, +report failures on stderr, and exit with a meaningful code. -## Define the flag variables +With dsc-go-rdk, all of that plumbing already exists. This step shows what the library does for +you and adds the one piece of validation that's yours to write. -Open the `cmd/root.go` file in the editor. +## What the library already does -Add variables for each of the fields of the `Settings` type so users can specify those values at -the command line. - -```go -var targetScope config.Scope -var targetEnsure config.Ensure -var updateAutomatically bool -var updateFrequency config.Frequency -``` - -## Handle arguments as input - -The first input method the resource should support is command line arguments. This makes using the -resource outside of DSC easier, like when iteratively developing the command. It's also the most -common way users expect to call a command directly. - -### Define the persistent flags - -Next, find the `init` function at the bottom of the file. Inside it, define persistent flags so -users can pass the values to both `get` and `set` as arguments. - -```go -func init() { - rootCmd.PersistentFlags().Var( - enumflag.New(&targetScope, "scope", config.ScopeMap, enumflag.EnumCaseInsensitive), - "scope", - "The target scope for the configuration.", - ) - rootCmd.RegisterFlagCompletionFunc("scope", config.ScopeFlagCompletion) - - rootCmd.PersistentFlags().Var( - enumflag.New(&targetEnsure, "ensure", config.EnsureMap, enumflag.EnumCaseInsensitive), - "ensure", - "Whether the configuration file should exist.", - ) - rootCmd.RegisterFlagCompletionFunc("ensure", config.EnsureFlagCompletion) - - rootCmd.PersistentFlags().BoolVar( - &updateAutomatically, - "updateAutomatically", - false, - "Whether the configuration should set the app to automatically update.", - ) - - rootCmd.PersistentFlags().Var( - &updateFrequency, - "updateFrequency", - "How frequently the configuration should update, between 1 and 90 days inclusive.", - ) -} -``` - -Use the `enumflag` module to for the `ensure` and `scope` flags. It handles parsing the user inputs -and converting them to the enumeration values. Use the flag completion functions you defined -earlier to ensure that users can opt into shell completions for those flags. - -### Validate input flags - -With the `Settings` defined and command line flags added to the root command, you can begin -validating that the settings flags work as expected. - -Open the `cmd/get.go` file in the editor. - -At the bottom of the file, create a new `getState` function that takes two parameters, a pointer to -`cobra.Command` and a slice of strings, and returns an error. - -```go -func getState(cmd *cobra.Command, args []string) error { - return nil -} -``` - -Replace the `Run` entry in the `getCmd` variable's definition with the `RunE` field set to the -`getState` function. Update the documentation for the command to be more specific to the DSC -Resource. - -```go -var getCmd = &cobra.Command{ - Use: "get", - Short: "Gets the current state of a tstoy configuration file.", - Long: `The get command returns the current state of a tstoy configuration -file as a JSON blob to stdout.`, - RunE: getState, -} -``` - -Next, update the `getState` function to report the value of any specified flags. You'll replace -this implementation later, but it's useful for validating that the flags work as expected. - -```go -func getState(cmd *cobra.Command, args []string) error { - if targetScope != config.ScopeUndefined { - fmt.Println("Specified --scope as", targetScope) - } - if targetEnsure != config.EnsureUndefined { - fmt.Println("Specified --ensure as", targetEnsure) - } - if rootCmd.PersistentFlags().Lookup("updateAutomatically").Changed { - fmt.Println("Specified --updateAutomatically as", updateAutomatically) - } - if updateFrequency != 0 { - fmt.Println("Specified --updateFrequency as", updateFrequency) - } - return nil -} -``` - -Run the DSC Resource with different flags to verify the output. +The generated CLI accepts input two ways, and the generated manifest advertises both: ```sh -go run ./main.go get --scope machine --ensure absent -go run ./main.go get --updateAutomatically --updateFrequency 45 -go run ./main.go get --updateAutomatically=false --ensure Absent +go run . get --input '{ "scope": "machine" }' +echo '{ "scope": "machine" }' | go run . get ``` -```Output -Specified --ensure as absent -Specified --scope as machine - -Specified --updateAutomatically as true -Specified --updateFrequency as 45 - -Specified --ensure as absent -Specified --updateAutomatically as false -``` - -Next, you can test that the arguments are validating correctly: +Try the failure paths. Without any input: ```sh -go run ./main.go get --scope 1 -go run ./main.go get --scope incorrect -go run ./main.go get --updateFrequency 100 +go run . get ``` -```Output -Error: invalid argument "1" for "--scope" flag: must be 'machine', 'user' - -Error: invalid argument "incorrect" for "--scope" flag: must be 'machine', 'user' - -Error: invalid argument "100" for "--updateFrequency" flag: invalid value 100; -must be an integer between 1 and 90, inclusive +```json +{"error":"no input provided: use --input or pipe JSON to stdin"} ``` -With validation confirmed, the command can accept command-line arguments. - -## Handle JSON input over stdin - -When command-based DSC Resources are called by `dsc` itself, they may get their input as a JSON -blob over `stdin`. While specifying flags at the command line is useful for testing, it's more -robust for the DSC Resource to support sending input over `stdin`. This also makes it easier for -other integrating tools to interact with the DSC Resource. - -### Add handlers for JSON input - -Create the `input` folder in the project root. Inside it, create the `input.go` file. This file -defines how you handle input from `stdin`. +The message is a JSON object on stderr (the structured trace format the DSC engine +ingests and re-surfaces), and the process exits with code `4`, DSC's convention for +invalid input. Malformed JSON behaves the same way: ```sh -mkdir ./input -touch ./input/input.go -``` - -Open `input/input.go` and set the package name to `input`. - -```go -package input +go run . get --input '{ not json' ``` -Now you need to ensure that the DSC Resource can handle a JSON blob as input along with the other -flags. Implement a new type that satisfies the [pflag.Value][05] interface. - -Define a type called `JSONFlag` as a struct with the `Target` field as the `any` type. - -```go -type JSONFlag struct { - Target any -} +```json +{"error":"input is not valid JSON"} ``` -Implement the `String`, `Set`, and `Type` methods for `JSONFlag`. +Valid JSON that doesn't match the `Settings` struct (for example, `"updateFrequency": +"weekly"`) exits with code `3`, the JSON deserialization error code. You'll see the full +exit code table in the generated manifest in step 6. -```go -func (f *JSONFlag) String() string { - b, err := json.Marshal(f.Target) - if err != nil { - return "failed to marshal object" - } - return string(b) -} +## Add semantic validation -func (f *JSONFlag) Set(v string) error { - return json.Unmarshal([]byte(v), f.Target) -} +Schema validation catches structural problems, but only when the resource is invoked +through DSC. Someone running `gotstoy` directly bypasses the schema. The resource is +responsible for its own semantic validation. -func (f *JSONFlag) Type() string { - return "json" -} -``` - -### Add handler for stdin - -Next, the DSC Resource needs a function that can handle reading from `stdin`. The function must -operate on the list of arguments for the DSC Resource. If there's input on `stdin`, it's added to -the list of arguments with the `--inputJSON` flag. +The one rule get needs: `scope` must be present and valid. Add this to `settings.go`: ```go -func HandleStdIn(args []string) []string { - info, _ := os.Stdin.Stat() - if (info.Mode() & os.ModeCharDevice) == os.ModeCharDevice { - // do nothing - } else { - stdin, err := io.ReadAll(os.Stdin) - if err != nil { - panic(err) - } - - // remove surrounding whitespace - jsonBlob := strings.Trim(string(stdin), "\n") - jsonBlob = strings.Trim(jsonBlob, "\r") - jsonBlob = strings.TrimSpace(jsonBlob) - // only add to arguments if the string is non-empty. - if jsonBlob != "" { - args = append(args, "--inputJSON", jsonBlob) - } - } - - return args +import dsc "github.com/LibreDsc/dsc-go-rdk" + +// validateScope checks the required scope property. Validation failures map +// to DSC's invalid-input exit code (4). +func validateScope(scope string) error { + switch scope { + case "machine", "user": + return nil + case "": + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "the scope property is required; must be one of: machine, user") + default: + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "invalid scope '%s'; must be one of: machine, user", scope) + } } ``` -The function doesn't need to validate that the input is valid JSON. Instead, the `JSONFlag` and the -command handle the validation. Implementing the function to append the JSON as an argument also -gives the user the choice to pass a JSON blob as a normal argument. - -### Add inputJSON to the root command { toc_md="Add `inputJSON` flag" } - -Before you can pass a JSON blob to the commands, you must update the root command to accept the -`--inputJson` flag. +`dsc.NewExitCodeErrorf` attaches an exit code to the error. When a handler returns it, the +library logs the message to stderr in the trace format and exits with that code. You +never call `os.Exit` or print errors yourself. -Open `cmd/root.go` and add the `inputJSON` variable with its type as a pointer to -`config.Settings`. +Wire it into the stub `Get` in `main.go`: ```go -var inputJSON *config.Settings -``` - -In the `init` function, add a new persistent flag for `--inputJSON`. - -```go -rootCmd.PersistentFlags().Var( - &input.JSONFlag{Target: &inputJSON}, - "inputJSON", - "Specify options as a JSON blob instead of using the scope, ensure, and update* flags.", -) -``` - -The new flag uses the `JSONFlag` type defined in the `input` package and sets the `Target` field to -the `inputJSON` variable. Because that variable has the `Settings` type, when the flag -automatically unmarshals the input, it deserializes the blob to `Settings` for the DSC Resource. - -### Update root command to handle stdin { toc_text "Update root command for stdin" } - -Finally, you must update the `Execute` function to take a list of arguments, because argument -passing must be explicitly handled to support `stdin`. - -```go -// Unlike normal cobra apps, this one sets the args explicitly from main to -// account for JSON blobs sent from stdin. -func Execute(args []string) { - rootCmd.SetArgs(args) - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } +func (Handler) Get(_ context.Context, in Settings) (Settings, error) { + if err := validateScope(in.Scope); err != nil { + return in, err + } + return in, nil } ``` -### Update main to handle `stdin` - -Now that the `HandleStdIn` function is defined and the root command is updated, `main` needs to be -updated to pass the arguments to the `Execute` function and handle JSON over `stdin`. +Verify the validation: -Open `main.go` and replace the `main` function. - -```go -func main() { - args := []string{} - for index, arg := range os.Args { - // skip the first index, because it's the application name - if index > 0 { - args = append(args, arg) - } - } - - // Check stdin and add any found JSON blob after an --inputJSON flag. - args = input.HandleStdIn(args) - - // execute with the combined arguments - cmd.Execute(args) -} +```sh +go run . get --input '{ "scope": "cluster" }' ``` -## Verify using JSON as input - -Now that the DSC Resource can accept JSON as input over `stdin` or as an argument, the `get` -command needs to handle that input. - -Open `cmd/get.go` and ensure that the `getState` function prints the value for the `inputJSON` -variable if it's not null. - -```go -func getState(cmd *cobra.Command, args []string) error { - if inputJSON != nil { - fmt.Println("Specified inputJSON as:") - (*inputJSON).Print() - } - if targetScope != config.ScopeUndefined { - fmt.Println("Specified --scope as", targetScope) - } - if targetEnsure != config.EnsureUndefined { - fmt.Println("Specified --ensure as", targetEnsure) - } - if rootCmd.PersistentFlags().Lookup("updateAutomatically").Changed { - fmt.Println("Specified --updateAutomatically as", updateAutomatically) - } - if updateFrequency != 0 { - fmt.Println("Specified --updateFrequency as", updateFrequency) - } - return nil -} +```json +{"error":"invalid scope 'cluster'; must be one of: machine, user"} ``` -You can verify the behavior with a few commands: - ```sh -go run ./main.go get --inputJSON '{ "scope": "machine" }' -go run ./main.go get --inputJSON '{ "scope": "machine" }' --scope user -'{ "scope": "machine" }' | go run ./main.go get -'{ "scope": "machine" }' | go run ./main.go get --scope user -'{ "ensure": "present" }' | go run ./main.go get +echo $? # $LASTEXITCODE in PowerShell ``` -```Output -Specified inputJSON as: -{"scope":"machine"} - -Specified inputJSON as: -{"scope":"machine"} -Specified --scope as user - -Specified inputJSON as: -{"scope":"machine"} - -Specified inputJSON as: -{"scope":"machine"} -Specified --scope as user - -Specified inputJSON as: -{"ensure":"present"} +```text +4 ``` -The DSC Resource is now fully implemented to handle input as arguments and as a JSON blob over -stdin. +If you want diagnostic logging as you develop, `dsc.Logger` writes leveled JSON trace +lines to stderr (for example `dsc.Logger.Debugf("checking %s", path)`), honoring the +`DSC_TRACE_LEVEL` environment variable the engine sets. Stdout stays reserved for +protocol output. -[05]: https://pkg.go.dev/github.com/spf13/pflag#Value +With input handling covered, you can implement the real get operation. diff --git a/samples/go/resources/first/docs/4-implement-get.md b/samples/go/resources/first/docs/4-implement-get.md index f730351..5826a3b 100644 --- a/samples/go/resources/first/docs/4-implement-get.md +++ b/samples/go/resources/first/docs/4-implement-get.md @@ -5,305 +5,128 @@ dscs: menu_title: 4. Implement get --- -To implement the get command, the DSC Resource needs to be able to find and marshal the settings -from a specific `tstoy` configuration file. +To implement the get operation, the DSC Resource needs to find a specific `tstoy` +configuration file and translate its contents into an instance of `Settings`. -Recall from [About the TSToy application][01] that you can use the `tstoy show path` command to get -the full path to the applications configuration files. The DSC Resource can use those commands -instead of trying to generate the paths itself. +Recall from [About the TSToy application][01] that you can use the `tstoy show path` +command to get the full path to the application's configuration files. The DSC Resource +can use that command instead of trying to generate the paths itself. -## Define get helper functions and methods { toc_md="Define `get` helpers" } +## Define get helper functions { toc_md="Define `get` helpers" } -Open the `config/config.go` file. In it, add the `getAppConfigPath` function. It should take a -`Scope` value as input and return a string and error. +Open `settings.go`. Add the `configPath` function, which asks `tstoy` where the config +file for a scope lives: ```go -func getAppConfigPath(s Scope) (string, error) { - args := []string{"show", "path", s.String()} - - output, err := exec.Command("tstoy", args...).Output() - if err != nil { - return "", err - } - - // We need to trim trailing whitespace automatically emitted for the path. - path := string(output) - path = strings.Trim(path, "\n") - path = strings.Trim(path, "\r") - - return path, nil +// configPath asks the tstoy application where the scope's config file lives. +func configPath(scope string) (string, error) { + out, err := exec.Command("tstoy", "show", "path", scope).Output() + if err != nil { + return "", fmt.Errorf( + "failed to query tstoy for the %s config file path (is tstoy on PATH?): %w", + scope, err) + } + return strings.TrimSpace(string(out)), nil } ``` -The function generates the arguments to send to `tstoy` and calls the command. - -Next, update the `Settings` struct to include a private field for the configuration path and -implement the public `GetConfigPath` function to retrieve the path for the instance of the -configuration file. +Next, add `readConfigMap` to read a config file as a raw map. Reading into a map instead +of a struct matters for set later: it preserves any settings in the file that this +resource doesn't manage. ```go -type Settings struct { - Ensure Ensure `json:"ensure,omitempty"` - Scope Scope `json:"scope,omitempty"` - UpdateAutomatically *bool `json:"updateAutomatically,omitempty"` - UpdateFrequency Frequency `json:"updateFrequency,omitempty"` - configPath string -} - -func (s *Settings) GetConfigPath() (string, error) { - if s.configPath == "" { - path, err := getAppConfigPath(s.Scope) - if err != nil { - return "", err - } - s.configPath = path - } - - return s.configPath, nil +func readConfigMap(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("config file '%s' is not valid JSON: %w", path, err) + } + return cfg, nil } ``` -The `GetConfigPath` function reduces the number of calls the DSC Resource needs to make to the -application when you implement the `set` command. - -Now that the DSC Resource can find the correct path, it needs to be able to retrieve settings from -the configuration file. You need to implement two more private functions: - -1. `getAppConfigMap` to retrieve the configuration file settings as a generic `map[string]any` - object. -1. `getAppConfigSettings` to convert the generic map into a `Settings` instance. - -First, implement `getAppConfigMap` to read the configuration file and unmarshal the JSON. +Finally, add `settingsAt`, which turns a config file into the resource's state model: ```go -func getAppConfigMap(path string) (map[string]any, error) { - var config map[string]any - - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - err = json.Unmarshal(data, &config) - return config, err +// settingsAt reads the config file into the resource's state model. A +// missing file is the valid "absent" state, not an error. +func settingsAt(scope, path string) (Settings, error) { + cfg, err := readConfigMap(path) + if errors.Is(err, fs.ErrNotExist) { + return Settings{Scope: scope, Ensure: "absent"}, nil + } + if err != nil { + return Settings{Scope: scope}, err + } + + current := Settings{Scope: scope, Ensure: "present"} + if updates, ok := cfg["updates"].(map[string]any); ok { + if auto, ok := updates["automatic"].(bool); ok { + current.UpdateAutomatically = &auto + } + if freq, ok := updates["checkFrequency"].(float64); ok { + current.UpdateFrequency = int(freq) + } + } + return current, nil } ``` -Next, implement `getAppConfigSettings` to convert the map into a `Settings` instance. - -```go -func getAppConfigSettings(scope Scope, config map[string]any) (Settings, error) { - // ensure the map keys are all strings. - maps.IntfaceKeysToStrings(config) - - // Since we found the config, we know the scope and ensure state. - settings := Settings{ - Scope: scope, - Ensure: EnsurePresent, - } +The first branch encodes an important DSC convention: when the instance doesn't exist, get +reports that as valid state (`ensure: absent`) — not as an error. DSC needs the "it's +not there" answer to decide whether set has work to do. - // Check for the update settings - updates, ok := config["updates"] - if ok { - for key, value := range updates.(map[string]any) { - switch key { - case "automatic": - auto := value.(bool) - settings.UpdateAutomatically = &auto - case "checkFrequency": - intValue := int(value.(float64)) - frequency := Frequency(intValue) - settings.UpdateFrequency = frequency - } - } - } +## Implement the Get method - return settings, nil -} -``` - -With those private functions implemented, you can add methods to `Settings` for retrieving the map -of settings and the actual state. +Replace the stub `Get` method with the real implementation: ```go -func (s *Settings) GetConfigMap() (map[string]any, error) { - path, err := s.GetConfigPath() - if err != nil { - return nil, err - } - return getAppConfigMap(path) -} - -func (s *Settings) GetConfigSettings() (Settings, error) { - config, err := s.GetConfigMap() - if errors.Is(err, os.ErrNotExist) { - return Settings{ - Ensure: EnsureAbsent, - Scope: s.Scope, - }, nil - } else if err != nil { - return Settings{}, err - } - - return getAppConfigSettings(s.Scope, config) +// Get returns the current state of the config file for the requested scope. +func (Handler) Get(_ context.Context, in Settings) (Settings, error) { + if err := validateScope(in.Scope); err != nil { + return in, err + } + path, err := configPath(in.Scope) + if err != nil { + return in, err + } + return settingsAt(in.Scope, path) } ``` -## Update getState to return one instance { toc_md="Support returning one instance" } +That's the entire get operation. The library already handles parsing the input JSON into +`Settings`, serializing the returned state as one compact JSON line on stdout, and mapping +any returned error to a trace message and exit code. -Open the `cmd/get.go` file and return to the `getState` function. Instead of printing the inputs, -the function should: +## Try it out -1. Create an instance of `Settings` from the inputs. -1. Validate the instance. -1. Get the current settings from the system. -1. Print the results. - -```go -func getState(cmd *cobra.Command, args []string) error { - // Only the scope is used when retrieving current state. - s := config.Settings{ - Scope: targetScope, - } - - err := s.Validate() - if err != nil { - return fmt.Errorf("can't get settings; %s", err) - } - - config, err := s.GetConfigSettings() - if err != nil { - return fmt.Errorf("failed to get settings; %s", err) - } - - return config.Print() -} -``` - -Now you can run the updated command to see how it works: +With `tstoy` on your `PATH`, get the current state of both scopes: ```sh -go run ./main.go get -go run ./main.go get --scope machine -go run ./main.go get --inputJSON '{ "scope": "user" }' -'{ "scope": "user" }' | go run ./main.go get --scope machine -go run ./main.go get --scope machine --ensure present -``` - -```Output -Error: can't get settings; the Scope setting isn't defined. Must define a Scope -for Settings - -{"ensure":"absent","scope":"machine"} - -{"ensure":"absent","scope":"user"} - -{"ensure":"absent","scope":"machine"} - -{"ensure":"absent","scope":"machine"} -``` - -## Update getState to return all instances { toc_md="Support returning all instances" } - -DSC Resources may optionally return the current state for every manageable instance. This is -convenient for users who want to get information about a resource with a single command. It's also -useful for higher-order tools that can cache current state. - -To add this functionality, add the `all` variable as a boolean in `cmd/get.go`. - -```go -var all bool -``` - -In the `init` function, add `--all` as a new flag for the command. - -```go -func init() { - rootCmd.AddCommand(getCmd) - getCmd.Flags().BoolVar( - &all, - "all", - false, - "Get the configurations for all scopes.", - ) -} +go run . get --input '{ "scope": "machine" }' +echo '{ "scope": "user" }' | go run . get ``` -Update the `getState` function to handle the new flag by making the behavior loop. The function -should handle a few different cases for the input: - -- If the `--all` flag is used, the function should return the instance for both scopes. -- If the `--targetScope` flag is used, the function should return the instance for that scope. -- If `--targetScope` is used with a JSON blob from `--inputJSON` or stdin, the JSON value should be - ignored. -- If the command receives a JSON blob from `--inputJSON` or stdin without the `--targetScope` flag, - the command should use that value. - -```go -func getState(cmd *cobra.Command, args []string) error { - list := []config.Settings{} - if all { - list = append( - list, - config.Settings{Scope: config.ScopeMachine}, - config.Settings{Scope: config.ScopeUser}, - ) - } else if targetScope != config.ScopeUndefined { - // explicit --scope overrides JSON - list = append(list, config.Settings{Scope: targetScope}) - } else if inputJSON != nil { - list = append(list, *inputJSON) - } else { - // fails but with consistent messaging - list = append(list, config.Settings{Scope: targetScope}) - } - - for _, s := range list { - - err := s.Validate() - if err != nil { - return fmt.Errorf("can't get settings; %s", err) - } - - config, err := s.GetConfigSettings() - if err != nil { - return fmt.Errorf("failed to get settings; %s", err) - } - - err = config.Print() - if err != nil { - return err - } - } - - return nil -} +```json +{"scope":"machine","ensure":"absent"} +{"scope":"user","ensure":"absent"} ``` -Run the updated command: +If you've never configured TSToy, both scopes report `ensure: absent` — the files don't +exist yet. Create one with the `tstoy` application itself and see the resource pick it up: ```sh -go run ./main.go get --all -go run ./main.go get --scope machine -go run ./main.go get --inputJSON '{"scope": "user"}' -go run ./main.go get --inputJSON '{"scope": "user"}' --scope machine -'{ - "scope": "machine", - "ensure": "present" -}' | go run ./main.go get +tstoy set user --auto=true --frequency 45 +go run . get --input '{ "scope": "user" }' ``` -```Output -{"ensure":"absent","scope":"machine"} -{"ensure":"absent","scope":"user"} - -{"ensure":"absent","scope":"machine"} - -{"ensure":"absent","scope":"user"} - -{"ensure":"absent","scope":"machine"} - -{"ensure":"absent","scope":"machine"} +```json +{"scope":"user","ensure":"present","updateAutomatically":true,"updateFrequency":45} ``` +The resource now reports real state. Next, you'll teach it to change that state. + [01]: /tstoy/about diff --git a/samples/go/resources/first/docs/5-implement-set.md b/samples/go/resources/first/docs/5-implement-set.md index fdfa3c0..d855392 100644 --- a/samples/go/resources/first/docs/5-implement-set.md +++ b/samples/go/resources/first/docs/5-implement-set.md @@ -5,497 +5,165 @@ dscs: menu_title: 5. Implement set --- -Up to this point, the DSC Resource has been primarily concerned with representing and getting the -current state of an instance. To be fully useful, it needs to be able to change a configuration file -to enforce the desired state. +Up to this point, the DSC Resource has been primarily concerned with representing and +getting the current state of an instance. To be fully useful, it needs to be able to +change a configuration file to enforce the desired state. -## Minimally implement set +## Validate the desired state -Open the `cmd/set.go` file. - -At the bottom of the file, create a new `setState` function that takes two parameters, a pointer to -`cobra.Command` and a slice of strings, and returns an error. - -```go -func setState(cmd *cobra.Command, args []string) error { - return nil -} -``` - -Replace the `Run` entry in the `setCmd` variable's definition with the `RunE` field set to the -`setState` function. Update the documentation for the command to be more specific to the DSC -Resource. +Set accepts more properties than get, so it needs more validation: `ensure` must be a +valid value (defaulting to `present` when omitted, matching the schema default), and +`updateFrequency` must be in range. Add this to `settings.go`: ```go -var setCmd = &cobra.Command{ - Use: "set", - Short: "Sets a tstoy configuration file to the desired state.", - Long: `The set command ensures that the tstoy configuration file for a -specific scope has the desired settings. It returns the updated settings state -as a JSON blob to stdout.`, - RunE: setState, +// validateDesired normalizes and validates a desired state before set. +func validateDesired(s *Settings) error { + if err := validateScope(s.Scope); err != nil { + return err + } + switch s.Ensure { + case "": + s.Ensure = "present" + case "present", "absent": + default: + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "invalid ensure '%s'; must be one of: present, absent", s.Ensure) + } + if s.Ensure == "present" && s.UpdateFrequency != 0 && + (s.UpdateFrequency < 1 || s.UpdateFrequency > 90) { + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "invalid updateFrequency %d; must be an integer between 1 and 90, inclusive", + s.UpdateFrequency) + } + return nil } ``` -Next, update the `setState` function to convert the inputs into an instance of `Settings`. For now, -the function should validate the desired state and print it. - -```go -func setState(cmd *cobra.Command, args []string) error { - enforcing := config.Settings{} - - if inputJSON != nil { - enforcing = *inputJSON - } - if targetScope != config.ScopeUndefined { - enforcing.Scope = targetScope - } - if targetEnsure != config.EnsureUndefined { - enforcing.Ensure = targetEnsure - } - if rootCmd.PersistentFlags().Lookup("updateAutomatically").Changed { - enforcing.UpdateAutomatically = &updateAutomatically - } - if updateFrequency != 0 { - enforcing.UpdateFrequency = updateFrequency - } - - err := enforcing.Validate() - if err != nil { - return fmt.Errorf("can't enforce settings; %s", err) - } - - return enforcing.Print() -} -``` - -Verify the behavior for the set command. - -```sh -go run ./main.go set --scope machine --ensure present --updateAutomatically=false - -'{ - "scope": "user", - "ensure": "present", - "updateAutomatically": true, - "updateFrequency": 45 -}' | go run ./main.go set - -'{ - "scope": "user", - "ensure": "present", - "updateAutomatically": true, - "updateFrequency": 45 -}' | go run ./main.go set --ensure absent -``` - -```Output -{"ensure":"present","scope":"machine","updateAutomatically":false} - -{"ensure":"present","scope":"user","updateAutomatically":true,"updateFrequency":45} - -{"ensure":"absent","scope":"user","updateAutomatically":true,"updateFrequency":45} -``` - -## Implement helper functions and methods for set { toc_md="Implement `set` helpers" } - -At this point, the DSC Resource is able to validate the desired state. It needs to be able to -actually change the configuration files. +## Implement the Set method -Open `config/config.go` and define an `Enforce` method for `Settings` that returns a pointer to an -instance of `Settings` and an error. It should: +Add the `Set` method to the handler. Implementing `Set` makes the handler satisfy the +library's `Settable` interface — the resource, and the manifest you generate in the next +step, gain the set capability from this method's existence alone. -1. Validate the settings. -1. Get the current settings for that scope. -1. Decide what it needs to do to enforce the desired state, if anything. +The method handles three cases: remove the file (`ensure: absent`), create it, or update +it. The create and update paths collapse into one, because the method merges the desired +settings into whatever the file currently contains — preserving settings the resource +doesn't manage. ```go -func (s *Settings) Enforce() (*Settings, error) { - err := s.Validate() - if err != nil { - return nil, err - } - - current, err := s.GetConfigSettings() - if err != nil { - return nil, err - } - - if s.Ensure == EnsureAbsent { - // remove the config file - } - - if current.Ensure == EnsureAbsent { - // create the config file - } - - // update the config file - return s, nil +// Set enforces the desired state and returns the state after enforcement. +func (Handler) Set(_ context.Context, desired Settings) (Settings, error) { + if err := validateDesired(&desired); err != nil { + return desired, err + } + + path, err := configPath(desired.Scope) + if err != nil { + return desired, err + } + + if desired.Ensure == "absent" { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return desired, fmt.Errorf("failed to remove config file '%s': %w", path, err) + } + return Settings{Scope: desired.Scope, Ensure: "absent"}, nil + } + + // Read the existing config file as a raw map so settings this resource + // doesn't manage are preserved. + cfg, err := readConfigMap(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return desired, err + } + if cfg == nil { + cfg = map[string]any{} + } + + updates, _ := cfg["updates"].(map[string]any) + if updates == nil { + updates = map[string]any{} + } + if desired.UpdateAutomatically != nil { + updates["automatic"] = *desired.UpdateAutomatically + } + if desired.UpdateFrequency != 0 { + updates["checkFrequency"] = desired.UpdateFrequency + } + if len(updates) > 0 { + cfg["updates"] = updates + } + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return desired, fmt.Errorf("failed to create folder for config file: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return desired, fmt.Errorf("unable to convert settings to json: %w", err) + } + if err := os.WriteFile(path, data, 0o640); err != nil { + return desired, fmt.Errorf("unable to write config file: %w", err) + } + + // Re-read from disk so the returned after-state is the actual state. + return settingsAt(desired.Scope, path) } ``` -This shows that the method needs to handle three different change types for the configuration file: +Details worth noticing: -1. It needs to remove the configuration file it's not supposed to exist. -1. It needs to create the configuration file when it's supposed to exist and doesn't exist. -1. It needs to update the configuration file when it's supposed to exist and does exist. +- Removing an already-absent file succeeds. Set must be _idempotent_ — DSC may enforce + the same configuration repeatedly, and enforcing an already-correct state is success, + not an error. +- The `*bool` pointer earns its keep here: `updates["automatic"]` is only touched when the + user actually specified `updateAutomatically`, so an unspecified property never + overwrites the current value. +- The method returns `settingsAt(...)` rather than `desired`: the contract for set output + is the _actual_ state after enforcement, which may include properties the desired state + didn't mention (like an update frequency already in the file). -Remember that a DSC Resource should be idempotent, only making changes when required. +## Declare the set behavior -### Handle removing an instance - -Implement the remove method first. It should take an instance of `Settings` as input and return -both a pointer to an instance of `Settings` and an error. +Tell the library what set returns by extending the `ResourceConfig` in `main.go`: ```go -func (s *Settings) remove(current Settings) (*Settings, error) { - if current.Ensure == EnsureAbsent { - return s, nil - } - - // At this point, s.GetConfigPath() has already run without an error, - // so we can rely on accessing the private field directly. - err := os.Remove(s.configPath) - if err != nil { - return ¤t, err - } - - return s, nil -} + // Set prints the state after enforcement; the resource is idempotent + // and validates state itself, so DSC skips its pre-set test. + SetReturn: dsc.SetReturnState, + ImplementsPretest: true, ``` -If the file doesn't exist, the method returns the desired state and nil. If it does exist, the -method tries to delete the file. If the operation fails, it returns the current state and the error -message. If the operation succeeds, it returns the desired state and nil. - -### Handle creating an instance +`SetReturn: dsc.SetReturnState` declares that set prints the post-enforcement state — +the library frames the output and the manifest advertises `return: state`. +`ImplementsPretest: true` tells DSC the resource handles being invoked unconditionally, so +the engine doesn't need to run a test before every set. -Next, implement the `create` method. It needs the same inputs and outputs as `remove`. It should -create the file and parent folders if needed, then compose the JSON for the configuration file and -write it. +## Try it out -```go -func (s *Settings) create(currentSettings Settings) (*Settings, error) { - configDir := filepath.Dir(s.configPath) - if err := os.MkdirAll(configDir, 0750); err != nil { - return ¤tSettings, fmt.Errorf( - "failed to create folder for config file in '%s': %s", - configDir, - err, - ) - } - configFile, err := os.Create(s.configPath) - if err != nil { - return ¤tSettings, fmt.Errorf( - "failed to create config file '%s': %s", - s.configPath, - err, - ) - } +Enforce a desired state for the user scope and read it back: - // Create the JSON for the tstoy configuration file. - // Can't just marshal the Settings instance because it's a representation - // of the settings, not a literal blob of the settings. - settings := make(map[string]any) - updates := make(map[string]any) - addUpdates := false - if s.UpdateAutomatically != nil { - addUpdates = true - updates["automatic"] = *s.UpdateAutomatically - } - if s.UpdateFrequency != 0 { - addUpdates = true - updates["checkFrequency"] = s.UpdateFrequency - } - if addUpdates { - settings["updates"] = updates - } - - configJSON, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return ¤tSettings, fmt.Errorf( - "unable to convert settings to json: %s", - err, - ) - } - - _, err = configFile.Write(configJSON) - if err != nil { - return ¤tSettings, fmt.Errorf( - "unable to write config file: %s", - err, - ) - } - - return s, nil -} +```sh +go run . set --input '{ + "scope": "user", + "updateAutomatically": true, + "updateFrequency": 30 +}' +go run . get --input '{ "scope": "user" }' ``` -### Handle updating an instance - -With `create` and `remove` implemented, the last method to implement is `update`. It needs the same -inputs and outputs as the others. It should: - -1. Retrieve the actual map of settings in the configuration file. -1. Update only the settings that are out of sync. -1. Only update the configuration file if at least one setting needs enforcing. - -```go -func (s *Settings) update(current Settings) (*Settings, error) { - writeConfig := false - - currentMap, err := current.GetConfigMap() - if err != nil { - return nil, err - } - - // ensure the map keys are all strings. - maps.IntfaceKeysToStrings(currentMap) - - // Check for the update settings - updates, ok := currentMap["updates"] - if !ok { - currentMap["updates"] = make(map[string]any) - updates = currentMap["updates"] - } - - // Only update if desired state defines UpdateAutomatically and: - // 1. Current state doesn't define it, or - // 2. Current state's setting doesn't match desired state. - shouldSetUA := false - if s.UpdateAutomatically != nil { - if current.UpdateAutomatically == nil { - shouldSetUA = true - } else if *s.UpdateAutomatically != *current.UpdateAutomatically { - shouldSetUA = true - } - } - - if shouldSetUA { - writeConfig = true - updates.(map[string]any)["automatic"] = *s.UpdateAutomatically - } else if current.UpdateAutomatically != nil { - updates.(map[string]any)["automatic"] = *current.UpdateAutomatically - } - - // Only update if desired state defines UpdateFrequency and: - // 1. Current state doesn't define it, or - // 2. Current state's setting doesn't match desired state. - if s.UpdateFrequency != 0 && s.UpdateFrequency != current.UpdateFrequency { - writeConfig = true - updates.(map[string]any)["checkFrequency"] = s.UpdateFrequency - } else if current.UpdateFrequency != 0 { - updates.(map[string]any)["checkFrequency"] = current.UpdateFrequency - } - - // no changes made, leave config untouched - if !writeConfig { - return s, nil - } - - currentMap["updates"] = updates.(map[string]any) - - configJson, err := json.MarshalIndent(currentMap, "", " ") - if err != nil { - return ¤t, fmt.Errorf( - "unable to convert updated settings to json: %s", - err, - ) - } - - err = os.WriteFile(s.configPath, configJson, 0750) - if err != nil { - return ¤t, fmt.Errorf( - "unable to write updated config file: %s", - err, - ) - } - - return s, nil -} +```json +{"scope":"user","ensure":"present","updateAutomatically":true,"updateFrequency":30} +{"scope":"user","ensure":"present","updateAutomatically":true,"updateFrequency":30} ``` -## Finish implementing `set` - -With the `create`, `remove`, and `update` methods implemented, update the `Enforce` method to -call them as required. +Then remove it: -```go -func (s *Settings) Enforce() (*Settings, error) { - err := s.Validate() - if err != nil { - return nil, err - } - - current, err := s.GetConfigSettings() - if err != nil { - return nil, err - } - - if s.Ensure == EnsureAbsent { - return s.remove(current) - } - - if current.Ensure == EnsureAbsent { - return s.create(current) - } - - return s.update(current) -} +```sh +go run . set --input '{ "scope": "user", "ensure": "absent" }' ``` -Open `cmd/set.go` and edit the `setState` function to call the `Enforce` method. - -```go -func setState(cmd *cobra.Command, args []string) error { - enforcing := config.Settings{} - - if inputJSON != nil { - enforcing = *inputJSON - } - if targetScope != config.ScopeUndefined { - enforcing.Scope = targetScope - } - if targetEnsure != config.EnsureUndefined { - enforcing.Ensure = targetEnsure - } - if rootCmd.PersistentFlags().Lookup("updateAutomatically").Changed { - enforcing.UpdateAutomatically = &updateAutomatically - } - if updateFrequency != 0 { - enforcing.UpdateFrequency = updateFrequency - } - - final, err := enforcing.Enforce() - if err != nil { - return fmt.Errorf("can't enforce settings; %s", err) - } - - return final.Print() -} +```json +{"scope":"user","ensure":"absent"} ``` -## Verify behavior - -With the set command fully implemented, you can verify the behavior: - -1. Show TSToy's configuration information before changing any state. - - ```sh - tstoy show - ``` - - ```Output - Default configuration: { - "Updates": { - "Automatic": false, - "CheckFrequency": 90 - } - } - Machine configuration: {} - User configuration: {} - Final configuration: { - "Updates": { - "Automatic": false, - "CheckFrequency": 90 - } - } - ``` - -1. Run the `get` command to see how the DSC Resource reports on current state: - - ```sh - go run ./main.go get --scope machine - ``` - - ```json - {"ensure":"absent","scope":"machine"} - ``` - -1. Enforce the desired state with the `set` command. - - ```sh - go run ./main.go set --scope machine --ensure present --updateAutomatically=false - ``` - - ```json - {"ensure":"present","scope":"machine","updateAutomatically":false} - ``` - -1. Verify that the output from the `set` command matches the output from `get` after enforcing the - desired state. - - ```sh - go run ./main.go get --scope machine - ``` - - ```json - {"ensure":"present","scope":"machine","updateAutomatically":false} - ``` - -1. Use the `tstoy show` command to see how the configuration changes affected TSToy. - - ```sh - tstoy show --only machine,final - ``` - - ```Output - Machine configuration: { - "Updates": { - "Automatic": false - } - } - Final configuration: { - "Updates": { - "Automatic": false, - "CheckFrequency": 90 - } - } - ``` - -1. Enforce desired state for the user-scope configuration file. - - ```sh - '{ - "scope": "user", - "ensure": "present", - "updateAutomatically": true, - "updateFrequency": 45 - }' | go run ./main.go set - ``` - - ```json - {"ensure":"present","scope":"user","updateAutomatically":true,"updateFrequency":45} - ``` - -1. Use the `tstoy show` command to see how the configuration changes affected TSToy. - - ```sh - tstoy show - ``` - - ```Output - Default configuration: { - "Updates": { - "Automatic": false, - "CheckFrequency": 90 - } - } - Machine configuration: { - "Updates": { - "Automatic": false - } - } - User configuration: { - "Updates": { - "Automatic": true, - "CheckFrequency": 45 - } - } - Final configuration: { - "Updates": { - "Automatic": true, - "CheckFrequency": 45 - } - } - ``` +The resource is now fully implemented. The remaining step before DSC can use it is the +resource manifest, which with the RDK, you don't have to write by hand. diff --git a/samples/go/resources/first/docs/6-author-manifest.md b/samples/go/resources/first/docs/6-author-manifest.md index b5efd5d..3b24e8f 100644 --- a/samples/go/resources/first/docs/6-author-manifest.md +++ b/samples/go/resources/first/docs/6-author-manifest.md @@ -1,165 +1,96 @@ --- -title: Step 6 - Author the DSC Resource manifest +title: Step 6 - Generate the DSC Resource manifest weight: 6 dscs: - menu_title: 6. Author manifest + menu_title: 6. Generate the manifest --- -The DSC Resource is now fully implemented. The last required step to use it with DSC is to author a -resource manifest. Command-based DSC Resources must have a JSON file that follows the naming -convention `.dsc.resource.json`. That's the manifest file for the resource. It -informs DSC and other higher-order tools about how the DSC Resource is implemented. +Command-based DSC Resources must have a manifest — a JSON file following the naming +convention `.dsc.resource.json` — that tells DSC and higher-order tools +how the resource is implemented: how to invoke each operation, what the instance schema +is, and what the exit codes mean. -Create a new file called `gotstoy.dsc.resource.json` in the project folder and open it. +When authoring a resource by hand, writing the manifest and keeping it synchronized with +the code is a meaningful chunk of the work. With dsc-go-rdk you don't write it at all: the +library generates the manifest from the `ResourceConfig` plus the capabilities your +handler actually implements, so the manifest can never drift from the code. -```sh -touch ./gotstoy.dsc.resource.json -code ./gotstoy.dsc.resource.json -``` +## Inspect the generated manifest -Add basic metadata for the DSC Resource. +Every dsc-go-rdk resource has a `manifest` subcommand: -```json -{ - "manifestVersion": "1.0", - "type": "TSToy.Example/gotstoy", - "version": "0.1.0", - "description": "A DSC Resource written in go to manage TSToy." -} -``` - -To inform DSC about how to get the current state of an instance, add the `get` key to the manifest. - -```json -{ - "manifestVersion": "1.0", - "type": "TSToy.Example/gotstoy", - "version": "0.1.0", - "description": "A DSC Resource written in go to manage TSToy.", - "get": { - "executable": "gotstoy", - "args": ["get"], - "input": "stdin" - } -} +```sh +go run . manifest ``` -The `executable` key indicates the name of the binary `dsc` should use. The `args` key indicates -that `dsc` should call `gotstoy get` to get the current state. The `input` key indicates that `dsc` -should pass the settings to the DSC Resource as a JSON blob over `stdin`. Even though the DSC -Resource can use argument flags, setting this value to JSON makes the integration more robust and -maintainable. - -Next, define the `set` key in the manifest to inform DSC how to enforce the desired state of an -instance. +The command prints the manifest as one JSON line. To write it as a pretty-printed file +with the conventional name, use `--out-dir`: -```json -{ - "manifestVersion": "1.0", - "type": "TSToy.Example/gotstoy", - "version": "0.1.0", - "description": "A DSC Resource written in go to manage TSToy.", - "get": { - "executable": "gotstoy", - "args": ["get"], - "input": "stdin" - }, - "set": { - "executable": "gotstoy", - "args": ["set"], - "input": "stdin", - "preTest": true, - "return": "state" - } -} +```sh +go build -o gotstoy . +./gotstoy manifest --out-dir . ``` -In this section of the manifest, the `preTest` option indicates that the DSC Resource validates the -instance state itself inside the set command. DSC won't test instances of the resource before -invoking the set operation. - -This section also defines the `return` key as `state`, which indicates that the resource returns -the current state of the instance when the command finishes. - -The last section of the manifest that needs to be defined is the `schema`. - -## Define the resource schema - -For this resource, add the JSON Schema representing valid settings in the `embedded` key. An -instance of the resource must meet these criteria: - -1. The instance must be an object. -1. The instance must define the `scope` property. -1. The `scope` property must be a string and set to either `machine` or `user`. -1. If the `ensure` property is specified, must be a string and set to either `present` or `absent`. - If `ensure` isn't specified, it should default to `present`. -1. If the `updateAutomatically` property is specified, it must be a boolean value. -1. If the `updateFrequency` property is specified, it must be an integer between `1` and `90`, - inclusive. +This creates `tstoy.example.gotstoy.dsc.resource.json` — the resource type name, +lowercased, with `/` replaced by `.`: ```json { - "manifestVersion": "1.0", - "type": "TSToy.Example/gotstoy", - "version": "0.1.0", - "description": "A DSC Resource written in go to manage TSToy.", - "get": { - "executable": "gotstoy", - "args": ["get"], - "input": "stdin" - }, - "set": { - "executable": "gotstoy", - "args": ["set"], - "input": "stdin", - "preTest": true, - "return": "state" - }, - "schema": { - "embedded": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Golang TSToy Resource", - "type": "object", - "required": [ - "scope" - ], - "properties": { - "scope": { - "title": "Target configuration scope", - "description": "Defines which of TSToy's config files to manage.", - "type": "string", - "enum": [ - "machine", - "user" - ] - }, - "ensure": { - "title": "Ensure configuration file existence", - "description": "Defines whether the config file should exist.", - "type": "string", - "enum": [ - "present", - "absent" - ], - "default": "present" - }, - "updateAutomatically": { - "title": "Should update automatically", - "description": "Indicates whether TSToy should check for updates when it starts.", - "type": "boolean" - }, - "updateFrequency": { - "title": "Update check frequency", - "description": "Indicates how many days TSToy should wait before checking for updates.", - "type": "integer", - "minimum": 1, - "maximum": 90 - } - } - } - } + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "TSToy.Example/gotstoy", + "version": "0.1.0", + "description": "A DSC Resource written in Go to manage TSToy.", + "tags": ["tstoy", "example", "go"], + "get": { + "executable": "gotstoy", + "args": ["get", { "jsonInputArg": "--input", "mandatory": true }] + }, + "set": { + "executable": "gotstoy", + "args": ["set", { "jsonInputArg": "--input", "mandatory": true }], + "implementsPretest": true, + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Error", + "2": "Resource error", + "3": "JSON serialization error", + "4": "Invalid input", + "5": "Schema validation error", + "6": "Resource not found" + }, + "schema": { + "embedded": { "...": "the schema from step 2, embedded verbatim" } + } } ``` -When authoring a JSON Schema, always include the `title` and `description` keys for every property. -Authoring tools, like VS Code, use those keys to give users context. +Walking through what the library derived and from where: + +- The `$schema`, `type`, `version`, `description`, and `tags` come from your + `ResourceConfig`. +- The `get` and `set` sections exist because your handler implements `Get` and `Set` — + nothing else. If you later add an `Export` method, an `export` section appears + automatically. +- `{ "jsonInputArg": "--input", "mandatory": true }` tells DSC to pass the instance JSON + as the value of the `--input` argument — exactly the invocation you've been typing by + hand since step 1. +- `implementsPretest` and `return: state` come from the `ImplementsPretest` and + `SetReturn` settings you added in step 5. +- There's no `test` section: the handler doesn't implement a test operation, so DSC falls + back to its _synthetic test_ — it runs get and compares the actual state against the + desired state property by property. For this resource, that's exactly the right + behavior, so there's nothing to write. +- The `exitCodes` table documents the codes you saw in step 3, letting DSC render friendly + messages when the resource fails. +- `schema.embedded` is the full schema from step 2. + +## Manifest discovery + +DSC discovers resources by searching every folder in the `PATH` environment variable — +and, if defined, `DSC_RESOURCE_PATH` — for files matching `*.dsc.resource.json`. With +the manifest written next to the `gotstoy` binary and that folder in `PATH`, DSC can find +and invoke the resource. + +You'll validate that integration in the next step. diff --git a/samples/go/resources/first/docs/7-validate.md b/samples/go/resources/first/docs/7-validate.md index 5261676..b50f950 100644 --- a/samples/go/resources/first/docs/7-validate.md +++ b/samples/go/resources/first/docs/7-validate.md @@ -9,12 +9,14 @@ The DSC Resource is now fully implemented. ## Build the resource -To use it with DSC, you need to compile it and ensure DSC can find it in the `PATH`. +To use it with DSC, you need to compile it, generate its manifest, and ensure DSC can find +both in the `PATH`. ``````tabs ````tab { name="Build on Windows" } ```powershell go build -o gotstoy.exe . +.\gotstoy.exe manifest --out-dir . $env:Path = $PWD.Path + ';' + $env:Path ``` ```` @@ -22,6 +24,7 @@ $env:Path = $PWD.Path + ';' + $env:Path ````tab { name="Build on Linux or macOS" } ```sh go build -o gotstoy . +./gotstoy manifest --out-dir . export PATH=$(pwd):$PATH ``` ```` @@ -29,8 +32,8 @@ export PATH=$(pwd):$PATH ## List the resource with DSC { toc_text="List the resource" } -With the resource built and added to the PATH with its manifest, you can use it with DSC instead of -calling it directly. +With the resource built and its manifest next to it in the `PATH`, you can use it with DSC +instead of calling it directly. First, verify that DSC recognizes the DSC Resource. @@ -40,65 +43,22 @@ dsc resource list TSToy.Example/gotstoy ```yaml type: TSToy.Example/gotstoy -version: '' -path: C:\code\dsc\gotstoy\gotstoy.dsc.resource.json +kind: resource +version: 0.1.0 +capabilities: +- get +- set +description: A DSC Resource written in Go to manage TSToy. directory: C:\code\dsc\gotstoy -implementedAs: Command +implementedAs: null author: null properties: [] -requires: null -manifest: - manifestVersion: '1.0' - type: TSToy.Example/gotstoy - version: 0.1.0 - description: A DSC Resource written in go to manage TSToy. - get: - executable: gotstoy - args: - - get - input: stdin - set: - executable: gotstoy - args: - - set - input: stdin - preTest: true - return: state - schema: - embedded: - $schema: https://json-schema.org/draft/2020-12/schema - title: Golang TSToy Resource - type: object - required: - - scope - properties: - scope: - title: Target configuration scope - description: Defines which of TSToy's config files to manage. - type: string - enum: - - machine - - user - ensure: - title: Ensure configuration file existence - description: Defines whether the config file should exist. - type: string - enum: - - present - - absent - default: present - updateAutomatically: - title: Should update automatically - description: Indicates whether TSToy should check for updates when it starts. - type: boolean - updateFrequency: - title: Update check frequency - description: Indicates how many days TSToy should wait before checking for updates. - type: integer - minimum: 1 - maximum: 90 +requireAdapter: null +manifest: # the manifest you generated in step 6 ``` +Note the capabilities: `get` and `set`, exactly the methods your handler implements. + ## Manage state with `dsc resource` Get the current state of the machine-scope configuration file. @@ -109,61 +69,66 @@ Get the current state of the machine-scope configuration file. ```yaml actualState: - ensure: present scope: machine - updateAutomatically: false + ensure: absent ``` -Test whether the user-scope configuration file is absent. +Test whether the machine-scope configuration file is present. Remember that the resource +itself has no test operation — DSC synthesizes it from get: ```sh '{ "scope": "machine", - "ensure": "absent" + "ensure": "present", + "updateAutomatically": false }' | dsc resource test --resource TSToy.Example/gotstoy ``` ```yaml -expected_state: +desiredState: scope: machine - ensure: absent -actualState: ensure: present - scope: machine updateAutomatically: false +actualState: + scope: machine + ensure: absent +inDesiredState: false differingProperties: - ensure +- updateAutomatically ``` -Remove the machine-scope configuration file. +Enforce the desired state: ```sh '{ - "scope": "machine", - "ensure": "absent" + "scope": "machine", + "ensure": "present", + "updateAutomatically": false }' | dsc resource set --resource TSToy.Example/gotstoy ``` ```yaml beforeState: - ensure: present scope: machine - updateAutomatically: false -afterState: ensure: absent +afterState: scope: machine + ensure: present + updateAutomatically: false changedProperties: - ensure +- updateAutomatically ``` ## Manage state with `dsc config` -Save the following configuration file as `gotstoy.dsc.config.yaml`. It defines an instance for both -configuration scopes, disabling automatic updates in the machine scope and enabling it with a -30-day frequency in the user scope. +Save the following configuration file as `gotstoy.dsc.config.yaml`. It defines an instance +for both configuration scopes, disabling automatic updates in the machine scope and +enabling it with a 30-day frequency in the user scope. ```yaml -$schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json resources: - name: All Users Configuration type: TSToy.Example/gotstoy @@ -180,39 +145,10 @@ resources: updateFrequency: 30 ``` -Get the current state of the instances defined in the configuration: +Test whether the instances are in the desired state: ```sh -cat gotstoy.dsc.config.yaml | dsc config get -``` - -```yaml -results: -- name: All Users Configuration - type: TSToy.Example/gotstoy - result: - actualState: - ensure: absent - scope: machine -- name: Current User Configuration - type: TSToy.Example/gotstoy - result: - actualState: - ensure: present - scope: user - updateAutomatically: true - updateFrequency: 45 -messages: [] -hadErrors: false -``` - -The command returns a result for each instance in the configuration, showing the instance's name, -resource type, and actual state. The resource didn't raise any errors or emit any messages. - -Next, test whether the instances are in the desired state: - -```sh -cat gotstoy.dsc.config.yaml | dsc config test +dsc config test --file gotstoy.dsc.config.yaml ``` ```yaml @@ -221,42 +157,40 @@ results: type: TSToy.Example/gotstoy result: desiredState: - updateAutomatically: false scope: machine ensure: present + updateAutomatically: false actualState: - ensure: absent scope: machine - differingProperties: - - updateAutomatically - - ensure + ensure: present + updateAutomatically: false + inDesiredState: true + differingProperties: [] - name: Current User Configuration type: TSToy.Example/gotstoy result: desiredState: - ensure: present scope: user - updateFrequency: 30 + ensure: present updateAutomatically: true + updateFrequency: 30 actualState: - ensure: present scope: user + ensure: present updateAutomatically: true updateFrequency: 45 + inDesiredState: false differingProperties: - updateFrequency messages: [] hadErrors: false ``` -The results show that both resources are out of the desired state. The machine scope configuration -file doesn't exist, while the user scope configuration file has an incorrect value for the update -frequency. - -Enforce the configuration with the `set` command. +The machine scope is already in the desired state from the earlier `dsc resource set`; the +user scope has an incorrect update frequency. Enforce the configuration: ```sh -cat gotstoy.dsc.config.yaml | dsc config set +dsc config set --file gotstoy.dsc.config.yaml ``` ```yaml @@ -265,26 +199,25 @@ results: type: TSToy.Example/gotstoy result: beforeState: - ensure: absent scope: machine - afterState: ensure: present + updateAutomatically: false + afterState: scope: machine + ensure: present updateAutomatically: false - changedProperties: - - ensure - - updateAutomatically + changedProperties: [] - name: Current User Configuration type: TSToy.Example/gotstoy result: beforeState: - ensure: present scope: user + ensure: present updateAutomatically: true updateFrequency: 45 afterState: - ensure: present scope: user + ensure: present updateAutomatically: true updateFrequency: 30 changedProperties: @@ -293,9 +226,8 @@ messages: [] hadErrors: false ``` -The results show that the resource created the machine scope configuration file and set the -`updateAutomatically` property for it. The results also show that the resource changed the update -frequency for the user scope configuration file. +The results show that the resource corrected the update frequency for the user scope and +left the already-correct machine scope untouched. -Together, these steps minimally confirm that the resource can be used with DSC. DSC is able to get, -test, and set resource instances individually and in configuration documents. +Together, these steps minimally confirm that the resource can be used with DSC. DSC is +able to get, test, and set resource instances individually and in configuration documents. diff --git a/samples/go/resources/first/docs/_index.md b/samples/go/resources/first/docs/_index.md index ea0ac0e..505a580 100644 --- a/samples/go/resources/first/docs/_index.md +++ b/samples/go/resources/first/docs/_index.md @@ -8,46 +8,55 @@ platen: collapse_section: true --- -With DSC v3, you can author command-based DSC Resources in any language. This enables you to manage -applications in the programming language you and your team prefer, or in the same language as the -application you're managing. +With Microsoft DSC, you can author command-based DSC Resources in any language. This enables you +to manage applications in the programming language you and your team prefer, or in the +same language as the application you're managing. -This tutorial describes how you can implement a DSC Resource in Go to manage an application's -configuration files. While this tutorial creates a resource to manage the fictional -[TSToy application][02], the principles apply when you author any command-based resource. +This tutorial describes how you can implement a DSC Resource in Go to manage an +application's configuration files. While this tutorial creates a resource to manage the +fictional [TSToy application][02], the principles apply when you author any command-based +resource. + +The resource is built with [dsc-go-rdk][03], a Go library that implements the Microsoft DSC +command-based resource protocol for you: subcommand dispatch, JSON input handling, output +framing, exit codes, JSON Schema generation, and manifest generation. You implement typed +`get` and `set` operations over a plain Go struct; the library handles everything the DSC +engine expects from the executable. In this tutorial, you learn how to: - Create a small Go application to use as a DSC Resource. -- Define the properties of the resource. -- Implement `get` and `set` commands for the resource. -- Write a manifest for the resource. +- Define the properties of the resource as a Go struct. +- See how the library handles JSON input and errors for you. +- Implement the `get` and `set` operations for the resource. +- Generate the resource's manifest. - Manually test the resource. ## Prerequisites - Familiarize yourself with the structure of a command-based DSC Resource. - Read [About the TSToy application][02], install `tstoy`, and add it to your `PATH`. -- Go 1.19 or higher +- Go 1.26 or higher - VS Code with the Go extension ## Steps -1. [Create the DSC Resource][03] -1. [Define the configuration settings][04] -1. [Handle input][05] -1. [Implement get][06] -1. [Implement set][07] -1. [Author the DSC Resource manifest][08] -1. [Validate the DSC Resource with DSC][09] -1. [Review and next steps][10] +1. [Create the DSC Resource][04] +1. [Define the configuration settings][05] +1. [See how input is handled][06] +1. [Implement get][07] +1. [Implement set][08] +1. [Generate the DSC Resource manifest][09] +1. [Validate the DSC Resource with DSC][10] +1. [Review and next steps][11] [02]: /tstoy/about/ -[03]: 1-create.md -[04]: 2-define-config-settings.md -[05]: 3-handle-input.md -[06]: 4-implement-get.md -[07]: 5-implement-set.md -[08]: 6-author-manifest.md -[09]: 7-validate.md -[10]: review.md +[03]: https://github.com/LibreDsc/dsc-go-rdk +[04]: 1-create.md +[05]: 2-define-config-settings.md +[06]: 3-handle-input.md +[07]: 4-implement-get.md +[08]: 5-implement-set.md +[09]: 6-author-manifest.md +[10]: 7-validate.md +[11]: review.md diff --git a/samples/go/resources/first/docs/review.md b/samples/go/resources/first/docs/review.md index 1b9b4eb..1b6ca0b 100644 --- a/samples/go/resources/first/docs/review.md +++ b/samples/go/resources/first/docs/review.md @@ -5,32 +5,45 @@ weight: 100 In this tutorial, you: -1. Scaffolded a new Go app as a DSC Resource. -1. Defined the configurable settings to manage the TSToy application's configuration files and - update behavior. -1. Added flags to enable users to configure TSToy in the terminal with validation and completion - suggestions. -1. Added handling so the DSC Resource can use JSON input with a flag or from `stdin`. -1. Implemented the `get` command to return the current state of a TSToy configuration file as an - instance of the DSC Resource. -1. Added handling so the `get` command can retrieve every instance of the DSC Resource. -1. Implemented the `set` command to idempotently enforce the desired state for TSToy's +1. Created a new Go module and turned it into a working DSC protocol CLI with dsc-go-rdk. +1. Defined the configurable settings for TSToy's configuration files as a plain Go struct, + and generated the instance JSON Schema from it. +1. Saw how the library handles JSON input, structured error output, and exit codes, and + added the resource's own semantic validation. +1. Implemented the get operation to return the current state of a TSToy configuration file + as an instance of the DSC Resource. +1. Implemented the set operation to idempotently enforce the desired state for TSToy's configuration files. -1. Tested the DSC Resource as a standalone application. -1. Authored a DSC Resource manifest and defined a JSON Schema for instances of the DSC Resource. +1. Generated the DSC Resource manifest from the resource's configuration and capabilities. 1. Tested the integration of the DSC Resource with DSC itself. -At the end of this implementation, you have a functional command-based DSC Resource written in Go. +At the end of this implementation, you have a functional command-based DSC Resource +written in Go. The code you wrote is almost entirely TSToy domain logic — the protocol +details (argument parsing, stdin handling, output framing, exit codes, schema, and +manifest) live in the library, where they're implemented once and tested against the DSC +engine's contract. ## Clean up -If you're not going to continue to work with this DSC Resource, delete the `gotstoy` folder and the -files in it. +If you're not going to continue to work with this DSC Resource, delete the `gotstoy` +folder and the files in it. Remove any TSToy configuration files you created: + +```sh +tstoy show path machine +tstoy show path user +``` + +Delete those files if they exist. ## Next steps -1. Read about command-based DSC Resources, learn how they work, and consider why the DSC Resource - in this tutorial is implemented this way. -1. Consider how this DSC Resource can be improved. Are there any edge cases or features it doesn't - handle? Can you make the user experience in the terminal more delightful? Update the - implementation with your improvements. +The capability model makes the resource easy to grow — each new interface your handler +implements is automatically dispatched, framed, and advertised in the manifest: + +1. Implement `Export` (`Export(ctx, filter) ([]Settings, error)`) to enumerate both + scopes, then try `dsc resource export`. +1. Implement `Test` with domain-aware comparison (for example, treating an unspecified + `updateFrequency` as "don't care") and see the manifest gain a `test` method. +1. Implement `SetWhatIf` and try `dsc config set --what-if` with native what-if output. +1. Read about command-based DSC Resources, learn how they work, and consider why the DSC + Resource in this tutorial is implemented this way. diff --git a/samples/go/resources/first/go.mod b/samples/go/resources/first/go.mod index 2c9976e..980a7db 100644 --- a/samples/go/resources/first/go.mod +++ b/samples/go/resources/first/go.mod @@ -1,18 +1,5 @@ module github.com/PowerShell/DSC-Samples/go/resources/first -go 1.19 +go 1.26 -require ( - github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect - github.com/fatih/color v1.15.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/knadh/koanf/maps v0.1.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/spf13/cobra v1.7.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/thediveo/enumflag v0.10.1 // indirect - golang.org/x/sys v0.6.0 // indirect -) +require github.com/LibreDsc/dsc-go-rdk v0.1.0 diff --git a/samples/go/resources/first/go.sum b/samples/go/resources/first/go.sum index 9b7656d..f387cb9 100644 --- a/samples/go/resources/first/go.sum +++ b/samples/go/resources/first/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/LibreDsc/dsc-go-rdk v0.1.0 h1:BC5ZT8TsrHptZnZwYi8a4UdiVCpK5b9yWaA3wovHx5U= +github.com/LibreDsc/dsc-go-rdk v0.1.0/go.mod h1:W4sigtGkBwNWULQKHZQ7DNpg5Au9ZiLkEX8YcOi2ovc= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= diff --git a/samples/go/resources/first/gotstoy.dsc.config.yaml b/samples/go/resources/first/gotstoy.dsc.config.yaml index cbbff5f..7841d13 100644 --- a/samples/go/resources/first/gotstoy.dsc.config.yaml +++ b/samples/go/resources/first/gotstoy.dsc.config.yaml @@ -1,4 +1,4 @@ -$schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json resources: - name: All Users Configuration type: TSToy.Example/gotstoy diff --git a/samples/go/resources/first/gotstoy.dsc.resource.json b/samples/go/resources/first/gotstoy.dsc.resource.json deleted file mode 100644 index b27c1f8..0000000 --- a/samples/go/resources/first/gotstoy.dsc.resource.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "manifestVersion": "1.0", - "type": "TSToy.Example/gotstoy", - "version": "0.1.0", - "description": "A DSC Resource written in go to manage TSToy.", - "tags": ["tstoy", "example", "go"], - "get": { - "executable": "gotstoy", - "args": ["get"], - "input": "stdin" - }, - "set": { - "executable": "gotstoy", - "args": ["set"], - "input": "stdin", - "preTest": true, - "return": "state" - }, - "schema": { - "embedded": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Golang TSToy Resource", - "type": "object", - "required": [ - "scope" - ], - "properties": { - "scope": { - "title": "Target configuration scope", - "description": "Defines which of TSToy's config files to manage.", - "type": "string", - "enum": [ - "machine", - "user" - ] - }, - "ensure": { - "title": "Ensure configuration file existence", - "description": "Defines whether the config file should exist.", - "type": "string", - "enum": [ - "present", - "absent" - ], - "default": "present" - }, - "updateAutomatically": { - "title": "Should update automatically", - "description": "Indicates whether TSToy should check for updates when it starts.", - "type": "boolean" - }, - "updateFrequency": { - "title": "Update check frequency", - "description": "Indicates how many days TSToy should wait before checking for updates.", - "type": "integer", - "minimum": 1, - "maximum": 90 - } - } - } - } -} \ No newline at end of file diff --git a/samples/go/resources/first/main.go b/samples/go/resources/first/main.go index 6da2428..3af2027 100644 --- a/samples/go/resources/first/main.go +++ b/samples/go/resources/first/main.go @@ -2,37 +2,43 @@ // Licensed under the MIT License. // // This sample code is not supported under any Microsoft standard support program or service. -// This sample code is provided AS IS without warranty of any kind. Microsoft disclaims all implied -// warranties including, without limitation, any implied warranties of merchantability or of -// fitness for a particular purpose. The entire risk arising out of the use or performance of the -// sample code and documentation remains with you. In no event shall Microsoft, its authors, or -// anyone else involved in the creation, production, or delivery of the scripts be liable for any -// damages whatsoever (including, without limitation, damages for loss of business profits, -// business interruption, loss of business information, or other pecuniary loss) arising out of the -// use of or inability to use the sample scripts or documentation, even if Microsoft has been -// advised of the possibility of such damages. +// This sample code is provided AS IS without warranty of any kind. package main -import ( - "os" - - "github.com/PowerShell/DSC-Samples/go/resources/first/cmd" - "github.com/PowerShell/DSC-Samples/go/resources/first/input" -) +import dsc "github.com/LibreDsc/dsc-go-rdk" func main() { - args := []string{} - for index, arg := range os.Args { - // skip the first index, because it's the application name - if index > 0 { - args = append(args, arg) - } - } + r := dsc.MustResource[Settings](Handler{}, dsc.ResourceConfig{ + Type: "TSToy.Example/gotstoy", + Version: "0.1.0", + Description: "A DSC Resource written in Go to manage TSToy.", + Tags: []string{"tstoy", "example", "go"}, - // Check stdin and add any found JSON blob after an --inputJSON flag. - args = input.HandleStdIn(args) + // Set prints the state after enforcement; the resource is idempotent + // and validates state itself, so DSC skips its pre-set test. + SetReturn: dsc.SetReturnState, + ImplementsPretest: true, - // execute with the combined arguments - cmd.Execute(args) + SchemaOptions: dsc.SchemaOptions{ + SchemaDescription: "Golang TSToy Resource", + Descriptions: map[string]string{ + "scope": "Defines which of TSToy's config files to manage.", + "ensure": "Defines whether the config file should exist.", + "updateAutomatically": "Indicates whether TSToy should check for" + + " updates when it starts.", + "updateFrequency": "Indicates how many days TSToy should wait" + + " before checking for updates.", + }, + // Constraints the struct tags can't express. + Overrides: func(schema map[string]any) { + props := schema["properties"].(map[string]any) + props["ensure"].(map[string]any)["default"] = "present" + frequency := props["updateFrequency"].(map[string]any) + frequency["minimum"] = 1 + frequency["maximum"] = 90 + }, + }, + }) + r.Main("gotstoy") } diff --git a/samples/go/resources/first/settings.go b/samples/go/resources/first/settings.go new file mode 100644 index 0000000..18ce38b --- /dev/null +++ b/samples/go/resources/first/settings.go @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// This sample code is not supported under any Microsoft standard support program or service. +// This sample code is provided AS IS without warranty of any kind. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + dsc "github.com/LibreDsc/dsc-go-rdk" +) + +// Settings models one TSToy configuration file as DSC state. The struct is +// the resource's contract: dsc-go-rdk generates the instance JSON Schema from its +// fields and tags, and every operation receives and returns it. +type Settings struct { + Scope string `json:"scope" enum:"machine,user"` + Ensure string `json:"ensure,omitempty" enum:"present,absent"` + UpdateAutomatically *bool `json:"updateAutomatically,omitempty"` + UpdateFrequency int `json:"updateFrequency,omitempty"` +} + +// Handler implements the resource's capabilities: Gettable and Settable. +// TSToy models existence with its own ensure property, so there is no +// Deletable — set with ensure:absent removes the file. +type Handler struct{} + +// Get returns the current state of the config file for the requested scope. +func (Handler) Get(_ context.Context, in Settings) (Settings, error) { + if err := validateScope(in.Scope); err != nil { + return in, err + } + path, err := configPath(in.Scope) + if err != nil { + return in, err + } + return settingsAt(in.Scope, path) +} + +// Set enforces the desired state and returns the state after enforcement. +func (Handler) Set(_ context.Context, desired Settings) (Settings, error) { + if err := validateDesired(&desired); err != nil { + return desired, err + } + + path, err := configPath(desired.Scope) + if err != nil { + return desired, err + } + + if desired.Ensure == "absent" { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return desired, fmt.Errorf("failed to remove config file '%s': %w", path, err) + } + return Settings{Scope: desired.Scope, Ensure: "absent"}, nil + } + + // Read the existing config file as a raw map so settings this resource + // doesn't manage are preserved. + cfg, err := readConfigMap(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return desired, err + } + if cfg == nil { + cfg = map[string]any{} + } + + updates, _ := cfg["updates"].(map[string]any) + if updates == nil { + updates = map[string]any{} + } + if desired.UpdateAutomatically != nil { + updates["automatic"] = *desired.UpdateAutomatically + } + if desired.UpdateFrequency != 0 { + updates["checkFrequency"] = desired.UpdateFrequency + } + if len(updates) > 0 { + cfg["updates"] = updates + } + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return desired, fmt.Errorf("failed to create folder for config file: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return desired, fmt.Errorf("unable to convert settings to json: %w", err) + } + if err := os.WriteFile(path, data, 0o640); err != nil { + return desired, fmt.Errorf("unable to write config file: %w", err) + } + + // Re-read from disk so the returned after-state is the actual state. + return settingsAt(desired.Scope, path) +} + +// validateScope checks the required scope property. Validation failures map +// to DSC's invalid-input exit code (4). +func validateScope(scope string) error { + switch scope { + case "machine", "user": + return nil + case "": + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "the scope property is required; must be one of: machine, user") + default: + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "invalid scope '%s'; must be one of: machine, user", scope) + } +} + +// validateDesired normalizes and validates a desired state before set. +func validateDesired(s *Settings) error { + if err := validateScope(s.Scope); err != nil { + return err + } + switch s.Ensure { + case "": + s.Ensure = "present" + case "present", "absent": + default: + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "invalid ensure '%s'; must be one of: present, absent", s.Ensure) + } + if s.Ensure == "present" && s.UpdateFrequency != 0 && + (s.UpdateFrequency < 1 || s.UpdateFrequency > 90) { + return dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, + "invalid updateFrequency %d; must be an integer between 1 and 90, inclusive", + s.UpdateFrequency) + } + return nil +} + +// configPath asks the tstoy application where the scope's config file lives. +func configPath(scope string) (string, error) { + out, err := exec.Command("tstoy", "show", "path", scope).Output() + if err != nil { + return "", fmt.Errorf( + "failed to query tstoy for the %s config file path (is tstoy on PATH?): %w", + scope, err) + } + return strings.TrimSpace(string(out)), nil +} + +// settingsAt reads the config file into the resource's state model. A +// missing file is the valid "absent" state, not an error. +func settingsAt(scope, path string) (Settings, error) { + cfg, err := readConfigMap(path) + if errors.Is(err, fs.ErrNotExist) { + return Settings{Scope: scope, Ensure: "absent"}, nil + } + if err != nil { + return Settings{Scope: scope}, err + } + + current := Settings{Scope: scope, Ensure: "present"} + if updates, ok := cfg["updates"].(map[string]any); ok { + if auto, ok := updates["automatic"].(bool); ok { + current.UpdateAutomatically = &auto + } + if freq, ok := updates["checkFrequency"].(float64); ok { + current.UpdateFrequency = int(freq) + } + } + return current, nil +} + +func readConfigMap(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("config file '%s' is not valid JSON: %w", path, err) + } + return cfg, nil +} diff --git a/samples/go/resources/first/tstoy.example.gotstoy.dsc.resource.json b/samples/go/resources/first/tstoy.example.gotstoy.dsc.resource.json new file mode 100644 index 0000000..5926366 --- /dev/null +++ b/samples/go/resources/first/tstoy.example.gotstoy.dsc.resource.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "TSToy.Example/gotstoy", + "version": "0.1.0", + "description": "A DSC Resource written in Go to manage TSToy.", + "tags": [ + "tstoy", + "example", + "go" + ], + "get": { + "executable": "gotstoy", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "gotstoy", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": true, + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Error", + "2": "Resource error", + "3": "JSON serialization error", + "4": "Invalid input", + "5": "Schema validation error", + "6": "Resource not found" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Golang TSToy Resource", + "properties": { + "ensure": { + "default": "present", + "description": "Defines whether the config file should exist.", + "enum": [ + "present", + "absent" + ], + "type": "string" + }, + "scope": { + "description": "Defines which of TSToy's config files to manage.", + "enum": [ + "machine", + "user" + ], + "type": "string" + }, + "updateAutomatically": { + "description": "Indicates whether TSToy should check for updates when it starts.", + "type": "boolean" + }, + "updateFrequency": { + "description": "Indicates how many days TSToy should wait before checking for updates.", + "maximum": 90, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "scope" + ], + "type": "object" + } + } +} diff --git a/tstoy/go.mod b/tstoy/go.mod index 70fd665..9606ee8 100644 --- a/tstoy/go.mod +++ b/tstoy/go.mod @@ -1,6 +1,6 @@ module github.com/PowerShell/DSC-Samples/tstoy -go 1.19 +go 1.26 require ( github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 From fce701f6695b9a0472b1d817511e389ad83dd5cc Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:04:45 +0200 Subject: [PATCH 3/3] Remove Python example --- .site/go.mod | 2 +- samples/python/resources/first/.gitignore | 166 ------------- samples/python/resources/first/README.md | 3 - samples/python/resources/first/build.ps1 | 95 ------- .../resources/first/src/commands/get.py | 51 ---- .../resources/first/src/config/config.py | 233 ------------------ .../resources/first/src/config/manager.py | 171 ------------- .../resources/first/src/core/console.py | 12 - samples/python/resources/first/src/main.py | 15 -- .../resources/first/src/models/models.py | 25 -- .../python/resources/first/src/pyproject.toml | 21 -- .../resources/first/src/resources/strings.py | 7 - .../resources/first/src/schema/schema.py | 48 ---- .../resources/first/src/utils/logger.py | 109 -------- .../first/tests/acceptance.tests.ps1 | 81 ------ 15 files changed, 1 insertion(+), 1038 deletions(-) delete mode 100644 samples/python/resources/first/.gitignore delete mode 100644 samples/python/resources/first/README.md delete mode 100644 samples/python/resources/first/build.ps1 delete mode 100644 samples/python/resources/first/src/commands/get.py delete mode 100644 samples/python/resources/first/src/config/config.py delete mode 100644 samples/python/resources/first/src/config/manager.py delete mode 100644 samples/python/resources/first/src/core/console.py delete mode 100644 samples/python/resources/first/src/main.py delete mode 100644 samples/python/resources/first/src/models/models.py delete mode 100644 samples/python/resources/first/src/pyproject.toml delete mode 100644 samples/python/resources/first/src/resources/strings.py delete mode 100644 samples/python/resources/first/src/schema/schema.py delete mode 100644 samples/python/resources/first/src/utils/logger.py delete mode 100644 samples/python/resources/first/tests/acceptance.tests.ps1 diff --git a/.site/go.mod b/.site/go.mod index d161c76..c885129 100644 --- a/.site/go.mod +++ b/.site/go.mod @@ -1,5 +1,5 @@ module github.com/PowerShell/DSC-Samples/_site -go 1.16 +go 1.26 require github.com/platenio/platen/modules/platen v0.0.0-20231124141037-5a875309774c // indirect diff --git a/samples/python/resources/first/.gitignore b/samples/python/resources/first/.gitignore deleted file mode 100644 index 863ea61..0000000 --- a/samples/python/resources/first/.gitignore +++ /dev/null @@ -1,166 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be added to the global gitignore or merged into this project gitignore. For PyCharm -# Community Edition, use 'PyCharm CE' in the configurations. -.idea/ - -# Build output -python.zip - -# uv -.uv/ -uv.lock \ No newline at end of file diff --git a/samples/python/resources/first/README.md b/samples/python/resources/first/README.md deleted file mode 100644 index 3b4bcce..0000000 --- a/samples/python/resources/first/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Python TSToy resource - -To be filled in diff --git a/samples/python/resources/first/build.ps1 b/samples/python/resources/first/build.ps1 deleted file mode 100644 index 44d662f..0000000 --- a/samples/python/resources/first/build.ps1 +++ /dev/null @@ -1,95 +0,0 @@ -[CmdletBinding()] -param ( - [ValidateSet('build', 'test')] - [string]$mode = 'build', - [string]$name = 'pythontstoy' -) - -function Build-PythonProject { - [CmdletBinding()] - param ( - [Parameter()] - [string]$Name - ) - begin { - Write-Verbose -Message "Starting Python project build process" - - $sourceDir = Join-Path -Path $PSScriptRoot -ChildPath 'src' - $outputDir = Join-Path -Path $PSScriptRoot -ChildPath 'dist' - } - - process { - Install-Uv - - Push-Location -Path $sourceDir -ErrorAction Stop - - try { - # Create virtual environment - & uv venv - - # Activate it - & .\.venv\Scripts\activate.ps1 - - # Sync all the dependencies - & uv sync - - # Create executable - $pyInstallerArgs = @( - 'main.py', - '-F', - '--clean', - '--distpath', $outputDir, - '--name', $Name - ) - & pyinstaller.exe @pyInstallerArgs - } - finally { - deactivate - Pop-Location -ErrorAction Ignore - } - } - - end { - Write-Verbose -Message "Python project build process completed" - } -} - -function Install-Uv() { - begin { - Write-Verbose -Message "Installing Uv dependencies" - } - - process { - if ($IsWindows) { - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Write-Verbose -Message "Installing uv package manager on Windows" - Invoke-RestMethod https://astral.sh/uv/install.ps1 | Invoke-Expression - - } - $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" - } - elseif ($IsLinux) { - curl -LsSf https://astral.sh/uv/install.sh | sh - } - } - - end { - Write-Verbose -Message "Uv installation process completed" - } -} - -switch ($mode) { - 'build' { - Build-PythonProject -Name $name - } - 'test' { - Build-PythonProject -Name $name - - $testContainer = New-PesterContainer -Path (Join-Path 'tests' 'acceptance.tests.ps1') -Data @{ - Name = $name - } - - Invoke-Pester -Container $testContainer -Output Detailed - } - -} \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/get.py b/samples/python/resources/first/src/commands/get.py deleted file mode 100644 index f66ec61..0000000 --- a/samples/python/resources/first/src/commands/get.py +++ /dev/null @@ -1,51 +0,0 @@ -import click -import json -import sys -from utils.logger import Logger -from config.config import Settings - -# Create a logger instance for this module -logger = Logger() - -@click.command('get') -@click.option('--input', 'input_json', - help='JSON input data with required scope field', - required=True, - type=str) -def get_command(input_json): - """Get command that retrieves configuration based on scope from validated JSON input""" - - try: - try: - data = json.loads(input_json) - except json.JSONDecodeError as e: - logger.error(f"Invalid JSON format - {e}", "get_command", input_data=input_json) - sys.exit(1) - - if not Settings.validate_input(data, logger): - sys.exit(1) - - settings = Settings.from_dict(data) - - if not settings.validate(): - logger.error(f"Settings validation failed for scope: {settings.scope}", "get_command", scope=settings.scope) - sys.exit(1) - - logger.info(f"Processing settings for scope: {settings.scope}", "get_command", scope=settings.scope) - - result_settings, err = settings.enforce() - if err: - logger.error(f"Failed to enforce settings for scope {settings.scope}", "get_command", - scope=settings.scope, error_details=str(err)) - sys.exit(1) - - result_settings.print_config() - logger.info(f"Settings retrieved successfully for scope: {settings.scope}", "get_command", - scope=settings.scope) - - return result_settings - - except Exception as e: - logger.critical(f"Unexpected error in get_command", "get_command", - error_type=type(e).__name__, error_message=str(e)) - sys.exit(1) \ No newline at end of file diff --git a/samples/python/resources/first/src/config/config.py b/samples/python/resources/first/src/config/config.py deleted file mode 100644 index e015991..0000000 --- a/samples/python/resources/first/src/config/config.py +++ /dev/null @@ -1,233 +0,0 @@ -from dataclasses import dataclass, field -from typing import Literal, Optional, Dict, Any, Tuple, Set -from pathlib import Path -from config.manager import ConfigManager -from utils.logger import Logger -from schema.schema import validate_resource, RESOURCE_SCHEMA -import json - -@dataclass -class Settings: - scope: Literal["user", "machine"] - _exist: bool = True - updateAutomatically: Optional[bool] = None - updateFrequency: Optional[int] = None - config_path: str = field(default="", init=False, repr=False) - _provided_properties: Set[str] = field(default_factory=set, init=False, repr=False) - - def __post_init__(self): - """Initialize dependencies after dataclass creation""" - self.config_manager = ConfigManager() - self.logger = Logger() - - @classmethod - def validate_input(cls, data: Dict[str, Any], logger: Logger) -> bool: - is_valid, validation_error = validate_resource(data) - if not is_valid: - logger.error(f"Input validation failed: {validation_error}", "Settings") - return False - - allowed_properties = list(RESOURCE_SCHEMA["properties"].keys()) - logger.info(f"Valid properties per schema: {allowed_properties}", "Settings") - logger.info(f"Provided properties: {list(data.keys())}", "Settings") - - return True - - def get_config_path(self) -> Tuple[str, Optional[Exception]]: - try: - if not self.scope: - return "", ValueError("scope is required") - - if self.scope == "machine": - config_path = self.config_manager.get_machine_config_path() - elif self.scope == "user": - config_path = self.config_manager.get_user_config_path() - else: - return "", ValueError(f"invalid scope: {self.scope}") - - self.config_path = str(config_path) - return self.config_path, None - - except Exception as e: - return "", e - - def get_config_map(self) -> Tuple[Optional[Dict[str, Any]], Optional[Exception]]: - try: - config_path, err = self.get_config_path() - if err: - return None, err - - if not Path(config_path).exists(): - self.logger.info(f"Config file not found: {config_path}", "Settings") - return {}, None # Return empty map if file doesn't exist - - # Load configuration - config_data = self.config_manager.load_config_file(Path(config_path)) - if config_data is None: - self.logger.info(f"Config file loaded but empty: {config_path}", "Settings") - return {}, None - - self.logger.info(f"Config loaded successfully: {json.dumps(config_data)}", "Settings") - return config_data, None - - except Exception as e: - return None, e - - def get_config_settings(self) -> Tuple[Optional['Settings'], Optional[Exception]]: - try: - config_map, err = self.get_config_map() - if err: - return None, err - - if not config_map: - self.logger.info("No configuration found, returning with _exist=False", "Settings") - return Settings(scope=self.scope, _exist=False), None - - settings = Settings(scope=self.scope, _exist=True) - - if 'updates' in config_map: - updates = config_map['updates'] - - if 'updateAutomatically' in updates: - settings.updateAutomatically = bool(updates['updateAutomatically']) - - if 'updateFrequency' in updates: - settings.updateFrequency = int(updates['updateFrequency']) - - self.logger.info(f"Loaded settings - updateAutomatically: {settings.updateAutomatically}, " + - f"updateFrequency: {settings.updateFrequency}", "Settings") - else: - self.logger.info("No 'updates' section found in config file", "Settings") - - return settings, None - - except Exception as e: - self.logger.error(f"Error in get_config_settings: {str(e)}", "Settings") - return None, e - - def enforce(self) -> Tuple[Optional['Settings'], Optional[Exception]]: - try: - current_settings, err = self.get_config_settings() - if err: - return None, err - - if not current_settings: - self.logger.info("Failed to get settings, returning with scope only", "Settings") - return Settings(scope=self.scope, _exist=True), None - - if not Path(self.config_path).exists(): - self.logger.info("Config file doesn't exist, returning scope only with _exist=false", "Settings") - result = Settings(scope=self.scope, _exist=False) - result.updateAutomatically = None - result.updateFrequency = None - return result, None - - self.logger.info("Current settings from config file: " + - f"updateAutomatically={current_settings.updateAutomatically}, " + - f"updateFrequency={current_settings.updateFrequency}", "Settings") - - if self._has_properties_to_validate(): - validated_settings = self._validate_settings(current_settings) - return validated_settings, None - else: - self.logger.info("No properties to validate, returning all: " + - f"updateAutomatically={current_settings.updateAutomatically}, " + - f"updateFrequency={current_settings.updateFrequency}", "Settings") - - return current_settings, None - - except Exception as e: - self.logger.error(f"Error in enforce: {str(e)}", "Settings") - return None, e - - def _has_properties_to_validate(self) -> bool: - properties = set(self._provided_properties) - if 'scope' in properties: - properties.remove('scope') - if '_exist' in properties: - properties.remove('_exist') - - has_properties = len(properties) > 0 - self.logger.info(f"Properties to validate: {properties}, has_properties: {has_properties}", "Settings") - return has_properties - - def _validate_settings(self, current_settings: 'Settings') -> 'Settings': - validated_settings = Settings( - scope=self.scope, - _exist=True, # Start with True, will be set to False if validation fails - updateAutomatically=current_settings.updateAutomatically, - updateFrequency=current_settings.updateFrequency - ) - - validation_failed = False - - if 'updateAutomatically' in self._provided_properties: - if current_settings.updateAutomatically is None: - validation_failed = True - self.logger.info(f"updateAutomatically not found in config (requested: {self.updateAutomatically})", "Settings") - elif current_settings.updateAutomatically != self.updateAutomatically: - validation_failed = True - self.logger.info(f"updateAutomatically mismatch - requested: {self.updateAutomatically}, found: {current_settings.updateAutomatically}", "Settings") - else: - self.logger.info(f"updateAutomatically validation passed: {self.updateAutomatically}", "Settings") - - if 'updateFrequency' in self._provided_properties: - if current_settings.updateFrequency is None: - validation_failed = True - self.logger.info(f"updateFrequency not found in config (requested: {self.updateFrequency})", "Settings") - elif current_settings.updateFrequency != self.updateFrequency: - validation_failed = True - self.logger.info(f"updateFrequency mismatch - requested: {self.updateFrequency}, found: {current_settings.updateFrequency}", "Settings") - else: - self.logger.info(f"updateFrequency validation passed: {self.updateFrequency}", "Settings") - - if validation_failed: - validated_settings._exist = False - self.logger.info("Validation failed, setting _exist to False", "Settings") - else: - self.logger.info("All validations passed, _exist remains True", "Settings") - - return validated_settings - - def to_json(self, exclude_private: bool = False, exclude_none: bool = True) -> str: - data = {} - data["_exist"] = self._exist - - if self.scope is not None: - data["scope"] = self.scope - - if not exclude_none or self.updateAutomatically is not None: - data["updateAutomatically"] = self.updateAutomatically - - if not exclude_none or self.updateFrequency is not None: - data["updateFrequency"] = self.updateFrequency - - if exclude_none: - data = {k: v for k, v in data.items() if v is not None or k == "_exist"} - - self.logger.info(f"Serializing to JSON: {data}", "Settings") - - return json.dumps(data) - - def print_config(self) -> None: - json_output = self.to_json(exclude_private=False, exclude_none=True) - self.logger.info(f"Printing configuration: {json_output}", "Settings") - print(json_output) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Settings': - settings = cls( - scope=data.get('scope'), - _exist=data.get('_exist', True), # Default to True if not specified - updateAutomatically=data.get('updateAutomatically'), - updateFrequency=data.get('updateFrequency') - ) - - settings._provided_properties = set(data.keys()) - - return settings - - def validate(self) -> bool: - if not self.scope or self.scope not in ['user', 'machine']: - return False - return True \ No newline at end of file diff --git a/samples/python/resources/first/src/config/manager.py b/samples/python/resources/first/src/config/manager.py deleted file mode 100644 index 25f5d45..0000000 --- a/samples/python/resources/first/src/config/manager.py +++ /dev/null @@ -1,171 +0,0 @@ -import os -import json -import pathlib -from typing import Dict, Any -from resources.strings import Strings -from utils.logger import Logger - - -class ConfigSource: - """Represents a configuration source.""" - DEFAULT = "default" - MACHINE = "machine" - USER = "user" - ENV = "environment" - CLI = "cli" - -class ConfigManager: - def __init__(self): - self.default_config = { - "updates": { - "updateAutomatically": False, - "updateFrequency": 180 - } - } - self.machine_config = {} - self.user_config = {} - self.env_config = {} - self.cli_config = {} - self.config = {} - - # Track loaded sources for reporting - self.loaded_sources = [] - - self.logger = Logger() - - def get_machine_config_path(self) -> pathlib.Path: - if os.name == 'nt': # Windows - return pathlib.Path(os.environ.get('PROGRAMDATA', 'C:/ProgramData')) / 'tstoy' / 'config.json' - else: # Unix-like - return pathlib.Path('/etc/tstoy/config.json') - - def get_user_config_path(self) -> pathlib.Path: - if os.name == 'nt': # Windows - config_dir = pathlib.Path(os.environ.get('APPDATA')) - else: # Unix-like - config_dir = pathlib.Path.home() / '.config' - - return config_dir / 'tstoy' / 'config.json' - - def load_config_file(self, path: pathlib.Path) -> Dict[str, Any]: - if not path.exists(): - self.logger.info(Strings.CONFIG_NOT_FOUND.format(path)) - return {} - - try: - with open(path, 'r') as f: - config = json.load(f) - self.logger.info(Strings.CONFIG_LOADED.format(path)) - return config - except json.JSONDecodeError as e: - self.logger.error(Strings.CONFIG_INVALID.format(path, str(e))) - return {} - except IOError as e: - self.logger.error(Strings.CONFIG_INVALID.format(path, str(e))) - return {} - - def save_config_file(self, path: pathlib.Path, config: Dict[str, Any]) -> bool: - try: - # Create parent directories if they don't exist - path.parent.mkdir(parents=True, exist_ok=True) - - with open(path, 'w') as f: - json.dump(config, f, indent=2) - self.logger.info(Strings.CONFIG_UPDATED.format(path)) - return True - except Exception as e: - self.logger.error(Strings.ERROR_WRITE_CONFIG.format(path, str(e))) - return False - - def load_default_config(self): - self.config = self.default_config.copy() - self.loaded_sources.append(ConfigSource.DEFAULT) - - def load_machine_config(self): - path = self.get_machine_config_path() - self.machine_config = self.load_config_file(path) - if self.machine_config: - self._merge_config(self.machine_config) - self.loaded_sources.append(ConfigSource.MACHINE) - - def load_user_config(self): - path = self.get_user_config_path() - self.user_config = self.load_config_file(path) - if self.user_config: - self._merge_config(self.user_config) - self.loaded_sources.append(ConfigSource.USER) - - def load_environment_config(self, prefix: str): - env_config = {} - for key, value in os.environ.items(): - if key.startswith(prefix): - # Convert DSCPY_UPDATES_AUTOMATIC to updates.automatic - config_key = key[len(prefix):].lower().replace('_', '.') - - # Convert string value to appropriate type - if value.lower() in ('true', 'yes', '1'): - env_config[config_key] = True - elif value.lower() in ('false', 'no', '0'): - env_config[config_key] = False - elif value.isdigit(): - env_config[config_key] = int(value) - else: - env_config[config_key] = value - - if env_config: - self.env_config = env_config - self._merge_config(env_config) - self.loaded_sources.append(ConfigSource.ENV) - - - def _merge_config(self, source: Dict[str, Any]): - def deep_merge(target, source): - for key, value in source.items(): - if key in target and isinstance(target[key], dict) and isinstance(value, dict): - deep_merge(target[key], value) - else: - target[key] = value - - deep_merge(self.config, source) - - def get_merged_config(self) -> Dict[str, Any]: - return self.config - - def get_config_sources(self) -> list: - return self.loaded_sources - - def get_all_config_files(self) -> list: - try: - user_config_dir = self.get_user_config_path().parent - if user_config_dir.exists(): - # Return a list of all JSON files in the config directory - return list(user_config_dir.glob('*.json')) - else: - self.logger.warning(f"Config directory does not exist: {user_config_dir}") - return [] - except Exception as e: - self.logger.error(f"Error enumerating config files: {str(e)}") - return [] - - def get_config_by_name(self, name: str) -> Dict[str, Any]: - if name == 'default': - return self.default_config.copy() - - # Try to find a specific config file with this name - user_config_dir = self.get_user_config_path().parent - config_path = user_config_dir / f"{name}.json" - - if config_path.exists(): - config = self.load_config_file(config_path) - return config - else: - # If no specific file exists, return the merged config - # This behavior can be changed based on requirements - self.logger.warning(f"No configuration found for name: {name}") - return self.get_merged_config() - - def load_all_configs(self, env_prefix: str = "TSTOY_"): - self.load_default_config() - self.load_machine_config() - self.load_user_config() - self.load_environment_config(env_prefix) diff --git a/samples/python/resources/first/src/core/console.py b/samples/python/resources/first/src/core/console.py deleted file mode 100644 index d5e95ac..0000000 --- a/samples/python/resources/first/src/core/console.py +++ /dev/null @@ -1,12 +0,0 @@ -class Console: - @staticmethod - def info(message: str): - print(f"INFO: {message}") - - @staticmethod - def error(message: str): - print(f"ERROR: {message}") - - @staticmethod - def warning(message: str): - print(f"WARNING: {message}") diff --git a/samples/python/resources/first/src/main.py b/samples/python/resources/first/src/main.py deleted file mode 100644 index 28c2ce3..0000000 --- a/samples/python/resources/first/src/main.py +++ /dev/null @@ -1,15 +0,0 @@ -import click -from commands.get import get_command - - -@click.group() -def main(): - """Python TSToy CLI tool.""" - pass - -# TODO: Add more commands and move away in main.py -main.add_command(get_command) - - -if __name__ == '__main__': - main() diff --git a/samples/python/resources/first/src/models/models.py b/samples/python/resources/first/src/models/models.py deleted file mode 100644 index 501e57a..0000000 --- a/samples/python/resources/first/src/models/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses import dataclass, asdict -from typing import Literal, Optional - -import json - -@dataclass -class TsToy: - scope: Literal["user", "machine"] - _exist: bool = True - updateAutomatically: Optional[bool] = None - updateFrequency: Optional[int] = None - - def to_json(self, include_none: bool = False) -> str: - data = asdict(self) - if not include_none: - data = {k: v for k, v in data.items() if v is not None} - return json.dumps(data) - - def to_dict(self, include_none: bool = False) -> dict: - data = asdict(self) - - if not include_none: - data = {k: v for k, v in data.items() if v is not None} - - return data \ No newline at end of file diff --git a/samples/python/resources/first/src/pyproject.toml b/samples/python/resources/first/src/pyproject.toml deleted file mode 100644 index e3a0914..0000000 --- a/samples/python/resources/first/src/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "tstoy" -version = "0.1.0" -description = "A command-line interface application built with Click" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "click>=8.2.1", - "jsonschema>=4.24.0", - "pyinstaller>=6.13.0", -] - -[project.scripts] -tstoy = "main:main" - -[tool.setuptools] -packages = ["commands", "core", "resources", "utils"] diff --git a/samples/python/resources/first/src/resources/strings.py b/samples/python/resources/first/src/resources/strings.py deleted file mode 100644 index fe7bc6e..0000000 --- a/samples/python/resources/first/src/resources/strings.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: Convert to localization strings -class Strings: - CONFIG_NOT_FOUND = "Configuration file not found: {}" - CONFIG_LOADED = "Configuration loaded from: {}" - CONFIG_INVALID = "Invalid configuration file {}: {}" - CONFIG_UPDATED = "Configuration saved to: {}" - ERROR_WRITE_CONFIG = "Error writing configuration to {}: {}" diff --git a/samples/python/resources/first/src/schema/schema.py b/samples/python/resources/first/src/schema/schema.py deleted file mode 100644 index b0208ce..0000000 --- a/samples/python/resources/first/src/schema/schema.py +++ /dev/null @@ -1,48 +0,0 @@ -import jsonschema -import json - -RESOURCE_SCHEMA = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Python TSToy Resource", - "type": "object", - "required": ["scope"], - "additionalProperties": False, - "properties": { - "scope": { - "title": "Target configuration scope", - "description": "Defines which of TSToy's config files to manage.", - "type": "string", - "enum": ["machine", "user"], - }, - "_exist": { - "title": "Should configuration exist", - "description": "Defines whether the config file should exist.", - "type": "boolean", - "default": True, - }, - "updateAutomatically": { - "title": "Should update automatically", - "description": "Indicates whether TSToy should check for updates when it starts.", - "type": "boolean", - }, - "updateFrequency": { - "title": "Update check frequency", - "description": "Indicates how many days TSToy should wait before checking for updates.", - "type": "integer", - "minimum": 1, - "maximum": 180, - }, - } -} - -def validate_resource(instance): - """Validate resource instance against schema.""" - try: - jsonschema.validate(instance=instance, schema=RESOURCE_SCHEMA) - return True, None - except jsonschema.exceptions.ValidationError as err: - return False, f"Validation error: {err.message}" - -def get_schema(): - """Dump the schema as formatted JSON string.""" - return json.dumps(RESOURCE_SCHEMA, separators=(',', ':')) \ No newline at end of file diff --git a/samples/python/resources/first/src/utils/logger.py b/samples/python/resources/first/src/utils/logger.py deleted file mode 100644 index c158c96..0000000 --- a/samples/python/resources/first/src/utils/logger.py +++ /dev/null @@ -1,109 +0,0 @@ -import json -import sys -import datetime -import inspect -from typing import Dict, Any -from enum import Enum - - -class LogLevel(Enum): - """Enumeration for log levels""" - DEBUG = "DEBUG" - INFO = "INFO" - WARNING = "WARNING" - ERROR = "ERROR" - CRITICAL = "CRITICAL" - - -class Logger: - """ - A structured JSON logger class that outputs messages to stderr. - - Features: - - JSON formatted output - - Configurable log levels - - Automatic timestamp generation - - Caller information tracking - - Customizable output stream - """ - - def __init__(self, output_stream=None, include_caller_info: bool = True): - self.output_stream = output_stream or sys.stderr - self.include_caller_info = include_caller_info - - def _get_caller_info(self) -> Dict[str, Any]: - if not self.include_caller_info: - return {} - - try: - # Get the frame of the caller (skip internal methods) - frame = inspect.currentframe() - for _ in range(3): # Skip _get_caller_info, _log, and the log level method - frame = frame.f_back - if frame is None: - break - - if frame: - return { - "file": frame.f_code.co_filename.split('\\')[-1], # Just filename - "line": frame.f_lineno, - "function": frame.f_code.co_name - } - except Exception: - pass - - return {} - - def _log(self, level: LogLevel, message: str, target: str = None, **kwargs): - log_entry = { - "timestamp": datetime.datetime.now().isoformat() + "Z", - "level": level.value, - "fields": {"message": message}, - "target": target or "unknown" - } - - # Add caller information if enabled - caller_info = self._get_caller_info() - if caller_info: - log_entry["line_number"] = caller_info.get("line", "Unknown") - log_entry["file"] = caller_info.get("file", "Unknown") - log_entry["function"] = caller_info.get("function", "Unknown") - - # Add any additional fields to the fields section - if kwargs: - log_entry["fields"].update(kwargs) - - try: - json_output = json.dumps(log_entry, separators=(",", ":")) - self.output_stream.write(json_output + '\n') - self.output_stream.flush() - except Exception as e: - # Fallback to basic error output - fallback_msg = f"[LOG ERROR] Failed to write log: {str(e)}\n" - self.output_stream.write(fallback_msg) - self.output_stream.flush() - - def debug(self, message: str, target: str = None, **kwargs): - self._log(LogLevel.DEBUG, message, target, **kwargs) - - def info(self, message: str, target: str = None, **kwargs): - self._log(LogLevel.INFO, message, target, **kwargs) - - def warning(self, message: str, target: str = None, **kwargs): - self._log(LogLevel.WARNING, message, target, **kwargs) - - def error(self, message: str, target: str = None, **kwargs): - self._log(LogLevel.ERROR, message, target, **kwargs) - - def critical(self, message: str, target: str = None, **kwargs): - self._log(LogLevel.CRITICAL, message, target, **kwargs) - - def log_config_loaded(self, config_path: str, config_type: str, **kwargs): - self.info(f"Loaded {config_type} configuration", "config_manager", - config_path=config_path, **kwargs) - - def log_config_error(self, error_msg: str, config_path: str = None, **kwargs): - self.error(f"Configuration error: {error_msg}", "config_manager", - config_path=config_path, **kwargs) - - diff --git a/samples/python/resources/first/tests/acceptance.tests.ps1 b/samples/python/resources/first/tests/acceptance.tests.ps1 deleted file mode 100644 index 54a468d..0000000 --- a/samples/python/resources/first/tests/acceptance.tests.ps1 +++ /dev/null @@ -1,81 +0,0 @@ -param ( - [string]$Name = 'pythontstoy' -) - -BeforeAll { - $oldPath = $env:Path - $env:Path = [System.IO.Path]::PathSeparator + (Join-Path (Split-Path $PSScriptRoot -Parent) 'dist') - - if ($IsWindows) { - $script:machinePath = Join-Path $env:ProgramData 'tstoy' 'config.json' - $script:userPath = Join-Path $env:APPDATA 'tstoy' 'config.json' - } - else { - $script:machinePath = Join-Path $env:HOME '.config' 'tstoy' 'config.json' - $script:userPath = Join-Path $env:HOME '.config' 'tstoy' 'config.json' - } -} - -Describe 'TSToy acceptance tests' { - Context "Help command" { - It 'Should return help' { - $help = & $Name --help - $help | Should -Not -BeNullOrEmpty - $LASTEXITCODE | Should -Be 0 - } - } - - Context "Input validation" { - It 'Should fail with invalid input' { - $out = & $Name get --input '{}' 2>&1 - $LASTEXITCODE | Should -Be 1 - $out | Should -BeLike '*"level":"ERROR"*"message":"Input validation failed: Validation error: ''scope'' is a required property"*' - } - } - - Context "Scope validation" -ForEach @( @{ scope = 'user' }, @{ scope = 'machine' } ) { - BeforeAll { - if ($IsWindows) { - Remove-Item -Path $script:userPath -ErrorAction Ignore - Remove-Item -Path $script:machinePath -ErrorAction Ignore - } - elseif ($IsLinux) { - Remove-Item -Path $script:userPath -ErrorAction Ignore - Remove-Item -Path $script:machinePath -ErrorAction Ignore - } - } - - It "Should not exist scope: " { - $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $out._exist | Should -BeFalse - } - - It 'Should exist when file is present' { - $config = @{ - updates = @{ - updateAutomatically = $false - updateFrequency = 180 - } - } | ConvertTo-Json -Depth 10 - - if ($_.scope -eq 'user') { - $scriptPath = $script:userPath - } - else { - $scriptPath = $script:machinePath - } - - New-Item -Path $scriptPath -ItemType File -Value $config -Force | Out-Null - - $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $out._exist | Should -BeTrue - } - } -} - - -AfterAll { - $env:Path = $oldPath -} \ No newline at end of file