How to write a Microsoft DSC resource in Go in under 100 lines

Development made easy in Go with a Resource Development Kit (RDK)

How to write a Microsoft DSC resource in Go in under 100 lines

There is something interesting happening around Microsoft's DSC landscape. Within the last few months, communities started to reach the same conclusion: writing a Microsoft DSC resource involves too much protocol plumbing, and that plumbing can easily be moved to a library.

OpenDSC was the first one that built a .NET library for rapid custom resource creation in .NET. Over in the PowerShell world, I created a pull request to add the same type of support for class-based DSC resources (completely with runtime JSON schema generation and the _exist canonical property).

And in Go, there's the dsc-go-rdk. The RDK part stands for Resource Development Kit, and this RDK was written in Go just because the command-based resource contract has finally stabilized enough that the boilerplate around it is worth abstracting.

That boilerplate is the least interesting part of writing a DSC resource. But you still need to learn about it to understand what's happening behind the scenes. So, in this blog post, you will learn about the protocol and then build a simple DSC resource in under 100 lines.

What the protocol actually asks of you

A DSC command-based DSC resource is just an executable (or an entry point). The engine runs it, hands it JSON, and reads JSON back.

That sounds pretty simple, right? But at some point, you discover the contract has certain opinions:

  • get writes one compact JSON object to stdout.
  • set writes either nothing, or the after-state, or the after-state + a second line listing the properties that changed.
  • test injects an _inDesiredState flag into the state it returns.
  • export writes JSON Lines.
  • delete writes nothing at all.

And there are more opinions, but you get the point. Before the engine will even look at your binary, it needs a manifest file describing every one of those methods and an embedded JSON Schema for your instance.

None of this is difficult, actually. But they are "chores."

Input arrives via a flag or piped through stdin, so you're parsing arguments. Traces go to stderr in a specific JSON shape, gated by an environment variable, so you're writing a logger. Failures map to a documented table of exit codes, so you're maintaining an error-to-exit-code map. And on and on. What you're actually doing is plumbing, even though you haven't written any domain logic to get your resource live.

That's the gap an RDK fills. You only implement typed Get, Set, and Test methods over your own state type, and the library speaks the wire protocol on your behalf. Here's the whole thing: first a short setup, then a complete, working resource in sixty-three lines. After that, we'll take it apart.

Setup: what you need

You need less than you'd think. First of all, Go 1.26 or later, then one go get, and the build side is done:

go mod init go.resource/dscfile
go get github.com/LibreDsc/dsc-go-rdk@latest

That second command is the only dependency you'll take on. dsc-go-rdk itself has zero dependencies beyond the obvious Go standard library, so nothing else lands in your go.sum.

But what about Microsoft's DSC engine itself? Well, that's only needed to run your finished resource, not to test it. The binary you're about to write speaks the protocol on its own, which means you can develop and debug the whole thing on a machine that has never seen dsc.exe.

[!NOTE]
If you want to follow along to the end where you want the engine to discover and drive your resource, install Microsoft DSC.

Everything below is going to live in a single file: main.go.

Step 1 - Model the state as a struct

What does one instance of this thing look like?

For a file, that's going to be a path and some content. In Go, it will be a struct, and you're done. This struct is the resource's contract. The RDK generates the JSON Schema for it, parses incoming instances into it, and serializes every operation's result from it.

type File struct {
	dsc.ExistProperty
	Path    string `json:"path" description:"Absolute path of the file to manage."`
	Content string `json:"content,omitempty" description:"The text the file should contain."`
}

Three things in those four lines are doing work here:

  1. The JSON tags decide more than field names. A property without omitempty, like path, comes out required in the generated schema. Add the omitempty and it becomes optional.
  2. The description tag becomes the property's description in the schema.
  3. Lastly, the dsc.ExistProperty opts the resource into create/delete semantics. It becomes that canonical _exist boolean, defaulting to true.

Now, build the binary and ask it for its schema by running dscfile schema. The following result should return to your console:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": ["path"],
  "properties": {
    "_exist":  { "type": "boolean", "default": true, "description": "Indicates whether the instance should exist." },
    "path":    { "type": "string", "description": "Absolute path of the file to manage." },
    "content": { "type": "string", "description": "The text the file should contain." }
  }
}

You just wrote four lines of struct. This document is generated, so you don't have to maintain it.

Step 2 - Implement the operations you care about

