Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ including discovery and DSC resource operations, also honor
uses `<user cache directory>/dscpkg/packages`. `--platform` overrides the detected
`os_arch` archive key, for example `linux_amd64`.

## Declare installed resources with DscPkg/Resource
## Declare installed resources with DscPkg/resources

DSC **3.3.0** can discover the native `DscPkg/Resource` resource directly beside
DSC **3.3.0** can discover the native `DscPkg/resources` resource directly beside
the dscpkg executable. It does not need to install itself through dscpkg.
Use [`examples/00-resources.yaml`](examples/00-resources.yaml) as a dependency
configuration:
Expand All @@ -54,17 +54,15 @@ configuration:
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
resources:
- name: LinuxGroups
type: DscPkg/Resource
type: DscPkg/resources
properties:
resource: DscExtras.Linux/groups
state: installed
repositories:
- https://bpoe.github.io/dsc-extras/
- name: LinuxServices
type: DscPkg/Resource
type: DscPkg/resources
properties:
resource: DscExtras.Linux/services
state: installed
repositories:
- https://bpoe.github.io/dsc-extras/
```
Expand All @@ -80,8 +78,6 @@ The instance properties are:

- `resource` (required): DSC type, such as `DscExtras.Linux/groups`.
- `version` (optional): exact resource version. Add `version: 0.0.1` to pin it.
- `state` (optional): defaults to `installed`. `missing` is also an observed state;
Set rejects removal requests, without deleting files or registrations.
- `repositories` (optional): ordered origins used only when installation is
necessary. No implicit repository is configured; supply these when applying
a configuration to an empty store.
Expand All @@ -92,10 +88,12 @@ The instance properties are:
providing the resource, not the desired resource version. CLI
`--package-version` remains an independent exact selector.

Get reports the exact installed version, or `state: missing`. Without a version,
Get reports the exact installed version, or omits `version` and the observed
package fields when no matching resource is installed. Without a version,
it reports the newest locally installed matching version. Test uses that same
local inspection and reports `_inDesiredState` and differing properties using
DSC's `stateAndDiff` contract. **Neither Get nor Test contacts repositories,
DSC's `stateAndDiff` contract. A missing resource reports `version` as differing,
including when no exact version was requested. **Neither Get nor Test contacts repositories,
creates a store, or writes state.** A compatible installation of any version
satisfies an unversioned desired state, including one supplied by a shared package
without an individual registry entry.
Expand All @@ -118,14 +116,14 @@ dscpkg resource export

Test returns two JSON Lines: the observed state including `_inDesiredState`,
then an array of differing property names. Export needs no input and emits one
`DscPkg/Resource` instance per installed resource type/exact-version pair, in
`DscPkg/resources` instance per installed resource type/exact-version pair, in
deterministic, deduplicated JSON Lines. It includes unregistered resources from
shared packages, never contacts repositories, and does not modify the store.
Only Export adds `_name`, using the existing resource type and exact version
directly, for example `"DscExtras.Linux/groups@0.1.1"`. DSC uses this as the
configuration instance name and removes `_name` from the generated properties.
Get, Test, and Set do not emit `_name`.
Use `dsc resource export --resource DscPkg/Resource` to obtain a DSC configuration.
Use `dsc resource export --resource DscPkg/resources` to obtain a DSC configuration.
Export filtering is disabled.

Get/Test/Export parse JSON and YAML native manifests and the `resources` entries
Expand Down Expand Up @@ -271,7 +269,7 @@ extension and its executable must be allowed by that configuration.

This extension is strictly **local discovery** of already installed packages.
Install dependencies explicitly with `dscpkg install` or declare them with
`DscPkg/Resource`; no future remote-install protocol is implemented.
`DscPkg/resources`; no future remote-install protocol is implemented.

## Commands

Expand Down Expand Up @@ -490,7 +488,7 @@ directory on PATH, keeping **all three files** together: `dscpkg` (`dscpkg.exe`
on Windows), `dscpkg.dsc.extension.json`, and `dscpkg.dsc.resource.json`.
This also applies to manual ZIP installation: copy all three files from `bin`
into a directory on PATH, open a new terminal, and verify with
`dsc extension list DscPkg/Discover` and `dsc resource list DscPkg/Resource`.
`dsc extension list DscPkg/Discover` and `dsc resource list DscPkg/resources`.
For a source build, copy both repository manifests alongside the executable. If
`DSC_RESOURCE_PATH` is set, follow the bootstrap instructions above. Alternatively,
use the appropriate native installer:
Expand Down
10 changes: 5 additions & 5 deletions cmd/dscpkg/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,18 +189,18 @@ func TestResourceCommands(t *testing.T) {
for _, test := range []struct {
operation, input, output, errorText string
}{
{"get", `{"resource":"Example/One"}`, `{"resource":"Example/One","state":"missing"}` + "\n", ""},
{"test", `{"resource":"Example/One"}`, `{"resource":"Example/One","state":"missing","_inDesiredState":false}` + "\n" + `["state"]` + "\n", ""},
{"test", `{"resource":"Example/One","state":"missing"}`, `{"resource":"Example/One","state":"missing","_inDesiredState":true}` + "\n[]\n", ""},
{"get", `{"resource":"Example/One"}`, `{"resource":"Example/One"}` + "\n", ""},
{"test", `{"resource":"Example/One"}`, `{"resource":"Example/One","_inDesiredState":false}` + "\n" + `["version"]` + "\n", ""},
{"test", `{"resource":"Example/One","version":"1.0.0"}`, `{"resource":"Example/One","_inDesiredState":false}` + "\n" + `["version"]` + "\n", ""},
{"export", "", "", ""},
{"export", "not read", "", ""},
{"get", "{}", "", "invalid resource"},
{"get", "null", "", "invalid resource"},
{"get", `{"resource":"Example/One","unknown":true}`, "", "unknown field"},
{"test", `{"resource":"Example/One","state":"absent"}`, "", "invalid resource state"},
{"test", `{"resource":"Example/One","state":"installed"}`, "", `unknown field "state"`},
{"get", `{"resource":"Example/One"} {}`, "", "exactly one"},
{"set", `{"resource":"Example/One"}`, "", "repository"},
{"set", `{"resource":"Example/One","state":"missing"}`, "", "removal is not supported"},
{"set", `{"resource":"Example/One","state":"missing"}`, "", `unknown field "state"`},
} {
t.Run(test.operation+test.input, func(t *testing.T) {
var out, diagnostics bytes.Buffer
Expand Down
8 changes: 1 addition & 7 deletions dscpkg.dsc.resource.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json",
"type": "DscPkg/Resource",
"type": "DscPkg/resources",
"version": "0.1.0",
"description": "Ensure a DSC resource is installed in the local dscpkg package store.",
"get": {
Expand Down Expand Up @@ -44,12 +44,6 @@
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$",
"description": "Exact resource version; omit to accept any installed version."
},
"state": {
"type": "string",
"enum": ["installed", "missing"],
"default": "installed",
"description": "Only installed is supported for Set; missing is an observed state."
},
"repositories": {
"type": "array",
"items": {"type": "string", "minLength": 1},
Expand Down
6 changes: 2 additions & 4 deletions examples/00-resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@ $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json

resources:
- name: LinuxGroups
type: DscPkg/Resource
type: DscPkg/resources
properties:
resource: DscExtras.Linux/groups
state: installed
repositories:
- https://bpoe.github.io/dsc-extras/

- name: LinuxServices
type: DscPkg/Resource
type: DscPkg/resources
properties:
resource: DscExtras.Linux/services
state: installed
repositories:
- https://bpoe.github.io/dsc-extras/
21 changes: 3 additions & 18 deletions internal/dscpkg/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dscpkg

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
Expand All @@ -15,7 +14,6 @@ import (
type ResourceState struct {
Resource string `json:"resource"`
Version string `json:"version,omitempty"`
State string `json:"state"`
Repositories []string `json:"repositories,omitempty"`
Package string `json:"package,omitempty"`
PackageVersion string `json:"packageVersion,omitempty"`
Expand All @@ -37,9 +35,6 @@ func validateResourceState(desired ResourceState) error {
if desired.Version != "" && !validSegment(strings.ToLower(desired.Version)) {
return fmt.Errorf("invalid resource version %q", desired.Version)
}
if desired.State != "" && desired.State != "installed" && desired.State != "missing" {
return fmt.Errorf("invalid resource state %q: expected installed or missing", desired.State)
}
return nil
}

Expand Down Expand Up @@ -98,7 +93,7 @@ func (c *Client) InstalledResources(stderr io.Writer) ([]ResourceState, error) {
return nil, fmt.Errorf("manifest %q has invalid resource version %q", manifest.ManifestPath, identity.Version)
}
result = append(result, ResourceState{
Resource: identity.Type, Version: identity.Version, State: "installed",
Resource: identity.Type, Version: identity.Version,
Package: manifest.Package, PackageVersion: manifest.PackageVersion, Path: manifest.Path,
})
}
Expand Down Expand Up @@ -150,23 +145,16 @@ func (c *Client) GetResource(desired ResourceState, stderr io.Writer) (ResourceS
return state, nil
}
}
return ResourceState{Resource: desired.Resource, State: "missing", Repositories: desired.Repositories}, nil
return ResourceState{Resource: desired.Resource, Repositories: desired.Repositories}, nil
}

func (c *Client) TestResource(desired ResourceState, stderr io.Writer) (ResourceState, []string, error) {
actual, err := c.GetResource(desired, stderr)
if err != nil {
return ResourceState{}, nil, err
}
state := desired.State
if state == "" {
state = "installed"
}
differences := make([]string, 0)
if actual.State != state {
differences = append(differences, "state")
}
if desired.Version != "" && !strings.EqualFold(desired.Version, actual.Version) {
if actual.Version == "" || (desired.Version != "" && !strings.EqualFold(desired.Version, actual.Version)) {
differences = append(differences, "version")
}
compliant := len(differences) == 0
Expand All @@ -175,9 +163,6 @@ func (c *Client) TestResource(desired ResourceState, stderr io.Writer) (Resource
}

func (c *Client) SetResource(desired ResourceState, stderr io.Writer) (ResourceState, error) {
if desired.State == "missing" {
return ResourceState{}, errors.New("state missing is observation-only; resource removal is not supported")
}
actual, differences, err := c.TestResource(desired, stderr)
if err != nil {
return ResourceState{}, err
Expand Down
8 changes: 2 additions & 6 deletions internal/dscpkg/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func TestResourceStateAndSharedSet(t *testing.T) {
client := NewClient(filepath.Join(t.TempDir(), "missing"), nil)
desired := ResourceState{Resource: testResourceOne, Repositories: []string{server.URL}}
actual, differences, err := client.TestResource(desired, io.Discard)
if err != nil || actual.State != "missing" || !reflect.DeepEqual(differences, []string{"state"}) {
if err != nil || actual.Version != "" || !reflect.DeepEqual(differences, []string{"version"}) {
t.Fatalf("missing: %+v %v %v", actual, differences, err)
}
if _, err := os.Stat(client.PackagesDir); !os.IsNotExist(err) {
Expand Down Expand Up @@ -213,13 +213,9 @@ func TestResourceStateAndSharedSet(t *testing.T) {
}
desired.Version = "2.0.0"
actual, diff, err := client.TestResource(desired, io.Discard)
if err != nil || actual.State != "missing" || !reflect.DeepEqual(diff, []string{"state", "version"}) {
if err != nil || actual.Version != "" || !reflect.DeepEqual(diff, []string{"version"}) {
t.Fatalf("wrong version: %+v %v %v", actual, diff, err)
}
desired.State = "missing"
if _, err := client.SetResource(desired, io.Discard); err == nil {
t.Fatal("Set must reject removal")
}
}

func TestResourceExplicitSetAndNoAutomaticUpgrade(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion packaging/linux/test-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ verify_install() {
env -u DSC_RESOURCE_PATH -u DSC_RESTRICTED_PATH "$DSC_TEST_BIN" --ignore-settings-file \
extension list DscPkg/Discover -o json | grep -F '"type":"DscPkg/Discover"'
env -u DSC_RESOURCE_PATH -u DSC_RESTRICTED_PATH "$DSC_TEST_BIN" --ignore-settings-file \
resource list DscPkg/Resource -o json | grep -F '"type":"DscPkg/Resource"'
resource list DscPkg/resources -o json | grep -F '"type":"DscPkg/resources"'
dscpkg publish -h
if [[ $format == deb ]]; then
# dpkg-query, not Bash, expands this field.
Expand Down
4 changes: 2 additions & 2 deletions packaging/windows/test-install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ function Assert-Installed {
$extension = $extensions | ConvertFrom-Json
Assert-Equal $extension.type 'DscPkg/Discover' 'Installed DSC extension'
Assert-Equal $extension.path $installedExtension 'Installed DSC extension path'
$resources = & $env:DSC_TEST_BIN --ignore-settings-file resource list DscPkg/Resource -o json
$resources = & $env:DSC_TEST_BIN --ignore-settings-file resource list DscPkg/resources -o json
if ($LASTEXITCODE -ne 0) { throw 'DSC resource discovery failed.' }
$resource = $resources | ConvertFrom-Json
Assert-Equal $resource.type 'DscPkg/Resource' 'Installed DSC resource'
Assert-Equal $resource.type 'DscPkg/resources' 'Installed DSC resource'
Assert-Equal $resource.path $installedResource 'Installed DSC resource path'
} finally {
$env:DSC_RESOURCE_PATH, $env:DSC_RESTRICTED_PATH = $resourcePath, $restrictedPath
Expand Down
37 changes: 20 additions & 17 deletions tests/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import (

func integrationResourceScenario(t *testing.T, dsc, cli, temp, repository string, downloads *atomic.Int64, run func(string, ...string) []byte) {
t.Helper()
list := integrationObjects(t, run(dsc, "resource", "list", "DscPkg/Resource", "-o", "json"))
if len(list) != 1 || list["DscPkg/Resource"] == nil {
list := integrationObjects(t, run(dsc, "resource", "list", "DscPkg/resources", "-o", "json"))
if len(list) != 1 || list["DscPkg/resources"] == nil {
t.Fatalf("resource manifest was not bootstrapped through PATH: %v", list)
}
input := fmt.Sprintf(`{"resource":"DscPkg.Test/One","state":"installed","repositories":[%q]}`, repository)
input := fmt.Sprintf(`{"resource":"DscPkg.Test/One","repositories":[%q]}`, repository)
assertState := func(operation, input, field, expected string) []byte {
t.Helper()
output := run(dsc, "resource", operation, "--resource", "DscPkg/Resource", "--input", input, "-o", "json")
output := run(dsc, "resource", operation, "--resource", "DscPkg/resources", "--input", input, "-o", "json")
var result map[string]json.RawMessage
if err := json.Unmarshal(output, &result); err != nil {
t.Fatal(err)
Expand All @@ -36,16 +36,19 @@ func integrationResourceScenario(t *testing.T, dsc, cli, temp, repository string
if bytes.Contains(result[field], []byte(`"_name"`)) {
t.Fatalf("%s returned Export-only _name: %s", operation, output)
}
if bytes.Contains(result[field], []byte(`"state"`)) {
t.Fatalf("%s returned removed state property: %s", operation, output)
}
return output
}
assertState("get", input, "actualState", `"state":"missing"`)
output := assertState("test", input, "actualState", `"state":"missing"`)
assertState("get", input, "actualState", `"resource":"DscPkg.Test/One"`)
output := assertState("test", input, "actualState", `"resource":"DscPkg.Test/One"`)
var compliance struct {
InDesiredState bool `json:"inDesiredState"`
DiffProperties []string `json:"differingProperties"`
}
if err := json.Unmarshal(output, &compliance); err != nil || compliance.InDesiredState ||
len(compliance.DiffProperties) != 1 || compliance.DiffProperties[0] != "state" {
len(compliance.DiffProperties) != 1 || compliance.DiffProperties[0] != "version" {
t.Fatalf("missing resource should not be compliant: %s %v", output, err)
}
if output := run(cli, "resource", "export"); len(output) != 0 {
Expand All @@ -54,16 +57,14 @@ func integrationResourceScenario(t *testing.T, dsc, cli, temp, repository string
config := fmt.Sprintf(`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
resources:
- name: One
type: DscPkg/Resource
type: DscPkg/resources
properties:
resource: DscPkg.Test/One
state: installed
repositories: [%q]
- name: Two
type: DscPkg/Resource
type: DscPkg/resources
properties:
resource: DscPkg.Test/Two
state: installed
repositories: [%q]
`, repository, repository)
configPath := filepath.Join(temp, "00-resources.yaml")
Expand All @@ -76,28 +77,27 @@ resources:
t.Fatalf("shared resources require exactly one archive download; got %d", downloads.Load()-before)
}
installed := downloads.Load()
output = assertState("test", input, "actualState", `"state":"installed"`)
output = assertState("test", input, "actualState", `"version":"1.0.0"`)
if err := json.Unmarshal(output, &compliance); err != nil || !compliance.InDesiredState {
t.Fatalf("installed resource should be compliant: %s %v", output, err)
}
// Invalid repository origins prove that compliant operations do not resolve remotely.
offline := `{"resource":"DscPkg.Test/Two","version":"1.0.0","repositories":["invalid://offline"]}`
assertState("get", offline, "actualState", `"package":"example/shared"`)
assertState("test", offline, "actualState", `"state":"installed"`)
assertState("set", offline, "afterState", `"state":"installed"`)
assertState("test", offline, "actualState", `"version":"1.0.0"`)
assertState("set", offline, "afterState", `"version":"1.0.0"`)
run(dsc, "config", "set", "--file", configPath, "-o", "json")
if downloads.Load() != installed {
t.Fatal("repeat Set downloaded a package")
}
export := run(dsc, "resource", "export", "--resource", "DscPkg/Resource", "-o", "json")
export := run(dsc, "resource", "export", "--resource", "DscPkg/resources", "-o", "json")
var exported struct {
Resources []struct {
Name string `json:"name"`
Type string `json:"type"`
Properties struct {
Resource string `json:"resource"`
Version string `json:"version"`
State string `json:"state"`
Name json.RawMessage `json:"_name"`
} `json:"properties"`
} `json:"resources"`
Expand All @@ -106,11 +106,14 @@ resources:
t.Fatalf("DSC Export must return two schema-validated instances: %s %v", export, err)
}
for _, resource := range exported.Resources {
if resource.Type != "DscPkg/Resource" || resource.Properties.Version != "1.0.0" || resource.Properties.State != "installed" ||
if resource.Type != "DscPkg/resources" || resource.Properties.Version != "1.0.0" ||
resource.Name != resource.Properties.Resource+"@"+resource.Properties.Version || len(resource.Properties.Name) != 0 {
t.Fatalf("invalid exported resource: %+v", resource)
}
}
if bytes.Contains(export, []byte(`"state"`)) {
t.Fatalf("Export returned removed state property: %s", export)
}
first := run(cli, "resource", "export")
if !bytes.Equal(first, run(cli, "resource", "export")) || bytes.Count(first, []byte("\n")) != 2 {
t.Fatalf("Export must produce deterministic JSON Lines: %s", first)
Expand Down
Loading