Repository: score-spec/score-compose
Pull request #116 (2024-04-19 13:53:10)

feat: added resources subcommands
This PR adds the `resources list` and `resources get-outputs` subcommands to score-compose.

This make it much easier to connect post-generate steps to the compose project. For example:

- extracting the generated dns name for making requests to an ingress route
- extracing the database usernamd and password to access it from a test container

etc.

This also includes some related documentation improvements.

3 output formats are supported: raw json (the default), yaml (better for humans), and Go template which allows for extracting particular values or combinations of values when the environment does not have access to tools like `jq` or `yq` or wants to extract multiple values in one shot for example `--format 'amqp://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .vhost }}'`


Patch:
diff --git a/README.md b/README.md
index e2b6fa1..2885f8f 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,9 @@ See the [examples](./examples) for more examples of using Score and provisioning
 - [05-volume-mounts](examples/05-volume-mounts) - an example of an "empty-dir" volume resource with `type: volume`
 - [06-resource-provisioning](examples/06-resource-provisioning) - detailed example and information about resource provisioning and the operation of the `template://` and `cmd://` provisioners
 - [07-overrides](examples/07-overrides) - details of how to override aspects of the input Score file and output Docker compose files
-- [08-service-port-resource](examples/08-service-port-resource) - an example of using the `service` resource type to link between workloads
+- [08-service-port-resource](examples/08-service-port-resource) - an example of using the `service-port` resource type to link between workloads
+- [09-dns-and-route](examples/09-dns-and-route) - an example of using the `dns` and `route` resources to route http requests
+- [10-amqp-rabbitmq](examples/10-amqp-rabbitmq) - an example the default `amqp` resource provisioner
 
 If you're getting started, you can use `score-compose init` to create a basic `score.yaml` file in the current directory along with a `.score-compose/` working directory.
 