In this part, you can decide what you genuinely want to expose and care about. A handler is any type that implements one or more capability interfaces over your state struct.

Gettable is the only one that's mandatory. The others can be created on an opt-in basis, allowing those to be discovered by the interface assertion when you construct the resource.

You declare capabilities by writing methods, not by filling in a table.

type Handler struct{}

func (Handler) Get(_ context.Context, in File) (File, error) {
	if in.Path == "" {
		return in, dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, "path is required")
	}
	data, err := os.ReadFile(in.Path)
	if errors.Is(err, fs.ErrNotExist) {
		return dsc.NotFound(in, "Go.Resource/File", in.Path)
	}
	if err != nil {
		return in, err
	}
	return File{Path: in.Path, Content: string(data)}, nil
}

func (Handler) Set(_ context.Context, desired File) (File, error) {
	if err := os.WriteFile(desired.Path, []byte(desired.Content), 0o644); err != nil {
		return desired, err
	}
	return File{Path: desired.Path, Content: desired.Content}, nil
}

func (Handler) Delete(_ context.Context, in File) error {
	if err := os.Remove(in.Path); err != nil && !errors.Is(err, fs.ErrNotExist) {
		return err
	}
	return nil
}

Here, three methods have been implemented. Get returns the current state. Set enforces state. And Delete removes the instances that are passed in.

Three details in those methods are worth slowing down for, because they're exactly where hand-written resources tend to go wrong.

Firstly, absence is not an error. A missing file is a legitimate state and, arguably, the most important one since it's where every configuration that creates the file starts from. So when os.ReadFile says the file isn't there, Get doesn't fail. It returns dsc.NotFound(...). That helper hands back the input instance with _exist set to false and a nil error. In DSC land, this says "this isn't here."

[!NOTE]
Get this wrong and the engine sees a broken resource instead of an absent instance, and every run that should create the file fails before it starts. This is the most common mistake in a resource.

Second, delete is idempotent. Deleting something that's already gone must succeed. That's what the fs.ErrNotExist check is for.

And thirdly, errors become exit codes. Return any error and the library maps it to what the protocol expects: a *NotFoundError becomes exit code 6, a JSON decoding failure becomes 3, anything else becomes 2. If you want to choose the code yourself, wrap the error and use dsc.NewExitCodeErrorf(dsc.ExitInvalidInput, ...).

But notice something that was missing: there is no Test method, and that's deliberately done. Without a Testable implementation, the manifest advertises no test method, and the engine falls back to its own synthetic test. A synthetic test in the engine calls get and compares the result against the desired state, property by property. So that means you get test for free, unless you need control over it.

Here's the full set that can be implemented to memorize:

| Interface                | Manifest capability |
|--------------------------|---------------------|
| `Gettable` (required)    | `get`               |
| `Settable`               | `set`               |
| `Testable`               | `test`              |
| `Deletable`              | `delete`            |
| `Exportable`             | `export`            |

Write the method, and the capability appears the next time the manifest is generated. When you remove it, it disappears.

Step 3 - Declare the resource

Two things are left to do: tell the library who this resource is, and hand it over main. Identity, version, and the few behavioral switches the manifest needs to advertise all live in one place, ResourceConfig:

func main() {
	r := dsc.MustResource[File](Handler{}, dsc.ResourceConfig{
		Type:        "Go.Resource/File",
		Version:     "0.1.0",
		Description: "Manages the content of a text file.",
		Tags:        []string{"file", "demo"},
		SetReturn:   dsc.SetReturnStateAndDiff,
	})
	r.Main("dscfile")
}

Type and Version are not free-form strings. The type name must match <owner>[.<group>][.<area>]/<name> and the version must be a semantic version. Both are validated when the resource is constructed. That's why MustResource panics on a bad config instead of returning an error: a failure here can only be a typo, and a typo should stop you the first time you run the binary.

The one genuinely interesting choice in the ResourceConfig is the SetReturn: dsc.SetReturnStateAndDiff. It promises the engine that set will report the resulting state and the list of properties it changed. The library will keep that promise on your behalf: it calls your Get before your Set, diffs the before-state against the after-state, and emits the changed-property array itself. You'll see that in action in a moment.

And the last part, the r.Main("dscfile") is the entire CLI. Subcommand dispatch, --input and stdin handling, per-operation output framing, exit codes, and the schema and manifest subcommands you'll use shortly — all behind one call.

