Repository: score-spec/score-compose
Pull request #66 (2024-03-10 16:37:26)

feat: added implementation of provisioners with an initial template provisioner
This PR adds the concept of resource provisioners to score-humanitec! 🎉 

See here for a video walkthrough of the PR. https://www.loom.com/share/bf8199692a64479bb7cf196f03495634?sid=86c1b031-4810-4a75-8fe6-32c9dda42308

A Provisioner is an implementation that can take the "primed" resource and calculate it's new state, shared state, outputs, and compose project modifications. This is defined in the provisioners/core.go file which determines what data the provisioner has access to.

Provisioners are identified by a Uri which indicates what kind of provisioner it is, as well as where to find it and how to contact it.

The first implementations of provisioners here are the `builtin://environment` which provides environment variables and the `template://...` which is configured as a set of Go templates which are called in order with a data object which is passed between them.

Future kinds of provisioners will be things like `exec://<command>/<args>` and `http(s)://hostname/path`.

Provisioners will be loadded from disk from one or more `*.provisioners.yaml` files found in the .score-compose directory.

The run command is modified now to have some fake provisioners which just set the output functions. Other than that there is no change in external functionality yet which will come in the next PR with the actual generate subcommand.

### Example provisioner

Here's an example of a default redis provisioner that works with this template engine:

```yaml
# The default redis provisioner adds a redis service to the project which returns a host, port, username, and password.
- uri: template://default-provisioners/redis
  # By default, match all redis types regardless of class and id. If you want to override this, create another
  # provisioner definition with a higher priority.
  type: redis
  # Init template has the default port and a random service name and password if needed later
  init: |
    port: 6379
    randomServiceName: redis-{{ randAlphaNum 6 }}
    randomPassword: {{ randAlphaNum 16 | quote }}
  # The only state we need to persist is the chosen random service name and password
  state: |
    serviceName: {{ dig "serviceName" .Init.randomServiceName .State | quote }}
    password: {{ dig "password" .Init.randomPassword .State | quote }}
  # Return the outputs schema that consumers expect
  outputs: |
    host: {{ .State.serviceName }}
    port: {{ .Init.port }}
    username: default
    password: {{ .State.password | quote }}
  # write the config file to the mounts directory
  files: |
    {{ .State.serviceName }}/redis.conf: |
      requirepass {{ .State.password }}
      port {{ .Init.port }}
      save 60 1
      loglevel warning
  # add a volume for persistence of the redis data
  volumes: |
    {{ .State.serviceName }}-data:
      name: {{ .State.serviceName }}-data
      driver: local
      labels:
        dev.score.compose.res.uid: {{ .Uid }}
  # And the redis service itself with volumes bound in
  services: |
    {{ .State.serviceName }}:
      labels:
        dev.score.compose.res.uid: {{ .Uid }}
      image: redis:7
      entrypoint: ["redis-server"]
      command: ["/usr/local/etc/redis/redis.conf"]
      volumes:
      - type: bind
        source: {{ .MountsDirectory }}/{{ .State.serviceName }}/redis.conf
        target: /usr/local/etc/redis/redis.conf
        read_only: true
      - type: volume
        source: {{ .State.serviceName }}-data
        target: /data
        volume:
          nocopy: true
```

