diff --git a/README.md b/README.md index be76b8b..2446148 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,16 @@ For details of how the standard "template" provisioner works, see the `template: `score-k8s` comes with out-of-the-box support for: -| Type | Class | Params | Output | -| ------------- | ------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| volume | default | (none) | `source` | -| redis | default | (none) | `host`, `port`, `username`, `password` | -| postgres | default | (none) | `host`, `port`, `name` (aka `database`), `username`, `password` | -| mysql | default | (none) | `host`, `port`, `name` (aka `database`), `username`, `password` | -| dns | default | (none) | `host` | -| route | default | `host`, `path`, `port` | | +| Type | Class | Params | Output | +| ------------- | ------- | ---------------------- |-----------------------------------------------------------------| +| volume | default | (none) | `source` | +| redis | default | (none) | `host`, `port`, `username`, `password` | +| postgres | default | (none) | `host`, `port`, `name` (aka `database`), `username`, `password` | +| mysql | default | (none) | `host`, `port`, `name` (aka `database`), `username`, `password` | +| dns | default | (none) | `host` | +| route | default | `host`, `path`, `port` | | +| mongodb | default | (none) | `host`, `port`, `username`, `password`, `name`, `connection` | +| ampq | default | (nont) | `host`, `port`, `username`, `password`, `vhost` | Users are encouraged to write their own custom provisioners to support new resource types or to modify the implementations above. @@ -65,11 +67,17 @@ Examples: # Initialise a new score-k8s project score-k8s init + # Or disable the default score file generation if you already have a score file + score-k8s init --no-sample + + # Optionally loading in provisoners from a remote url + score-k8s 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 + -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 + --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. ``` ### Generate diff --git a/internal/provisioners/loader/load.go b/internal/provisioners/loader/load.go index fee0c9a..c402ece 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 +} diff --git a/main_init.go b/main_init.go index 8aac26c..08dad00 100644 --- a/main_init.go +++ b/main_init.go @@ -15,6 +15,7 @@ package main import ( + "fmt" "log/slog" "os" "path/filepath" @@ -22,16 +23,19 @@ import ( "github.com/pkg/errors" "github.com/score-spec/score-go/framework" scoretypes "github.com/score-spec/score-go/types" + "github.com/score-spec/score-go/uriget" "github.com/spf13/cobra" "gopkg.in/yaml.v3" "github.com/score-spec/score-k8s/internal/project" "github.com/score-spec/score-k8s/internal/provisioners/default" + "github.com/score-spec/score-k8s/internal/provisioners/loader" ) const ( initCmdFileFlag = "file" initCmdFileNoSampleFlag = "no-sample" + initCmdProvisionerFlag = "provisioners" ) var initCmd = &cobra.Command{ @@ -43,10 +47,20 @@ empty state and default provisioners file into the '.score-k8s' subdirectory. The '.score-k8s' directory contains state that will be used to generate any Kubernetes resource manifests including potentially sensitive data and raw secrets, so this should not be checked into generic source control. + +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: ` # Initialise a new score-k8s project - score-k8s init`, + score-k8s init + + # Or disable the default score file generation if you already have a score file + score-k8s init --no-sample + + # Optionally loading in provisoners from a remote url + score-k8s init --provisioners https://raw.githubusercontent.com/user/repo/main/example.yaml`, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true @@ -126,6 +140,26 @@ potentially sensitive data and raw secrets, so this should not be checked into g slog.Info("Skipping creation of initial Score file since it already exists", "file", 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 { + slog.Debug(fmt.Sprintf("Successfully loaded %d resource provisioners", len(provs))) + } + + slog.Info(fmt.Sprintf("Read more about the Score specification at https://docs.score.dev/docs/")) + return nil }, } @@ -133,6 +167,7 @@ potentially sensitive data and raw secrets, so this should not be checked into g func init() { initCmd.Flags().StringP(initCmdFileFlag, "f", "score.yaml", "The score file to initialize") 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/main_init_test.go b/main_init_test.go index c1ba374..6731065 100644 --- a/main_init_test.go +++ b/main_init_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/require" "github.com/score-spec/score-k8s/internal/project" + "github.com/score-spec/score-k8s/internal/provisioners/loader" ) func TestInitNominal(t *testing.T) { @@ -120,3 +121,36 @@ 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-k8s"), loader.DefaultSuffix) + assert.NoError(t, err) + if assert.Greater(t, len(provs), 2) { + assert.Equal(t, "template://two", provs[0].Uri()) + assert.Equal(t, "template://one", provs[1].Uri()) + } +}