Now count what you've written: sixty-three lines, imports included. That's the promise from the title delivered straight to your code editor. Let's drive the CLI.

Step 4 - Drive the protocol by hand

As mentioned in the introduction, you can build your CLI, and you have a working DSC resource. You don't need DSC's engine to prove that. The binary is the protocol, so you can exercise every operation from your shell and read exactly what the engine would read. This is the fastest debugging loop you'll get, and it's worth trying it out before you run it through dsc.exe.

go build -o dscfile.exe .

# File doesn't exist yet
.\dscfile.exe get --input '{"path":"C:/temp/test.txt"}'
# {"_exist":false,"path":"C:/temp/test.txt"}

# set prints two lines: the after-state, then what changed
.\dscfile.exe set --input '{"path":"C:/temp/tst.txt","content":"hello dsc"}'
# {"path":"C:/temp/test.txt","content":"hello dsc"}
# ["content","path"]

# change one property and the diff narrows
.\dscfile.exe set --input '{"path":"C:/temp/test.txt","content":"hello again"}'
# {"path":"C:/temp/test.txt","content":"hello again"}
# ["content"]

.\dscfile.exe delete --input '{"path":"C:/temp/test.txt"}'   # prints nothing, by contract
Figure 1: Debugging dscfile.exe directly

It's interesting to look at the two diff arrays. First time creating the file reported every property has changed, but the second set, which only altered the text, reports only content. You wrote none of that logic. That's the SetReturn: SetReturnStateAndDiff in play from the previous section. The library ran your Get before your Set and compared the states.

[!NOTE]
Two more things to mention. Input doesn't have to arrive through the flag. You can also use STDIN ('{"path":"C:/temp/test.txt"}' | .\dscfile.exe get). And if you want to see what the resource is doing internally, set $env:DSC_TRACE_LEVEL = 'debug'.

Step 5 - Generate the manifest

The manifest is the file that hooks your executable into DSC's engine. It names every method, the arguments to invoke it with, the exit code table, and it embeds your instance schema. For your own sanity, you don't want to hand-write this file because it's the one that's likely to drift out of sync with your code.

So, don't do it, but let the binary do it, as it already knows everything it contains.

.\dscfile.exe manifest --out-dir .
# writes go.resource.file.dsc.resource.json

If you've installed the engine during the setup, you can add it to the $env:DSC_RESOURCE_PATH environment variable:

$env:DSC_RESOURCE_PATH = (Get-Location).Path
dsc resource list Go.Resource/File
Figure 2: Discovering executable through DSC's engine

Now you can nearly run the identical commands with the input, but this time through dsc.exe:

dsc resource test -r Go.Resource/File --input '{"path":"C:/temp/dsc.txt","content":"managed by dsc"}'
# "inDesiredState":false,"differingProperties":["_exist"]

dsc resource set -r Go.Resource/File --input '{"path":"C:/temp/dsc.txt","content":"managed by dsc"}'
# "changedProperties":["content","path"]

dsc resource test -r Go.Resource/File --input '{"path":"C:/temp/dsc.txt","content":"managed by dsc"}'
# "inDesiredState":true,"differingProperties":[]

Stepping a bit back to look at what has happened here. First, capabilities list exactly what the handler implements (get, set, and delete), and nothing else. There's no export, because you never wrote one.

Then, if you ran the first command through DSC's engine (dsc resource test), it reported _exist as the single differing property. That's the engine's synthetic test we talked about. The manifest advertises no test method, so the engine ran get itself, received the input echoed back with _exist: false, compared it against the desired state, and drew the right conclusion: the file doesn't exist.

All of it was without having to write the manifest yourself. That's a point to take away: regenerating the manifest is a build step, not a maintenance task for you.

Where this leaves you

Roughly seventy lines of Go, and every one of them is about a file. The RDK plumbs all the other things for you. No file parse arguments, output, changed properties, or a written manifest. That's the whole purpose of an RDK, and it's the argument why OpenDSC already has one in .NET, and that DscResource.Base is in the making for PowerShell at the same time.

Honestly, this is the simplest possible resource you can make. Real ones grow if you get a more complex result back than the synthetic test can't check, or if you want to implement Export, maybe several resources in one binary via Manager). If you want to learn more, here are a couple of pointers:

Pick the language you already write, as the contract is the same underneath!