Repository: score-spec/score-compose
Pull request #276 (2025-03-12 13:17:45)

feat: added --patch-templates flag to 
This PR adds a MUCH REQUESTED feature for solving the issues around adjusting or augmenting the output of the conversion process. Previously, folks have had to use compose mixins (other compose files included when running `compose up`) or write complex yq scripts to patch the output in place.

This PR adds --patch-templates that are included in the project and run using familiar go template syntax every time you run `generate` so you can't forget to run or include them.

This can be used to do things like:

* adding a field to each container service
* adding a field to only the score workload services
* adjusting the output of a resource-provisioned service without writing a custom provisioner
* renaming services
* etc

The rules of how this works:

1. Multiple template files can be specified by URI at `init` time. The same as the existing `--provisioners` flag. The contents are validated and included in the project.
2. When running `generate` we run through each template:
  2.1. Run the template with the current compose state. 
  2.2. The output of the template is parsed as an array of JSON patches to set or delete paths.
  2.3. The patches are applied to form the new current compose state

This is tested and documented in this PR.

As a practical example, this mechanism is very useful for working with Dapr in docker compose since we can inject sidecars per workload as well as the central Dapr placement service.

We'll be bringing over a similar feature to `score-k8s` for adjusting the manifest outputs there too.

This also succinctly address the needs of #142 and #242.

mathieu-benoit: This is amazing, @astromechza!

More use cases covered and experience improved/boosted for `score-compose` users!
astromechza: > Like we have an `s` at the end of `--provisioners`, because an Array, should we also have `--patch-templates` instead of `--patch-template`?
> 
> Otherwise, this is awesome! LGTM!

Sure @mathieu-benoit, i'm not strongly opinionated either way, but might as well set a convention.


Patch:
diff --git a/README.md b/README.md
index 6fefbb1..4dbcbe4 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ See the [examples](./examples) for more examples of using Score and provisioning
 - [13-kafka-topic](examples/13-kafka-topic) - an example of the default `kafka-topic` resource provisioner
 - [14-elasticsearch](examples/14-elasticsearch) - an example of the default `elasticsearch` resource provisioner
 - [15-mssql-database](examples/15-mssql-database) - an example of the default `mssql` resource provisioner
+- [16-patching-templates](examples/16-patching-templates) - a guide to patch templates
 
 If you're getting started, you can use `score-compose init` to create a basic `score.yaml` file in the current directory along with a `.score-compose/` working directory.
 
@@ -84,6 +85,16 @@ not be checked into source control. Add it to your .gitignore file if you use Gi
 The project name will be used as a Docker compose project name when the final compose files are written. This name
 acts as a namespace when multiple score files and containers are used.
 
+Custom provisioners can be installed by uri using the --provisioners flag. The provisioners will be installed and take
+precedence in the order they are defined over the default provisioners. If init has already been called with provisioners
+the new provisioners will take precedence.
+
+To adjust the way the compose project is generated, or perform post processing actions, you can use the --patch-templates
+flag to provide one or more template files by uri. Each template file is stored in the project and then evaluated as a 
+Golang text/template and should output a yaml/json encoded array of patches. Each patch is an object with required 'op' 
+(set or delete), 'patch' (a dot-separated json path), a 'value' if the 'op' == 'set', and an optional 'description' for 
+showing in the logs.
+
 Usage:
   score-compose init [flags]
 
@@ -101,12 +112,26 @@ Examples:
   # Optionally loading in provisoners from a remote url
   score-compose init --provisioners https://raw.githubusercontent.com/user/repo/main/example.yaml
 
+  # Optionally adding a couple of patching templates
+  score-compose init --patch-templates ./patching.tpl --patch-templates https://raw.githubusercontent.com/user/repo/main/example.tpl
+
+URI Retrieval:
+  The --provisioners and --patch-templates arguments support URI retrieval for pulling the contents from a URI on disk
+  or over the network. These support:
+    - Files       : file:///local/file or ./local/file
+    - HTTP        : http://host/file
+    - HTTPS       : https://host/file
+    - Git (SSH)   : git-ssh://git@host/repo.git/file
+    - Git (HTTPS) : git-https://host/repo.git/file
+    - OCI         : oci://[registry/][namespace/]repository[:tag|@digest][#file]
+
 Flags:
