Repository: score-spec/score-compose
Pull request #175 (2024-09-11 22:41:12)

feat: support validating and installing provisioner files by uri
Installing provisioners by copying yaml files around is not a great UX and makes things unecessarily multi-step.

This PR improves this by adding a `--provisioners <URI>` flag which downloads a provisioner file using either http, git, or local filesystems and adds it into the .score-compose state directory at the top of the stack.

astromechza: @mathieu-benoit good point, fixed up and added a test :)

Patch:
diff --git a/README.md b/README.md
index 77cfc76..2e77d1f 100644
--- a/README.md
+++ b/README.md
@@ -76,10 +76,26 @@ acts as a namespace when multiple score files and containers are used.
 Usage:
   score-compose init [flags]
 
+Examples:
+
+  # Define a score file to generate
+  score-compose init --file score2.yaml
+
+  # Or override the docker compose project name
+  score-compose init --project score-compose2
+
+  # Or disable the default score file generation if you already have a score file
+  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
+
 Flags:
-  -f, --file string      The score file to initialize (default "./score.yaml")
-  -h, --help             help for init
-  -p, --project string   Set the name of the docker compose project (defaults to the current directory name)
+  -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, and  git-https://host/repo.git/file formats.
 
 Global Flags:
       --quiet           Mute any logging output
diff --git a/examples/06-resource-provisioning/README.md b/examples/06-resource-provisioning/README.md
index df50f5e..fcd6641 100644
--- a/examples/06-resource-provisioning/README.md
+++ b/examples/06-resource-provisioning/README.md
@@ -130,7 +130,7 @@ ports:
 
 ## The `*.provisioners.yaml` files
 
