Repository: score-spec/score-compose (go)

Issue:
[feature request] Add a new `score-compose provisioners list` command
Add a new `score-compose provisioners list` command generating in the output this table or a more advanced/detailed version with `-o json`: https://docs.score.dev/docs/score-implementation/score-compose/resources-provisioners/.

- It's not something in `score-go` as it's specific to each implementation.
- This should list the overriden `provisioners` imported via the `score-compose init --provisioners` command

Pull request description:
add provisioners command
<!--- Provide a general summary of your changes in the Title above -->

#### Description
<!--- Describe your changes in detail -->

#### What does this PR do?
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

Fix #237

#### Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] New chore (expected functionality to be implemented)

#### Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My change requires a change to the documentation.
  - [ ] I have updated the documentation accordingly.
- [x] I've signed off with an email address that matches the commit author.


Patch:
diff --git a/go.mod b/go.mod
index 6f03938..9bb6c36 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.3.0
 	github.com/compose-spec/compose-go/v2 v2.4.8
 	github.com/mitchellh/mapstructure v1.5.0
+	github.com/olekukonko/tablewriter v0.0.5
 	github.com/score-spec/score-go v1.9.3
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/pflag v1.0.6
@@ -26,6 +27,7 @@ require (
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/huandu/xstrings v1.5.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/mattn/go-runewidth v0.0.9 // indirect
 	github.com/mattn/go-shellwords v1.0.12 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
diff --git a/go.sum b/go.sum
index e38c49e..85234a9 100644
--- a/go.sum
+++ b/go.sum
@@ -34,6 +34,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
 github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
@@ -42,6 +44,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
diff --git a/internal/command/default.provisioners.yaml b/internal/command/default.provisioners.yaml
index d9189e8..af97a96 100644
--- a/internal/command/default.provisioners.yaml
+++ b/internal/command/default.provisioners.yaml
@@ -26,6 +26,9 @@
       driver: local
       labels:
         dev.score.compose.res.uid: {{ .Uid }}
+  expected_outputs:
+    - source
+    - type
 
 # The default provisioner for service resources, this expects a workload and port name and will return the hostname and
 # port required to contact it. This will validate that the workload and port exist, but won't enforce a dependency
@@ -41,6 +44,12 @@
     {{ if not $p }}{{ fail "unknown service port" }}{{ end }}
     hostname: {{ $w.ServiceName | quote }}
     port: {{ $p.TargetPort }}
+  expected_outputs:
+    - hostname
+    - port
+  supported_params:
+    - workload
+    - port
 
 # The default redis provisioner adds a redis service to the project which returns a host, port, username, and password.
 - uri: template://default-provisioners/redis
@@ -97,6 +106,11 @@
           nocopy: true
   info_logs: |
     - "{{.Uid}}: To connect to redis: \"docker run -it --network {{ .ComposeProjectName }}_default --rm redis redis-cli -h {{ .State.serviceName | squote }} -a {{ .State.password | squote }}\""
+  expected_outputs:
+    - host
+    - port
+    - username
+    - password
 
 # The default postgres provisioner adds a postgres instance and then ensures that the required databases are created on
 # startup.
@@ -189,6 +203,13 @@
     {{ if ne .Init.publishPort "0" }}
     - "{{.Uid}}: Or connect your postgres client to \"postgres://{{ .State.username }}:{{ .State.password }}@localhost:{{ .Init.publishPort }}/{{ .State.database }}\""
     {{ end }}
+  expected_outputs:
+    - host
+    - port
+    - name
+    - database
+    - username
+    - password
 
 # This resource provides a minio based S3 bucket with AWS-style credentials.
 # This provides some common and well known outputs that can be used with any generic AWS s3 client.
@@ -285,6 +306,14 @@
     {{ if ne .Init.publishPort 0 }}
     - "{{.Uid}}: Or enter {{ dig .Init.sk "instanceUsername" "" .Shared }} / {{ dig .Init.sk "instancePassword" "" .Shared }} at https://localhost:{{ .Init.publishPort }}"
     {{ end }}
+  expected_outputs:
+    - bucket
+    - access_key_id
+    - secret_key
+    - endpoint
+    - region
+    - aws_access_key_id
+    - aws_secret_key
 
 # The default AMQP provisioner provides a simple rabbitmq instance with default configuration and plugins.
 - uri: template://default-provisioners/rabbitmq
@@ -369,6 +398,12 @@
     {{ if ne .Init.publishManagementPort 0 }}
     - "{{.Uid}}: Browse the rabbitmq UI at \"http://localhost:{{ .Init.publishManagementPort }}\""
     {{ end }}
+  expected_outputs:
+    - host
+    - port
+    - vhost
+    - username
+    - password
 
 # The default dns provisioner just outputs localhost as the hostname every time.
 # This is because without actual control of a dns resolver we can't do any accurate routing on any other name. This
@@ -381,6 +416,8 @@
     instanceHostname: {{ dig "instanceHostname" .Init.randomHostname .State | quote }}
   outputs: |
     host: {{ .State.instanceHostname }}
+  expected_outputs:
+    - host
 
 # The default route provisioner sets up an nginx service with an HTTP service that can route on our prefix paths.
 # It assumes the hostnames and routes provided have no overlaps. Weird behavior may happen if there are overlaps.
@@ -483,6 +520,10 @@
   info_logs: |
     {{ $p := (dig .Init.sk "instancePort" 0 .Shared) }}
     - "{{.Uid}}: To connect to this route, http://{{ .Params.host }}:{{ $p }}{{ .Params.path }} (make sure {{ .Params.host }} resolves to localhost)"
+  supported_params:
+    - host
+    - port
+    - path
 
 # The default mongodb provisioner adds a mongodb service to the project which returns a host, port, username, and password, and connection string.
 - uri: template://default-provisioners/mongodb
@@ -531,6 +572,12 @@
           nocopy: true
   info_logs: |
     - "{{.Uid}}: To connect to mongo: \"docker exec -ti {{ .ComposeProjectName }}-{{ .State.serviceName }}-1 mongosh -u {{ .State.username }} -p {{ .State.password }}\""
+  expected_outputs:
+    - host
+    - port
+    - username
+    - password
+    - connection
 
 # The default mysql provisioner adds a mysql instance and then ensures that the required databases are created on
 # startup.
@@ -611,6 +658,13 @@
     - "{{.Uid}}: Or connect your mysql client to \"
     mysql://{{ .State.username }}:{{ .State.password }}@{{ dig .Init.sk "instanceServiceName" "" .Shared }}:{{ .Init.publishPort }}/{{ .State.database }}\""
     {{ end }}
+  expected_outputs:
+    - host
+    - port
+    - name
+    - database
+    - username
+    - password
 
 - uri: template://default-provisioners/kafka-topic
   type: kafka-topic
@@ -667,6 +721,11 @@
     port: "{{ .Init.brokerPort }}"
     name: {{ .State.topic }}
     num_partitions: 3
+  expected_outputs:
+    - host
+    - port
+    - name
+    - num_partitions
 
 # The default elasticsearch provisioner adds a elasticsearch instance.
 - uri: template://default-provisioners/elasticsearch
@@ -799,6 +858,11 @@
         \tdocker cp [CONTAINER-NAME]:/usr/share/elasticsearch/config/certs/ca/ca.crt /tmp/ \n
         and than check connection per culr like: \n
         \tcurl --cacert /tmp/ca.crt -u {{ .State.username }}:{{ .State.password }} https://localhost:{{ .Init.publishPort }}"
+  expected_outputs:
+    - host
+    - port
+    - username
+    - password
 
 - uri: template://default-provisioners/mssql
   type: mssql
@@ -846,4 +910,11 @@
     {{ if ne .Init.publishPort "0" }}
     - "{{.Uid}}: Or connect your mssql client to \"
     Server=tcp:{{ .State.service }},1433;Initial Catalog={{ .State.database }};User ID={{ .State.username }};Password={{ .State.password }};\""
-    {{ end }}
\ No newline at end of file
+    {{ end }}
+  expected_outputs:
+    - server
+    - port
+    - connection
+    - database
+    - username
+    - password
diff --git a/internal/command/fixtures/provisioners.custom.golden b/internal/command/fixtures/provisioners.custom.golden
new file mode 100644
index 0000000..258fa4e
--- /dev/null
+++ b/internal/command/fixtures/provisioners.custom.golden
@@ -0,0 +1,140 @@
+- uri: template://test-provisioners/without-class-without-params
+  type: without-class-without-params
+  expected_outputs:
+    - o1
+    - o2
+
+- uri: template://test-provisioners/with-class-without-params
+  type: with-class-without-params
+  class: c1
+  expected_outputs:
+    - o1
+    - o2
+
+- uri: template://test-provisioners/without-class-with-params-in-outputs
+  type: without-class-with-params-in-outputs
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+
+- uri: template://test-provisioners/with-class-with-params-in-outputs
+  type: with-class-with-params-in-outputs
+  class: c1
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+
+- uri: template://test-provisioners/without-class-with-params-in-shared
+  type: without-class-with-params-in-shared
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - psh1
+
+- uri: template://test-provisioners/with-class-with-params-in-shared
+  type: with-class-with-params-in-shared
+  class: c1
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - psh1
+
+- uri: template://test-provisioners/without-class-with-params-in-state
+  type: without-class-with-params-in-state
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - psh1
+
+- uri: template://test-provisioners/with-class-with-params-in-state
+  type: with-class-with-params-in-state
+  class: c1
+  outputs: |
+    o1: o1
+    o2: o2
+  state: |
+    {{ if not .Params.p1 }}{{ fail "expected 'p1' param for the target workload name" }}{{ end }}
+    {{ if not .Params.pst1 }}{{ fail "expected 'pst1' param for the target workload name" }}{{ end }}
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - pst1
+
+- uri: template://test-provisioners/without-class-with-params-in-shared-outputs
+  type: without-class-with-params-in-shared-outputs
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+    - psh1
+
+- uri: template://test-provisioners/with-class-with-params-in-shared-outputs
+  type: with-class-with-params-in-shared-outputs
+  class: c1
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+    - psh1
+
+- uri: template://test-provisioners/without-class-with-params-in-state-outputs
+  type: without-class-with-params-in-state-outputs
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+    - pst1
+
+- uri: template://test-provisioners/with-class-with-params-in-state-outputs
+  type: with-class-with-params-in-state-outputs
+  class: c1
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+    - pst1
+
+- uri: template://test-provisioners/without-class-with-params-in-state-outputs-state-shared
+  type: without-class-with-params-in-state-outputs-shared
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+    - pst1
+    - psh1
+
+- uri: template://test-provisioners/with-class-with-params-in-state-outputs-shared
+  type: with-class-with-params-in-state-outputs-shared
+  class: c1
+  expected_outputs:
+    - o1
+    - o2
+  supported_params:
+    - p1
+    - po1
+    - pst1
+    - psh1
\ No newline at end of file
diff --git a/internal/command/provisioners.go b/internal/command/provisioners.go
new file mode 100644
index 0000000..2d09a45
--- /dev/null
+++ b/internal/command/provisioners.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 (
+	"fmt"
+	"log/slog"
+	"os"
+	"sort"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners"
+	"github.com/score-spec/score-compose/internal/provisioners/loader"
+	"github.com/score-spec/score-compose/internal/util"
+)
+
+var (
+	provisionersGroup = &cobra.Command{
+		Use:   "provisioners",
+		Short: "Subcommands related to provisioners",
+	}
+	provisionersList = &cobra.Command{
+		Use:   "list [--format table|json]",
+		Short: "List the provisioners",
+		Long: `The list command will list out the provisioners. This requires an active score compose state
+after 'init' or 'generate' has been run. The list of provisioners will be empty if no provisioners are defined.
+`,
+		Args:          cobra.ArbitraryArgs,
+		SilenceErrors: true,
+		RunE:          listProvisioners,
+	}
+)
+
+func listProvisioners(cmd *cobra.Command, args []string) error {
+	cmd.SilenceUsage = true
+	wd, _ := os.Getwd()
+	sd, ok, err := project.LoadStateDirectory(wd)
+	if err != nil {
+		return fmt.Errorf("failed to load existing state directory: %w", err)
+	} else if !ok {
+		return fmt.Errorf("no state directory found, run 'score-compose init' first")
+	}
+	slog.Debug(fmt.Sprintf("Listing provisioners in project '%s'", sd.State.Extras.ComposeProjectName))
+
+	provisioners, err := loader.LoadProvisionersFromDirectory(sd.Path, loader.DefaultSuffix)
+	if err != nil {
+		return fmt.Errorf("failed to load provisioners in %s: %w", sd.Path, err)
+	}
+
+	if len(provisioners) == 0 {
+		slog.Info("No provisioners found")
+		return nil
+	}
+
+	outputFormat := cmd.Flag("format").Value.String()
+
+	displayProvisioners(provisioners, outputFormat)
+
+	return nil
+}
+
+func displayProvisioners(loadedProvisioners []provisioners.Provisioner, outputFormat string) {
+	var outputFormatter util.OutputFormatter
+
+	sortedProvisioners := sortProvisionersByType(loadedProvisioners)
+
+	switch outputFormat {
+	case "json":
+		type jsonData struct {
+			Type    string
+			Class   string
+			Params  []string
+			Outputs []string
+		}
+		var outputs []jsonData
+		for _, provisioner := range sortedProvisioners {
+			outputs = append(outputs, jsonData{
+				Type:    provisioner.Type(),
+				Class:   provisioner.Class(),
+				Params:  provisioner.Params(),
+				Outputs: provisioner.Outputs(),
+			})
+		}
+		outputFormatter = &util.JSONOutputFormatter[[]jsonData]{Data: outputs}
+	default:
+		rows := [][]string{}
+
+		for _, provisioner := range sortedProvisioners {
+			rows = append(rows, []string{provisioner.Type(), provisioner.Class(), strings.Join(provisioner.Params(), ", "), strings.Join(provisioner.Outputs(), ", ")})
+		}
+
+		headers := []string{"Type", "Class", "Params", "Outputs"}
+
+		outputFormatter = &util.TableOutputFormatter{
+			Headers: headers,
+			Rows:    rows,
+		}
+	}
+	outputFormatter.Display()
+}
+
+func sortProvisionersByType(provisioners []provisioners.Provisioner) []provisioners.Provisioner {
+	sort.Slice(provisioners, func(i, j int) bool {
+		return provisioners[i].Type() < provisioners[j].Type()
+	})
+	return provisioners
+}
+
+func init() {
+	provisionersList.Flags().StringP("format", "f", "table", "Format of the output: table (default), json")
+	provisionersGroup.AddCommand(provisionersList)
+	rootCmd.AddCommand(provisionersGroup)
+}
diff --git a/internal/command/run.go b/internal/command/run.go
index e866e93..a5d7feb 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -331,6 +331,14 @@ type legacyVolumeProvisioner struct {
 	MatchResourceUid framework.ResourceUid
 }
 
+func (l *legacyVolumeProvisioner) Params() []string {
+	return []string{}
+}
+
+func (l *legacyVolumeProvisioner) Outputs() []string {
+	return []string{}
+}
+
 func (l *legacyVolumeProvisioner) Uri() string {
 	return "builtin://legacy-volume"
 }
@@ -342,3 +350,11 @@ func (l *legacyVolumeProvisioner) Match(resUid framework.ResourceUid) bool {
 func (l *legacyVolumeProvisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
 	return &provisioners.ProvisionOutput{ResourceOutputs: map[string]interface{}{}}, nil
 }
+
+func (l *legacyVolumeProvisioner) Class() string {
+	return l.MatchResourceUid.Class()
+}
+
+func (l *legacyVolumeProvisioner) Type() string {
+	return l.MatchResourceUid.Type()
+}
diff --git a/internal/provisioners/cmdprov/commandprov.go b/internal/provisioners/cmdprov/commandprov.go
index 4b8fa90..0571669 100644
--- a/internal/provisioners/cmdprov/commandprov.go
+++ b/internal/provisioners/cmdprov/commandprov.go
@@ -39,12 +39,33 @@ type Provisioner struct {
 	ResClass       *string  `yaml:"class,omitempty"`
 	ResId          *string  `yaml:"id,omitempty"`
 	Args           []string `yaml:"args"`
+	ResourceParams []string `yaml:"params,omitempty"`
+	ResOutputs     []string `yaml:"outputs,omitempty"`
 }
 
 func (p *Provisioner) Uri() string {
 	return p.ProvisionerUri
 }
 
+func (p *Provisioner) Class() string {
+	if p.ResClass == nil {
+		return ""
+	}
+	return *p.ResClass
+}
+
+func (p *Provisioner) Type() string {
+	return p.ResType
+}
+
+func (p *Provisioner) Params() []string {
+	return p.ResourceParams
+}
+
+func (p *Provisioner) Outputs() []string {
+	return p.ResOutputs
+}
+
 func (p *Provisioner) Match(resUid framework.ResourceUid) bool {
 	if resUid.Type() != p.ResType {
 		return false
diff --git a/internal/provisioners/core.go b/internal/provisioners/core.go
index 450682a..486c82b 100644
--- a/internal/provisioners/core.go
+++ b/internal/provisioners/core.go
@@ -101,12 +101,20 @@ type Provisioner interface {
 	Uri() string
 	Match(resUid framework.ResourceUid) bool
 	Provision(ctx context.Context, input *Input) (*ProvisionOutput, error)
+	Class() string
+	Type() string
+	Params() []string
+	Outputs() []string
 }
 
 type ephemeralProvisioner struct {
 	uri       string
 	matchUid  framework.ResourceUid
 	provision func(ctx context.Context, input *Input) (*ProvisionOutput, error)
+	class     string
+	eType     string
+	params    []string
+	outputs   []string
 }
 
 func (e *ephemeralProvisioner) Uri() string {
@@ -116,11 +124,26 @@ func (e *ephemeralProvisioner) Uri() string {
 func (e *ephemeralProvisioner) Match(resUid framework.ResourceUid) bool {
 	return resUid == e.matchUid
 }
-
 func (e *ephemeralProvisioner) Provision(ctx context.Context, input *Input) (*ProvisionOutput, error) {
 	return e.provision(ctx, input)
 }
 
+func (e *ephemeralProvisioner) Outputs() []string {
+	return e.outputs
+}
+
+func (e *ephemeralProvisioner) Class() string {
+	return e.class
+}
+
+func (e *ephemeralProvisioner) Type() string {
+	return e.eType
+}
+
+func (e *ephemeralProvisioner) Params() []string {
+	return e.params
+}
+
 // NewEphemeralProvisioner is mostly used for internal testing and uses the given provisioner function to provision an exact resource.
 func NewEphemeralProvisioner(uri string, matchUid framework.ResourceUid, inner func(ctx context.Context, input *Input) (*ProvisionOutput, error)) Provisioner {
 	return &ephemeralProvisioner{uri: uri, matchUid: matchUid, provision: inner}
diff --git a/internal/provisioners/envprov/envprov.go b/internal/provisioners/envprov/envprov.go
index 7ad3574..18d6199 100644
--- a/internal/provisioners/envprov/envprov.go
+++ b/internal/provisioners/envprov/envprov.go
@@ -81,19 +81,33 @@ func (e *Provisioner) LookupOutput(keys ...string) (interface{}, error) {
 }
 
 func (e *Provisioner) GenerateSubProvisioner(resName string, resUid framework.ResourceUid) provisioners.Provisioner {
+	class := "default"
+	if strings.Contains(string(resUid), ".") {
+		class = resUid.Class()
+	}
+
+	rType := ""
+	if strings.Contains(string(resUid), "#") {
+		rType = resUid.Type()
+	}
+
 	return &envVarResourceTracker{
-		uid:    resUid,
-		inner:  e,
-		prefix: strings.ToUpper(resName),
+		uid:      resUid,
+		inner:    e,
+		prefix:   strings.ToUpper(resName),
+		resClass: class,
+		resType:  rType,
 	}
 }
 
 // envVarResourceTracker is a child object of EnvVarTracker and is used as a fallback behavior for resource types
 // that are not supported natively: we treat them like environment variables instead with a prefix of the resource name.
 type envVarResourceTracker struct {
-	uid    framework.ResourceUid
-	prefix string
-	inner  *Provisioner
+	uid      framework.ResourceUid
+	prefix   string
+	inner    *Provisioner
+	resClass string
+	resType  string
 }
 
 func (e *envVarResourceTracker) Uri() string {
@@ -126,5 +140,37 @@ func (e *envVarResourceTracker) LookupOutput(keys ...string) (interface{}, error
 	return e.inner.lookupOutput(true, k)
 }
 
+func (e *envVarResourceTracker) Class() string {
+	return e.resClass
+}
+
+func (e *envVarResourceTracker) Type() string {
+	return e.resType
+}
+
+func (p *Provisioner) Class() string {
+	return p.Class()
+}
+
+func (p *Provisioner) Type() string {
+	return p.Type()
+}
+
+func (p *Provisioner) Outputs() []string {
+	return nil
+}
+
+func (p *Provisioner) Params() []string {
+	return nil
+}
+
+func (e *envVarResourceTracker) Outputs() []string {
+	return nil
+}
+
+func (e *envVarResourceTracker) Params() []string {
+	return nil
+}
+
 var _ provisioners.Provisioner = (*Provisioner)(nil)
 var _ provisioners.Provisioner = (*envVarResourceTracker)(nil)
diff --git a/internal/provisioners/templateprov/template.go b/internal/provisioners/templateprov/template.go
index 831c977..1a74d58 100644
--- a/internal/provisioners/templateprov/template.go
+++ b/internal/provisioners/templateprov/template.go
@@ -19,6 +19,7 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
+	"slices"
 	"strings"
 	"text/template"
 
@@ -72,6 +73,12 @@ type Provisioner struct {
 	// InfoLogsTemplate allows the provisioner to return informational messages for the user which may help connecting or
 	// testing the provisioned resource
 	InfoLogsTemplate string `yaml:"info_logs,omitempty"`
+
+	// SupportedParams is a list of parameters that the provisioner expects to be passed in.
+	SupportedParams []string `yaml:"supported_params,omitempty"`
+
+	// ExpectedOutputs is a list of expected outputs that the provisioner should return.
+	ExpectedOutputs []string `yaml:"expected_outputs,omitempty"`
 }
 
 func Parse(raw map[string]interface{}) (*Provisioner, error) {
@@ -94,6 +101,37 @@ func (p *Provisioner) Uri() string {
 	return p.ProvisionerUri
 }
 
+func (p *Provisioner) Class() string {
+	if p.ResClass == nil {
+		return "(any)"
+	}
+	return *p.ResClass
+}
+
+func (p *Provisioner) Type() string {
+	return p.ResType
+}
+
+func (p *Provisioner) Params() []string {
+	if p.SupportedParams == nil {
+		return []string{}
+	}
+	params := make([]string, len(p.SupportedParams))
+	copy(params, p.SupportedParams)
+	slices.Sort(params)
+	return params
+}
+
+func (p *Provisioner) Outputs() []string {
+	if p.ExpectedOutputs == nil {
+		return []string{}
+	}
+	outputs := make([]string, len(p.ExpectedOutputs))
+	copy(outputs, p.ExpectedOutputs)
+	slices.Sort(outputs)
+	return outputs
+}
+
 func (p *Provisioner) Match(resUid framework.ResourceUid) bool {
 	if resUid.Type() != p.ResType {
 		return false
diff --git a/internal/util/output_format.go b/internal/util/output_format.go
new file mode 100644
index 0000000..c21d666
--- /dev/null
+++ b/internal/util/output_format.go
@@ -0,0 +1,58 @@
+// 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 util
+
+import (
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/olekukonko/tablewriter"
+)
+
+type OutputFormatter interface {
+	Display()
+}
+
+type JSONOutputFormatter[T any] struct {
+	Data T
+}
+
+type TableOutputFormatter struct {
+	Headers []string
+	Rows    [][]string
+}
+
+func (t *TableOutputFormatter) Display() {
+	table := tablewriter.NewWriter(os.Stdout)
+	table.SetHeader(t.Headers)
+	table.AppendBulk(t.Rows)
+	table.SetAutoWrapText(false)
+	table.SetRowLine(true)
+	table.SetCenterSeparator("+")
+	table.SetColumnSeparator("|")
+	table.SetRowSeparator("-")
+	table.Render()
+}
+
+func (j *JSONOutputFormatter[T]) Display() {
+	output, err := json.MarshalIndent(j.Data, "", "  ")
+	if err != nil {
+		slog.Error(fmt.Sprintf("Failed to marshal data: %v", err))
+		return
+	}
+	fmt.Println(string(output))
+}


Test patch:
diff --git a/internal/command/provisioners_test.go b/internal/command/provisioners_test.go
new file mode 100644
index 0000000..d8c7f4c
--- /dev/null
+++ b/internal/command/provisioners_test.go
@@ -0,0 +1,114 @@
+// 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 (
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/score-spec/score-compose/internal/provisioners/loader"
+	"github.com/stretchr/testify/assert"
+)
+
+var (
+	update = flag.Bool("update", false, "update the golden files of this test")
+)
+
+func TestDisplayProvisioners(t *testing.T) {
+	tests := []struct {
+		name             string
+		fixture          string
+		format           string
+		expectedResponse string
+		expectedError    string
+	}{
+		{
+			name:             "display provisioners in table format",
+			fixture:          "provisioners.custom.golden",
+			format:           "table",
+			expectedResponse: "provisioners.list.valid.table.golden",
+			expectedError:    "",
+		},
+		{
+			name:             "display provisioners in json format",
+			fixture:          "provisioners.custom.golden",
+			format:           "json",
+			expectedResponse: "provisioners.list.valid.json.golden",
+			expectedError:    "",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			provisioners, err := loader.LoadProvisionersFromDirectory("fixtures/", test.fixture)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Capture the output
+			old := os.Stdout
+			r, w, _ := os.Pipe()
+			os.Stdout = w
+
+			displayProvisioners(provisioners, test.format)
+
+			w.Close()
+			os.Stdout = old
+			out, _ := io.ReadAll(r)
+
+			got := string(out)
+
+			expected, err := goldenValue(t, "testdata", test.expectedResponse, got, *update)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if test.expectedError != "" {
+				assert.Error(t, err)
+				assert.Contains(t, err.Error(), test.expectedError)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, expected, got)
+			}
+		})
+	}
+}
+
+func goldenValue(t *testing.T, path string, goldenFile string, actual string, update bool) (string, error) {
+	t.Helper()
+	goldenPath := filepath.Join(path, goldenFile)
+
+	f, err := os.OpenFile(goldenPath, os.O_RDWR, 0644)
+	defer f.Close()
+
+	if update {
+		_, err := f.WriteString(actual)
+		if err != nil {
+			return "", fmt.Errorf("failed to update golden file %s: %w", goldenPath, err)
+		}
+
+		return actual, nil
+	}
+
+	content, err := io.ReadAll(f)
+	if err != nil {
+		return "", fmt.Errorf("failed to read golden file %s: %w", goldenPath, err)
+	}
+
+	return string(content), nil
+}
diff --git a/internal/command/root_test.go b/internal/command/root_test.go
index 415d636..aa6fdd0 100644
--- a/internal/command/root_test.go
+++ b/internal/command/root_test.go
@@ -38,6 +38,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
+  provisioners  Subcommands related to provisioners
   resources     Subcommands related to provisioned resources
 
 Flags:
diff --git a/internal/command/testdata/provisioners.list.valid.json.golden b/internal/command/testdata/provisioners.list.valid.json.golden
new file mode 100644
index 0000000..0431a5a
--- /dev/null
+++ b/internal/command/testdata/provisioners.list.valid.json.golden
@@ -0,0 +1,172 @@
+[
+  {
+    "Type": "with-class-with-params-in-outputs",
+    "Class": "c1",
+    "Params": [
+      "p1",
+      "po1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "with-class-with-params-in-shared",
+    "Class": "c1",
+    "Params": [
+      "p1",
+      "psh1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "with-class-with-params-in-shared-outputs",
+    "Class": "c1",
+    "Params": [
+      "p1",
+      "po1",
+      "psh1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "with-class-with-params-in-state",
+    "Class": "c1",
+    "Params": [
+      "p1",
+      "pst1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "with-class-with-params-in-state-outputs",
+    "Class": "c1",
+    "Params": [
+      "p1",
+      "po1",
+      "pst1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "with-class-with-params-in-state-outputs-shared",
+    "Class": "c1",
+    "Params": [
+      "p1",
+      "po1",
+      "psh1",
+      "pst1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "with-class-without-params",
+    "Class": "c1",
+    "Params": [],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-with-params-in-outputs",
+    "Class": "(any)",
+    "Params": [
+      "p1",
+      "po1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-with-params-in-shared",
+    "Class": "(any)",
+    "Params": [
+      "p1",
+      "psh1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-with-params-in-shared-outputs",
+    "Class": "(any)",
+    "Params": [
+      "p1",
+      "po1",
+      "psh1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-with-params-in-state",
+    "Class": "(any)",
+    "Params": [
+      "p1",
+      "psh1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-with-params-in-state-outputs",
+    "Class": "(any)",
+    "Params": [
+      "p1",
+      "po1",
+      "pst1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-with-params-in-state-outputs-shared",
+    "Class": "(any)",
+    "Params": [
+      "p1",
+      "po1",
+      "psh1",
+      "pst1"
+    ],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  },
+  {
+    "Type": "without-class-without-params",
+    "Class": "(any)",
+    "Params": [],
+    "Outputs": [
+      "o1",
+      "o2"
+    ]
+  }
+]
diff --git a/internal/command/testdata/provisioners.list.valid.table.golden b/internal/command/testdata/provisioners.list.valid.table.golden
new file mode 100644
index 0000000..687376d
--- /dev/null
+++ b/internal/command/testdata/provisioners.list.valid.table.golden
@@ -0,0 +1,31 @@
++---------------------------------------------------+-------+---------------------+---------+
+|                       TYPE                        | CLASS |       PARAMS        | OUTPUTS |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-with-params-in-outputs                 | c1    | p1, po1             | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-with-params-in-shared                  | c1    | p1, psh1            | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-with-params-in-shared-outputs          | c1    | p1, po1, psh1       | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-with-params-in-state                   | c1    | p1, pst1            | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-with-params-in-state-outputs           | c1    | p1, po1, pst1       | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-with-params-in-state-outputs-shared    | c1    | p1, po1, psh1, pst1 | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| with-class-without-params                         | c1    |                     | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-with-params-in-outputs              | (any) | p1, po1             | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-with-params-in-shared               | (any) | p1, psh1            | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-with-params-in-shared-outputs       | (any) | p1, po1, psh1       | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-with-params-in-state                | (any) | p1, psh1            | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-with-params-in-state-outputs        | (any) | p1, po1, pst1       | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-with-params-in-state-outputs-shared | (any) | p1, po1, psh1, pst1 | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
+| without-class-without-params                      | (any) |                     | o1, o2  |
++---------------------------------------------------+-------+---------------------+---------+
diff --git a/internal/provisioners/templateprov/template_test.go b/internal/provisioners/templateprov/template_test.go
index 306f6b0..546a6dc 100644
--- a/internal/provisioners/templateprov/template_test.go
+++ b/internal/provisioners/templateprov/template_test.go
@@ -31,8 +31,10 @@ func TestProvision(t *testing.T) {
 	td := t.TempDir()
 	resUid := framework.NewResourceUid("w", "r", "thing", nil, nil)
 	p, err := Parse(map[string]interface{}{
-		"uri":  "template://example",
-		"type": resUid.Type(),
+		"uri":              "template://example",
+		"type":             resUid.Type(),
+		"expected_outputs": []string{"b", "c"},
+		"supported_params": []string{"ptest"},
 		"init": `
 a: {{ .Uid }}
 b: {{ .Type }}
@@ -95,4 +97,8 @@ some-svc:
 	assert.Equal(t, map[string]compose.NetworkConfig{"default": {Driver: "default"}}, out.ComposeNetworks)
 	assert.Equal(t, map[string]compose.VolumeConfig{"some-vol": {Driver: "default"}}, out.ComposeVolumes)
 	assert.Equal(t, map[string]compose.ServiceConfig{"some-svc": {Name: "foo"}}, out.ComposeServices)
+	assert.Equal(t, []string{"b", "c"}, p.Outputs())
+	assert.Equal(t, []string{"ptest"}, p.Params())
+	assert.Equal(t, "(any)", p.Class())
+	assert.Equal(t, resUid.Type(), p.Type())
 }