diff --git a/.gitignore b/.gitignore index 4ab4161..13bc905 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,12 @@ # Test ignores **/*.Results.xml + +# Ignore C# build artifacts +samples/csharp/bin +samples/csharp/obj +samples/csharp/.vs + +# Ignore any compiled windows binary +*.exe +*.dll \ No newline at end of file diff --git a/samples/csharp/.vscode/extensions.json b/samples/csharp/.vscode/extensions.json new file mode 100644 index 0000000..b2b9c69 --- /dev/null +++ b/samples/csharp/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "ms-dotnettools.csdevkit", + "ms-dotnettools.csharp", + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.vscodeintellicode-csharp", + ] +} \ No newline at end of file diff --git a/samples/csharp/README.md b/samples/csharp/README.md new file mode 100644 index 0000000..fe6eafb --- /dev/null +++ b/samples/csharp/README.md @@ -0,0 +1,428 @@ +# Write your first DSC Resource in C# + +This folder contains a working DSC Resource written in C# for the tstoy application. Throughout this repo you may see C# referred to as csharp. + +## Building the sample + +Before building the sample you will need to ensure you have the .net 8 SDK installed on your system. You may find out more about this on the Overview of [Install .NET on Windows, Linux, and macOS](https://learn.microsoft.com/en-us/dotnet/core/install/). + +Once you have .net 8 installed you may build the sample code in the following way: + +- On Linux or macOS: + + ```sh + dotnet restore + dotnet publish --self-contained -o . + export PATH=$(pwd):PATH + ``` + +- On Windows: + + ```powershell + dotnet restore + dotnet publish --self-contained -o . + $env:Path = $PWD.Path + ';' + $env:Path + ``` + +## Getting current state + +You can retrieve the current state of the resource with the `get` command. + +```sh +# Get current state with flags +csharptstoy get --scope machine --ensure present --updateAutomatically=false +``` + +```json +{"ensure":"present","scope":"machine"} +``` + +```sh +# Get with JSON over stdin +' +{ + "scope": "user", + "ensure": "present", + "updateAutomatically": true, + "updateFrequency": 45 +} +' | csharptstoy get +``` + +```json +{"ensure":"absent","scope":"user"} +``` + +```sh +# Get current state of all scopes: +csharptstoy get --all +``` + +```json +{"ensure":"absent","scope":"machine"} +{"ensure":"absent","scope":"user"} +``` + +## Setting desired state + +You can enforce the state of the resource with the `set` command. + +```sh +# Set the state with flags +csharptstoy set --scope machine --ensure present --updateAutomatically=false +# Set with JSON over stdin +' +{ + "scope": "user", + "ensure": "present", + "updateAutomatically": true, + "updateFrequency": 45 +} +' | csharptstoy set +# Get new state of all scopes: +csharptstoy get --all +``` + +```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} +``` + +## Verifying state + +After you've enforced state, you should 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` + +You can list `csharptstoy` as a DSC Resource to inspect it: + +```pwsh +dsc resource list TSToy.Example/csharptstoy -f yaml +``` + +```yaml +type: TSToy.Example/csharptstoy +kind: Resource +version: 0.1.0 +capabilities: +- Get +- Set +- Export +path: C:\Users\User\.local\bin\csharptstoy.dsc.resource.json +description: A DSC Resource written in C# to manage TSToy. +directory: C:\Users\User\.local\bin +implementedAs: null +author: null +properties: [] +requireAdapter: null +manifest: + $schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/10/resource/manifest.json + type: TSToy.Example/csharptstoy + version: 0.1.0 + description: A DSC Resource written in C# to manage TSToy. + tags: null + get: + executable: csharptstoy + args: + - get + input: stdin + set: + executable: csharptstoy + args: + - set + input: stdin + return: state + export: + executable: csharptstoy + args: + - get + - --all + schema: + embedded: + $schema: https://json-schema.org/draft/2020-12/schema + title: C# 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 +``` + +You can retrieve the current state of the resource: + +```powershell +$ResourceName = 'TSToy.Example/csharptstoy' +$MachineSettings = '{ "scope": "machine", "ensure": "present", "updateAutomatically": false }' +$UserSettings = @' +{ + "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 +$UserSettings | dsc resource test --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: [] +``` + +## 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://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/10/config/document.json +resources: +- name: All Users Configuration + type: TSToy.Example/csharptstoy + properties: + scope: machine + ensure: present + updateAutomatically: false +- name: Current User Configuration + type: TSToy.Example/csharptstoy + properties: + scope: user + ensure: present + updateAutomatically: true + updateFrequency: 45 +``` + +Get the current state of the resources with `dsc config get`: + +```powershell +$config | dsc config get +``` + +```yaml +results: +- name: All Users Configuration + type: TSToy.Example/csharptstoy + result: + actual_state: + ensure: present + scope: machine + updateAutomatically: false +- name: Current User Configuration + type: TSToy.Example/csharptstoy + result: + actual_state: + ensure: present + scope: user + updateAutomatically: true + updateFrequency: 45 +messages: [] +hadErrors: false +``` diff --git a/samples/csharp/csharp.csproj b/samples/csharp/csharp.csproj new file mode 100644 index 0000000..dfe65e3 --- /dev/null +++ b/samples/csharp/csharp.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + csharptstoy + true + win-x64 + true + + + + + + + diff --git a/samples/csharp/csharp.sln b/samples/csharp/csharp.sln new file mode 100644 index 0000000..14b9a72 --- /dev/null +++ b/samples/csharp/csharp.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "csharp", "csharp.csproj", "{410987BA-1B72-409E-8F39-B7343FAAF863}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {410987BA-1B72-409E-8F39-B7343FAAF863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {410987BA-1B72-409E-8F39-B7343FAAF863}.Debug|Any CPU.Build.0 = Debug|Any CPU + {410987BA-1B72-409E-8F39-B7343FAAF863}.Release|Any CPU.ActiveCfg = Release|Any CPU + {410987BA-1B72-409E-8F39-B7343FAAF863}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4006B552-404A-4AB5-A04C-83167C732311} + EndGlobalSection +EndGlobal diff --git a/samples/csharp/csharptstoy.dsc.resource.json b/samples/csharp/csharptstoy.dsc.resource.json new file mode 100644 index 0000000..2874ab4 --- /dev/null +++ b/samples/csharp/csharptstoy.dsc.resource.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/10/resource/manifest.json", + "type": "TSToy.Example/csharptstoy", + "version": "0.1.0", + "description": "A DSC Resource written in C# to manage TSToy.", + "get": { + "executable": "csharptstoy", + "args": ["get"], + "input": "stdin" + }, + "set": { + "executable": "csharptstoy", + "args": ["set"], + "input": "stdin", + "preTest": false, + "return": "state" + }, + "export": { + "executable": "csharptstoy", + "args": [ + "get", + "--all" + ] + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "C# 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/csharp/docs/1-create.md b/samples/csharp/docs/1-create.md new file mode 100644 index 0000000..4dec6a0 --- /dev/null +++ b/samples/csharp/docs/1-create.md @@ -0,0 +1,148 @@ +--- +title: Step 1 - Create the DSC Resource +weight: 1 +dscs: + menu_title: 1. Create the resource +--- + +After installing the prequsities of VS Code, ms-dotnettools.csdevkit and .net 8 +Navigate to development folder in a system shell and create a new project +```sh +mkdir ~\dev +cd ~\dev +dotnet new console -n csharptstoy +``` + +Open the newly created csharptstoy folder in VS Code this should create a `.sln` file for your project + +In this tutorial, you'll be creating a DSC Resource with [System.Commandline][01]. System.Commandline helps you create a +command line application in C#. It handles argument parsing, setting flags, shell completions, and autogenerates help. + +Launch the integrated terminal in VS Code and Use the following command to install `System.Commandline`. +Note: This library is still in preview but is stable enough for our purpose here. + +```sh +dotnet add package System.Commandline --prerelease +``` + +.net by nature compiles an application that is dynamically linked at runtime against the the target runtime. +This requires the .net runtime be installed on the system where you are using your resource. +Since this may not be possible in all of our Iot edge deployments we will make the resource selfcontained. +This make the binary larger and does not require us to install the runtime on the target machine + +Open the `csharptstoy.csproj` file and add the following lines under the `` tag. + +```xml + + ... + true + win-x64 + true + ... + +``` + +Once you have done the steps above opne the `Program.cs` file and replace the contents with the following. +This code block sets up the options (i.e `--scope`), rootcommand (`csharptstoy`), and subcommands (`get` and `set`) + +```c# +using System.CommandLine; + +namespace csharptstoy; + +class Program +{ + static async Task Main(string[] args) + { + var ensureOption = new Option( + name: "--ensure", + description: "Whether the configuration file should exist.", + getDefaultValue: () => "present") + .FromAmong( + "present", + "absent" + ); + var inputJSON = new Option( + name: "--inputJSON", + description: "Specify options as a JSON blob instead of using the scope, ensure, and update* flags."); + var scopeOption = new Option( + name: "--scope", + description: "The target scope for the configuration.") + .FromAmong( + "machine", + "user" + ); + var updateAutomaticallyOption = new Option( + name: "--updateAutomatically", + description: "Whether the configuration should set the app to automatically update."); + var updateFrequencyOption = new Option( + name: "--updateFrequency", + description: "How frequently the configuration should update, between 1 and 90 days inclusive."); + + var allOption = new Option( + name: "--all", + description: " Get the configurations for all scopes."); + + var rootCommand = new RootCommand("csharp DSC Resource for tstoy application"); + + rootCommand.AddGlobalOption(scopeOption); + rootCommand.AddGlobalOption(ensureOption); + rootCommand.AddGlobalOption(updateAutomaticallyOption); + rootCommand.AddGlobalOption(updateFrequencyOption); + rootCommand.AddGlobalOption(inputJSON); + + var getCommand = new Command("get", "Gets the current state of a tstoy configuration file.") + { + allOption + }; + + var setCommand = new Command("set", "Sets a tstoy configuration file to the desired state.") + { + }; + + rootCommand.AddCommand(getCommand); + rootCommand.AddCommand(setCommand); + + return rootCommand.InvokeAsync(args).Result; + } + +} +``` + +Not that you have a basic layout of your resource you may run the following command from the VS Code +integrated terminal to compile it. + +```sh +dotnet run +``` + +It should output the following text + +```text +Required command was not provided. + +Description: + csharp DSC Resource for tstoy application + +Usage: + csharptstoy [command] [options] + +Options: + --scope The target scope for the configuration. + --ensure Whether the configuration file should exist. [default: present] + --updateAutomatically Whether the configuration should set the app to automatically update. + --updateFrequency How frequently the configuration should update, between 1 and 90 days inclusive. + --inputJSON Specify options as a JSON blob instead of using the scope, ensure, and update* flags. + --version Show version information + -?, -h, --help Show help and usage information + +Commands: + get Gets the current state of a tstoy configuration file. + set Sets a tstoy configuration file to the desired state. +``` + +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]. + +[01]: https://www.nuget.org/packages/System.CommandLine#readme-body-tab +[02]: /tstoy/about diff --git a/samples/csharp/docs/2-define-config-settings.md b/samples/csharp/docs/2-define-config-settings.md new file mode 100644 index 0000000..6bf9df0 --- /dev/null +++ b/samples/csharp/docs/2-define-config-settings.md @@ -0,0 +1,110 @@ +--- +title: Step 2 - Define the configuration settings +weight: 2 +dscs: + menu_title: 2. Define settings +--- + +## Create the data structure for the json configuration + +Create the `json.cs` file in the project root. This file defines the +configuration types of the DSC Resource. It will also be used when we +serialize our objects to a file. Learn more at [How to write .NET objects as JSON (serialize)][01] + +```sh +dotnet new class -n json +``` + +Replace the default contents of the `json.cs` file with the following content: + +```csharp +using System.Text.Json.Serialization; +namespace csharptstoy; +``` + +Now you will define the structure of the the Tstoy Configuration File. +The configuration file should only contain the `updates` section. +The `Ensure` option for tstoy is represented by the file being absent or present +and the `Scope` option for tstoy is represented by the path of the config file. + +C# desires that Public properties be uppercase, but tstoy's configuration is case +sensitive and requires us to overwrite the property name with `[JsonPropertyName("updates")]` + +Below the `namespace` decleration enter the following class: + +```csharp +/// +/// Represents the Tstoy file +/// +public class TstoyFile +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("updates")] + public TstoyFileUpdates? Updates { get; set; } +} +``` + +In C# nested JSON objects need to be represented by other classes, so we need to define the +`TstoyFileUpdates` class. We also are going to define a `IsValueNotNull` method as well to do a quick +comparison of the properties that may be defined. + +Enter the following below the `TstoyFile` class: + +```csharp +/// +/// Represents the updates in a Tstoy file +/// +public class TstoyFileUpdates +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("automatic")] + public bool Automatic { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("checkFrequency")] + public int CheckFrequency { get; set; } + + /// + /// Checks if the TstoyFileUpdates object is not null. + /// + /// The TstoyFileUpdates object to check. + /// True if the object is not null, false otherwise. + public static bool IsValueNotNull(TstoyFileUpdates tstoyProperty) => tstoyProperty != null; +} +``` + +Finally since the we need to read and return the current state of all fields `Ensure`, `Scope`, +`UpdateAutomatically`, and `UpdateFrequency` to DSC in JSON format we should declare a +Class which represents this object. There are more advanced ways to write custom contracts in +C# for JSON, but this duplication of items will server our current purposes. + +When using the `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]` attribute we will +ignore any "default" or undefined values. [How to ignore properties with System.Text.Json][02] + +Enter the following Class below `TstoyFileUpdates` + +```csharp +/// +/// Represents the Tstoy configuration +/// +public class TstoyConfig +{ + [JsonPropertyName("ensure")] + public string? Ensure { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("updateAutomatically")] + public bool UpdateAutomatically { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("updateFrequency")] + public int UpdateFrequency{ get; set; } +} +``` + +Now that we have these data structures defined we may move on to handling input to the resource + +[01]: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/how-to +[02]: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/ignore-properties \ No newline at end of file diff --git a/samples/csharp/docs/3-helper-functions.md b/samples/csharp/docs/3-helper-functions.md new file mode 100644 index 0000000..e1a4a96 --- /dev/null +++ b/samples/csharp/docs/3-helper-functions.md @@ -0,0 +1,270 @@ +--- +title: Step 3 - Helper Functions +weight: 3 +dscs: + menu_title: 3. Helper Functions +--- + +Now that we have defined the structure of the data we will need for our resource +there are some helper functions that we need to define. + +## Define FileExists Function + +The tstoy application needs to be available in our `PATH` for the DSC resource to work additionally +the DSC Resource is needs to be able to read and write the defined configuration path. To this end +we should define a function to help us validate if a file exists. + +Open the `Program.cs` file and after the closing `}` for the `main` function enter the following code: + +```csharp + + internal static bool FileExists(string fileName) + { + if (File.Exists(fileName)) + return true; + + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathValue)) + { + foreach (var path in pathValue.Split(Path.PathSeparator)) + { + if (File.Exists(Path.Combine(path, fileName))) + return true; + } + } + return false; + } + +``` + + +## Define DeserializeConfig and DeserializeConfigFile Function + +Since we will be reading JSON from our application and from disk we need a consistent way to Deserialize +The data to objects we may use in our get and set functions we will define later. +The `TstoyConfig` represents all fields (`Ensure`, `Scope`, `UpdateAutomatically`, and `UpdateFrequency`) +While `TstoyFile` represents the fields written to disk (`UpdateAutomatically` and `UpdateFrequency`) +Read more about Deserialization here [How to read JSON as .NET objects (deserialize)][01] + +```csharp + + internal static TstoyConfig DeserializeConfig(string configText) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + TstoyConfig config = JsonSerializer.Deserialize(configText, options)!; + return config; + } + + internal static TstoyFile DeserializeConfigFile(string configPath) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var fileLines = File.ReadAllText(configPath); + TstoyFile fileContent = JsonSerializer.Deserialize(fileLines, options)!; + return fileContent; + } +``` + +when adding the above functions you will likely need to add the following statement at the top of the file. + +```csharp +using System.Text.Json; +``` + +## Define the GetConfigPath Function + +The tstoy command outputs the path for the `user` and `machine` configuration using the commands +`show path`. Since these may change in the future we want to ensure the resource may dynamically +call the function to retreive the current path for these configurations. + +As a result we need to define a function to execute tstoy with the desired parameters and return +the path as a string. + +First at the top of the file we need to declare a new namespace for `System.Diagnostics` +The top of `Program.cs` should look like this: + +```csharp +using System.CommandLine; +using System.Text.Json; +using System.Diagnostics; +``` + +Further down below the `DeserializeConfigFile` function define the `GetConfigPath` Function. +This should still be outside the `Main` function + +```csharp + internal static string GetConfigPath(string scopeOfConfig) + { + var options = new HashSet { "user", "machine" }; + string commandArguments(string scope) => options.Contains(scope) ? $" show path {scope}" : " show path"; + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "tstoy.exe", + WindowStyle = ProcessWindowStyle.Hidden, + Arguments = commandArguments(scopeOfConfig), + UseShellExecute = false, + RedirectStandardOutput = true + } + }; + try + { + p.Start(); + } + catch (Exception ex) + { + Console.WriteLine($"Error starting process: {ex.Message}"); + } + + var value = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(); + return value; + } +``` + +At this point your `Program.cs` should look like the following: + +```csharp +using System.CommandLine; +using System.Text.Json; +using System.Diagnostics; + +namespace csharptstoy; + +class Program +{ + static async Task Main(string[] args) + { + var ensureOption = new Option( + name: "--ensure", + description: "Whether the configuration file should exist.", + getDefaultValue: () => "present") + .FromAmong( + "present", + "absent" + ); + var inputJSON = new Option( + name: "--inputJSON", + description: "Specify options as a JSON blob instead of using the scope, ensure, and update* flags."); + var scopeOption = new Option( + name: "--scope", + description: "The target scope for the configuration.") + .FromAmong( + "machine", + "user" + ); + var updateAutomaticallyOption = new Option( + name: "--updateAutomatically", + description: "Whether the configuration should set the app to automatically update."); + var updateFrequencyOption = new Option( + name: "--updateFrequency", + description: "How frequently the configuration should update, between 1 and 90 days inclusive."); + + var allOption = new Option( + name: "--all", + description: " Get the configurations for all scopes."); + + var rootCommand = new RootCommand("csharp DSC Resource for tstoy application"); + + rootCommand.AddGlobalOption(scopeOption); + rootCommand.AddGlobalOption(ensureOption); + rootCommand.AddGlobalOption(updateAutomaticallyOption); + rootCommand.AddGlobalOption(updateFrequencyOption); + rootCommand.AddGlobalOption(inputJSON); + + var getCommand = new Command("get", "Gets the current state of a tstoy configuration file.") + { + allOption + }; + + var setCommand = new Command("set", "Sets a tstoy configuration file to the desired state.") + { + }; + + rootCommand.AddCommand(getCommand); + rootCommand.AddCommand(setCommand); + + return rootCommand.InvokeAsync(args).Result; + } + + #region Helper Functions + internal static bool FileExists(string fileName) + { + if (File.Exists(fileName)) + return true; + + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathValue)) + { + foreach (var path in pathValue.Split(Path.PathSeparator)) + { + if (File.Exists(Path.Combine(path, fileName))) + return true; + } + } + return false; + } + internal static TstoyConfig DeserializeConfig(string configText) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + TstoyConfig config = JsonSerializer.Deserialize(configText, options)!; + return config; + } + + internal static TstoyFile DeserializeConfigFile(string configPath) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var fileLines = File.ReadAllText(configPath); + TstoyFile fileContent = JsonSerializer.Deserialize(fileLines, options)!; + return fileContent; + } + internal static string GetConfigPath(string scopeOfConfig) + { + var options = new HashSet { "user", "machine" }; + string commandArguments(string scope) => options.Contains(scope) ? $" show path {scope}" : " show path"; + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "tstoy.exe", + WindowStyle = ProcessWindowStyle.Hidden, + Arguments = commandArguments(scopeOfConfig), + UseShellExecute = false, + RedirectStandardOutput = true + } + }; + try + { + p.Start(); + } + catch (Exception ex) + { + Console.WriteLine($"Error starting process: {ex.Message}"); + } + + var value = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(); + return value; + } + #endregion +} +``` + +The DSC Resource should compile and now we may move on to writing our Get subcommand + + +[01]: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/deserialization \ No newline at end of file diff --git a/samples/csharp/docs/4-implement-get.md b/samples/csharp/docs/4-implement-get.md new file mode 100644 index 0000000..c637943 --- /dev/null +++ b/samples/csharp/docs/4-implement-get.md @@ -0,0 +1,301 @@ +--- +title: Step 4 - Implement get functionality +weight: 4 +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. + +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 we wrote a helper function in the last section to do this for us. Now we can implement the read functions for the get command. + +## Define handlers for get subcommand + +Open the `Program.cs` file and navigate to the last line in the main fuction where the return is defined: `return rootCommand.InvokeAsync(args).Result;` + +Just above this and below the line we added the set command we will implement the handler for our get command. Start with the structure below. We are passing in the `scopeOption`, `allOption` and `inputJSON` to the handler and are defining local variables. + +```csharp + rootCommand.AddCommand(setCommand); + + getCommand.SetHandler(async (scopeValue, allValue, jsonConfig) => + { + + }, + scopeOption, allOption, inputJSON); + + return rootCommand.InvokeAsync(args).Result; + +``` + +Next inside the handler we need to read the json from stdio or from the parameter `--inputJSON` to ensure that we caputre input either way. When DSC passes info to a resource it may pipe this instead of binding to the argument. + +We will then need to use our Deserialization helper function to convert the json to a .net object we may use. + +```csharp + + getCommand.SetHandler(async (scopeValue, allValue, jsonConfig) => + { + if (Console.IsInputRedirected && string.IsNullOrEmpty(jsonConfig)) + { + jsonConfig = Console.In.ReadToEnd(); + } + + if (!string.IsNullOrEmpty(jsonConfig)) + { + var deserializedConfig = DeserializeConfig(jsonConfig); + if (string.IsNullOrEmpty(scopeValue) && !string.IsNullOrEmpty(deserializedConfig.Scope)) + { + scopeValue = deserializedConfig.Scope; + } + } + + }, + scopeOption, allOption, inputJSON); +``` + +Once the config has been deserialized and we have the scope, we need to get the configuration. Either for all of the instances or for a specific scope. + +If `--all` is passed to the command we will have the `allOption` defined, but we need a way to iterate over both machine and user configuration. For this we will define a temporary variable listing all of the completions for the `scopeOption` + +Now for our logic if `--all` is provided we will loop over `tempOptions` and read each config, otherwise we will read the provided scope. (note: don't worry about the `ReadConfig` function we will write that next) + +```csharp + getCommand.SetHandler(async (scopeValue, allValue, jsonConfig) => + { + //... code from previous step here + var tempOptions = scopeOption.GetCompletions(); + + if (allValue) + { + foreach (var tempOptionsValue in tempOptions) + { + string tempValue = tempOptionsValue.ToString(); + await ReadConfig(tempValue); + } + } + else + { + if (!string.IsNullOrEmpty(scopeValue)) + await ReadConfig(scopeValue); + else + throw new InvalidOperationException("must provide a scope"); + } + }, + scopeOption, allOption, inputJSON); +``` + +Now that we have the get handler defined we need to create the function that will actually read and return the config from disk. + +For this function we will need to define this outside the `Main` function and near the helper functions below. Since we are calling the handlers with the `async` keyword we need to implement `async Task` functions. + +Start the signature with the following: + +```csharp + // other helper functions above here... + internal static async Task ReadConfig(string scopeOfConfig) + { + + } +``` + +## Define helper function ReadConfig + +For this function we used earlier in the handler we passed the `scope` of the config, using the scope we need to retrieve the path to the file. We will use our helper function `GetConfigPath` to determine this path and store it as a string. + +Additionally we will need a `TstoyConfig` object so that we can represent the state of the config to DSC as JSON: `{"ensure":"absent","scope":"machine"}` + +```csharp + internal static async Task ReadConfig(string scopeOfConfig) + { + string configPath = GetConfigPath(scopeOfConfig); + var configValues = new TstoyConfig + { + Ensure = FileExists(configPath) ? "present" : "absent", + Scope = scopeOfConfig + }; + } +``` + +Now that we have the path of the configuration from `tstoy` we can do a quick check to see if the file exists with out helper function `FileExists`. + +If the file exists we will want to try and Deserialize the config file using the helper function `DeserializeConfigFile`. Recall the file only contains information about updates, so if those values are present we will want to update the `configValues` object for presentation to DSC. + +```csharp + internal static async Task ReadConfig(string scopeOfConfig) + { + //... code from previous step here + if (FileExists(configPath)) + { + try + { + TstoyFile fileContent = DeserializeConfigFile(configPath); + if (fileContent != null && TstoyFileUpdates.IsValueNotNull(fileContent.Updates)) + { + configValues.UpdateAutomatically = fileContent.Updates.Automatic; + configValues.UpdateFrequency = fileContent.Updates.CheckFrequency; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing configuration file: {ex.Message}"); + } + } + } +``` + +Finally in this ReadConfig we will need to take our `configValues` object and try to serialize it back to JSON so we may successfully pass it to DSC. + +```csharp + internal static async Task ReadConfig(string scopeOfConfig) + { + //... code from previous step here + try + { + var configOutput = JsonSerializer.Serialize(configValues); + Console.WriteLine(configOutput); + } + catch (Exception ex) + { + Console.WriteLine($"Error serializing configuration: {ex.Message}"); + } + } +``` + +Now that we have the `ReadConfig` function defined and the getCommand handler configured we should be able to read the configuration. + +One last section we should add in our `Main` function just above the `getCommand.SetHandler` section is an explicit check for tstoy. Up to know we have been assuming that it will be present, but we should ensure it is and fail early if it is missing. + +```csharp + if (!FileExists("tstoy.exe")) + throw new InvalidOperationException("tstoy.exe not found"); + + #region handlers + getCommand.SetHandler(async (scopeValue, allValue, jsonConfig) => + //... code from previous steps here +``` + +Your `Program.cs` should now include the following lines + +```csharp + // lines above removed for readability + rootCommand.AddCommand(setCommand); + + if (!FileExists("tstoy.exe")) + throw new InvalidOperationException("tstoy.exe not found"); + + #region handlers + getCommand.SetHandler(async (scopeValue, allValue, jsonConfig) => + { + var tempOptions = scopeOption.GetCompletions(); + if (Console.IsInputRedirected && string.IsNullOrEmpty(jsonConfig)) + { + jsonConfig = Console.In.ReadToEnd(); + } + + if (!string.IsNullOrEmpty(jsonConfig)) + { + var deserializedConfig = DeserializeConfig(jsonConfig); + if (string.IsNullOrEmpty(scopeValue) && !string.IsNullOrEmpty(deserializedConfig.Scope)) + { + scopeValue = deserializedConfig.Scope; + } + } + + if (allValue) + { + foreach (var tempOptionsValue in tempOptions) + { + string tempValue = tempOptionsValue.ToString(); + await ReadConfig(tempValue); + } + } + else + { + if (!string.IsNullOrEmpty(scopeValue)) + await ReadConfig(scopeValue); + else + throw new InvalidOperationException("must provide a scope"); + } + }, + scopeOption, allOption, inputJSON); + + return rootCommand.InvokeAsync(args).Result; + } + + #region Helper Functions + // lines removed for readability + + internal static async Task ReadConfig(string scopeOfConfig) + { + string configPath = GetConfigPath(scopeOfConfig); + var configValues = new TstoyConfig + { + Ensure = FileExists(configPath) ? "present" : "absent", + Scope = scopeOfConfig + }; + + if (FileExists(configPath)) + { + try + { + TstoyFile fileContent = DeserializeConfigFile(configPath); + if (fileContent != null && TstoyFileUpdates.IsValueNotNull(fileContent.Updates)) + { + configValues.UpdateAutomatically = fileContent.Updates.Automatic; + configValues.UpdateFrequency = fileContent.Updates.CheckFrequency; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing configuration file: {ex.Message}"); + } + } + + try + { + var configOutput = JsonSerializer.Serialize(configValues); + Console.WriteLine(configOutput); + } + catch (Exception ex) + { + Console.WriteLine($"Error serializing configuration: {ex.Message}"); + } + + } + #endregion +} +``` + +## Test the get command + +Test the DSC Resource now and Run the updated command: + +```sh +dotnet run get --all +dotnet run get --scope machine +dotnet run get --inputJSON '{"scope": "user"}' +dotnet run get --inputJSON '{"scope": "user"}' --scope machine +'{ + "scope": "machine", + "ensure": "present" +}' | dotnet run get +``` + +```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"} +``` + +[01]: /tstoy/about diff --git a/samples/csharp/docs/5-implement-set.md b/samples/csharp/docs/5-implement-set.md new file mode 100644 index 0000000..835df45 --- /dev/null +++ b/samples/csharp/docs/5-implement-set.md @@ -0,0 +1,499 @@ +--- +title: Step 5 - Implement set functionality +weight: 5 +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. + +## Define handlers for set subcommand + +Open the `Program.cs` file and navigate to the last line in the main fuction where the return is defined `return rootCommand.InvokeAsync(args).Result;` just below the end of the `getCommand` Handler enter the following code + +```csharp + scopeOption, allOption, inputJSON); + // above is end of getCommand.SetHandler + setCommand.SetHandler(async (scopeValue, ensureValue, updateAutomaticallyValue, updateFrequencyValue, jsonConfig) => + { + + }, + scopeOption, ensureOption, updateAutomaticallyOption, updateFrequencyOption, inputJSON); + #endregion + return rootCommand.InvokeAsync(args).Result; +``` + +Unlike the getCommand handler we will need more information to set the configuration. Now we are passing in the `scopeOption`, `ensureOption`, `updateAutomaticallyOption`, `updateFrequencyOption` and `inputJSON`. These are used to instantiate our `TstoyConfig` object as a base. + +```csharp + setCommand.SetHandler(async (scopeValue, ensureValue, updateAutomaticallyValue, updateFrequencyValue, jsonConfig) => + { + var config = new TstoyConfig + { + Ensure = ensureValue, + Scope = scopeValue, + UpdateAutomatically = updateAutomaticallyValue, + UpdateFrequency = updateFrequencyValue + }; + }, + scopeOption, ensureOption, updateAutomaticallyOption, updateFrequencyOption, inputJSON); +``` + +Recall in our getCommand handler we needed to read JSON from stdio or from the `--inputJSON` parameter. We will repeat the same actions here attempting to read the string if it is piped to the command and then attempt to Deserialize the config using our helper function `DeserializeConfig` + +Anything passed in as JSON takes precedence over the named parameters. + +```csharp + //... code from previous steps here + + if (Console.IsInputRedirected && string.IsNullOrEmpty(jsonConfig)) + { + jsonConfig = Console.In.ReadToEnd(); + } + + if (jsonConfig != null) + { + var deserializedConfig = DeserializeConfig(jsonConfig); + if (deserializedConfig != null) + { + config = deserializedConfig; + } + else + { + Console.WriteLine("Failed to deserialize config."); + return; + } + } + + //... code from previous steps here +``` + +Finally in our `setCommand` handler we need to try and write the configuration to disk with our helper function `WriteConfig` (note: don't worry about the `WriteConfig` function we will write that next) + +```csharp + //... code from previous steps here + try + { + await WriteConfig(config); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing configuration: {ex.Message}"); + } + //... code from previous steps here +``` + +## Define helper function WriteConfig + +While for our GetConfig helper function we only required the `scope` value, for our WriteConfig we will need to know all values of the Config. As a Result we will be passing the `TstoyConfig` object to our function. + +Start the signature of the function in the following way. This should be outside the main function below the `GetConfig` helper function. + +```csharp + internal static async Task WriteConfig(TstoyConfig inputConfig) + { + + } + #endregion +``` + +Before writing the configuration we should ensure that the object passed in is not empty. This will save us from all sorts of issues in the future. + +```csharp + internal static async Task WriteConfig(TstoyConfig inputConfig) + { + if (inputConfig.Scope != null) + { + + } + else + { + throw new InvalidOperationException("config is empty"); + } + } + #endregion +``` + +Inside our `if` block we may now start defining some required variables. We need the `scopeOfConfig` so we can get the correct configuration to update with `GetConfigPath`. + +After that we have some options we need to pass to our Serialization functions to match what is being done by the `gotstoy` DSC Resource. + +```csharp + if (inputConfig.Scope != null) + { + + string scopeOfConfig = inputConfig.Scope; + string configPath = GetConfigPath(scopeOfConfig); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + } +``` + +For the `Ensure` option we really only have two options either create or delete the file. If we want to ensure the file is absent then we should delte it only if the `FileExists`. + +```csharp + // inside the 'if (inputConfig.Scope != null)' + if (inputConfig.Ensure == "present") + { + // We will fill this in during the next steps + } + else + { + if (FileExists(configPath)) + { + File.Delete(configPath); + } + } +``` + +For our file creation we have a different JSON object defined for the file structure since this differes from the JSON object we have been returning to our console. For this we defined our `fileContent` variable. + +If the `FileExists` for the configuration we are updating we will attempt to `DeserializeConfigFile` from disk and update the file content. If the file doesn't exists we will start assigning the desired values + +```csharp + if (inputConfig.Ensure == "present") + { + TstoyFile fileContent = new TstoyFile { }; + + if (FileExists(configPath)) + { + try + { + fileContent = DeserializeConfigFile(configPath); + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing configuration file: {ex.Message}"); + } + + if (fileContent.Updates != null) + { + if (fileContent.Updates.Automatic != inputConfig.UpdateAutomatically) + fileContent.Updates.Automatic = inputConfig.UpdateAutomatically; + + if (fileContent.Updates.CheckFrequency != inputConfig.UpdateFrequency) + fileContent.Updates.CheckFrequency = inputConfig.UpdateFrequency; + } + } + else + { + TstoyFileUpdates updateValues = new TstoyFileUpdates + { + Automatic = inputConfig.UpdateAutomatically, + CheckFrequency = inputConfig.UpdateFrequency + }; + if (TstoyFileUpdates.IsValueNotNull(updateValues)) + fileContent.Updates = updateValues; + else + fileContent.Updates = null; + } + } +``` + +At this point we should have a fully formed `TstoyFile` object we may attempt to write to disk. For convenince we will create the directories if they don't currently exist. + +Before writing this file to disk we need to try and serialize the object to JSON. + +```csharp + if (inputConfig.Ensure == "present") + { + //... code from previous steps here + try + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + + await using FileStream createStream = File.Create(configPath); + await JsonSerializer.SerializeAsync(createStream, fileContent); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing configuration file: {ex.Message}"); + } + } +``` + +Finally, since we will need to return the JSON config to DSC we are going to Serialize the inputConfig so we can return this to the shell. + +```csharp + // inside the 'if (inputConfig.Scope != null)' + try + { + string configOutput = JsonSerializer.Serialize(inputConfig); + Console.WriteLine(configOutput); + } + catch (Exception ex) + { + Console.WriteLine($"Error serializing configuration: {ex.Message}"); + } +``` + +Your `Program.cs` should now include the following lines + +```csharp + // lines above removed for readability + scopeOption, allOption, inputJSON); + + setCommand.SetHandler(async (scopeValue, ensureValue, updateAutomaticallyValue, updateFrequencyValue, jsonConfig) => + { + var config = new TstoyConfig + { + Ensure = ensureValue, + Scope = scopeValue, + UpdateAutomatically = updateAutomaticallyValue, + UpdateFrequency = updateFrequencyValue + }; + + if (Console.IsInputRedirected && string.IsNullOrEmpty(jsonConfig)) + { + jsonConfig = Console.In.ReadToEnd(); + } + + if (jsonConfig != null) + { + var deserializedConfig = DeserializeConfig(jsonConfig); + if (deserializedConfig != null) + { + config = deserializedConfig; + } + else + { + Console.WriteLine("Failed to deserialize config."); + return; + } + } + + try + { + await WriteConfig(config); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing configuration: {ex.Message}"); + } + }, + scopeOption, ensureOption, updateAutomaticallyOption, updateFrequencyOption, inputJSON); + #endregion + + return rootCommand.InvokeAsync(args).Result; + } + + #region Helper Functions + // lines removed for readability + internal static async Task WriteConfig(TstoyConfig inputConfig) + { + if (inputConfig.Scope != null) + { + string scopeOfConfig = inputConfig.Scope; + string configPath = GetConfigPath(scopeOfConfig); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + if (inputConfig.Ensure == "present") + { + TstoyFile fileContent = new TstoyFile { }; + + if (FileExists(configPath)) + { + try + { + fileContent = DeserializeConfigFile(configPath); + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing configuration file: {ex.Message}"); + } + + if (fileContent.Updates != null) + { + if (fileContent.Updates.Automatic != inputConfig.UpdateAutomatically) + fileContent.Updates.Automatic = inputConfig.UpdateAutomatically; + + if (fileContent.Updates.CheckFrequency != inputConfig.UpdateFrequency) + fileContent.Updates.CheckFrequency = inputConfig.UpdateFrequency; + } + } + else + { + TstoyFileUpdates updateValues = new TstoyFileUpdates + { + Automatic = inputConfig.UpdateAutomatically, + CheckFrequency = inputConfig.UpdateFrequency + }; + if (TstoyFileUpdates.IsValueNotNull(updateValues)) + fileContent.Updates = updateValues; + else + fileContent.Updates = null; + + } + try + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + + await using FileStream createStream = File.Create(configPath); + await JsonSerializer.SerializeAsync(createStream, fileContent); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing configuration file: {ex.Message}"); + } + } + else + { + if (FileExists(configPath)) + { + File.Delete(configPath); + } + } + + try + { + string configOutput = JsonSerializer.Serialize(inputConfig); + Console.WriteLine(configOutput); + } + catch (Exception ex) + { + Console.WriteLine($"Error serializing configuration: {ex.Message}"); + } + } + else + { + throw new InvalidOperationException("config is empty"); + } + } + #endregion +} +``` + + +## 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 + dotnet run get --scope machine + ``` + + ```json + {"ensure":"absent","scope":"machine"} + ``` + +1. Enforce the desired state with the `set` command. + + ```sh + dotnet run set --scope machine --ensure present + ``` + + ```json + {"ensure":"present","scope":"machine"} + ``` + +1. Verify that the output from the `set` command matches the output from `get` after enforcing the + desired state. + + ```sh + dotnet run get --scope machine + ``` + + ```json + {"ensure":"present","scope":"machine"} + ``` + +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 + }' | dotnet run 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 + } + } + ``` diff --git a/samples/csharp/docs/6-author-manifest.md b/samples/csharp/docs/6-author-manifest.md new file mode 100644 index 0000000..cc8d598 --- /dev/null +++ b/samples/csharp/docs/6-author-manifest.md @@ -0,0 +1,165 @@ +--- +title: Step 6 - Author the DSC Resource manifest +weight: 6 +dscs: + menu_title: 6. Author 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. + +Create a new file called `csharptstoy.dsc.resource.json` in the project folder and open it. + +```sh +touch ./csharptstoy.dsc.resource.json +code ./csharptstoy.dsc.resource.json +``` + +Add basic metadata for the DSC Resource. + +```json +{ + "manifestVersion": "1.0", + "type": "TSToy.Example/csharptstoy", + "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/csharptstoy", + "version": "0.1.0", + "description": "A DSC Resource written in go to manage TSToy.", + "get": { + "executable": "csharptstoy", + "args": ["get"], + "input": "stdin" + } +} +``` + +The `executable` key indicates the name of the binary `dsc` should use. The `args` key indicates +that `dsc` should call `csharptstoy 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. + +```json +{ + "manifestVersion": "1.0", + "type": "TSToy.Example/csharptstoy", + "version": "0.1.0", + "description": "A DSC Resource written in go to manage TSToy.", + "get": { + "executable": "csharptstoy", + "args": ["get"], + "input": "stdin" + }, + "set": { + "executable": "csharptstoy", + "args": ["set"], + "input": "stdin", + "preTest": true, + "return": "state" + } +} +``` + +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. + +```json +{ + "manifestVersion": "1.0", + "type": "TSToy.Example/csharptstoy", + "version": "0.1.0", + "description": "A DSC Resource written in go to manage TSToy.", + "get": { + "executable": "csharptstoy", + "args": ["get"], + "input": "stdin" + }, + "set": { + "executable": "csharptstoy", + "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 + } + } + } + } +} +``` + +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. diff --git a/samples/csharp/docs/7-validate.md b/samples/csharp/docs/7-validate.md new file mode 100644 index 0000000..d355a9f --- /dev/null +++ b/samples/csharp/docs/7-validate.md @@ -0,0 +1,310 @@ +--- +title: Step 7 - Validate the DSC Resource with DSC +weight: 7 +dscs: + menu_title: 7. Validate the resource +--- + +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`. + +- On Linux or macOS: + + ```sh + dotnet restore + dotnet publish --self-contained -o . + export PATH=$(pwd):PATH + ``` + +- On Windows: + + ```powershell + dotnet restore + dotnet publish --self-contained -o . + $env:Path = $PWD.Path + ';' + $env: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. + +First, verify that DSC recognizes the DSC Resource. + +```sh +dsc resource list TSToy.Example/csharptstoy -f yaml +``` + +```yaml +type: TSToy.Example/csharptstoy +kind: Resource +version: 0.1.0 +capabilities: +- Get +- Set +- Export +path: C:\Users\User\.local\bin\csharptstoy.dsc.resource.json +description: A DSC Resource written in C# to manage TSToy. +directory: C:\Users\User\.local\bin +implementedAs: null +author: null +properties: [] +requireAdapter: null +manifest: + $schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/10/resource/manifest.json + type: TSToy.Example/csharptstoy + version: 0.1.0 + description: A DSC Resource written in C# to manage TSToy. + tags: null + get: + executable: csharptstoy + args: + - get + input: stdin + set: + executable: csharptstoy + args: + - set + input: stdin + return: state + export: + executable: csharptstoy + args: + - get + - --all + schema: + embedded: + $schema: https://json-schema.org/draft/2020-12/schema + title: C# 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 +``` + +## Manage state with `dsc resource` + +Get the current state of the machine-scope configuration file. + +```sh +'{ "scope": "machine" }' | dsc resource get --resource TSToy.Example/csharptstoy +``` + +```yaml +actualState: + ensure: present + scope: machine +``` + +Test whether the user-scope configuration file is absent. + +```sh +'{ + "scope": "machine", + "ensure": "absent" +}' | dsc resource test --resource TSToy.Example/csharptstoy +``` + +```yaml +desiredState: + scope: machine + ensure: absent +actualState: + ensure: present + scope: machine +inDesiredState: false +differingProperties: +- ensure +``` + +Remove the machine-scope configuration file. + +```sh +'{ + "scope": "machine", + "ensure": "absent" +}' | dsc resource set --resource TSToy.Example/csharptstoy +``` + +```yaml +beforeState: + ensure: present + scope: machine +afterState: + ensure: absent + scope: machine +changedProperties: +- ensure +``` + +## Manage state with `dsc config` + +Save the following configuration file as `csharptstoy.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://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/10/config/document.json +resources: +- name: All Users Configuration + type: TSToy.Example/csharptstoy + properties: + scope: machine + ensure: present + updateAutomatically: false +- name: Current User Configuration + type: TSToy.Example/csharptstoy + properties: + scope: user + ensure: present + updateAutomatically: true + updateFrequency: 30 +``` + +Get the current state of the instances defined in the configuration: + +```sh +cat csharptstoy.dsc.config.yaml | dsc config get +``` + +```yaml +results: +- name: All Users Configuration + type: TSToy.Example/csharptstoy + result: + actualState: + ensure: absent + scope: machine +- name: Current User Configuration + type: TSToy.Example/csharptstoy + 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 csharptstoy.dsc.config.yaml | dsc config test +``` + +```yaml +results: +- name: All Users Configuration + type: TSToy.Example/csharptstoy + result: + desiredState: + scope: machine + ensure: present + updateAutomatically: false + actualState: + ensure: absent + scope: machine + inDesiredState: false + differingProperties: + - ensure + - updateAutomatically +- name: Current User Configuration + type: TSToy.Example/csharptstoy + result: + desiredState: + scope: user + ensure: present + updateAutomatically: true + updateFrequency: 30 + actualState: + ensure: present + scope: user + 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. + +```sh +cat csharptstoy.dsc.config.yaml | dsc config set +``` + +```yaml +results: +- name: All Users Configuration + type: TSToy.Example/csharptstoy + result: + beforeState: + ensure: absent + scope: machine + afterState: + ensure: present + scope: machine + changedProperties: + - ensure +- name: Current User Configuration + type: TSToy.Example/csharptstoy + result: + beforeState: + ensure: present + scope: user + updateAutomatically: true + updateFrequency: 45 + afterState: + ensure: present + scope: user + updateAutomatically: true + updateFrequency: 30 + changedProperties: + - updateFrequency +messages: [] +hadErrors: false +``` + +The results show that the resource created the machine scope configuration file and set the +`updateFrequency` property for it. The results also show that the resource changed the update +frequency for the user scope configuration file. + +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/csharp/docs/_index.md b/samples/csharp/docs/_index.md new file mode 100644 index 0000000..06329b0 --- /dev/null +++ b/samples/csharp/docs/_index.md @@ -0,0 +1,53 @@ +--- +title: Write your first DSC Resource in C# +dscs: + tutorials_title: In Csharp + languages_title: Write a DSC Resource +platen: + menu: + 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. + +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. + +In this tutorial, you learn how to: + +- Create a small C# 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. +- 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`. +- .net8 +- VS Code with the ms-dotnettools.csdevkit + +## Steps + +1. [Create the DSC Resource][03] +1. [Define the configuration settings][04] +1. [Helper Functions][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] + +[02]: /tstoy/about/ +[03]: 1-create.md +[04]: 2-define-config-settings.md +[05]: 3-helper-functions.md +[06]: 4-implement-get.md +[07]: 5-implement-set.md +[08]: 6-author-manifest.md +[09]: 7-validate.md +[10]: review.md diff --git a/samples/csharp/json.cs b/samples/csharp/json.cs new file mode 100644 index 0000000..67b2d7c --- /dev/null +++ b/samples/csharp/json.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; +namespace csharptstoy; + +/// +/// Represents the Tstoy file +/// +public class TstoyFile +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("updates")] + public TstoyFileUpdates? Updates { get; set; } + +} + +/// +/// Represents the updates in a Tstoy file +/// +public class TstoyFileUpdates +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("automatic")] + public bool Automatic { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("checkFrequency")] + public int CheckFrequency { get; set; } + + /// + /// Checks if the TstoyFileUpdates object is not null. + /// + /// The TstoyFileUpdates object to check. + /// True if the object is not null, false otherwise. + public static bool IsValueNotNull(TstoyFileUpdates tstoyProperty) => tstoyProperty != null; +} + +/// +/// Represents the Tstoy configuration +/// +public class TstoyConfig +{ + [JsonPropertyName("ensure")] + public string? Ensure { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("updateAutomatically")] + public bool UpdateAutomatically { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("updateFrequency")] + public int UpdateFrequency{ get; set; } + +} + diff --git a/samples/csharp/program.cs b/samples/csharp/program.cs new file mode 100644 index 0000000..a8ceb95 --- /dev/null +++ b/samples/csharp/program.cs @@ -0,0 +1,340 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Text.Json; + +namespace csharptstoy; + +class Program +{ + static async Task Main(string[] args) + { + #region Options + var ensureOption = new Option( + name: "--ensure", + description: "Whether the configuration file should exist.", + getDefaultValue: () => "present") + .FromAmong( + "present", + "absent" + ); + var inputJSON = new Option( + name: "--inputJSON", + description: "Specify options as a JSON blob instead of using the scope, ensure, and update* flags."); + var scopeOption = new Option( + name: "--scope", + description: "The target scope for the configuration.") + .FromAmong( + "machine", + "user" + ); + var updateAutomaticallyOption = new Option( + name: "--updateAutomatically", + description: "Whether the configuration should set the app to automatically update."); + var updateFrequencyOption = new Option( + name: "--updateFrequency", + description: "How frequently the configuration should update, between 1 and 90 days inclusive."); + + var allOption = new Option( + name: "--all", + description: " Get the configurations for all scopes."); + #endregion + + #region Commands + var rootCommand = new RootCommand("csharp DSC Resource for tstoy application"); + + rootCommand.AddGlobalOption(scopeOption); + rootCommand.AddGlobalOption(ensureOption); + rootCommand.AddGlobalOption(updateAutomaticallyOption); + rootCommand.AddGlobalOption(updateFrequencyOption); + rootCommand.AddGlobalOption(inputJSON); + + var getCommand = new Command("get", "Gets the current state of a tstoy configuration file.") + { + allOption + }; + + var setCommand = new Command("set", "Sets a tstoy configuration file to the desired state.") + { + }; + + rootCommand.AddCommand(getCommand); + rootCommand.AddCommand(setCommand); + #endregion + + if (!FileExists("tstoy.exe")) + throw new InvalidOperationException("tstoy.exe not found"); + + #region handlers + getCommand.SetHandler(async (scopeValue, allValue, jsonConfig) => + { + var tempOptions = scopeOption.GetCompletions(); + if (Console.IsInputRedirected && string.IsNullOrEmpty(jsonConfig)) + { + jsonConfig = Console.In.ReadToEnd(); + } + + if (!string.IsNullOrEmpty(jsonConfig)) + { + var deserializedConfig = DeserializeConfig(jsonConfig); + if (string.IsNullOrEmpty(scopeValue) && !string.IsNullOrEmpty(deserializedConfig.Scope)) + { + scopeValue = deserializedConfig.Scope; + } + } + + if (allValue) + { + foreach (var tempOptionsValue in tempOptions) + { + string tempValue = tempOptionsValue.ToString(); + await ReadConfig(tempValue); + } + } + else + { + if (!string.IsNullOrEmpty(scopeValue)) + await ReadConfig(scopeValue); + else + throw new InvalidOperationException("must provide a scope"); + } + }, + scopeOption, allOption, inputJSON); + + setCommand.SetHandler(async (scopeValue, ensureValue, updateAutomaticallyValue, updateFrequencyValue, jsonConfig) => + { + var config = new TstoyConfig + { + Ensure = ensureValue, + Scope = scopeValue, + UpdateAutomatically = updateAutomaticallyValue, + UpdateFrequency = updateFrequencyValue + }; + + if (Console.IsInputRedirected && string.IsNullOrEmpty(jsonConfig)) + { + jsonConfig = Console.In.ReadToEnd(); + } + + if (jsonConfig != null) + { + var deserializedConfig = DeserializeConfig(jsonConfig); + if (deserializedConfig != null) + { + config = deserializedConfig; + } + else + { + Console.WriteLine("Failed to deserialize config."); + return; + } + } + + try + { + await WriteConfig(config); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing configuration: {ex.Message}"); + } + }, + scopeOption, ensureOption, updateAutomaticallyOption, updateFrequencyOption, inputJSON); + #endregion + + return rootCommand.InvokeAsync(args).Result; + } + + #region Helper Functions + internal static bool FileExists(string fileName) + { + if (File.Exists(fileName)) + return true; + + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathValue)) + { + foreach (var path in pathValue.Split(Path.PathSeparator)) + { + if (File.Exists(Path.Combine(path, fileName))) + return true; + } + } + return false; + } + + internal static TstoyConfig DeserializeConfig(string configText) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + TstoyConfig config = JsonSerializer.Deserialize(configText, options)!; + return config; + } + + internal static TstoyFile DeserializeConfigFile(string configPath) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var fileLines = File.ReadAllText(configPath); + TstoyFile fileContent = JsonSerializer.Deserialize(fileLines, options)!; + return fileContent; + } + + internal static string GetConfigPath(string scopeOfConfig) + { + var options = new HashSet { "user", "machine" }; + string commandArguments(string scope) => options.Contains(scope) ? $" show path {scope}" : " show path"; + + using var p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "tstoy.exe", + WindowStyle = ProcessWindowStyle.Hidden, + Arguments = commandArguments(scopeOfConfig), + UseShellExecute = false, + RedirectStandardOutput = true + } + }; + try + { + p.Start(); + } + catch (Exception ex) + { + Console.WriteLine($"Error starting process: {ex.Message}"); + } + + var value = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(); + return value; + } + + + internal static async Task ReadConfig(string scopeOfConfig) + { + string configPath = GetConfigPath(scopeOfConfig); + var configValues = new TstoyConfig + { + Ensure = FileExists(configPath) ? "present" : "absent", + Scope = scopeOfConfig + }; + + if (FileExists(configPath)) + { + try + { + TstoyFile fileContent = DeserializeConfigFile(configPath); + if (fileContent != null && TstoyFileUpdates.IsValueNotNull(fileContent.Updates)) + { + configValues.UpdateAutomatically = fileContent.Updates.Automatic; + configValues.UpdateFrequency = fileContent.Updates.CheckFrequency; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing configuration file: {ex.Message}"); + } + } + + try + { + var configOutput = JsonSerializer.Serialize(configValues); + Console.WriteLine(configOutput); + } + catch (Exception ex) + { + Console.WriteLine($"Error serializing configuration: {ex.Message}"); + } + + } + + internal static async Task WriteConfig(TstoyConfig inputConfig) + { + if (inputConfig.Scope != null) + { + string scopeOfConfig = inputConfig.Scope; + string configPath = GetConfigPath(scopeOfConfig); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + if (inputConfig.Ensure == "present") + { + TstoyFile fileContent = new TstoyFile { }; + + if (FileExists(configPath)) + { + try + { + fileContent = DeserializeConfigFile(configPath); + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing configuration file: {ex.Message}"); + } + + if (fileContent.Updates != null) + { + if (fileContent.Updates.Automatic != inputConfig.UpdateAutomatically) + fileContent.Updates.Automatic = inputConfig.UpdateAutomatically; + + if (fileContent.Updates.CheckFrequency != inputConfig.UpdateFrequency) + fileContent.Updates.CheckFrequency = inputConfig.UpdateFrequency; + } + } + else + { + TstoyFileUpdates updateValues = new TstoyFileUpdates + { + Automatic = inputConfig.UpdateAutomatically, + CheckFrequency = inputConfig.UpdateFrequency + }; + if (TstoyFileUpdates.IsValueNotNull(updateValues)) + fileContent.Updates = updateValues; + else + fileContent.Updates = null; + + } + try + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + + await using FileStream createStream = File.Create(configPath); + await JsonSerializer.SerializeAsync(createStream, fileContent); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing configuration file: {ex.Message}"); + } + } + else + { + if (FileExists(configPath)) + { + File.Delete(configPath); + } + } + + try + { + string configOutput = JsonSerializer.Serialize(inputConfig); + Console.WriteLine(configOutput); + } + catch (Exception ex) + { + Console.WriteLine($"Error serializing configuration: {ex.Message}"); + } + } + else + { + throw new InvalidOperationException("config is empty"); + } + } + #endregion +} \ No newline at end of file