-When you run `score-compose init`, a [99-default.provisioners.yaml](https://github.com/score-spec/score-compose/blob/main/internal/command/default.provisioners.yaml) file is created, which is a YAML file holding the definition of the built-in provisioners.
+When you run `score-compose init`, a [zz-default.provisioners.yaml](https://github.com/score-spec/score-compose/blob/main/internal/command/default.provisioners.yaml) file is created, which is a YAML file holding the definition of the built-in provisioners.
 
 When you run `score-compose generate`, all `*.provisioners.yaml` files are loaded in lexicographic order from the `.score-compose` directory. This allows projects to include their own custom provisioners that extend or override the defaults.
 
@@ -143,7 +143,30 @@ Each entry in the file has the following common fields, other fields may also ex
   id: <optional resource id>
 ```
 
-The uri of each provisioner is a combination of it's implementation (either `template://` or `cmd://`) and a unique identifier.
+The uri of each provisioner is a combination of its implementation (either `template://` or `cmd://`) and a unique identifier.
+Provisioners are matched in first-match order when loading the provisioner files lexicographically, so any custom provisioner
+files are matched first before `zz-default.provisioners.yaml`.
+
+### Installing provisioner files
+
+To easily install provisioners, `score-compose` provides the `--provisioners` flag for `init`, which downloads the provisioner
+file via a URL and installs it with the highest priority.
+
+For example, when running the following, the provisioners file B will be matched before A because B was installed after A:
+
+```
+score-compose init --provisioners https://example.com/provisioner-A.yaml --provisionerss https://example.com/provisioner-B.yaml
+```
+
+The provisioners can be loaded from the following kinds of urls:
+
+- Files: `./a/relative/path.provisioners.yaml`, `/an/absolute/path.provisioners.yaml`, `file://a/file/uri.provisioners.yaml`.
+- Http: `http://example.com/a.provisioners.yaml`, `https://example.com/b.provisioners.yaml`
+- Git over ssh: `git-ssh://git@github.com/user/repo.git/common.provisioners.yaml`
+- Git over http: `git-http://github.com/user/repo.git/common.provisioners.yaml`
+
+This is commonly used to import custom provisioners or common provisioners used by your team or organization and supported
+by your platform.
 
 ### The `template://` provisioner
 
diff --git a/go.mod b/go.mod
index 61b4ef7..95c6903 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
 	github.com/compose-spec/compose-go/v2 v2.1.3
 	github.com/imdario/mergo v0.3.16
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/score-spec/score-go v1.7.2
+	github.com/score-spec/score-go v1.8.1
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.9.0
diff --git a/go.sum b/go.sum
index 5254dbe..fa72f8d 100644
--- a/go.sum
+++ b/go.sum
@@ -57,8 +57,10 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
-github.com/score-spec/score-go v1.7.2 h1:kRqm7506pAhJVMIfZDkfhOAoIedm5A3IfjTjtoBOyRg=
-github.com/score-spec/score-go v1.7.2/go.mod h1:3QSPH5QVMIX7FdhktLLFtjLQTL1/ENqrWDe5lSdZGFc=
+github.com/score-spec/score-go v1.8.0 h1:Dc2Hbz7pONKgsVjjSeIgmciGpG7PojkqlGtKwgmkAJA=
+github.com/score-spec/score-go v1.8.0/go.mod h1:3QSPH5QVMIX7FdhktLLFtjLQTL1/ENqrWDe5lSdZGFc=
+github.com/score-spec/score-go v1.8.1 h1:Q4X62t9wKkx+jVCb55NISvRT17MkH3p82DQfxssmk+o=
+github.com/score-spec/score-go v1.8.1/go.mod h1:3QSPH5QVMIX7FdhktLLFtjLQTL1/ENqrWDe5lSdZGFc=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
diff --git a/internal/command/init.go b/internal/command/init.go
index 9d93405..ea30246 100644
--- a/internal/command/init.go
+++ b/internal/command/init.go
@@ -24,6 +24,7 @@ import (
 	"strings"
 
 	"github.com/score-spec/score-go/framework"
+	"github.com/score-spec/score-go/uriget"
 	"github.com/spf13/cobra"
 
 	"github.com/score-spec/score-compose/internal/project"
@@ -70,6 +71,7 @@ resources: {}
 	initCmdFileFlag         = "file"
 	initCmdFileProjectFlag  = "project"
 	initCmdFileNoSampleFlag = "no-sample"
+	initCmdProvisionerFlag  = "provisioners"
 )
 
 //go:embed default.provisioners.yaml
@@ -87,6 +89,10 @@ 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.
 `,
 	Example: `
   # Define a score file to generate
@@ -96,7 +102,10 @@ acts as a namespace when multiple score files and containers are used.
   score-compose init --project score-compose2
 
   # Or disable the default score file generation if you already have a score file
-  score-compose init --no-sample`,
+  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`,
 
 	// don't print the errors - we print these ourselves in main()
 	SilenceErrors: true,
@@ -188,6 +197,18 @@ acts as a namespace when multiple score files and containers are used.
 			slog.Info(fmt.Sprintf("Found existing Score file '%s'", initCmdScoreFile))
 		}
 
+		if v, _ := cmd.Flags().GetStringArray(initCmdProvisionerFlag); len(v) > 0 {
+			for i, vi := range v {
+				data, err := uriget.GetFile(cmd.Context(), vi)
+				if err != nil {
+					return fmt.Errorf("failed to load provisioner %d: %w", i+1, err)
+				}
+				if err := loader.SaveProvisionerToDirectory(sd.Path, vi, data); err != nil {
+					return fmt.Errorf("failed to save provisioner %d: %w", i+1, err)
+				}
+			}
+		}
+
 		if provs, err := loader.LoadProvisionersFromDirectory(sd.Path, loader.DefaultSuffix); err != nil {
 			return fmt.Errorf("failed to load existing provisioners: %w", err)
 		} else {
@@ -204,6 +225,7 @@ 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, "A provisioners file to install. May be specified multiple times. Supports http://host/file, https://host/file, git-ssh://git@host/repo.git/file, and  git-https://host/repo.git/file formats.")
 
 	rootCmd.AddCommand(initCmd)
 }
diff --git a/internal/provisioners/loader/load.go b/internal/provisioners/loader/load.go
index 232fa4f..5f1ecda 100644
--- a/internal/provisioners/loader/load.go
+++ b/internal/provisioners/loader/load.go
@@ -16,12 +16,17 @@ package loader
 
 import (
 	"bytes"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/binary"
 	"fmt"
 	"log/slog"
+	"math"
 	"net/url"
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 
 	"gopkg.in/yaml.v3"
 
@@ -92,3 +97,44 @@ func LoadProvisionersFromDirectory(path string, suffix string) ([]provisioners.P
 	}
 	return out, nil
 }
