diff --git a/.site/go.mod b/.site/go.mod index b0774c4..c885129 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.26 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