diff --git a/examples/06-resource-provisioning/README.md b/examples/06-resource-provisioning/README.md
index 7d36aab..efde7f1 100644
--- a/examples/06-resource-provisioning/README.md
+++ b/examples/06-resource-provisioning/README.md
@@ -86,6 +86,32 @@ workload-two-example-1  | redis://default:v2otaQdY6052ylWq@redis-MSFdjH:6379
 workload-one-example-1  | redis://default:94GBXwsRLtDLagPg@redis-2tKndj:6379
 ```
 
+## Inspecting resources
+
+Two subcommands make it a bit easier to see the list of resources provisioned and inspect what outputs are available. These can be very useful while building out a new Score compose project.
+
+`score-compose resources list` will output the list of resource uids:
+
+```console
+$ score-compose resources list
+redis.default#main-cache
+redis.default#workload-two.cache-c
+```
+
+And `score-compose resources get-outputs` can preview the last outputs:
+
+```console
+$ score-compose resources get-outputs 'redis.default#main-cache' --format yaml
+host: redis-OVAJ8l
+password: TbxVySxGXWIwaosD
+port: 6379
+username: default
+```
+
+This makes it much easier to use the outputs in your integration tests and CI pipelines. For example, a test may need to connect to a particular database or queue in order to inspect its state.
+
+**NOTE**: experts may wish to look at the `.score-compose/state.yaml` file directly.
+
 ## The `*.provisioners.yaml` files
 
 When you run `score-compose init`, a [99-default.provisioners.yaml](https://github.com/score-spec/score-compose/blob/main/internal/command/default.provisioners.yaml) file is created, which is a YAML file holding the definition of the built-in provisioners.
diff --git a/go.mod b/go.mod
index 37d3a1f..56ca405 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
 	github.com/compose-spec/compose-go/v2 v2.0.0-rc.8
 	github.com/imdario/mergo v0.3.13
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/score-spec/score-go v1.5.2
+	github.com/score-spec/score-go v1.5.3
 	github.com/spf13/cobra v1.6.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.9.0
diff --git a/go.sum b/go.sum
index 9a541af..622d8e4 100644
--- a/go.sum
+++ b/go.sum
@@ -51,6 +51,8 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6Ng
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
 github.com/score-spec/score-go v1.5.2 h1:6zzeqx6D7Np7WnJmWWkhX89t5SmN6f0/6xYh40QJFZc=
 github.com/score-spec/score-go v1.5.2/go.mod h1:3QSPH5QVMIX7FdhktLLFtjLQTL1/ENqrWDe5lSdZGFc=
+github.com/score-spec/score-go v1.5.3 h1:K2qujBslD71vttRPE/djxQ0qb4oXZDhOUqzszRQP76U=
+github.com/score-spec/score-go v1.5.3/go.mod h1:3QSPH5QVMIX7FdhktLLFtjLQTL1/ENqrWDe5lSdZGFc=
 github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
diff --git a/internal/command/resources.go b/internal/command/resources.go
new file mode 100644
index 0000000..e0a0c98
--- /dev/null
+++ b/internal/command/resources.go
@@ -0,0 +1,118 @@
+// Copyright 2024 Humanitec
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package command
+
+import (
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/score-spec/score-go/framework"
+	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v3"
+
+	"github.com/score-spec/score-compose/internal/project"
+)
+
+const (
+	getOutputsCmdFormatFlag = "format"
+)
+
+var (
+	resourcesGroup = &cobra.Command{
+		Use:   "resources",
+		Short: "Subcommands related to provisioned resources",
+	}
+	listResources = &cobra.Command{
+		Use:   "list",
+		Short: "List the resource uids",
+		Long: `The list command will list out the provisioned resource uids. This requires an active score compose state
+after 'init' or 'generate' has been run. The list of uids will be empty if no resources are provisioned.
+`,
+		Args:          cobra.ExactArgs(0),
+		SilenceErrors: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cmd.SilenceUsage = true
+			sd, ok, err := project.LoadStateDirectory(".")
+			if err != nil {
+				return fmt.Errorf("failed to load existing state directory: %w", err)
+			} else if !ok {
+				return fmt.Errorf("state directory does not exist, please run \"score-compose init\" first")
+			}
+			slog.Debug(fmt.Sprintf("Loaded state directory with docker compose project '%s'", sd.State.Extras.ComposeProjectName))
+			currentState := &sd.State
+			resIds, err := currentState.GetSortedResourceUids()
+			if err != nil {
+				return fmt.Errorf("failed to sort resources: %w", err)
+			}
+			for _, id := range resIds {
+				_, _ = cmd.OutOrStdout().Write([]byte(id))
+				_, _ = cmd.OutOrStdout().Write([]byte("\n"))
+			}
+			return nil
+		},
+	}
+	getResourceOutputs = &cobra.Command{
+		Use:   "get-outputs TYPE.CLASS#ID",
+		Short: "Return the resource outputs",
+		Long: `The get-outputs command will print the outputs of the resource from the last provisioning. The data will
+be returned as json.
+`,
+		Args:          cobra.ExactArgs(1),
+		SilenceErrors: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cmd.SilenceUsage = true
+			sd, ok, err := project.LoadStateDirectory(".")
+			if err != nil {
+				return fmt.Errorf("failed to load existing state directory: %w", err)
+			} else if !ok {
+				return fmt.Errorf("state directory does not exist, please run \"score-compose init\" first")
+			}
+			slog.Debug(fmt.Sprintf("Loaded state directory with docker compose project '%s'", sd.State.Extras.ComposeProjectName))
+			if res, ok := sd.State.Resources[framework.ResourceUid(args[0])]; ok {
+				outputs := res.Outputs
+				if outputs == nil {
+					outputs = make(map[string]interface{})
+				}
+				formatValue := cmd.Flags().Lookup(getOutputsCmdFormatFlag).Value.String()
+				switch formatValue {
+				case "json":
+					return json.NewEncoder(cmd.OutOrStdout()).Encode(outputs)
+				case "yaml":
+					return yaml.NewEncoder(cmd.OutOrStdout()).Encode(outputs)
+				default:
+					prepared, err := template.New("").Funcs(sprig.FuncMap()).Parse(formatValue)
+					if err != nil {
+						return fmt.Errorf("failed to parse format template: %w", err)
+					}
+					if err := prepared.Execute(cmd.OutOrStdout(), outputs); err != nil {
+						return fmt.Errorf("failed to execute template: %w", err)
+					}
+					return nil
+				}
+			}
+			return fmt.Errorf("no such resource '%s'", args[0])
+		},
+	}
+)
+
+func init() {
+	getResourceOutputs.Flags().StringP(getOutputsCmdFormatFlag, "f", "json", "Format of the output: json, yaml, or a Go template with sprig functions")
+	resourcesGroup.AddCommand(listResources)
+	resourcesGroup.AddCommand(getResourceOutputs)
+	rootCmd.AddCommand(resourcesGroup)
+}


Test patch:
diff --git a/internal/command/resources_test.go b/internal/command/resources_test.go
new file mode 100644
index 0000000..f485b45
--- /dev/null
+++ b/internal/command/resources_test.go
@@ -0,0 +1,128 @@
+// Copyright 2024 Humanitec
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package command
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+func TestResourcesHelp(t *testing.T) {
+	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "--help"})
+	assert.NoError(t, err)
+	assert.Equal(t, `Subcommands related to provisioned resources
+
+Usage:
+  score-compose resources [command]
+
+Available Commands:
+  get-outputs Return the resource outputs
+  list        List the resource uids
+
+Flags:
+  -h, --help   help for resources
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
+
+Use "score-compose resources [command] --help" for more information about a command.
+`, stdout)
+	assert.Equal(t, "", stderr)
+
+	stdout2, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"help", "resources"})
+	assert.NoError(t, err)
+	assert.Equal(t, stdout, stdout2)
+	assert.Equal(t, "", stderr)
+}
+
+func TestResourcesExample(t *testing.T) {
+	td := changeToTempDir(t)
+	_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"})
+	assert.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  container:
+    image: thing
+resources:
+  vol:
+    type: volume
+  dns:
+    type: dns
+`), 0600))
+	_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
+	assert.NoError(t, err)
+
+	t.Run("list", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "list"})
+		assert.NoError(t, err)
+		assert.Equal(t, `dns.default#example.dns
+volume.default#example.vol
+`, stdout)
+	})
+
+	t.Run("get not found", func(t *testing.T) {
+		_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "get-outputs", "foo"})
+		assert.EqualError(t, err, "no such resource 'foo'")
+	})
+
+	t.Run("get dns", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "get-outputs", "dns.default#example.dns"})
+		assert.NoError(t, err)
+		var out map[string]interface{}
+		if assert.NoError(t, json.Unmarshal([]byte(stdout), &out)) {
+			assert.NotEmpty(t, out["host"].(string))
+		}
+	})
+
+	t.Run("get vol", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "get-outputs", "volume.default#example.vol"})
+		assert.NoError(t, err)
+		var out map[string]interface{}
+		if assert.NoError(t, json.Unmarshal([]byte(stdout), &out)) {
+			assert.NotEmpty(t, out["source"].(string))
+		}
+	})
+
+	t.Run("format json", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "get-outputs", "volume.default#example.vol", "--format", "json"})
+		assert.NoError(t, err)
+		var out map[string]interface{}
+		assert.NoError(t, json.Unmarshal([]byte(stdout), &out))
+	})
+
+	t.Run("format yaml", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "get-outputs", "volume.default#example.vol", "--format", "yaml"})
+		assert.NoError(t, err)
+		var out map[string]interface{}
+		assert.NoError(t, yaml.Unmarshal([]byte(stdout), &out))
+	})
+
+	t.Run("format template", func(t *testing.T) {
+		stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"resources", "get-outputs", "volume.default#example.vol", "--format", `{{ . | len }}`})
+		assert.NoError(t, err)
+		assert.Equal(t, "1", stdout)
+	})
+}
diff --git a/internal/command/root_test.go b/internal/command/root_test.go
index 8226e61..49b2b81 100644
--- a/internal/command/root_test.go
+++ b/internal/command/root_test.go
@@ -37,6 +37,7 @@ Available Commands:
   generate    Convert one or more Score files into a Docker compose manifest
   help        Help about any command
   init        Initialise a new score-compose project with local state directory and score file
+  resources   Subcommands related to provisioned resources
   run         Translate the SCORE file to docker-compose configuration
 
 Flags:
diff --git a/internal/command/run_test.go b/internal/command/run_test.go
index 1550c61..2c25ae3 100644
--- a/internal/command/run_test.go
+++ b/internal/command/run_test.go
@@ -32,7 +32,7 @@ import (
 
 // executeAndResetCommand is a test helper that runs and then resets a command for executing in another test.
 func executeAndResetCommand(ctx context.Context, cmd *cobra.Command, args []string) (string, string, error) {
-	beforeOut, beforeErr := cmd.OutOrStderr(), cmd.ErrOrStderr()
+	beforeOut, beforeErr := cmd.OutOrStdout(), cmd.ErrOrStderr()
 	defer func() {
 		cmd.SetOut(beforeOut)
 		cmd.SetErr(beforeErr)