-  -f, --file string                The score file to initialize (default "./score.yaml")
-  -h, --help                       help for init
-      --no-sample                  Disable generation of the sample score file
-  -p, --project string             Set the name of the docker compose project (defaults to the current directory name)
-      --provisioner stringArray    A provisioners file to install. May be specified multiple times. Supports http://host/file, https://host/file, git-ssh://git@host/repo.git/file, git-https://host/repo.git/file and oci://[registry/][namespace/]repository[:tag|@digest][#file] formats.
+  -f, --file string                  The score file to initialize (default "./score.yaml")
+  -h, --help                         help for init
+      --no-sample                    Disable generation of the sample score file
+      --patch-templates stringArray   Patching template files to include. May be specified multiple times. Supports URI retrieval.
+  -p, --project string               Set the name of the docker compose project (defaults to the current directory name)
+      --provisioners stringArray     Provisioner files to install. May be specified multiple times. Supports URI retrieval.
 
 Global Flags:
       --quiet           Mute any logging output
diff --git a/examples/15-mssql-database/README.md b/examples/15-mssql-database/README.md
index 4cb815e..d7cc042 100644
--- a/examples/15-mssql-database/README.md
+++ b/examples/15-mssql-database/README.md
@@ -1,4 +1,4 @@
-# 12 - MSSQL Server database
+# 15 - MSSQL Server database
 
 There is also a `mssql` resource provisioner as an example
 of a mssql database. This uses the official image from <https://hub.docker.com/r/microsoft/mssql-serverl>.