#### Types of changes
- [ ] 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:
- [ ] 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 b6c566e..f009501 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.21
 toolchain go1.21.0
 
 require (
+	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/compose-spec/compose-go/v2 v2.0.0-rc.8
 	github.com/imdario/mergo v0.3.13
 	github.com/score-spec/score-go v1.2.0
@@ -16,10 +17,14 @@ require (
 )
 
 require (
+	github.com/Masterminds/goutils v1.1.1 // indirect
+	github.com/Masterminds/semver/v3 v3.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/distribution/reference v0.5.0 // indirect
 	github.com/docker/go-connections v0.4.0 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
+	github.com/google/uuid v1.1.1 // indirect
+	github.com/huandu/xstrings v1.3.3 // indirect
 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/mattn/go-shellwords v1.0.12 // indirect
@@ -29,12 +34,15 @@ require (
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
+	github.com/shopspring/decimal v1.2.0 // indirect
 	github.com/sirupsen/logrus v1.9.0 // indirect
+	github.com/spf13/cast v1.3.1 // indirect
 	github.com/tidwall/gjson v1.14.4 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
+	golang.org/x/crypto v0.3.0 // indirect
 	golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
 	golang.org/x/sync v0.3.0 // indirect
-	golang.org/x/sys v0.1.0 // indirect
+	golang.org/x/sys v0.2.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )
diff --git a/go.sum b/go.sum
index 7c446bb..dfd2ebc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,9 @@
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
+github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
+github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
+github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
 github.com/compose-spec/compose-go/v2 v2.0.0-rc.8 h1:b7l+GqFF+2W4M4kLQUDRTGhqmTiRwT3bYd9X7xrxp5Q=
 github.com/compose-spec/compose-go/v2 v2.0.0-rc.8/go.mod h1:bEPizBkIojlQ20pi2vNluBa58tevvj0Y18oUSHPyfdc=
 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -12,6 +18,11 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
+github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
@@ -23,10 +34,12 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 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.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -38,13 +51,19 @@ 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.2.0 h1:1fEJXoK3YtL8Ko7RrY5lSfx80/Qs19zM/NJMuG9jlYA=
 github.com/score-spec/score-go v1.2.0/go.mod h1:nt6TOq2Ld9SiH3Fd9NF8tiJ9L7S17OE3FNgCrSet5GQ=
+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=
 github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI=
 github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -58,16 +77,46 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
 github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
 github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
+golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
 golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/command/run.go b/internal/command/run.go
index 81bdc77..b3f3a06 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -8,11 +8,11 @@ The Apache Software Foundation (http://www.apache.org/).
 package command
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log/slog"
-	"maps"
 	"os"
 	"sort"
 	"strings"
@@ -29,6 +29,8 @@ import (
 
 	"github.com/score-spec/score-compose/internal/compose"
 	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners"
+	"github.com/score-spec/score-compose/internal/provisioners/envprov"
 )
 
 const (
@@ -187,8 +189,12 @@ func run(cmd *cobra.Command, args []string) error {
 
 	// Instead of actually calling the resource provisioning system, we skip it and fill in the supported resources
 	// ourselves.
-	vars := new(compose.EnvVarTracker)
-	state, err = fillInLegacyResourceOutputFunctions(spec.Metadata["name"].(string), state, vars)
+	provisionerList, envProvisioner, err := buildLegacyProvisioners(spec.Metadata["name"].(string), state)
+	if err != nil {
+		return err
+	}
+
+	state, err = provisioners.ProvisionResources(context.Background(), state, provisionerList, nil)
 	if err != nil {
 		return fmt.Errorf("failed to provision resources: %w", err)
 	}
@@ -253,7 +259,7 @@ func run(cmd *cobra.Command, args []string) error {
 		// Write .env file
 		//
 		envVars := make([]string, 0)
-		for key, val := range vars.Accessed() {
+		for key, val := range envProvisioner.Accessed() {
 			var envVar = fmt.Sprintf("%s=%v\n", key, val)
 			envVars = append(envVars, envVar)
 		}
@@ -269,27 +275,35 @@ func run(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-func fillInLegacyResourceOutputFunctions(workloadName string, state *project.State, evt *compose.EnvVarTracker) (*project.State, error) {
-	out := *state
-	out.Resources = maps.Clone(state.Resources)
+func buildLegacyProvisioners(workloadName string, state *project.State) ([]provisioners.Provisioner, *envprov.Provisioner, error) {
+	envProv := new(envprov.Provisioner)
+	out := []provisioners.Provisioner{envProv}
 	for resName, res := range state.Workloads[workloadName].Spec.Resources {
 		resUid := project.NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
-		resState := state.Resources[resUid]
 		if resUid.Type() == "environment" {
-			if resUid.Class() != "default" {
-				return nil, fmt.Errorf("resources '%s': '%s.%s' is not supported in score-compose", resUid, resUid.Type(), resUid.Class())
-			}
-			resState.OutputLookupFunc = evt.LookupOutput
+			// handled by env prov which is already added above
 		} else if resUid.Type() == "volume" && resUid.Class() == "default" {
-			resState.OutputLookupFunc = func(keys ...string) (interface{}, error) {
-				return nil, fmt.Errorf("resource has no outputs")
-			}
+			out = append(out, &legacyVolumeProvisioner{MatchResourceUid: resUid})
 		} else {
 			slog.Warn(fmt.Sprintf("resources.%s: '%s.%s' is not directly supported in score-compose, references will be converted to environment variables", resName, resUid.Type(), resUid.Class()))
-			fake := evt.GenerateResource(resName)
-			resState.OutputLookupFunc = fake.LookupOutput
+			out = append(out, envProv.GenerateSubProvisioner(resName, resUid))
 		}
-		out.Resources[resUid] = resState
 	}
-	return &out, nil
+	return out, envProv, nil
+}
+
+type legacyVolumeProvisioner struct {
+	MatchResourceUid project.ResourceUid
+}
+
+func (l *legacyVolumeProvisioner) Uri() string {
+	return "builtin://legacy-volume"
+}
+
+func (l *legacyVolumeProvisioner) Match(resUid project.ResourceUid) bool {
+	return l.MatchResourceUid == resUid
+}
+
+func (l *legacyVolumeProvisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
+	return &provisioners.ProvisionOutput{ResourceOutputs: map[string]interface{}{}}, nil
 }
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index b755dc7..6859e81 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -94,12 +94,9 @@ func ConvertSpec(spec *score.Workload, resources map[string]project.OutputLookup
 					return nil, fmt.Errorf("containers.%s.volumes[%d].path: can't mount named volume with sub path '%s': not supported", containerName, idx, *vol.Path)
 				}
 
-				// TODO: deprecate this - volume should be linked directly
 				resolvedVolumeSource, err := ctx.Substitute(vol.Source)
 				if err != nil {
 					return nil, fmt.Errorf("containers.%s.volumes[%d].source: %w", containerName, idx, err)
-				} else if resolvedVolumeSource != vol.Source {
-					slog.Warn(fmt.Sprintf("containers.%s.volumes[%d].source: interpolation will be deprecated in the future", containerName, idx))
 				}
 
 				if res, ok := spec.Resources[resolvedVolumeSource]; !ok {
@@ -108,6 +105,14 @@ func ConvertSpec(spec *score.Workload, resources map[string]project.OutputLookup
 					return nil, fmt.Errorf("containers.%s.volumes[%d].source: resource '%s' is not a volume", containerName, idx, resolvedVolumeSource)
 				}
 
+				if outputFunc, ok := resources[resolvedVolumeSource]; ok {
+					if v, err := outputFunc("source"); err != nil {
+						slog.Warn(fmt.Sprintf("containers.%s.volumes[%d].source: failed to find 'source' key in volume resource '%s': %v", containerName, idx, resolvedVolumeSource, err))
+					} else if sv, ok := v.(string); ok {
+						resolvedVolumeSource = sv
+					}
+				}
+
 				volumes[idx] = compose.ServiceVolumeConfig{
 					Type:     "volume",
 					Source:   resolvedVolumeSource,
diff --git a/internal/compose/envvar_tracker.go b/internal/compose/envvar_tracker.go
deleted file mode 100644
index ebb0bda..0000000
--- a/internal/compose/envvar_tracker.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package compose
-
-import (
-	"maps"
-	"os"
-	"strings"
-)
-
-// EnvVarTracker is used to provide the `environment` resource type. This tracks what keys are accessed and replaces
-// them with outputs that are environment variable references that docker compose will support.
-// This keeps track of which keys were accessed so that we can produce a reference file or list of keys for the user
-// to understand what inputs docker compose will require at launch time.
-type EnvVarTracker struct {
-	// lookup is an environment variable lookup function, if nil this will be defaulted to os.LookupEnv
-	lookup func(key string) (string, bool)
-	// accessed is the map of accessed environment variables and the value they had at access time
-	accessed map[string]string
-}
-
-func (e *EnvVarTracker) Accessed() map[string]string {
-	return maps.Clone(e.accessed)
-}
-
-// the env var tracker is a resource itself (an environment resource)
-var _ ResourceWithOutputs = (*EnvVarTracker)(nil)
-
-func (e *EnvVarTracker) lookupOutput(required bool, keys ...string) (interface{}, error) {
-	if len(keys) == 0 {
-		panic("requires at least 1 key")
-	}
-	envVarKey := strings.ToUpper(strings.Join(keys, "_"))
-
-	// in theory we can replace more unexpected characters
-	envVarKey = strings.ReplaceAll(envVarKey, "-", "_")
-	envVarKey = strings.ReplaceAll(envVarKey, ".", "_")
-
-	if e.lookup == nil {
-		e.lookup = os.LookupEnv
-	}
-	if e.accessed == nil {
-		e.accessed = make(map[string]string, 1)
-	}
-
-	if v, ok := e.lookup(envVarKey); ok {
-		e.accessed[envVarKey] = v
-	} else {
-		e.accessed[envVarKey] = ""
-	}
-	if required {
-		envVarKey += "?required"
-	}
-	return "${" + envVarKey + "}", nil
-}
-
-func (e *EnvVarTracker) LookupOutput(keys ...string) (interface{}, error) {
-	return e.lookupOutput(false, keys...)
-}
-
-func (e *EnvVarTracker) GenerateResource(resName string) ResourceWithOutputs {
-	return &envVarResourceTracker{
-		inner:  e,
-		prefix: resName,
-	}
-}
-
-// 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 {
-	prefix string
-	inner  *EnvVarTracker
-}
-
-func (e *envVarResourceTracker) LookupOutput(keys ...string) (interface{}, error) {
-	next := make([]string, 1+len(keys))
-	next[0] = e.prefix
-	for i, k := range keys {
-		next[1+i] = k
-	}
-	return e.inner.lookupOutput(true, next...)
-}
diff --git a/internal/provisioners/core.go b/internal/provisioners/core.go
new file mode 100644
index 0000000..190be03
--- /dev/null
+++ b/internal/provisioners/core.go
@@ -0,0 +1,198 @@
+package provisioners
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"maps"
+	"os"
+	"path/filepath"
+	"slices"
+
+	compose "github.com/compose-spec/compose-go/v2/types"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/util"
+)
+
+// Input is the set of thins passed to the provisioner implementation. It provides context, previous state, and shared
+// state used by all resources.
+type Input struct {
+	// -- aspects from the resource declaration --
+
+	ResourceUid      string                 `json:"resource_uid"`
+	ResourceType     string                 `json:"resource_type"`
+	ResourceClass    string                 `json:"resource_class"`
+	ResourceId       string                 `json:"resource_id"`
+	ResourceParams   map[string]interface{} `json:"resource_params"`
+	ResourceMetadata map[string]interface{} `json:"resource_metadata"`
+
+	// -- current state --
+
+	ResourceState map[string]interface{} `json:"resource_state"`
+	SharedState   map[string]interface{} `json:"shared_state"`
+
+	// -- configuration --
+
+	ComposeProjectName string `json:"compose_project_name"`
+	MountDirectoryPath string `json:"mount_directory_path"`
+}
+
+// ProvisionOutput is the output returned from a provisioner implementation.
+type ProvisionOutput struct {
+	ResourceState        map[string]interface{}           `json:"resource_state"`
+	ResourceOutputs      map[string]interface{}           `json:"resource_outputs"`
+	SharedState          map[string]interface{}           `json:"shared_state"`
+	RelativeDirectories  map[string]bool                  `json:"relative_directories"`
+	RelativeFileContents map[string]*string               `json:"relative_file_contents"`
+	ComposeNetworks      map[string]compose.NetworkConfig `json:"compose_networks"`
+	ComposeVolumes       map[string]compose.VolumeConfig  `json:"compose_volumes"`
+	ComposeServices      map[string]compose.ServiceConfig `json:"compose_services"`
+
+	// For testing and legacy reasons, built in provisioners can set a direct lookup function
+	OutputLookupFunc project.OutputLookupFunc `json:"-"`
+}
+
+type Provisioner interface {
+	Uri() string
+	Match(resUid project.ResourceUid) bool
+	Provision(ctx context.Context, input *Input) (*ProvisionOutput, error)
+}
+
+// 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) {
+	out := *state
+	out.Resources = maps.Clone(state.Resources)
+
+	existing, ok := out.Resources[resUid]
+	if !ok {
+		return nil, fmt.Errorf("failed to apply to state - unknown res uid")
+	}
+
+	// State must ALWAYS be updated. If we don't get state back, we assume it's now empty.
+	if po.ResourceState != nil {
+		existing.State = po.ResourceState
+	} else {
+		existing.State = make(map[string]interface{})
+	}
+
+	// Same with outputs, it must ALWAYS be updated.
+	if po.ResourceOutputs != nil {
+		existing.Outputs = po.ResourceOutputs
+	} else {
+		existing.Outputs = make(map[string]interface{})
+	}
+
+	if po.OutputLookupFunc != nil {
+		existing.OutputLookupFunc = po.OutputLookupFunc
+	}
+
+	if po.SharedState != nil {
+		out.SharedState = util.PatchMap(state.SharedState, po.SharedState)
+	}
+
+	for relativePath, b := range po.RelativeDirectories {
+		relativePath = filepath.Clean(relativePath)
+		if !filepath.IsLocal(relativePath) {
+			return nil, fmt.Errorf("failing to write non relative volume directory '%s'", relativePath)
+		}
+		dst := filepath.Join(state.MountsDirectory, relativePath)
+		if b {
+			slog.Debug(fmt.Sprintf("Ensuring mount directory '%s' exists", dst))
+			if err := os.MkdirAll(dst, 0755); err != nil && !errors.Is(err, os.ErrExist) {
+				return nil, fmt.Errorf("failed to create volume directory '%s': %w", dst, err)
+			}
+		} else {
+			slog.Debug(fmt.Sprintf("Ensuring mount directory '%s' no longer exists", dst))
+			if err := os.RemoveAll(dst); err != nil && !errors.Is(err, os.ErrNotExist) {
+				return nil, fmt.Errorf("failed to delete volume directory '%s': %w", dst, err)
+			}
+		}
+	}
+
+	for relativePath, b := range po.RelativeFileContents {
+		relativePath = filepath.Clean(relativePath)
+		if !filepath.IsLocal(relativePath) {
+			return nil, fmt.Errorf("failing to write non relative volume directory '%s'", relativePath)
+		}
+		dst := filepath.Join(state.MountsDirectory, relativePath)
+		if b != nil {
+			slog.Debug(fmt.Sprintf("Ensuring mount file '%s' exists", dst))
+			if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil && !errors.Is(err, os.ErrExist) {
+				return nil, fmt.Errorf("failed to create directories for file '%s': %w", dst, err)
+			}
+			if err := os.WriteFile(dst, []byte(*b), 0644); err != nil {
+				return nil, fmt.Errorf("failed to write file '%s': %w", dst, err)
+			}
+		} else {
+			slog.Debug(fmt.Sprintf("Ensuring mount file '%s' no longer exists", dst))
+			if err := os.Remove(dst); err != nil && !errors.Is(err, os.ErrNotExist) {
+				return nil, fmt.Errorf("failed to delete file '%s': %w", dst, err)
+			}
+		}
+	}
+
+	for networkName, network := range po.ComposeNetworks {
+		if project.Networks == nil {
+			project.Networks = make(compose.Networks)
+		}
+		project.Networks[networkName] = network
+	}
+	for volumeName, volume := range po.ComposeVolumes {
+		if project.Volumes == nil {
+			project.Volumes = make(compose.Volumes)
+		}
+		project.Volumes[volumeName] = volume
+	}
+	for serviceName, service := range po.ComposeServices {
+		if project.Services == nil {
+			project.Services = make(compose.Services)
+		}
+		project.Services[serviceName] = service
+	}
+
+	out.Resources[resUid] = existing
+	return &out, nil
+}
+
+func ProvisionResources(ctx context.Context, state *project.State, provisioners []Provisioner, composeProject *compose.Project) (*project.State, error) {
+	out := state
+
+	for resUid, resState := range state.Resources {
+		provisionerIndex := slices.IndexFunc(provisioners, func(provisioner Provisioner) bool {
+			return provisioner.Match(resUid)
+		})
+		if provisionerIndex < 0 {
+			return nil, fmt.Errorf("resource '%s' is not supported by any provisioner", resUid)
+		}
+		provisioner := provisioners[provisionerIndex]
+		if resState.ProvisionerUri != "" && resState.ProvisionerUri != provisioner.Uri() {
+			return nil, fmt.Errorf("resource '%s' was previously provisioned by a different provider - undefined behavior", resUid)
+		}
+
+		output, err := provisioner.Provision(ctx, &Input{
+			ResourceUid:        string(resUid),
+			ResourceType:       resUid.Type(),
+			ResourceClass:      resUid.Class(),
+			ResourceId:         resUid.Id(),
+			ResourceParams:     resState.Params,
+			ResourceMetadata:   resState.Metadata,
+			ResourceState:      resState.State,
+			SharedState:        out.SharedState,
+			ComposeProjectName: state.ComposeProjectName,
+			MountDirectoryPath: state.MountsDirectory,
+		})
+		if err != nil {
+			return nil, fmt.Errorf("resource '%s': failed to provision: %w", resUid, err)
+		}
+
+		out, err = output.ApplyToStateAndProject(out, resUid, composeProject)
+		if err != nil {
+			return nil, fmt.Errorf("resource '%s': failed to apply outputs: %w", resUid, err)
+		}
+	}
+
+	return out, nil
+}
diff --git a/internal/provisioners/envprov/envprov.go b/internal/provisioners/envprov/envprov.go
new file mode 100644
index 0000000..6f45258
--- /dev/null
+++ b/internal/provisioners/envprov/envprov.go
@@ -0,0 +1,117 @@
+package envprov
+
+import (
+	"context"
+	"fmt"
+	"maps"
+	"net/url"
+	"os"
+	"strings"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners"
+)
+
+// The Provisioner is an environment provision which returns a suitable expression for accessing an environment variable
+// within the compose project at deploy time. This provisioner also tracks what env vars are accessed so that they can
+// be added to the .env file later.
+type Provisioner struct {
+	// LookupFunc is an environment variable LookupFunc function, if nil this will be defaulted to os.LookupEnv
+	LookupFunc func(key string) (string, bool)
+	// accessed is the map of accessed environment variables and the value they had at access time
+	accessed map[string]string
+}
+
+func (e *Provisioner) Uri() string {
+	return "builtin://environment"
+}
+
+func (e *Provisioner) Match(resUid project.ResourceUid) bool {
+	return resUid.Type() == "environment" && resUid.Class() == "default" && strings.Contains(resUid.Id(), ".")
+}
+
+func (e *Provisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
+	if len(input.ResourceParams) > 0 {
+		return nil, fmt.Errorf("no params expected")
+	}
+	return &provisioners.ProvisionOutput{OutputLookupFunc: e.LookupOutput}, nil
+}
+
+func (e *Provisioner) Accessed() map[string]string {
+	return maps.Clone(e.accessed)
+}
+
+func (e *Provisioner) lookupOutput(required bool, envVarKey string) (interface{}, error) {
+	if e.LookupFunc == nil {
+		e.LookupFunc = os.LookupEnv
+	}
+	if e.accessed == nil {
+		e.accessed = make(map[string]string, 1)
+	}
+
+	if v, ok := e.LookupFunc(envVarKey); ok {
+		e.accessed[envVarKey] = v
+	} else {
+		e.accessed[envVarKey] = ""
+	}
+	if required {
+		envVarKey += "?required"
+	}
+	return "${" + envVarKey + "}", nil
+}
+
+func (e *Provisioner) LookupOutput(keys ...string) (interface{}, error) {
+	if len(keys) != 1 {
+		return nil, fmt.Errorf("environment resource only supports a single lookup key")
+	}
+	return e.lookupOutput(false, keys[0])
+}
+
+func (e *Provisioner) GenerateSubProvisioner(resName string, resUid project.ResourceUid) provisioners.Provisioner {
+	return &envVarResourceTracker{
+		uid:    resUid,
+		inner:  e,
+		prefix: strings.ToUpper(resName),
+	}
+}
+
+// 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    project.ResourceUid
+	prefix string
+	inner  *Provisioner
+}
+
+func (e *envVarResourceTracker) Uri() string {
+	return "builtin://environment/" + url.PathEscape(string(e.uid))
+}
+
+func (e *envVarResourceTracker) Match(resUid project.ResourceUid) bool {
+	return e.uid == resUid
+}
+
+func (e *envVarResourceTracker) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
+	return &provisioners.ProvisionOutput{
+		OutputLookupFunc: e.LookupOutput,
+	}, nil
+}
+
+func (e *envVarResourceTracker) LookupOutput(keys ...string) (interface{}, error) {
+	if len(keys) < 1 {
+		return nil, fmt.Errorf("at least one output lookup key is required")
+	}
+	sb := new(strings.Builder)
+	_, _ = sb.WriteString(e.prefix)
+	for _, k := range keys {
+		_, _ = sb.WriteString("_")
+		_, _ = sb.WriteString(k)
+	}
+	k := strings.ReplaceAll(sb.String(), "-", "_")
+	k = strings.ReplaceAll(k, ".", "_")
+	k = strings.ToUpper(k)
+	return e.inner.lookupOutput(true, k)
+}
+
+var _ provisioners.Provisioner = (*Provisioner)(nil)
+var _ provisioners.Provisioner = (*envVarResourceTracker)(nil)
diff --git a/internal/provisioners/loader/load.go b/internal/provisioners/loader/load.go
new file mode 100644
index 0000000..7b6e8ca
--- /dev/null
+++ b/internal/provisioners/loader/load.go
@@ -0,0 +1,72 @@
+package loader
+
+import (
+	"bytes"
+	"fmt"
+	"log/slog"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+
+	"github.com/score-spec/score-compose/internal/provisioners"
+	"github.com/score-spec/score-compose/internal/provisioners/templateprov"
+)
+
+const DefaultSuffix = ".provisioners.yaml"
+
+// LoadProvisioners loads a list of provisioners from the raw contents from a yaml file.
+func LoadProvisioners(raw []byte) ([]provisioners.Provisioner, error) {
+	var intermediate []map[string]interface{}
+	if err := yaml.NewDecoder(bytes.NewReader(raw)).Decode(&intermediate); err != nil {
+		return nil, fmt.Errorf("failed to decode file: %w", err)
+	}
+	out := make([]provisioners.Provisioner, 0, len(intermediate))
+	for i, m := range intermediate {
+		uri, _ := m["uri"].(string)
+		u, err := url.Parse(uri)
+		if err != nil {
+			return nil, fmt.Errorf("%d: invalid uri '%s'", i, u)
+		} else if u.Scheme == "" {
+			return nil, fmt.Errorf("%d: missing uri schema '%s'", i, u)
+		}
+		switch u.Scheme {
+		case "template":
+			if p, err := templateprov.Parse(m); err != nil {
+				return nil, fmt.Errorf("%d: %s: failed to parse: %w", i, uri, err)
+			} else {
+				slog.Debug(fmt.Sprintf("Loaded provisioner %s", p.Uri()))
+				out = append(out, p)
+			}
+		default:
+			return nil, fmt.Errorf("%d: unsupported provisioner type '%s'", i, u.Scheme)
+		}
+	}
+	return out, nil
+}
+
+// LoadProvisionersFromDirectory loads all providers we can find in files that end in the common suffix.
+func LoadProvisionersFromDirectory(path string, suffix string) ([]provisioners.Provisioner, error) {
+	slog.Debug(fmt.Sprintf("Loading providers with suffix %s in directory '%s'", suffix, path))
+	items, err := os.ReadDir(path)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]provisioners.Provisioner, 0)
+	for _, item := range items {
+		if !item.IsDir() && strings.HasSuffix(item.Name(), suffix) {
+			raw, err := os.ReadFile(filepath.Join(path, item.Name()))
+			if err != nil {
+				return nil, fmt.Errorf("failed to read '%s': %w", item.Name(), err)
+			}
+			p, err := LoadProvisioners(raw)
+			if err != nil {
+				return nil, fmt.Errorf("failed to load '%s': %w", item.Name(), err)
+			}
+			out = append(out, p...)
+		}
+	}
+	return out, nil
+}
diff --git a/internal/provisioners/templateprov/template.go b/internal/provisioners/templateprov/template.go
new file mode 100644
index 0000000..f4d9c30
--- /dev/null
+++ b/internal/provisioners/templateprov/template.go
@@ -0,0 +1,211 @@
+package templateprov
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"log/slog"
+	"strings"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+	compose "github.com/compose-spec/compose-go/v2/types"
+	"gopkg.in/yaml.v3"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners"
+	"github.com/score-spec/score-compose/internal/util"
+)
+
+// Provisioner is the decoded template provisioner.
+// A template provisioner provisions a resource by evaluating a series of Go text/templates that have access to some
+// input parameters, previous state, and utility functions. Each parameter is expected to return a JSON object.
+type Provisioner struct {
+	ProvisionerUri string  `yaml:"uri"`
+	ResType        string  `yaml:"type"`
+	ResClass       *string `yaml:"class,omitempty"`
+	ResId          *string `yaml:"id,omitempty"`
+
+	// The InitTemplate is always evaluated first, it is used as temporary or working set data that may be needed in the
+	// later templates. It has access to the resource inputs and previous state.
+	InitTemplate string `yaml:"init,omitempty"`
+	// StateTemplate generates the new state of the resource based on the init and previous state.
+	StateTemplate string `yaml:"state,omitempty"`
+	// SharedStateTemplate generates modifications to the shared state, based on the init and current state.
+	SharedStateTemplate string `yaml:"shared,omitempty"`
+	// OutputsTemplate generates the outputs of the resource, based on the init and current state.
+	OutputsTemplate string `yaml:"outputs,omitempty"`
+
+	// RelativeDirectoriesTemplate generates a set of directories to create (true) or delete (false). These may then
+	// be used in mounting requests for volumes or service mounts.
+	RelativeDirectoriesTemplate string `yaml:"directories,omitempty"`
+	// RelativeFilesTemplate generates a set of file contents to write (non nil) or delete (nil) from the mounts
+	// directory. These will then be used during service bind mounting.
+	RelativeFilesTemplate string `yaml:"files,omitempty"`
+
+	// ComposeNetworksTemplate generates a set of networks to add to the compose project. These will replace any with
+	// the same name already.
+	ComposeNetworksTemplate string `yaml:"networks,omitempty"`
+	// ComposeVolumesTemplate generates a set of volumes to add to the compose project. These will replace any with
+	// the same name already.
+	ComposeVolumesTemplate string `yaml:"volumes,omitempty"`
+	// ComposeServicesTemplate generates a set of services to add to the compose project. These will replace any with
+	// the same name already.
+	ComposeServicesTemplate string `yaml:"services,omitempty"`
+
+	// 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"`
+}
+
+func Parse(raw map[string]interface{}) (*Provisioner, error) {
+	p := new(Provisioner)
+	intermediate, _ := yaml.Marshal(raw)
+	dec := yaml.NewDecoder(bytes.NewReader(intermediate))
+	dec.KnownFields(true)
+	if err := dec.Decode(&p); err != nil {
+		return nil, err
+	}
+	if p.ProvisionerUri == "" {
+		return nil, fmt.Errorf("uri not set")
+	} else if p.ResType == "" {
+		return nil, fmt.Errorf("type not set")
+	}
+	return p, nil
+}
+
+func (p *Provisioner) Uri() string {
+	return p.ProvisionerUri
+}
+
+func (p *Provisioner) Match(resUid project.ResourceUid) bool {
+	if resUid.Type() != p.ResType {
+		return false
+	} else if p.ResClass != nil && resUid.Class() != *p.ResClass {
+		return false
+	} else if p.ResId != nil && resUid.Id() != *p.ResId {
+		return false
+	}
+	return true
+}
+
+func renderTemplateAndDecode(raw string, data interface{}, out interface{}) error {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return nil
+	}
+	prepared, err := template.New("").Funcs(sprig.FuncMap()).Parse(raw)
+	if err != nil {
+		return fmt.Errorf("failed to parse template: %w", err)
+	}
+	buff := new(bytes.Buffer)
+	if err := prepared.Execute(buff, data); err != nil {
+		return fmt.Errorf("failed to execute template: %w", err)
+	}
+	buffContents := buff.String()
+	if strings.TrimSpace(buffContents) == "" {
+		return nil
+	}
+	dec := yaml.NewDecoder(strings.NewReader(buffContents))
+	dec.KnownFields(true)
+	if err := dec.Decode(out); err != nil {
+		slog.Debug(fmt.Sprintf("template output was '%s' from template '%s'", buffContents, raw))
+		return fmt.Errorf("failed to decode output: %w", err)
+	}
+	return nil
+}
+
+// Data is the structure sent to each template during rendering.
+type Data struct {
+	Uid      string
+	Type     string
+	Class    string
+	Id       string
+	Params   map[string]interface{}
+	Metadata map[string]interface{}
+
+	Init   map[string]interface{}
+	State  map[string]interface{}
+	Shared map[string]interface{}
+
+	ComposeProjectName string
+	MountsDirectory    string
+}
+
+func (p *Provisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
+	out := &provisioners.ProvisionOutput{}
+
+	// The data payload that gets passed into each template
+	data := Data{
+		Uid:                input.ResourceUid,
+		Type:               input.ResourceType,
+		Class:              input.ResourceClass,
+		Id:                 input.ResourceId,
+		Params:             input.ResourceParams,
+		Metadata:           input.ResourceMetadata,
+		State:              input.ResourceState,
+		Shared:             input.SharedState,
+		ComposeProjectName: input.ComposeProjectName,
+		MountsDirectory:    input.MountDirectoryPath,
+	}
+
+	init := make(map[string]interface{})
+	if err := renderTemplateAndDecode(p.InitTemplate, &data, &init); err != nil {
+		return nil, fmt.Errorf("init template failed: %w", err)
+	}
+	data.Init = init
+
+	out.ResourceState = make(map[string]interface{})
+	if err := renderTemplateAndDecode(p.StateTemplate, &data, &out.ResourceState); err != nil {
+		return nil, fmt.Errorf("state template failed: %w", err)
+	}
+	data.State = out.ResourceState
+
+	out.SharedState = make(map[string]interface{})
+	if err := renderTemplateAndDecode(p.SharedStateTemplate, &data, &out.SharedState); err != nil {
+		return nil, fmt.Errorf("shared template failed: %w", err)
+	}
+	data.Shared = util.PatchMap(data.Shared, out.SharedState)
+
+	out.ResourceOutputs = make(map[string]interface{})
+	if err := renderTemplateAndDecode(p.OutputsTemplate, &data, &out.ResourceOutputs); err != nil {
+		return nil, fmt.Errorf("outputs template failed: %w", err)
+	}
+
+	out.RelativeDirectories = make(map[string]bool)
+	if err := renderTemplateAndDecode(p.RelativeDirectoriesTemplate, &data, &out.RelativeDirectories); err != nil {
+		return nil, fmt.Errorf("directories template failed: %w", err)
+	}
+
+	out.RelativeFileContents = make(map[string]*string)
+	if err := renderTemplateAndDecode(p.RelativeFilesTemplate, &data, &out.RelativeFileContents); err != nil {
+		return nil, fmt.Errorf("files template failed: %w", err)
+	}
+
+	out.ComposeNetworks = make(map[string]compose.NetworkConfig)
+	if err := renderTemplateAndDecode(p.ComposeNetworksTemplate, &data, &out.ComposeNetworks); err != nil {
+		return nil, fmt.Errorf("networks template failed: %w", err)
+	}
+
+	out.ComposeServices = make(map[string]compose.ServiceConfig)
+	if err := renderTemplateAndDecode(p.ComposeServicesTemplate, &data, &out.ComposeServices); err != nil {
+		return nil, fmt.Errorf("services template failed: %w", err)
+	}
+
+	out.ComposeVolumes = make(map[string]compose.VolumeConfig)
+	if err := renderTemplateAndDecode(p.ComposeVolumesTemplate, &data, &out.ComposeVolumes); err != nil {
+		return nil, fmt.Errorf("volumes template failed: %w", err)
+	}
+
+	var infoLogs []string
+	if err := renderTemplateAndDecode(p.InfoLogsTemplate, &data, &infoLogs); err != nil {
+		return nil, fmt.Errorf("info logs template failed: %w", err)
+	}
+	for _, log := range infoLogs {
+		slog.Info(fmt.Sprintf("%s: %s", input.ResourceUid, log))
+	}
+
+	return out, nil
+}
+
+var _ provisioners.Provisioner = (*Provisioner)(nil)
diff --git a/internal/util/maps.go b/internal/util/maps.go
new file mode 100644
index 0000000..483391e
--- /dev/null
+++ b/internal/util/maps.go
@@ -0,0 +1,44 @@
+package util
+
+import "maps"
+
+func isMap(a any) bool {
+	_, isMap := a.(map[string]any)
+	return isMap
+}
+
+// PatchMap performs a JSON Merge Patch as defined in https://datatracker.ietf.org/doc/html/rfc7386.
+//
+// This should return a new map without modifying the current or patch inputs.
+// Notes:
+//   - if new is nil, the output is an empty object - this allows for in-place
+//   - if a key is not a map, it will be treated as scalar according to the
+//     JSON Merge Patch strategy. This includes structs and slices.
+func PatchMap(current map[string]interface{}, patch map[string]interface{}) map[string]interface{} {
+	// small shortcut here
+	if len(patch) == 0 {
+		return current
+	}
+	out := maps.Clone(current)
+	if out == nil {
+		out = make(map[string]interface{})
+	}
+	for k, patchValue := range patch {
+		if patchValue == nil {
+			delete(out, k)
+		} else if existingValue, ok := out[k]; ok && isMap(patchValue) {
+			patchMap := patchValue.(map[string]interface{})
+			if isMap(existingValue) {
+				out[k] = PatchMap(existingValue.(map[string]interface{}), patchMap)
+			} else {
+				out[k] = PatchMap(map[string]interface{}{}, patchMap)
+			}
+		} else if isMap(patchValue) {
+			patchMap := patchValue.(map[string]interface{})
+			out[k] = PatchMap(map[string]interface{}{}, patchMap)
+		} else {
+			out[k] = patchValue
+		}
+	}
+	return out
+}


Test patch:
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index 2877bc5..5a76ad1 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -16,6 +16,7 @@ import (
 	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/score-spec/score-compose/internal/util"
 )
 
@@ -280,12 +281,13 @@ func TestScoreConvert(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			evt := new(EnvVarTracker)
-			resourceOutputs := map[string]project.OutputLookupFunc{
-				"env":      evt.LookupOutput,
-				"app-db":   evt.GenerateResource("app-db").LookupOutput,
-				"some-dns": evt.GenerateResource("some-dns").LookupOutput,
-			}
+			evt := new(envprov.Provisioner)
+			resourceOutputs := map[string]project.OutputLookupFunc{"env": evt.LookupOutput}
+			po, _ := evt.GenerateSubProvisioner("app-db", "").Provision(nil, nil)
+			resourceOutputs["app-db"] = po.OutputLookupFunc
+			po, _ = evt.GenerateSubProvisioner("some-dns", "").Provision(nil, nil)
+			resourceOutputs["some-dns"] = po.OutputLookupFunc
+
 			proj, err := ConvertSpec(tt.Source, resourceOutputs)
 
 			if tt.Error != nil {
diff --git a/internal/compose/templates_test.go b/internal/compose/templates_test.go
index 3a37643..2b347fe 100644
--- a/internal/compose/templates_test.go
+++ b/internal/compose/templates_test.go
@@ -14,6 +14,7 @@ import (
 	assert "github.com/stretchr/testify/assert"
 
 	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners/envprov"
 )
 
 func TestMapVar(t *testing.T) {
@@ -21,16 +22,17 @@ func TestMapVar(t *testing.T) {
 		"name":  "test-name",
 		"other": map[string]interface{}{"key": "value"},
 	}
-	evt := new(EnvVarTracker)
-	evt.lookup = func(key string) (string, bool) {
+	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":     evt.GenerateResource("db").LookupOutput,
+		"db":     po.OutputLookupFunc,
 		"static": resourceWithStaticOutputs{"x": "a"}.LookupOutput,
 	})
 	assert.NoError(t, err)
@@ -83,16 +85,17 @@ func TestSubstitute(t *testing.T) {
 	var meta = score.WorkloadMetadata{
 		"name": "test-name",
 	}
-	evt := new(EnvVarTracker)
-	evt.lookup = func(key string) (string, bool) {
+	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":  evt.GenerateResource("db").LookupOutput,
+		"db":  po.OutputLookupFunc,
 	})
 	assert.NoError(t, err)
 
diff --git a/internal/provisioners/core_test.go b/internal/provisioners/core_test.go
new file mode 100644
index 0000000..a596aa2
--- /dev/null
+++ b/internal/provisioners/core_test.go
@@ -0,0 +1,99 @@
+package provisioners
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+	"slices"
+	"testing"
+
+	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/util"
+)
+
+func TestApplyToStateAndProject(t *testing.T) {
+	resUid := project.NewResourceUid("w", "r", "t", nil, nil)
+	startState := &project.State{
+		Resources: map[project.ResourceUid]project.ScoreResourceState{
+			resUid: {},
+		},
+	}
+
+	t.Run("set first provision with no outputs", func(t *testing.T) {
+		td := t.TempDir()
+		startState.MountsDirectory = td
+		composeProject := &compose.Project{}
+		output := &ProvisionOutput{}
+		afterState, err := output.ApplyToStateAndProject(startState, resUid, composeProject)
+		require.NoError(t, err)
+		assert.Equal(t, project.ScoreResourceState{
+			State:   map[string]interface{}{},
+			Outputs: map[string]interface{}{},
+		}, afterState.Resources[resUid])
+	})
+
+	t.Run("set first provision with some outputs", func(t *testing.T) {
+		td := t.TempDir()
+		startState.MountsDirectory = td
+		composeProject := &compose.Project{}
+		output := &ProvisionOutput{
+			ResourceState:   map[string]interface{}{"a": "b", "c": nil},
+			ResourceOutputs: map[string]interface{}{"x": "y"},
+			SharedState:     map[string]interface{}{"i": "j", "k": nil},
+			RelativeDirectories: map[string]bool{
+				"one/two/three": true,
+				"four":          false,
+				"five":          true,
+			},
+			RelativeFileContents: map[string]*string{
+				"one/two/three/thing.txt": util.Ref("hello-world"),
+				"six/other.txt":           util.Ref("blah"),
+				"something.md":            nil,
+			},
+			ComposeNetworks: map[string]compose.NetworkConfig{
+				"some-network": {Name: "network"},
+			},
+			ComposeServices: map[string]compose.ServiceConfig{
+				"some-service": {Name: "service"},
+			},
+			ComposeVolumes: map[string]compose.VolumeConfig{
+				"some-volume": {Name: "volume"},
+			},
+		}
+		afterState, err := output.ApplyToStateAndProject(startState, resUid, composeProject)
+		require.NoError(t, err)
+		assert.Equal(t, project.ScoreResourceState{
+			State:   map[string]interface{}{"a": "b", "c": nil},
+			Outputs: map[string]interface{}{"x": "y"},
+		}, afterState.Resources[resUid])
+		assert.Equal(t, map[string]interface{}{"i": "j"}, afterState.SharedState)
+		assert.Len(t, composeProject.Networks, 1)
+		assert.Len(t, composeProject.Volumes, 1)
+		assert.Len(t, composeProject.Services, 1)
+		paths := make([]string, 0)
+		_ = filepath.WalkDir(td, func(path string, d fs.DirEntry, err error) error {
+			if d.IsDir() {
+				if items, _ := os.ReadDir(path); len(items) > 0 {
+					return nil
+				}
+				path, _ = filepath.Rel(td, path)
+				paths = append(paths, path+"/")
+			} else {
+				path, _ = filepath.Rel(td, path)
+				paths = append(paths, path)
+			}
+			return nil
+		})
+		slices.Sort(paths)
+		assert.Equal(t, []string{
+			"five/",
+			"one/two/three/thing.txt",
+			"six/other.txt",
+		}, paths)
+	})
+
+}
diff --git a/internal/provisioners/envprov/envprov_test.go b/internal/provisioners/envprov/envprov_test.go
new file mode 100644
index 0000000..cea4f62
--- /dev/null
+++ b/internal/provisioners/envprov/envprov_test.go
@@ -0,0 +1,83 @@
+package envprov
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/score-spec/score-compose/internal/provisioners"
+)
+
+func TestProvisioner(t *testing.T) {
+	p := new(Provisioner)
+
+	t.Run("test match", func(t *testing.T) {
+		assert.True(t, p.Match("environment.default#w.r"))
+		assert.False(t, p.Match("environment.default#thing"))
+		assert.False(t, p.Match("environment.foo#w.r"))
+		assert.False(t, p.Match("other.default#w.r"))
+	})
+
+	t.Run("provision", func(t *testing.T) {
+		po, err := p.Provision(context.Background(), new(provisioners.Input))
+		assert.NoError(t, err)
+
+		for _, tc := range [][]string{
+			{"thing", "${thing}"},
+			{"HELLO", "${HELLO}"},
+			{"HELLO", "world", "environment resource only supports a single lookup key"},
+		} {
+			keys, expected := tc[:len(tc)-1], tc[len(tc)-1]
+			t.Run(strings.Join(tc, "."), func(t *testing.T) {
+				res, err := po.OutputLookupFunc(keys...)
+				if err != nil {
+					assert.EqualError(t, err, expected)
+				} else {
+					assert.Equal(t, expected, res)
+				}
+			})
+		}
+	})
+
+	t.Run("sub resource", func(t *testing.T) {
+		sub := p.GenerateSubProvisioner("thing", "thing2.default#w.thing")
+
+		t.Run("test match", func(t *testing.T) {
+			assert.True(t, sub.Match("thing2.default#w.thing"))
+			assert.False(t, sub.Match("thing2.default#w.thing2"))
+			assert.False(t, sub.Match("thing2.default#thing3"))
+		})
+
+		t.Run("provision", func(t *testing.T) {
+			po, err := sub.Provision(context.Background(), new(provisioners.Input))
+			assert.NoError(t, err)
+
+			for _, tc := range [][]string{
+				{"thing", "${THING_THING?required}"},
+				{"HELLO", "${THING_HELLO?required}"},
+				{"HELLO", "world", "${THING_HELLO_WORLD?required}"},
+				{"at least one output lookup key is required"},
+			} {
+				keys, expected := tc[:len(tc)-1], tc[len(tc)-1]
+				t.Run(strings.Join(tc, "."), func(t *testing.T) {
+					res, err := po.OutputLookupFunc(keys...)
+					if err != nil {
+						assert.EqualError(t, err, expected)
+					} else {
+						assert.Equal(t, expected, res)
+					}
+				})
+			}
+		})
+
+		t.Run("access", func(t *testing.T) {
+			assert.Equal(t, map[string]string{
+				"HELLO": "", "THING_HELLO": "", "THING_HELLO_WORLD": "", "THING_THING": "", "thing": "",
+			}, p.Accessed())
+		})
+
+	})
+
+}
diff --git a/internal/provisioners/loader/load_test.go b/internal/provisioners/loader/load_test.go
new file mode 100644
index 0000000..7487c38
--- /dev/null
+++ b/internal/provisioners/loader/load_test.go
@@ -0,0 +1,73 @@
+package loader
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/util"
+)
+
+func TestLoadProvisioners(t *testing.T) {
+
+	t.Run("empty", func(t *testing.T) {
+		p, err := LoadProvisioners([]byte(`[]`))
+		require.NoError(t, err)
+		assert.Len(t, p, 0)
+	})
+
+	t.Run("nominal", func(t *testing.T) {
+		p, err := LoadProvisioners([]byte(`
+- uri: template://example
+  type: thing
+  class: default
+  id: specific
+  state: |
+    number: {{ 1 }}
+`))
+		require.NoError(t, err)
+		assert.Len(t, p, 1)
+		assert.Equal(t, "template://example", p[0].Uri())
+		assert.True(t, p[0].Match(project.NewResourceUid("w", "r", "thing", nil, util.Ref("specific"))))
+	})
+
+	t.Run("unknown schema", func(t *testing.T) {
+		_, err := LoadProvisioners([]byte(`
+- uri: blah://example
+  type: thing
+`))
+		require.EqualError(t, err, "0: unsupported provisioner type 'blah'")
+	})
+
+	t.Run("missing uri", func(t *testing.T) {
+		_, err := LoadProvisioners([]byte(`
+- type: thing
+`))
+		require.EqualError(t, err, "0: missing uri schema ''")
+	})
+
+}
+
+func TestLoadProvisionersFromDirectory(t *testing.T) {
+	td := t.TempDir()
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "00.p.yaml"), []byte(`
+- uri: template://example-a
+  type: thing
+`), 0600))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "01.p.yaml"), []byte(`
+- uri: template://example-b
+  type: thing
+`), 0600))
+
+	p, err := LoadProvisionersFromDirectory(td, ".p.yaml")
+	require.NoError(t, err)
+	uris := make([]string, len(p))
+	for i, prv := range p {
+		uris[i] = prv.Uri()
+	}
+	assert.Equal(t, []string{"template://example-a", "template://example-b"}, uris)
+}
diff --git a/internal/provisioners/templateprov/template_test.go b/internal/provisioners/templateprov/template_test.go
new file mode 100644
index 0000000..e17578c
--- /dev/null
+++ b/internal/provisioners/templateprov/template_test.go
@@ -0,0 +1,84 @@
+package templateprov
+
+import (
+	"context"
+	"testing"
+
+	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners"
+	"github.com/score-spec/score-compose/internal/util"
+)
+
+func TestProvision(t *testing.T) {
+	td := t.TempDir()
+	resUid := project.NewResourceUid("w", "r", "thing", nil, nil)
+	p, err := Parse(map[string]interface{}{
+		"uri":  "template://example",
+		"type": resUid.Type(),
+		"init": `
+a: {{ .Uid }}
+b: {{ .Type }}
+`,
+		"state": `
+a: {{ .Init.a }}
+b: stuff
+p: {{ .ComposeProjectName }}
+`,
+		"shared": `
+c: 1
+`,
+		"outputs": `
+b: {{ .State.b | upper }}
+c: {{ .Shared.c }}
+`,
+		"directories": `{"blah": true}`,
+		"files":       `{"blah/foo": "content"}`,
+		"networks": `
+default:
+  driver: default
+`,
+		"volumes": `
+some-vol:
+  driver: default
+`,
+		"services": `
+some-svc:
+  name: foo
+`,
+		"info_logs": `
+- This is a message
+- This is another message
+`,
+	})
+	require.NoError(t, err)
+	out, err := p.Provision(context.Background(), &provisioners.Input{
+		ResourceUid:        string(resUid),
+		ResourceType:       resUid.Type(),
+		ResourceClass:      resUid.Class(),
+		ResourceId:         resUid.Id(),
+		ResourceParams:     map[string]interface{}{"pk": "pv"},
+		ResourceMetadata:   map[string]interface{}{"mk": "mv"},
+		ResourceState:      map[string]interface{}{"sk": "sv"},
+		SharedState:        map[string]interface{}{"ssk": "ssv"},
+		ComposeProjectName: "test",
+		MountDirectoryPath: td,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, out)
+	assert.Equal(t, map[string]interface{}{
+		"a": "thing.default#w.r",
+		"b": "stuff",
+		"p": "test",
+	}, out.ResourceState)
+	assert.Equal(t, map[string]interface{}{"c": 1}, out.SharedState)
+	assert.Equal(t, map[string]interface{}{"b": "STUFF", "c": 1}, out.ResourceOutputs)
+	assert.Equal(t, map[string]bool{"blah": true}, out.RelativeDirectories)
+	assert.Equal(t, map[string]*string{"blah/foo": util.Ref("content")}, out.RelativeFileContents)
+	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)
+}
diff --git a/internal/util/maps_test.go b/internal/util/maps_test.go
new file mode 100644
index 0000000..b2f1331
--- /dev/null
+++ b/internal/util/maps_test.go
@@ -0,0 +1,73 @@
+package util
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPatchMap(t *testing.T) {
+
+	t.Run("nils", func(t *testing.T) {
+		assert.Nil(t, PatchMap(nil, nil))
+	})
+
+	t.Run("patch some into nil", func(t *testing.T) {
+		assert.Equal(t, map[string]interface{}{
+			"a": "b",
+			"d": map[string]interface{}{
+				"e": "f",
+				"h": map[string]interface{}{},
+			},
+			"i": []interface{}{"a", "b"},
+		}, PatchMap(nil, map[string]interface{}{
+			"a": "b",
+			"c": nil,
+			"d": map[string]interface{}{
+				"e": "f",
+				"g": nil,
+				"h": map[string]interface{}{},
+			},
+			"i": []interface{}{"a", "b"},
+		}))
+	})
+
+	t.Run("patch some into some", func(t *testing.T) {
+		before := map[string]interface{}{
+			"a": "x",
+			"d": map[string]interface{}{
+				"h": map[string]interface{}{
+					"x": "y",
+				},
+			},
+		}
+		assert.Equal(t, map[string]interface{}{
+			"a": "b",
+			"d": map[string]interface{}{
+				"e": "f",
+				"h": map[string]interface{}{
+					"x": "y",
+				},
+			},
+			"i": []interface{}{"a", "b"},
+		}, PatchMap(before, map[string]interface{}{
+			"a": "b",
+			"c": nil,
+			"d": map[string]interface{}{
+				"e": "f",
+				"g": nil,
+				"h": map[string]interface{}{},
+			},
+			"i": []interface{}{"a", "b"},
+		}))
+		assert.Equal(t, map[string]interface{}{
+			"a": "x",
+			"d": map[string]interface{}{
+				"h": map[string]interface{}{
+					"x": "y",
+				},
+			},
+		}, before)
+	})
+
+}