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

Issue:
[bug] Support placeholder resolution in resource params
We currently support placeholder (`${metadata.foo}` or `${resources.x.y}`) in the container.variables and container.volumes.source field.

As we bring over proper resource provisioning support, we need to enable substitutions in the resource params field. For example a common usecase is:

```
resources:
  my-route:
    type: route
    params:
      host: ${resources.dns.host}
```

This would fail currently, since `${resources.dns.host}` would be kept as is, and not be resolved in a constructive way. This will only be a problem when we actually support real resource provisioners! (which I'm currently working on).

The solution here will need to be a acyclic graph detection:

1. for all resources, find any whose substitution params can be satisfied
2. if there are none, but many resources exist, there's a cycle - throw an error
3. provision these resources
4. with the remaining resources, go to 1

While ensuring that each resource is only "provisioned" once per generate command.

Pull request:
feat: added topological sort of resources before param resolution
Until now, placeholder substitution has only been accepted within workload variables and volume sources. This PR adds placeholder substitution inside workload resource params. This fixes #68.

Eg:

```
resources:
  thing:
    type: foo
    params:
      x: ${metadata.name}
      y: ${resources.bar.key}
```

This implicitely adds a dependency from resource `thing` to another workload-local resource named `bar`. Because placeholder resolution takes place within a "owning" workload, and only one specification of the resource can have params, this also has access to the metadata from the owning resource. (If there are two definitions and both have identical params, then the first will be the owner).

This requires a topological sort of the resources using Kahn's algorithm, a well known and quite optimal solution to this problem which also performs the cycle detection for us.


Patch:
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index e2f975e..c71955e 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -32,13 +32,10 @@ func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.
 		return nil, errors.New("workload does not have any containers to convert into a compose service")
 	}
 
-	var project = compose.Project{
-		Services: make(compose.Services),
-	}
+	substitutionFunction := project.BuildSubstitutionFunction(spec.Metadata, resources)
 
-	ctx, err := buildContext(spec.Metadata, resources)
-	if err != nil {
-		return nil, fmt.Errorf("preparing context: %w", err)
+	var composeProject = compose.Project{
+		Services: make(compose.Services),
 	}
 
 	var ports []compose.ServicePortConfig
@@ -73,7 +70,7 @@ func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.
 
 		var env = make(compose.MappingWithEquals, len(cSpec.Variables))
 		for key, val := range cSpec.Variables {
-			resolved, err := ctx.Substitute(val)
+			resolved, err := project.SubstituteString(val, substitutionFunction)
 			if err != nil {
 				return nil, fmt.Errorf("containers.%s.variables.%s: %w", containerName, key, err)
 			}
@@ -94,7 +91,7 @@ func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.
 					return nil, fmt.Errorf("containers.%s.volumes[%d].path: can't mount named volume with sub path '%s': not supported", containerName, idx, *vol.Path)
 				}
 
-				resolvedVolumeSource, err := ctx.Substitute(vol.Source)
+				resolvedVolumeSource, err := project.SubstituteString(vol.Source, substitutionFunction)
 				if err != nil {
 					return nil, fmt.Errorf("containers.%s.volumes[%d].source: %w", containerName, idx, err)
 				}
@@ -173,7 +170,7 @@ func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.
 			svc.Ports = nil
 			svc.NetworkMode = "service:" + firstService
 		}
-		project.Services[svc.Name] = svc
+		composeProject.Services[svc.Name] = svc
 	}
-	return &project, nil
+	return &composeProject, nil
 }