diff --git a/examples/16-patching-templates/README.md b/examples/16-patching-templates/README.md
new file mode 100644
index 0000000..ef0d0d6
--- /dev/null
+++ b/examples/16-patching-templates/README.md
@@ -0,0 +1,93 @@
+# 16 - Compose Patching Templates
+
+A common requirement is for users to slightly modify or adjust the output of the conversion process. This can be done
+by providing one or more patching templates at `init` time. These patching templates generate JSON patches which are
+applied on top of the output compose file, just before being written. Patching templates have access to the current
+compose spec as `.Compose`, the map of workload name to Score Spec as `.Workloads`, and can use any functions from [Masterminds/sprig](https://github.com/Masterminds/sprig).
+
+Each patch produced by a template looks like a yaml/json blob with keys `op` (set or delete), `path` 
+(the .-separated json path. use backslash to escape), a `value` to set, and an optional `description`.
+
+Example paths:
+
+```
+services.some\.thing     # patches the some.thing service
+services.foo.ports.0     # modifies the first item in the ports array
+services.foo.ports.-1    # adds to the end of the ports array
+something.:0000.xyz      # patches the xyz item in the "0000" item of something (: escapes a numeric index)
+```
+
+This example shows how you might use these.
+
+In [patch-1.tpl](./patch-1.tpl), we describe a patch which updates all Score workload services to have a read only
+root file system. This may more accurate represent the default production security configuration and reduce local
+testing drift.
+
+```
+{{ range $name, $cfg := .Compose.services }}
+    {{ if ne (dig "annotations" "compose.score.dev/workload-name" "" $cfg) "" }}
+- op: set
+  path: services.{{ $name }}.read_only
+  value: true
+  description: Set services to read only root fs
+    {{ end }}
+{{ end }}
+```
+
+In [patch-2.tpl](./patch-2.tpl), we slightly modify the output of the `amqp` resource provisioning so that the
+management port is exposed on the Rabbitmq container. This means we didn't need to write our own provisioner just to
+adjust that value.
+
+```
+{{ range $name, $cfg := .Compose.services }}
+    {{ if and (eq $cfg.image "rabbitmq:3-management-alpine") (eq $cfg.restart "always") }}
+- op: set
+  path: services.{{ $name }}.ports
+  value:
+  - target: 15672
+    published: "15672"
+  description: Expose the management port of the rabbitmq resource service
+    {{ end }}
+{{ end }}
+```
+
+And finally, in [patch-3.tpl](./patch-3.tpl), we exposed a debugging port from the score workload.
+
+```
+- op: set
+  patch: services.hello-world-hello.ports
+  value:
+  - target: 9999
+    published: 9999
+```
+
+Because patch templates have access to the set of workload specifications in `.Workloads`, they can be used to implement
+additional conversions or Score features which are not provided by the Score spec yet or are too specialized. For example,
+there is no way currently in the Score spec to specify that the workload should run as privileged. But we can do this
+by first adding an annotation or custom metadata to a workload:
+
+```
+...
+metadata
+  name: my-workload
+  extensions:
+    privileged: true
+...
+```
+
+And then adding a patch template that will look for this and update all service containers with `privileged: true`:
+
+```
+{{ range $name, $spec := .Workloads }}
+    {{ if (dig "metadata" "extensions" "privileged" false $spec) }}
+        {{ range $cname, $_ := $spec.containers }}
+- op: set
+  path: services.{{ $name }}-{{ $cname }}.privileged
+  value: true
+  description: Enable privileged mode on service containers
+        {{ end }}
+    {{ end }}
+{{ end }}
+```
+
+In this way, you can extend the behavior of `score-compose`.
diff --git a/examples/16-patching-templates/patch-1.tpl b/examples/16-patching-templates/patch-1.tpl
new file mode 100644
index 0000000..584754f
--- /dev/null
+++ b/examples/16-patching-templates/patch-1.tpl
@@ -0,0 +1,8 @@
+{{ range $name, $cfg := .Compose.services }}
+    {{ if ne (dig "annotations" "compose.score.dev/workload-name" "" $cfg) "" }}
+- op: set
+  path: services.{{ $name }}.read_only
+  value: true
+  description: Set services to read only root fs
+    {{ end }}
+{{ end }}
diff --git a/examples/16-patching-templates/patch-2.tpl b/examples/16-patching-templates/patch-2.tpl
new file mode 100644
index 0000000..0b31206
--- /dev/null
+++ b/examples/16-patching-templates/patch-2.tpl
@@ -0,0 +1,10 @@
+{{ range $name, $cfg := .Compose.services }}
+    {{ if and (eq $cfg.image "rabbitmq:3-management-alpine") (eq $cfg.restart "always") }}
+- op: set
+  path: services.{{ $name }}.ports
+  value:
+  - target: 15672
+    published: "15672"
+  description: Expose the management port of the rabbitmq resource service
+    {{ end }}
+{{ end }}
diff --git a/examples/16-patching-templates/patch-3.tpl b/examples/16-patching-templates/patch-3.tpl
new file mode 100644
index 0000000..44e8d50
--- /dev/null
+++ b/examples/16-patching-templates/patch-3.tpl
@@ -0,0 +1,5 @@
+- op: set
+  path: services.hello-world-hello.ports
+  value:
+  - target: 9999
+    published: "9999"
diff --git a/examples/16-patching-templates/score.yaml b/examples/16-patching-templates/score.yaml
new file mode 100644
index 0000000..66f047e
--- /dev/null
+++ b/examples/16-patching-templates/score.yaml
@@ -0,0 +1,12 @@
+apiVersion: score.dev/v1b1
+
+metadata:
+  name: hello-world
+
+containers:
+  hello:
+    image: busybox
+
+resources:
+  amqp:
+    type: amqp
diff --git a/go.mod b/go.mod
index f6dcedb..88d9e39 100644
--- a/go.mod
+++ b/go.mod
@@ -51,4 +51,4 @@ require (
 	golang.org/x/sys v0.31.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	oras.land/oras-go/v2 v2.5.0 // indirect
-)
\ No newline at end of file
+)
diff --git a/internal/command/generate.go b/internal/command/generate.go
index 7177f86..7847e43 100644
--- a/internal/command/generate.go
+++ b/internal/command/generate.go
@@ -34,6 +34,7 @@ import (
 	"gopkg.in/yaml.v3"
 
 	"github.com/score-spec/score-compose/internal/compose"
+	"github.com/score-spec/score-compose/internal/patching"
 	"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"
@@ -279,6 +280,14 @@ arguments.
 			}
 		}
 
