diff --git a/README.md b/README.md index 383816d..0f49b4d 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ including discovery and DSC resource operations, also honor uses `/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: @@ -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/ ``` @@ -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. @@ -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. @@ -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 @@ -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 @@ -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: diff --git a/cmd/dscpkg/main_test.go b/cmd/dscpkg/main_test.go index 0a81b4d..2798c07 100644 --- a/cmd/dscpkg/main_test.go +++ b/cmd/dscpkg/main_test.go @@ -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 diff --git a/dscpkg.dsc.resource.json b/dscpkg.dsc.resource.json index 47669ed..265a8e5 100644 --- a/dscpkg.dsc.resource.json +++ b/dscpkg.dsc.resource.json @@ -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": { @@ -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}, diff --git a/examples/00-resources.yaml b/examples/00-resources.yaml index 713f036..d2ce9ed 100644 --- a/examples/00-resources.yaml +++ b/examples/00-resources.yaml @@ -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/ diff --git a/internal/dscpkg/resource.go b/internal/dscpkg/resource.go index 535e097..686a112 100644 --- a/internal/dscpkg/resource.go +++ b/internal/dscpkg/resource.go @@ -2,7 +2,6 @@ package dscpkg import ( "encoding/json" - "errors" "fmt" "io" "os" @@ -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"` @@ -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 } @@ -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, }) } @@ -150,7 +145,7 @@ 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) { @@ -158,15 +153,8 @@ func (c *Client) TestResource(desired ResourceState, stderr io.Writer) (Resource 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 @@ -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 diff --git a/internal/dscpkg/resource_test.go b/internal/dscpkg/resource_test.go index bf22632..254efee 100644 --- a/internal/dscpkg/resource_test.go +++ b/internal/dscpkg/resource_test.go @@ -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) { @@ -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) { diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh index 11667f1..889c883 100644 --- a/packaging/linux/test-install.sh +++ b/packaging/linux/test-install.sh @@ -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. diff --git a/packaging/windows/test-install.ps1 b/packaging/windows/test-install.ps1 index 3c15694..22d0a91 100644 --- a/packaging/windows/test-install.ps1 +++ b/packaging/windows/test-install.ps1 @@ -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 diff --git a/tests/resource_test.go b/tests/resource_test.go index c3c2450..2cae370 100644 --- a/tests/resource_test.go +++ b/tests/resource_test.go @@ -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) @@ -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 { @@ -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") @@ -76,20 +77,20 @@ 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"` @@ -97,7 +98,6 @@ resources: Properties struct { Resource string `json:"resource"` Version string `json:"version"` - State string `json:"state"` Name json.RawMessage `json:"_name"` } `json:"properties"` } `json:"resources"` @@ -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)