diff --git a/internal/compose/resources.go b/internal/compose/resources.go
deleted file mode 100644
index b498dbd..0000000
--- a/internal/compose/resources.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package compose
-
-import "fmt"
-
-// ResourceWithOutputs is an interface that resource implementations in the future may provide.
-// The keys here are the parts of a .-separated path traversal down a tree to return some data from the outputs of
-// the provisioned resource. If an error occurs looking up the output, an error should be thrown.
-// nil is a valid result since some resources may return null in their outputs.
-type ResourceWithOutputs interface {
-	LookupOutput(keys ...string) (interface{}, error)
-}
-
-type resourceWithStaticOutputs map[string]interface{}
-
-func (r resourceWithStaticOutputs) LookupOutput(keys ...string) (interface{}, error) {
-	var resolvedValue interface{}
-	resolvedValue = (map[string]interface{})(r)
-	for _, k := range keys {
-		mapV, ok := resolvedValue.(map[string]interface{})
-		if !ok {
-			return "", fmt.Errorf("cannot lookup key '%s', context is not a map", k)
-		}
-		resolvedValue, ok = mapV[k]
-		if !ok {
-			return "", fmt.Errorf("key '%s' not found", k)
-		}
-	}
-	return resolvedValue, nil
-}
-
-var _ ResourceWithOutputs = (resourceWithStaticOutputs)(nil)
diff --git a/internal/compose/templates.go b/internal/compose/templates.go
deleted file mode 100644
index 3bf5fc7..0000000
--- a/internal/compose/templates.go
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
-Apache Score
-Copyright 2022 The Apache Software Foundation
-
-This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
-*/
-package compose
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"maps"
-	"regexp"
-	"strings"
-
-	"github.com/score-spec/score-compose/internal/project"
-)
-
-var (
-	placeholderRegEx = regexp.MustCompile(`\$(\$|{([a-zA-Z0-9.\-_\\]+)})`)
-)
-
-// templatesContext ia an utility type that provides a context for '${...}' templates substitution
-type templatesContext struct {
-	meta      resourceWithStaticOutputs
-	resources map[string]project.OutputLookupFunc
-}
-
-// buildContext initializes a new templatesContext instance
-func buildContext(metadata map[string]interface{}, resources map[string]project.OutputLookupFunc) (*templatesContext, error) {
-	return &templatesContext{
-		meta:      maps.Clone(metadata),
-		resources: maps.Clone(resources),
-	}, nil
-}
-
-// Substitute replaces all matching '${...}' templates in a source string
-func (ctx *templatesContext) Substitute(src string) (string, error) {
-	var err error
-	result := placeholderRegEx.ReplaceAllStringFunc(src, func(str string) string {
-		// WORKAROUND: ReplaceAllStringFunc(..) does not provide match details
-		//             https://github.com/golang/go/issues/5690
-		var matches = placeholderRegEx.FindStringSubmatch(str)
-
-		// SANITY CHECK
-		if len(matches) != 3 {
-			err = errors.Join(err, fmt.Errorf("could not find a proper match in previously captured string fragment"))
-			return src
-		}
-
-		// EDGE CASE: Captures "$$" sequences and empty templates "${}"
-		if matches[2] == "" {
-			return matches[1]
-		} else if matches[2] == "$" {
-			return matches[2]
-		}
-
-		result, subErr := ctx.mapVar(matches[2])
-		err = errors.Join(err, subErr)
-		return result
-	})
-	return result, err
-}
-
-// MapVar replaces objects and properties references with corresponding values
-// Returns an empty string if the reference can't be resolved
-func (ctx *templatesContext) mapVar(ref string) (string, error) {
-	subRef := strings.Replace(ref, `\.`, "\000", -1)
-	parts := strings.Split(subRef, ".")
-	for i, part := range parts {
-		parts[i] = strings.Replace(part, "\000", ".", -1)
-	}
-
-	var resolvedValue interface{}
-
-	switch parts[0] {
-	case "metadata":
-		if len(parts) < 2 {
-			return "", fmt.Errorf("invalid ref '%s': requires at least a metadata key to lookup", ref)
-		}
-		if rv, err := ctx.meta.LookupOutput(parts[1:]...); err != nil {
-			return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
-		} else {
-			resolvedValue = rv
-		}
-	case "resources":
-		if len(parts) < 2 {
-			return "", fmt.Errorf("invalid ref '%s': requires at least a resource name to lookup", ref)
-		}
-		rv, ok := ctx.resources[parts[1]]
-		if !ok {
-			return "", fmt.Errorf("invalid ref '%s': no known resource '%s'", ref, parts[1])
-		} else if len(parts) == 2 {
-			// TODO: deprecate this - this is an annoying and nonsensical legacy thing
-			return parts[1], nil
-		} else if rv2, err := rv(parts[2:]...); err != nil {
-			return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
-		} else {
-			resolvedValue = rv2
-		}
-	default:
-		return "", fmt.Errorf("invalid ref '%s': unknown reference root", ref)
-	}
-
-	if asString, ok := resolvedValue.(string); ok {
-		return asString, nil
-	}
-	// TODO: work out how we might support other types here in the future
-	raw, err := json.Marshal(resolvedValue)
-	if err != nil {
-		return "", err
-	}
-	return string(raw), nil
-}
diff --git a/internal/project/state.go b/internal/project/state.go
index e7728f2..064691f 100644
--- a/internal/project/state.go
+++ b/internal/project/state.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"maps"
 	"reflect"
+	"slices"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
 	score "github.com/score-spec/score-go/types"