+
+// SaveProvisionerToDirectory saves the provisioner content (data) from the provisionerUrl to a new provisioners file
+// in the path directory.
+func SaveProvisionerToDirectory(path string, provisionerUrl string, data []byte) error {
+	// First validate whether this file contains valid provisioner data.
+	if _, err := LoadProvisioners(data); err != nil {
+		return fmt.Errorf("invalid provisioners file: %w", err)
+	}
+	// Append a heading indicating the source and time
+	data = append([]byte(fmt.Sprintf("# Downloaded from %s at %s\n", provisionerUrl, time.Now())), data...)
+	hashValue := sha256.Sum256([]byte(provisionerUrl))
+	hashName := base64.RawURLEncoding.EncodeToString(hashValue[:16]) + DefaultSuffix
+	// We use a time prefix to always put the most recently downloaded files first lexicographically. So subtract
+	// time from uint64 and convert it into a base64 two's complement binary representation.
+	timePrefix := base64.RawURLEncoding.EncodeToString(binary.BigEndian.AppendUint64([]byte{}, uint64(math.MaxInt64-time.Now().UnixNano())))
+
+	targetPath := filepath.Join(path, timePrefix+"."+hashName)
+	tmpPath := targetPath + ".tmp"
+	if err := os.WriteFile(tmpPath, data, 0600); err != nil {
+		return fmt.Errorf("failed to write file: %w", err)
+	} else if err := os.Rename(tmpPath, targetPath); err != nil {
+		return fmt.Errorf("failed to rename temp file: %w", err)
+	}
+	slog.Info(fmt.Sprintf("Wrote provisioner from '%s' to %s", provisionerUrl, targetPath))
+
+	// Remove any old files that have the same source.
+	if items, err := os.ReadDir(path); err != nil {
+		return err
+	} else {
+		for _, item := range items {
+			if strings.HasSuffix(item.Name(), hashName) && !strings.HasPrefix(item.Name(), timePrefix) {
+				if err := os.Remove(filepath.Join(path, item.Name())); err != nil {
+					return fmt.Errorf("failed to remove old copy of provisioner loaded from '%s': %w", provisionerUrl, err)
+				}
+				slog.Debug(fmt.Sprintf("Removed old copy of provisioner loaded from '%s'", provisionerUrl))
+			}
+		}
+	}
+
+	return nil
+}


Test patch:
diff --git a/internal/command/init_test.go b/internal/command/init_test.go
index 6dc8b1c..77ef160 100644
--- a/internal/command/init_test.go
+++ b/internal/command/init_test.go
@@ -26,6 +26,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-compose/internal/provisioners/loader"
 )
 
 func TestInitHelp(t *testing.T) {
@@ -40,6 +41,10 @@ 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.
+
 Usage:
   score-compose init [flags]
 
@@ -54,11 +59,15 @@ Examples:
   # Or disable the default score file generation if you already have a score file
   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
+
 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)
+  -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   A provisioners file to install. May be specified multiple times. Supports http://host/file, https://host/file, git-ssh://git@host/repo.git/file, and  git-https://host/repo.git/file formats.
 
 Global Flags:
       --quiet           Mute any logging output
@@ -221,3 +230,34 @@ func TestInitNominal_run_twice(t *testing.T) {
 		assert.Equal(t, map[string]interface{}{}, sd.State.SharedState)
 	}
 }
+
+func TestInitWithProvisioners(t *testing.T) {
+	td := t.TempDir()
+	wd, _ := os.Getwd()
+	require.NoError(t, os.Chdir(td))
+	defer func() {
+		require.NoError(t, os.Chdir(wd))
+	}()
+
+	td2 := t.TempDir()
+	assert.NoError(t, os.WriteFile(filepath.Join(td2, "one.provisioners.yaml"), []byte(`
+- uri: template://one
+  type: thing
+  outputs: "{}"
+`), 0644))
+	assert.NoError(t, os.WriteFile(filepath.Join(td2, "two.provisioners.yaml"), []byte(`
+- uri: template://two
+  type: thing
+  outputs: "{}"
+`), 0644))
+
+	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--provisioners", filepath.Join(td2, "one.provisioners.yaml"), "--provisioners", "file://" + filepath.Join(td2, "two.provisioners.yaml")})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+
+	provs, err := loader.LoadProvisionersFromDirectory(filepath.Join(td, ".score-compose"), loader.DefaultSuffix)
+	assert.NoError(t, err)
+	assert.Equal(t, "template://two", provs[0].Uri())
+	assert.Equal(t, "template://one", provs[1].Uri())
+}