+		for i, content := range currentState.Extras.PatchingTemplates {
+			slog.Info(fmt.Sprintf("Applying patching template %d", i+1))
+			superProject, err = patching.PatchServices(currentState, superProject, content)
+			if err != nil {
+				return fmt.Errorf("failed to patch template %d: %w", i+1, err)
+			}
+		}
+
 		if v, err := cmd.Flags().GetStringArray(generateCmdPublishFlag); err != nil {
 			return fmt.Errorf("failed to read publish property: %w", err)
 		} else {
diff --git a/internal/command/init.go b/internal/command/init.go
index 53e5b2d..9d43cf6 100644
--- a/internal/command/init.go
+++ b/internal/command/init.go
@@ -27,6 +27,7 @@ import (
 	"github.com/score-spec/score-go/uriget"
 	"github.com/spf13/cobra"
 
+	"github.com/score-spec/score-compose/internal/patching"
 	"github.com/score-spec/score-compose/internal/project"
 	"github.com/score-spec/score-compose/internal/provisioners/loader"
 )
@@ -68,10 +69,11 @@ service:
 
 resources: {}
 `
-	initCmdFileFlag         = "file"
-	initCmdFileProjectFlag  = "project"
-	initCmdFileNoSampleFlag = "no-sample"
-	initCmdProvisionerFlag  = "provisioners"
+	initCmdFileFlag          = "file"
+	initCmdFileProjectFlag   = "project"
+	initCmdFileNoSampleFlag  = "no-sample"
+	initCmdProvisionerFlag   = "provisioners"
+	initCmdPatchTemplateFlag = "patch-templates"
 )
 
 //go:embed default.provisioners.yaml
@@ -93,6 +95,12 @@ acts as a namespace when multiple score files and containers are used.
 Custom provisioners can be installed by uri using the --provisioners flag. The provisioners will be installed and take
 precedence in the order they are defined over the default provisioners. If init has already been called with provisioners
 the new provisioners will take precedence.
+
+To adjust the way the compose project is generated, or perform post processing actions, you can use the --patch-templates
+flag to provide one or more template files by uri. Each template file is stored in the project and then evaluated as a 
+Golang text/template and should output a yaml/json encoded array of patches. Each patch is an object with required 'op' 
+(set or delete), 'patch' (a dot-separated json path), a 'value' if the 'op' == 'set', and an optional 'description' for 
+showing in the logs. The template has access to '.Compose' and '.Workloads'.
 `,
 	Example: `
   # Define a score file to generate
@@ -105,7 +113,21 @@ the new provisioners will take precedence.
   score-compose init --no-sample
 
   # Optionally loading in provisoners from a remote url
-  score-compose init --provisioners https://raw.githubusercontent.com/user/repo/main/example.yaml`,
+  score-compose init --provisioners https://raw.githubusercontent.com/user/repo/main/example.yaml
+
+  # Optionally adding a couple of patching templates
+  score-compose init --patch-templates ./patching.tpl --patch-templates https://raw.githubusercontent.com/user/repo/main/example.tpl
+
+URI Retrieval:
+  The --provisioners and --patch-templates arguments support URI retrieval for pulling the contents from a URI on disk
+  or over the network. These support:
+    - HTTP        : http://host/file
+    - HTTPS       : https://host/file
+    - Git (SSH)   : git-ssh://git@host/repo.git/file
+    - Git (HTTPS) : git-https://host/repo.git/file
+    - OCI         : oci://[registry/][namespace/]repository[:tag|@digest][#file]
+    - Local File  : /path/to/local/file
+    - Stdin       : - (read from standard input)`,
 
 	// don't print the errors - we print these ourselves in main()
 	SilenceErrors: true,
@@ -116,6 +138,7 @@ the new provisioners will take precedence.
 		// load flag values
 		initCmdScoreFile, _ := cmd.Flags().GetString(initCmdFileFlag)
 		initCmdComposeProject, _ := cmd.Flags().GetString(initCmdFileProjectFlag)
+		initCmdPatchingFiles, _ := cmd.Flags().GetStringArray(initCmdPatchTemplateFlag)
 
 		// validate project
 		if initCmdComposeProject != "" {
@@ -125,17 +148,38 @@ the new provisioners will take precedence.
 			}
 		}
 
+		var templates []string
+		for _, u := range initCmdPatchingFiles {
+			slog.Info(fmt.Sprintf("Fetching patch template from %s", u))
+			content, err := uriget.GetFile(cmd.Context(), u)
+			if err != nil {
+				return fmt.Errorf("error fetching patch template from %s: %w", u, err)
+			} else if err = patching.ValidatePatchTemplate(string(content)); err != nil {
+				return fmt.Errorf("error parsing patch template from %s: %w", u, err)
+			}
+			templates = append(templates, string(content))
+		}
+
 		sd, ok, err := project.LoadStateDirectory(".")
 		if err != nil {
 			return fmt.Errorf("failed to load existing state directory: %w", err)
 		} else if ok {
 			slog.Info(fmt.Sprintf("Found existing state directory '%s'", sd.Path))
+			var hasChanges bool
 			if initCmdComposeProject != "" && sd.State.Extras.ComposeProjectName != initCmdComposeProject {
 				sd.State.Extras.ComposeProjectName = initCmdComposeProject
+				hasChanges = true
+			}
+			if len(templates) > 0 {
+				sd.State.Extras.PatchingTemplates = templates
+				hasChanges = true
+			}
+			if hasChanges {
 				if err := sd.Persist(); err != nil {
-					return fmt.Errorf("failed to persist new compose project name: %w", err)
+					return fmt.Errorf("failed to persist new state file: %w", err)
 				}
 			}
+
 		} else {
 
 			slog.Info(fmt.Sprintf("Writing new state directory '%s'", project.DefaultRelativeStateDirectory))
@@ -155,6 +199,9 @@ the new provisioners will take precedence.
 			if initCmdComposeProject != "" {
 				sd.State.Extras.ComposeProjectName = initCmdComposeProject
 			}
+			if len(templates) > 0 {
+				sd.State.Extras.PatchingTemplates = templates
+			}
 			slog.Info(fmt.Sprintf("Writing new state directory '%s' with project name '%s'", sd.Path, sd.State.Extras.ComposeProjectName))
 			if err := sd.Persist(); err != nil {
 				return fmt.Errorf("failed to persist new compose project name: %w", err)
@@ -233,14 +280,8 @@ func init() {
 	initCmd.Flags().StringP(initCmdFileFlag, "f", scoreFileDefault, "The score file to initialize")
 	initCmd.Flags().StringP(initCmdFileProjectFlag, "p", "", "Set the name of the docker compose project (defaults to the current directory name)")
 	initCmd.Flags().Bool(initCmdFileNoSampleFlag, false, "Disable generation of the sample score file")
-	initCmd.Flags().StringArray(initCmdProvisionerFlag, nil, "Provisioner files to install. May be specified multiple times. Supports:\n"+
-		"- HTTP        : http://host/file\n"+
-		"- HTTPS       : https://host/file\n"+
-		"- Git (SSH)   : git-ssh://git@host/repo.git/file\n"+
-		"- Git (HTTPS) : git-https://host/repo.git/file\n"+
-		"- OCI         : oci://[registry/][namespace/]repository[:tag|@digest][#file]\n"+
-		"- Local File  : /path/to/local/file\n"+
-		"- Stdin       : - (read from standard input)")
+	initCmd.Flags().StringArray(initCmdProvisionerFlag, nil, "Provisioner files to install. May be specified multiple times. Supports URI retrieval.")
+	initCmd.Flags().StringArray(initCmdPatchTemplateFlag, nil, "Patching template files to include. May be specified multiple times. Supports URI retrieval.")
 
 	rootCmd.AddCommand(initCmd)
 }
diff --git a/internal/patching/patching.go b/internal/patching/patching.go
new file mode 100644
index 0000000..0481db0
--- /dev/null
+++ b/internal/patching/patching.go
@@ -0,0 +1,134 @@
+// Copyright 2025 Humanitec
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package patching
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"strings"
+	"text/template"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
+	score "github.com/score-spec/score-go/types"
+	"github.com/tidwall/sjson"
+	"gopkg.in/yaml.v3"
+
+	"github.com/score-spec/score-compose/internal/project"
+)
+
+type PatchOperation struct {
+	Op          string      `json:"op"`
+	Path        string      `json:"path"`
+	Value       interface{} `json:"value,omitempty"`
+	Description string      `json:"description,omitempty"`
+}
+
+func yamlRoundTrip[k any, v any](input *k) (*v, error) {
+	raw, err := yaml.Marshal(input)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal input: %w", err)
+	}
+	var out *v
+	if err := yaml.Unmarshal(raw, &out); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal input: %w", err)
+	}
+	return out, nil
+}
+
+type patchTemplateInput struct {
+	Compose   map[string]interface{}
+	Workloads map[string]interface{}
+}
+
+func ValidatePatchTemplate(content string) error {
+	if _, err := template.New("").Funcs(sprig.FuncMap()).Parse(content); err != nil {
+		return fmt.Errorf("failed to parse template: %w", err)
+	}
+	return nil
+}
+
+func PatchServices(state *framework.State[project.StateExtras, project.WorkloadExtras, framework.NoExtras], composeProject *types.Project, rawTemplate string) (*types.Project, error) {
+	tmpl, err := template.New("").Funcs(sprig.FuncMap()).Parse(rawTemplate)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse template: %w", err)
+	}
+	buff := &bytes.Buffer{}
+	composeInputs, err := yamlRoundTrip[types.Project, map[string]interface{}](composeProject)
+	if err != nil {
+		return nil, err
+	}
+	workloadSpecs := make(map[string]score.Workload, len(state.Workloads))
+	for n, w := range state.Workloads {
+		workloadSpecs[n] = w.Spec
+	}
+	workloadInputs, err := yamlRoundTrip[map[string]score.Workload, map[string]interface{}](&workloadSpecs)
+	if err != nil {
+		return nil, err
+	}
+	if err := tmpl.Execute(buff, patchTemplateInput{
+		Workloads: *workloadInputs,
+		Compose:   *composeInputs,
+	}); err != nil {
+		return nil, fmt.Errorf("failed to execute template: %w", err)
+	}
+	var patches []PatchOperation
+	templatedPatches := strings.TrimSpace(buff.String())
+	if templatedPatches == "" {
+		return composeProject, nil
+	}
+
+	yamlDecoder := yaml.NewDecoder(strings.NewReader(templatedPatches))
+	yamlDecoder.KnownFields(true)
+	if err := yamlDecoder.Decode(&patches); err != nil {
+		slog.Debug("Raw patch output", slog.String("raw", templatedPatches))
+		return nil, fmt.Errorf("failed to unmarshal patches from template execution output: %w", err)
+	}
+	jsonInput, _ := json.Marshal(composeInputs)
+	for i, p := range patches {
+		if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
+			slog.Debug("Applying patch", slog.String("operation", p.Op), slog.String("path", p.Path), slog.Any("value", p.Value), slog.Any("description", p.Description))
+		} else {
+			desc := p.Description
+			if desc != "" {
+				desc = " (" + desc + ")"
+			}
+			slog.Info(fmt.Sprintf("Applying patch to %s%s", p.Path, desc))
+		}
+		switch p.Op {
+		case "set":
+			jsonInput, err = sjson.SetBytes(jsonInput, p.Path, p.Value)
+		case "delete":
+			jsonInput, err = sjson.DeleteBytes(jsonInput, p.Path)
+		default:
+			err = fmt.Errorf("unknown operation: %s", p.Op)
+		}
+		if err != nil {
+			return nil, fmt.Errorf("failed to perform patch operation %d: '%s '%s': %w", i+1, p.Op, p.Path, err)
+		}
+	}
+
+	var output *types.Project
+	decoder := json.NewDecoder(bytes.NewReader(jsonInput))
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(&output); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal patched output: %w", err)
+	}
+	return output, nil
+}
diff --git a/internal/project/project.go b/internal/project/project.go
index 0959d3a..faa86c5 100644
--- a/internal/project/project.go
+++ b/internal/project/project.go
@@ -37,8 +37,9 @@ const (
 type State = framework.State[StateExtras, WorkloadExtras, framework.NoExtras]
 
 type StateExtras struct {
-	ComposeProjectName string `yaml:"compose_project"`
-	MountsDirectory    string `yaml:"mounts_directory"`
+	ComposeProjectName string   `yaml:"compose_project"`
+	MountsDirectory    string   `yaml:"mounts_directory"`
+	PatchingTemplates  []string `yaml:"patching_templates"`
 }
 
 type WorkloadExtras struct {
@@ -57,6 +58,7 @@ type StateDirectory struct {
 }
 
 // Persist ensures that the directory is created and that the current config file has been written with the latest settings.
+// It may also provide some validation of state before writing it.
 func (sd *StateDirectory) Persist() error {
 	if sd.Path == "" {
 		return fmt.Errorf("path not set")


Test patch:
diff --git a/internal/command/generate_examples_test.go b/internal/command/generate_examples_test.go
index c94d86f..df85fcf 100644
--- a/internal/command/generate_examples_test.go
+++ b/internal/command/generate_examples_test.go
@@ -30,6 +30,7 @@ import (
 type exampleTestCase struct {
 	subDir           string
 	adds             []string
+	patchFiles       []string
 	expected         string
 	expectedContains string
 }
@@ -240,6 +241,15 @@ services:
 			subDir: "14-elasticsearch",
 			adds:   []string{"score.yaml"},
 		},
+		{
+			subDir: "15-mssql-database",
+			adds:   []string{"score.yaml"},
+		},
+		{
+			subDir:     "16-patching-templates",
+			adds:       []string{"score.yaml"},
+			patchFiles: []string{"patch-1.tpl", "patch-2.tpl", "patch-3.tpl"},
+		},
 	} {
 		t.Run(tc.subDir, func(t *testing.T) {
 			oldReader := rand.Reader
@@ -252,7 +262,12 @@ services:
 			require.NoError(t, os.RemoveAll(".score-compose"))
 			require.NoError(t, os.RemoveAll("compose.yaml"))
 
-			stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"})
+			args := []string{"init", "--no-sample"}
+			for _, f := range tc.patchFiles {
+				args = append(args, "--patch-templates", f)
+			}
+
+			stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, args)
 			require.NoError(t, err)
 			assert.Equal(t, "", stdout)
 
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index 55ec35c..ebee7c6 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -1415,3 +1415,71 @@ containers:
 	assert.EqualError(t, err, "--build cannot be used when multiple score files are provided")
 	assert.Equal(t, "", stdout)
 }
+
+func TestGenerateWithPatching(t *testing.T) {
+	td := changeToTempDir(t)
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+  custom:
+    privileged: true
+containers:
+  hello:
+    image: foo
+`), 0644))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "patch1.template"), []byte(`
+{{ range $name, $spec := .Workloads }}
+    {{ if (dig "metadata" "custom" "privileged" false $spec) }}
+        {{ range $cname, $_ := $spec.containers }}
+- op: set
+  path: services.{{ $name }}-{{ $cname }}.privileged
+  value: true
+  description: Enable privileged mode on service containers
+        {{ end }}
+    {{ end }}
+{{ end }}
+---
+`), 0644))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "patch2.template"), []byte(`
+{{ range $name, $cfg := .Compose.services }}
+- op: set
+  path: services.{{ $name }}-future
+  value: {{ toRawJson $cfg }}
+  description: Rename service {{ $name }}
+- op: delete
+  path: services.{{ $name }}
+  description: Delete service {{ $name }}
+{{ end }}
+---
+`), 0644))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "patch3.template"), []byte(`
+{{ range $name, $cfg := .Compose.services }}
+- op: set
+  path: services.{{ $name }}.read_only
+  value: true
+  description: Set services to read only root fs
+{{ end }}
+---
+`), 0644))
+	_, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--patch-templates", "patch1.template", "--patch-templates", "patch2.template", "--patch-templates", "patch3.template"})
+	assert.NoError(t, err)
+	t.Log(stderr)
+
+	_, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
+	assert.NoError(t, err)
+	t.Log(stderr)
+
+	raw, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
+	assert.NoError(t, err)
+	assert.Equal(t, string(raw), `name: "001"
+services:
+    example-hello-future:
+        annotations:
+            compose.score.dev/workload-name: example
+        hostname: example
+        image: foo
+        privileged: true
+        read_only: true
+`)
+}
diff --git a/internal/command/init_test.go b/internal/command/init_test.go
index b23adf4..a4bf411 100644
--- a/internal/command/init_test.go
+++ b/internal/command/init_test.go
@@ -48,6 +48,12 @@ Custom provisioners can be installed by uri using the --provisioners flag. The p
 precedence in the order they are defined over the default provisioners. If init has already been called with provisioners
 the new provisioners will take precedence.
 