@@ -38,8 +39,11 @@ type ScoreResourceState struct {
 	// we can deduplicate and work out where a resource came from.
 	Id string `yaml:"id"`
 
-	Metadata map[string]interface{}
-	Params   map[string]interface{}
+	Metadata map[string]interface{} `yaml:"metadata"`
+	Params   map[string]interface{} `yaml:"params"`
+	// SourceWorkload holds the workload name that had the best definition for this resource. "best" is either the
+	// first one or the one with params defined.
+	SourceWorkload string `yaml:"source_workload"`
 
 	// ProvisionerUri is the resolved provisioner uri that should be found in the config. This is tracked so that
 	// we identify which provisioner was used for a particular instance of the resource.
@@ -92,17 +96,19 @@ func (s *State) WithPrimedResources() (*State, error) {
 			resUid := NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
 			if existing, ok := out.Resources[resUid]; !ok {
 				out.Resources[resUid] = ScoreResourceState{
-					Type:     resUid.Type(),
-					Class:    resUid.Class(),
-					Id:       resUid.Id(),
-					Metadata: res.Metadata,
-					Params:   res.Params,
-					State:    map[string]interface{}{},
+					Type:           resUid.Type(),
+					Class:          resUid.Class(),
+					Id:             resUid.Id(),
+					Metadata:       res.Metadata,
+					Params:         res.Params,
+					SourceWorkload: workloadName,
+					State:          map[string]interface{}{},
 				}
 				primedResourceUids[resUid] = true
 			} else if !primedResourceUids[resUid] {
 				existing.Metadata = res.Metadata
 				existing.Params = res.Params
+				existing.SourceWorkload = workloadName
 				out.Resources[resUid] = existing
 				primedResourceUids[resUid] = true
 			} else {
@@ -112,6 +118,7 @@ func (s *State) WithPrimedResources() (*State, error) {
 						return nil, fmt.Errorf("resource '%s': multiple definitions with different params", resUid)
 					}
 					existing.Params = res.Params
+					existing.SourceWorkload = workloadName
 				}
 				if res.Metadata != nil {
 					if existing.Metadata != nil && !reflect.DeepEqual(existing.Metadata, res.Metadata) {
@@ -126,6 +133,108 @@ func (s *State) WithPrimedResources() (*State, error) {
 	return &out, nil
 }
 
+func (s *State) getResourceDependencies(workloadName, resName string, ru ResourceUid) (map[ResourceUid]bool, error) {
+	outMap := make(map[ResourceUid]bool)
+	res := s.Workloads[workloadName].Spec.Resources[resName]
+	if res.Params == nil {
+		return nil, nil
+	}
+	_, err := Substitute((map[string]interface{})(res.Params), func(ref string) (string, error) {
+		parts := SplitRefParts(ref)
+		if len(parts) > 1 && parts[0] == "resources" {
+			rr, ok := s.Workloads[workloadName].Spec.Resources[parts[1]]
+			if ok {
+				outMap[NewResourceUid(workloadName, parts[1], rr.Type, rr.Class, rr.Id)] = true
+			} else {
+				return ref, fmt.Errorf("refers to unknown resource names '%s'", parts[1])
+			}
+		}
+		return ref, nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("workload '%s' resource '%s': %w", workloadName, resName, err)
+	}
+	return outMap, nil
+}
+
+// GetSortedResourceUids returns a topological sorting of the resource uids. The output order is deterministic and
+// ensures that any resource output placeholder statements are strictly evaluated after their referenced resource.
+// If cycles are detected an error will be thrown.
+func (s *State) GetSortedResourceUids() ([]ResourceUid, error) {
+
+	// We're implementing Kahn's algorithm (https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm).
+	nodesWithNoIncomingEdges := make(map[ResourceUid]bool)
+	incomingEdges := make(map[ResourceUid]map[ResourceUid]bool, len(s.Resources))
+
+	// we must first gather all the dependencies of each resource
+	// many resources won't have dependencies and they go straight into the no-incoming-edges set
+	for workloadName, workload := range s.Workloads {
+		for resName, res := range workload.Spec.Resources {
+			resUid := NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
+			deps, err := s.getResourceDependencies(workloadName, resName, resUid)
+			if err != nil {
+				return nil, err
+			}
+			if len(deps) == 0 {
+				nodesWithNoIncomingEdges[resUid] = true
+			} else {
+				incomingEdges[resUid] = deps
+			}
+		}
+	}
+
+	// set up the output list
+	output := make([]ResourceUid, 0, len(nodesWithNoIncomingEdges)+len(incomingEdges))
+
+	// now iterate through the nodes with no incoming edges and subtract them from the
+	for len(nodesWithNoIncomingEdges) > 0 {
+
+		// to get a stable set, we grab whatever is on the set and convert it to a sorted list
+		subset := make([]ResourceUid, 0, len(nodesWithNoIncomingEdges))
+		for uid, _ := range nodesWithNoIncomingEdges {
+			subset = append(subset, uid)
+		}
+		clear(nodesWithNoIncomingEdges)
+		slices.Sort(subset)
+
+		// we can bulk append the subset to the output
+		output = append(output, subset...)
+
+		// remove a node from the no-incoming edges set
+		for _, fromUid := range subset {
+			// now find any nodes that had an edge going from this node to them
+			for toUid, m := range incomingEdges {
+				if m[fromUid] {
+					// and remove the edge
+					delete(m, fromUid)
+					// if there are no incoming edges, then move it to the no-incoming-edges set
+					if len(m) == 0 {
+						delete(incomingEdges, toUid)
+						nodesWithNoIncomingEdges[toUid] = true
+					}
+				}
+			}
+		}
+	}
+	// if we make no progress then there are cycles
+	if len(incomingEdges) > 0 {
+		return nil, fmt.Errorf("a cycle exists involving resource param placeholders")
+	}
+	return output, nil
+}
+
+func (s *State) GetSubstitutionContextForWorkload(workloadName string) (map[string]interface{}, error) {
+	resourceOutputs, err := s.GetResourceOutputForWorkload(workloadName)
+	if err != nil {
+		return nil, err
+	}
+	metadata := s.Workloads[workloadName].Spec.Metadata
+	return map[string]interface{}{
+		"metadata":  (map[string]interface{})(metadata),
+		"resources": resourceOutputs,
+	}, nil
+}
+
 // GetResourceOutputForWorkload returns an output function per resource name in the given workload. This is for
 // passing into the compose translation context to resolve placeholder references.
 // This does not modify the state.
diff --git a/internal/project/substitution.go b/internal/project/substitution.go
new file mode 100644
index 0000000..b63a742
--- /dev/null
+++ b/internal/project/substitution.go
@@ -0,0 +1,148 @@
+package project
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+var (
+	placeholderRegEx = regexp.MustCompile(`\$(\$|{([a-zA-Z0-9.\-_\\]+)})`)
+)
+
+func SplitRefParts(ref string) []string {
+	subRef := strings.Replace(ref, `\.`, "\000", -1)
+	parts := strings.Split(subRef, ".")
+	for i, part := range parts {
+		parts[i] = strings.Replace(part, "\000", ".", -1)
+	}
+	return parts
+}
+
+// SubstituteString replaces all matching '${...}' templates in a source string with whatever is returned
+// from the inner function.
+func SubstituteString(src string, inner func(string) (string, error)) (string, error) {
+	var err error
+	result := placeholderRegEx.ReplaceAllStringFunc(src, func(str string) string {
+		// WORKAROUND: ReplaceAllStringFunc(..) does not provide match details
+		//             https://github.com/golang/go/issues/5690
+		var matches = placeholderRegEx.FindStringSubmatch(str)
+
+		// SANITY CHECK
+		if len(matches) != 3 {
+			err = errors.Join(err, fmt.Errorf("could not find a proper match in previously captured string fragment"))
+			return src
+		}
+
+		// EDGE CASE: Captures "$$" sequences and empty templates "${}"
+		if matches[2] == "" {
+			return matches[1]
+		} else if matches[2] == "$" {
+			return matches[2]
+		}
+
+		result, subErr := inner(matches[2])
+		err = errors.Join(err, subErr)
+		return result
+	})
+	return result, err
+}
+
+// Substitute does the same thing as SubstituteString but recursively through a map. It returns a copy of the original map.
+func Substitute(source interface{}, inner func(string) (string, error)) (interface{}, error) {
+	if source == nil {
+		return nil, nil
+	}
+	switch v := source.(type) {
+	case string:
+		return SubstituteString(v, inner)
+	case map[string]interface{}:
+		out := make(map[string]interface{}, len(v))
+		for k, v := range v {
+			v2, err := Substitute(v, inner)
+			if err != nil {
+				return nil, fmt.Errorf("%s: %w", k, err)
+			}
+			out[k] = v2
+		}
+		return out, nil
+	case []interface{}:
+		out := make([]interface{}, len(v))
+		for i, i2 := range v {
+			i3, err := Substitute(i2, inner)
+			if err != nil {
+				return nil, fmt.Errorf("%d: %w", i, err)
+			}
+			out[i] = i3
+		}
+		return out, nil
+	default:
+		return source, nil
+	}
+}
+
+func mapLookupOutput(ctx map[string]interface{}) func(keys ...string) (interface{}, error) {
+	return func(keys ...string) (interface{}, error) {
+		var resolvedValue interface{}
+		resolvedValue = ctx
+		for _, k := range keys {
+			mapV, ok := resolvedValue.(map[string]interface{})
+			if !ok {
+				return "", fmt.Errorf("cannot lookup key '%s', context is not a map", k)
+			}
+			resolvedValue, ok = mapV[k]
+			if !ok {
+				return "", fmt.Errorf("key '%s' not found", k)
+			}
+		}
+		return resolvedValue, nil
+	}
+}
+
+func BuildSubstitutionFunction(metadata map[string]interface{}, resources map[string]OutputLookupFunc) func(string) (string, error) {
+	metadataLookup := mapLookupOutput(metadata)
+	return func(ref string) (string, error) {
+		parts := SplitRefParts(ref)
+		var resolvedValue interface{}
+		switch parts[0] {
+		case "metadata":
+			if len(parts) < 2 {
+				return "", fmt.Errorf("invalid ref '%s': requires at least a metadata key to lookup", ref)
+			}
+			if rv, err := metadataLookup(parts[1:]...); err != nil {
+				return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
+			} else {
+				resolvedValue = rv
+			}
+		case "resources":
+			if len(parts) < 2 {
+				return "", fmt.Errorf("invalid ref '%s': requires at least a resource name to lookup", ref)
+			}
+			rv, ok := resources[parts[1]]
+			if !ok {
+				return "", fmt.Errorf("invalid ref '%s': no known resource '%s'", ref, parts[1])
+			} else if len(parts) == 2 {
+				// TODO: deprecate this - this is an annoying and nonsensical legacy thing
+				return parts[1], nil
+			} else if rv2, err := rv(parts[2:]...); err != nil {
+				return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
+			} else {
+				resolvedValue = rv2
+			}
+		default:
+			return "", fmt.Errorf("invalid ref '%s': unknown reference root", ref)
+		}
+
+		if asString, ok := resolvedValue.(string); ok {
+			return asString, nil
+		}
+		// TODO: work out how we might support other types here in the future
+		raw, err := json.Marshal(resolvedValue)
+		if err != nil {
+			return "", err
+		}
+		return string(raw), nil
+	}
+}
diff --git a/internal/provisioners/core.go b/internal/provisioners/core.go
index 15605f0..4f66670 100644
--- a/internal/provisioners/core.go
+++ b/internal/provisioners/core.go
@@ -60,6 +60,29 @@ type Provisioner interface {
 	Provision(ctx context.Context, input *Input) (*ProvisionOutput, error)
 }
 
+type ephemeralProvisioner struct {
+	uri       string
+	matchUid  project.ResourceUid
+	provision func(ctx context.Context, input *Input) (*ProvisionOutput, error)
+}
+
+func (e *ephemeralProvisioner) Uri() string {
+	return e.uri
+}
+
+func (e *ephemeralProvisioner) Match(resUid project.ResourceUid) bool {
+	return resUid == e.matchUid
+}
+
+func (e *ephemeralProvisioner) Provision(ctx context.Context, input *Input) (*ProvisionOutput, error) {
+	return e.provision(ctx, input)
+}
+
+// NewEphemeralProvisioner is mostly used for internal testing and uses the given provisioner function to provision an exact resource.
+func NewEphemeralProvisioner(uri string, matchUid project.ResourceUid, inner func(ctx context.Context, input *Input) (*ProvisionOutput, error)) Provisioner {
+	return &ephemeralProvisioner{uri: uri, matchUid: matchUid, provision: inner}
+}
+
 // ApplyToStateAndProject takes the outputs of a provisioning request and applies to the state, file tree, and docker
 // compose project.
 func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid project.ResourceUid, project *compose.Project) (*project.State, error) {
@@ -170,7 +193,14 @@ func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid p
 func ProvisionResources(ctx context.Context, state *project.State, provisioners []Provisioner, composeProject *compose.Project) (*project.State, error) {
 	out := state
 
-	for resUid, resState := range state.Resources {
+	// provision in sorted order
+	orderedResources, err := out.GetSortedResourceUids()
+	if err != nil {
+		return nil, fmt.Errorf("failed to determine sort order for provisioning: %w", err)
+	}
+
+	for _, resUid := range orderedResources {
+		resState := out.Resources[resUid]
 		provisionerIndex := slices.IndexFunc(provisioners, func(provisioner Provisioner) bool {
 			return provisioner.Match(resUid)
 		})
@@ -182,17 +212,31 @@ func ProvisionResources(ctx context.Context, state *project.State, provisioners
 			return nil, fmt.Errorf("resource '%s' was previously provisioned by a different provider - undefined behavior", resUid)
 		}
 
+		var params map[string]interface{}
+		if resState.Params != nil && len(resState.Params) > 0 {
+			resOutputs, err := out.GetResourceOutputForWorkload(resState.SourceWorkload)
+			if err != nil {
+				return nil, fmt.Errorf("failed to find resource params for resource '%s': %w", resUid, err)
+			}
+			sf := project.BuildSubstitutionFunction(out.Workloads[resState.SourceWorkload].Spec.Metadata, resOutputs)
+			rawParams, err := project.Substitute(resState.Params, sf)
+			if err != nil {
+				return nil, fmt.Errorf("failed to substitute params for resource '%s': %w", resUid, err)
+			}
+			params = rawParams.(map[string]interface{})
+		}
+
 		output, err := provisioner.Provision(ctx, &Input{
 			ResourceUid:        string(resUid),
 			ResourceType:       resUid.Type(),
 			ResourceClass:      resUid.Class(),
 			ResourceId:         resUid.Id(),
-			ResourceParams:     resState.Params,
+			ResourceParams:     params,
 			ResourceMetadata:   resState.Metadata,
 			ResourceState:      resState.State,
 			SharedState:        out.SharedState,
-			ComposeProjectName: state.ComposeProjectName,
-			MountDirectoryPath: state.MountsDirectory,
+			ComposeProjectName: out.ComposeProjectName,
+			MountDirectoryPath: out.MountsDirectory,
 		})
 		if err != nil {
 			return nil, fmt.Errorf("resource '%s': failed to provision: %w", resUid, err)


Test patch:
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index b9c7a8c..7b4bb5b 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -446,3 +446,74 @@ services:
 		assert.NoError(t, cmd.Run())
 	})
 }
+
+func TestInitAndGenerate_with_dependent_resources(t *testing.T) {
+	td := changeToTempDir(t)
+	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+
+	// write custom providers
+	assert.NoError(t, os.WriteFile(filepath.Join(td, ".score-compose", "00-custom.provisioners.yaml"), []byte(`
+- uri: template://foo
+  type: foo
+  outputs: |
+    blah: value
+  services: |
+    foo-service:
+      image: foo-image
+- uri: template://bar
+  type: bar
+  services: |
+    bar-service:
+      image: {{ .Params.x }}
+`), 0644))
+
+	// write custom score file
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  example:
+    image: busybox
+resources:
+  first:
+    type: foo
+  second:
+    type: bar
+    params:
+      x: ${resources.first.blah}
+`), 0644))
+
+	// generate
+	stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	raw, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
+	assert.NoError(t, err)
+	assert.Equal(t, `name: "001"
+services:
+    bar-service:
+        image: value
+    example-example:
+        depends_on:
+            wait-for-resources:
+                condition: service_started
+                required: false
+        image: busybox
+    foo-service:
+        image: foo-image
+    wait-for-resources:
+        command:
+            - echo
+        depends_on:
+            bar-service:
+                condition: service_started
+                required: true
+            foo-service:
+                condition: service_started
+                required: true
+        image: alpine
+`, string(raw))
+}
diff --git a/internal/project/state_test.go b/internal/project/state_test.go
index 729366c..1e09e99 100644
--- a/internal/project/state_test.go
+++ b/internal/project/state_test.go
@@ -118,17 +118,25 @@ resources:
 		require.NoError(t, err)
 		assert.Len(t, start.Resources, 0)
 		assert.Equal(t, map[ResourceUid]ScoreResourceState{
-			"thing.default#example.one": {Type: "thing", Class: "default", Id: "example.one", State: map[string]interface{}{}},
-			"thing2.banana#example.two": {Type: "thing2", Class: "banana", Id: "example.two", State: map[string]interface{}{}},
+			"thing.default#example.one": {
+				Type: "thing", Class: "default", Id: "example.one", State: map[string]interface{}{},
+				SourceWorkload: "example",
+			},
+			"thing2.banana#example.two": {
+				Type: "thing2", Class: "banana", Id: "example.two", State: map[string]interface{}{},
+				SourceWorkload: "example",
+			},
 			"thing3.apple#dog": {
 				Type: "thing3", Class: "apple", Id: "dog", State: map[string]interface{}{},
-				Metadata: map[string]interface{}{"annotations": score.ResourceMetadata{"foo": "bar"}},
-				Params:   map[string]interface{}{"color": "green"},
+				Metadata:       map[string]interface{}{"annotations": score.ResourceMetadata{"foo": "bar"}},
+				Params:         map[string]interface{}{"color": "green"},
+				SourceWorkload: "example",
 			},
 			"thing4.default#elephant": {
 				Type: "thing4", Class: "default", Id: "elephant", State: map[string]interface{}{},
-				Metadata: map[string]interface{}{"x": "y"},
-				Params:   map[string]interface{}{"color": "blue"},
+				Metadata:       map[string]interface{}{"x": "y"},
+				Params:         map[string]interface{}{"color": "blue"},
+				SourceWorkload: "example",
 			},
 		}, next.Resources)
 	})
@@ -198,11 +206,133 @@ resources:
 			assert.Len(t, start.Resources, 0)
 			assert.Len(t, next.Resources, 3)
 			assert.Equal(t, map[ResourceUid]ScoreResourceState{
-				"thing.default#example1.one": {Type: "thing", Class: "default", Id: "example1.one", State: map[string]interface{}{}},
-				"thing.default#example2.one": {Type: "thing", Class: "default", Id: "example2.one", State: map[string]interface{}{}},
-				"thing2.default#dog":         {Type: "thing2", Class: "default", Id: "dog", State: map[string]interface{}{}},
+				"thing.default#example1.one": {
+					Type: "thing", Class: "default", Id: "example1.one", State: map[string]interface{}{},
+					SourceWorkload: "example1",
+				},
+				"thing.default#example2.one": {
+					Type: "thing", Class: "default", Id: "example2.one", State: map[string]interface{}{},
+					SourceWorkload: "example2",
+				},
+				"thing2.default#dog": {
+					Type: "thing2", Class: "default", Id: "dog", State: map[string]interface{}{},
+					SourceWorkload: "example1",
+				},
 			}, next.Resources)
 		})
 	})
 
 }
+
+func TestGetSortedResourceUids(t *testing.T) {
+
+	t.Run("empty", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+		}, nil, nil)
+		assert.NoError(t, err)
+		ru, err := s.GetSortedResourceUids()
+		assert.NoError(t, err)
+		assert.Empty(t, ru)
+	})
+
+	t.Run("one", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res": {Type: "thing", Params: map[string]interface{}{}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		ru, err := s.GetSortedResourceUids()
+		assert.NoError(t, err)
+		assert.Equal(t, []ResourceUid{"thing.default#eg.res"}, ru)
+	})
+
+	t.Run("one cycle", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res.blah}"}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		_, err = s.GetSortedResourceUids()
+		assert.EqualError(t, err, "a cycle exists involving resource param placeholders")
+	})
+
+	t.Run("two unrelated", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res1": {Type: "thing", Params: map[string]interface{}{}},
+				"res2": {Type: "thing", Params: map[string]interface{}{}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		ru, err := s.GetSortedResourceUids()
+		assert.NoError(t, err)
+		assert.Equal(t, []ResourceUid{"thing.default#eg.res1", "thing.default#eg.res2"}, ru)
+	})
+
+	t.Run("two linked", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res1": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res2.blah}"}},
+				"res2": {Type: "thing", Params: map[string]interface{}{}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		ru, err := s.GetSortedResourceUids()
+		assert.NoError(t, err)
+		assert.Equal(t, []ResourceUid{"thing.default#eg.res2", "thing.default#eg.res1"}, ru)
+	})
+
+	t.Run("two cycle", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res1": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res2.blah}"}},
+				"res2": {Type: "thing", Params: map[string]interface{}{"y": "${resources.res1.blah}"}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		_, err = s.GetSortedResourceUids()
+		assert.EqualError(t, err, "a cycle exists involving resource param placeholders")
+	})
+
+	t.Run("three linked", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res1": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res2.blah}"}},
+				"res2": {Type: "thing", Params: map[string]interface{}{}},
+				"res3": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res1.blah}"}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		ru, err := s.GetSortedResourceUids()
+		assert.NoError(t, err)
+		assert.Equal(t, []ResourceUid{"thing.default#eg.res2", "thing.default#eg.res1", "thing.default#eg.res3"}, ru)
+	})
+
+	t.Run("complex", func(t *testing.T) {
+		s, err := new(State).WithWorkload(&score.Workload{
+			Metadata: map[string]interface{}{"name": "eg"},
+			Resources: map[string]score.Resource{
+				"res1": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res2.blah} ${resources.res3.blah} ${resources.res4.blah} ${resources.res5.blah} ${resources.res6.blah}"}},
+				"res2": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res3.blah} ${resources.res4.blah} ${resources.res5.blah} ${resources.res6.blah}"}},
+				"res3": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res4.blah} ${resources.res5.blah} ${resources.res6.blah}"}},
+				"res4": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res5.blah} ${resources.res6.blah}"}},
+				"res5": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res6.blah}"}},
+				"res6": {Type: "thing", Params: map[string]interface{}{}},
+			},
+		}, nil, nil)
+		assert.NoError(t, err)
+		ru, err := s.GetSortedResourceUids()
+		assert.NoError(t, err)
+		assert.Equal(t, []ResourceUid{"thing.default#eg.res6", "thing.default#eg.res5", "thing.default#eg.res4", "thing.default#eg.res3", "thing.default#eg.res2", "thing.default#eg.res1"}, ru)
+	})
+
+}
diff --git a/internal/compose/templates_test.go b/internal/project/substitution_test.go
similarity index 63%
rename from internal/compose/templates_test.go
rename to internal/project/substitution_test.go
index 2b347fe..ee97a92 100644
--- a/internal/compose/templates_test.go
+++ b/internal/project/substitution_test.go
@@ -5,38 +5,41 @@ Copyright 2022 The Apache Software Foundation
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
 */
-package compose
+package project
 
 import (
+	"fmt"
+	"strings"
 	"testing"
 
 	score "github.com/score-spec/score-go/types"
-	assert "github.com/stretchr/testify/assert"
-
-	"github.com/score-spec/score-compose/internal/project"
-	"github.com/score-spec/score-compose/internal/provisioners/envprov"
+	"github.com/stretchr/testify/assert"
 )
 
-func TestMapVar(t *testing.T) {
-	var meta = score.WorkloadMetadata{
+var substitutionFunction func(string) (string, error)
+
+func init() {
+	substitutionFunction = BuildSubstitutionFunction(score.WorkloadMetadata{
 		"name":  "test-name",
 		"other": map[string]interface{}{"key": "value"},
-	}
-	evt := new(envprov.Provisioner)
-	evt.LookupFunc = func(key string) (string, bool) {
-		if key == "DEBUG" {
-			return "something", true
-		}
-		return "", false
-	}
-	po, _ := evt.GenerateSubProvisioner("db", "").Provision(nil, nil)
-	ctx, err := buildContext(meta, map[string]project.OutputLookupFunc{
-		"env":    evt.LookupOutput,
-		"db":     po.OutputLookupFunc,
-		"static": resourceWithStaticOutputs{"x": "a"}.LookupOutput,
+	}, map[string]OutputLookupFunc{
+		"env": func(keys ...string) (interface{}, error) {
+			if len(keys) != 1 {
+				return nil, fmt.Errorf("fail")
+			}
+			return "${" + keys[0] + "}", nil
+		},
+		"db": func(keys ...string) (interface{}, error) {
+			if len(keys) < 1 {
+				return nil, fmt.Errorf("fail")
+			}
+			return "${DB_" + strings.ToUpper(strings.Join(keys, "_")) + "?required}", nil
+		},
+		"static": mapLookupOutput(map[string]interface{}{"x": "a"}),
 	})
-	assert.NoError(t, err)
+}
 
+func TestSubstitutionFunction(t *testing.T) {
 	for _, tc := range []struct {
 		Input         string
 		Expected      string
@@ -62,7 +65,7 @@ func TestMapVar(t *testing.T) {
 		{Input: "resources.static.y", ExpectedError: "invalid ref 'resources.static.y': key 'y' not found"},
 	} {
 		t.Run(tc.Input, func(t *testing.T) {
-			res, err := ctx.mapVar(tc.Input)
+			res, err := substitutionFunction(tc.Input)
 			if tc.ExpectedError != "" {
 				assert.EqualError(t, err, tc.ExpectedError)
 			} else {
@@ -71,34 +74,9 @@ func TestMapVar(t *testing.T) {
 			}
 		})
 	}
-
-	assert.Equal(t, map[string]string{
-		"DEBUG":        "something",
-		"DB_HOST":      "",
-		"DB_NAME":      "",
-		"DB_NAME_USER": "",
-		"DB_PORT":      "",
-	}, evt.Accessed())
 }
 
-func TestSubstitute(t *testing.T) {
-	var meta = score.WorkloadMetadata{
-		"name": "test-name",
-	}
-	evt := new(envprov.Provisioner)
-	evt.LookupFunc = func(key string) (string, bool) {
-		if key == "DEBUG" {
-			return "something", true
-		}
-		return "", false
-	}
-	po, _ := evt.GenerateSubProvisioner("db", "").Provision(nil, nil)
-	ctx, err := buildContext(meta, map[string]project.OutputLookupFunc{
-		"env": evt.LookupOutput,
-		"db":  po.OutputLookupFunc,
-	})
-	assert.NoError(t, err)
-
+func TestSubstituteString(t *testing.T) {
 	for _, tc := range []struct {
 		Input         string
 		Expected      string
@@ -117,7 +95,7 @@ func TestSubstitute(t *testing.T) {
 		},
 	} {
 		t.Run(tc.Input, func(t *testing.T) {
-			res, err := ctx.Substitute(tc.Input)
+			res, err := SubstituteString(tc.Input, substitutionFunction)
 			if tc.ExpectedError != "" {
 				if !assert.EqualError(t, err, tc.ExpectedError) {
 					assert.Equal(t, "", res)
@@ -128,12 +106,35 @@ func TestSubstitute(t *testing.T) {
 			}
 		})
 	}
+}
+
+func TestSubstituteMap_success(t *testing.T) {
+	input := map[string]interface{}{
+		"a": "1",
+		"b": "${metadata.name}",
+		"c": []interface{}{"1", "${metadata.name}", map[string]interface{}{"d": "${metadata.name}"}},
+		"d": map[string]interface{}{
+			"e": "1",
+			"f": "${metadata.name}",
+		},
+	}
+	output, err := Substitute(input, substitutionFunction)
+	assert.NoError(t, err)
+	assert.Equal(t, map[string]interface {
+	}{
+		"a": "1",
+		"b": "test-name",
+		"c": []interface{}{"1", "test-name", map[string]interface{}{"d": "test-name"}},
+		"d": map[string]interface{}{
+			"e": "1",
+			"f": "test-name",
+		},
+	}, output)
+}
 
-	assert.Equal(t, map[string]string{
-		"DB_HOST":     "",
-		"DB_NAME":     "",
-		"DB_PASSWORD": "",
-		"DB_PORT":     "",
-		"DB_USER":     "",
-	}, evt.Accessed())
+func TestSubstituteMap_fail(t *testing.T) {
+	_, err := Substitute(map[string]interface{}{
+		"a": []interface{}{map[string]interface{}{"b": "${metadata.unknown}"}},
+	}, substitutionFunction)
+	assert.EqualError(t, err, "a: 0: b: invalid ref 'metadata.unknown': key 'unknown' not found")
 }
diff --git a/internal/provisioners/core_test.go b/internal/provisioners/core_test.go
index a596aa2..71e1aee 100644
--- a/internal/provisioners/core_test.go
+++ b/internal/provisioners/core_test.go
@@ -1,6 +1,7 @@
 package provisioners
 
 import (
+	"context"
 	"io/fs"
 	"os"
 	"path/filepath"
@@ -8,6 +9,7 @@ import (
 	"testing"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
+	score "github.com/score-spec/score-go/types"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
@@ -97,3 +99,50 @@ func TestApplyToStateAndProject(t *testing.T) {
 	})
 
 }
+
+func TestProvisionResourcesWithResourceParams(t *testing.T) {
+	state := new(project.State)
+	state, _ = state.WithWorkload(&score.Workload{
+		Metadata: map[string]interface{}{"name": "w1"},
+		Resources: map[string]score.Resource{
+			"a": {Type: "a", Params: map[string]interface{}{"x": "${resources.b.key}"}},
+			"b": {Type: "b"},
+		},
+	}, nil, nil)
+	state, _ = state.WithPrimedResources()
+	p := []Provisioner{
+		NewEphemeralProvisioner("ephemeral://blah", "a.default#w1.a", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
+			assert.Equal(t, map[string]interface{}{"x": "value"}, input.ResourceParams)
+			return &ProvisionOutput{}, nil
+		}),
+		NewEphemeralProvisioner("ephemeral://blah", "b.default#w1.b", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
+			return &ProvisionOutput{ResourceOutputs: map[string]interface{}{"key": "value"}}, nil
+		}),
+	}
+	after, err := ProvisionResources(context.Background(), state, p, nil)
+	if assert.NoError(t, err) {
+		assert.Len(t, after.Resources, 2)
+	}
+}
+
+func TestProvisionResourcesWithResourceParams_fail(t *testing.T) {
+	state := new(project.State)
+	state, _ = state.WithWorkload(&score.Workload{
+		Metadata: map[string]interface{}{"name": "w1"},
+		Resources: map[string]score.Resource{
+			"a": {Type: "a", Params: map[string]interface{}{"x": "${resources.b.unknown}"}},
+			"b": {Type: "b"},
+		},
+	}, nil, nil)
+	state, _ = state.WithPrimedResources()
+	p := []Provisioner{
+		NewEphemeralProvisioner("ephemeral://blah", "a.default#w1.a", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
+			return &ProvisionOutput{}, nil
+		}),
+		NewEphemeralProvisioner("ephemeral://blah", "b.default#w1.b", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
+			return &ProvisionOutput{ResourceOutputs: map[string]interface{}{}}, nil
+		}),
+	}
+	_, err := ProvisionResources(context.Background(), state, p, nil)
+	assert.EqualError(t, err, "failed to substitute params for resource 'a.default#w1.a': x: invalid ref 'resources.b.unknown': key 'unknown' not found")
+}