Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support validating and installing provisioner files by uri #175

Merged
merged 4 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions examples/06-resource-provisioning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
24 changes: 23 additions & 1 deletion internal/command/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -70,6 +71,7 @@ resources: {}
initCmdFileFlag = "file"
initCmdFileProjectFlag = "project"
initCmdFileNoSampleFlag = "no-sample"
initCmdProvisionerFlag = "provisioners"
)

//go:embed default.provisioners.yaml
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
48 changes: 44 additions & 4 deletions internal/command/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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]

Expand All @@ -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
Expand Down Expand Up @@ -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())
}
46 changes: 46 additions & 0 deletions internal/provisioners/loader/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
}
Loading