+To adjust the way the compose project is generated, or perform post processing actions, you can use the --patch-templates
+flag to provide one or more template files by uri. Each template file is stored in the project and then evaluated as a 
+Golang text/template and should output a yaml/json encoded array of patches. Each patch is an object with required 'op' 
+(set or delete), 'patch' (a dot-separated json path), a 'value' if the 'op' == 'set', and an optional 'description' for 
+showing in the logs. The template has access to '.Compose' and '.Workloads'.
+
 Usage:
   score-compose init [flags]
 
@@ -65,19 +71,27 @@ Examples:
   # Optionally loading in provisoners from a remote url
   score-compose init --provisioners https://raw.githubusercontent.com/user/repo/main/example.yaml
 
+  # Optionally adding a couple of patching templates
+  score-compose init --patch-templates ./patching.tpl --patch-templates https://raw.githubusercontent.com/user/repo/main/example.tpl
+
+URI Retrieval:
+  The --provisioners and --patch-templates arguments support URI retrieval for pulling the contents from a URI on disk
+  or over the network. These support:
+    - HTTP        : http://host/file
+    - HTTPS       : https://host/file
+    - Git (SSH)   : git-ssh://git@host/repo.git/file
+    - Git (HTTPS) : git-https://host/repo.git/file
+    - OCI         : oci://[registry/][namespace/]repository[:tag|@digest][#file]
+    - Local File  : /path/to/local/file
+    - Stdin       : - (read from standard input)
+
 Flags:
-  -f, --file string                The score file to initialize (default "./score.yaml")
-  -h, --help                       help for init
-      --no-sample                  Disable generation of the sample score file
-  -p, --project string             Set the name of the docker compose project (defaults to the current directory name)
-      --provisioners stringArray   Provisioner files to install. May be specified multiple times. Supports:
-                                   - HTTP        : http://host/file
-                                   - HTTPS       : https://host/file
-                                   - Git (SSH)   : git-ssh://git@host/repo.git/file
-                                   - Git (HTTPS) : git-https://host/repo.git/file
-                                   - OCI         : oci://[registry/][namespace/]repository[:tag|@digest][#file]
-                                   - Local File  : /path/to/local/file
-                                   - Stdin       : - (read from standard input)
+  -f, --file string                   The score file to initialize (default "./score.yaml")
+  -h, --help                          help for init
+      --no-sample                     Disable generation of the sample score file
+      --patch-templates stringArray   Patching template files to include. May be specified multiple times. Supports URI retrieval.
+  -p, --project string                Set the name of the docker compose project (defaults to the current directory name)
+      --provisioners stringArray      Provisioner files to install. May be specified multiple times. Supports URI retrieval.
 
 Global Flags:
       --quiet           Mute any logging output
@@ -275,3 +289,42 @@ func TestInitWithProvisioners(t *testing.T) {
 		}), fmt.Sprintf("Expected provisioner '%s' not found", expectedUri))
 	}
 }
+
+func TestInitWithPatchingFiles(t *testing.T) {
+	td := t.TempDir()
+	wd, _ := os.Getwd()
+	require.NoError(t, os.Chdir(td))
+	defer func() {
+		require.NoError(t, os.Chdir(wd))
+	}()
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "patch-templates-1"), []byte(`[]`), 0644))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "patch-templates-2"), []byte(`[]`), 0644))
+
+	t.Run("new", func(t *testing.T) {
+		_, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--patch-templates", filepath.Join(td, "patch-templates-1"), "--patch-templates", filepath.Join(td, "patch-templates-2")})
+		assert.NoError(t, err)
+		t.Log(stderr)
+		sd, ok, err := project.LoadStateDirectory(".")
+		assert.NoError(t, err)
+		if assert.True(t, ok) {
+			assert.Len(t, sd.State.Extras.PatchingTemplates, 2)
+		}
+	})
+
+	t.Run("update", func(t *testing.T) {
+		_, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--patch-templates", filepath.Join(td, "patch-templates-2")})
+		assert.NoError(t, err)
+		t.Log(stderr)
+		sd, ok, err := project.LoadStateDirectory(".")
+		assert.NoError(t, err)
+		if assert.True(t, ok) {
+			assert.Len(t, sd.State.Extras.PatchingTemplates, 1)
+		}
+	})
+
+	t.Run("bad patch", func(t *testing.T) {
+		assert.NoError(t, os.WriteFile(filepath.Join(td, "patch-templates-3"), []byte(`{{ what is this }}`), 0644))
+		_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--patch-templates", filepath.Join(td, "patch-templates-3")})
+		assert.Error(t, err, "failed to parse template: template: :1: function \"what\" not defined")
+	})
+}