From 59fadcb7e21377cda397fc5a41262de08fcffa25 Mon Sep 17 00:00:00 2001 From: Ben Meier Date: Fri, 10 May 2024 12:04:13 +0100 Subject: [PATCH] feat: added cmd provisioner Signed-off-by: Ben Meier --- README.md | 2 +- internal/provisioners/cmdprov/commandprov.go | 162 ++++++++++++++++++ .../provisioners/cmdprov/commandprov_test.go | 131 ++++++++++++++ .../default/zz-default.provisioners.yaml | 13 +- internal/provisioners/loader/load.go | 8 + internal/provisioners/loader/load_test.go | 16 +- 6 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 internal/provisioners/cmdprov/commandprov.go create mode 100644 internal/provisioners/cmdprov/commandprov_test.go diff --git a/README.md b/README.md index b4a56f1..54d1744 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Provisioners are loaded from any `*.provisioners.yaml` files in the local `.scor Generally, users will want to copy in the provisioners files that work with their cluster. For example, if the cluster has postgres or mysql operators installed, then -For details of how the standard "template" provisioner works, see the `template://example-provisioners/example-provisioner` provisioner [here](internal/provisioners/default/zz-default.provisioners.yaml). +For details of how the standard "template" provisioner works, see the `template://example-provisioners/example-provisioner` provisioner [here](internal/provisioners/default/zz-default.provisioners.yaml). For details of how the standard "cmd" provisioner works, see the `cmd://bash#example-provisioner` provisioner [here](internal/provisioners/default/zz-default.provisioners.yaml). ## Usage diff --git a/internal/provisioners/cmdprov/commandprov.go b/internal/provisioners/cmdprov/commandprov.go new file mode 100644 index 0000000..c8bc67a --- /dev/null +++ b/internal/provisioners/cmdprov/commandprov.go @@ -0,0 +1,162 @@ +// Copyright 2024 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 cmdprov + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "github.com/score-spec/score-go/framework" + "gopkg.in/yaml.v3" + + "github.com/score-spec/score-k8s/internal/provisioners" +) + +type Provisioner struct { + ProvisionerUri string `yaml:"uri"` + ResType string `yaml:"type"` + ResClass *string `yaml:"class,omitempty"` + ResId *string `yaml:"id,omitempty"` + Args []string `yaml:"args"` +} + +func (p *Provisioner) Uri() string { + return p.ProvisionerUri +} + +func (p *Provisioner) Match(resUid framework.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 decodeBinary(uri string) (string, error) { + parts, _ := url.Parse(uri) + pathParts := strings.Split(parts.EscapedPath(), "/") + switch parts.Hostname() { + case "": + return string(filepath.Separator) + filepath.Join(pathParts...), nil + case "~": + hd, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve user home directory: %w", err) + } + pathParts = slices.Insert(pathParts, 0, hd) + case ".": + pwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to resolve current working directory: %w", err) + } + pathParts = slices.Insert(pathParts, 0, pwd) + case "..": + pwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to resolve current working directory: %w", err) + } + pathParts = slices.Insert(pathParts, 0, filepath.Dir(pwd)) + default: + if len(pathParts) > 1 { + return "", fmt.Errorf("direct command reference cannot contain additional path parts") + } + b, err := exec.LookPath(parts.Hostname()) + if err != nil { + return "", fmt.Errorf("failed to find '%s' on path: %w", parts.Hostname(), err) + } + pathParts = slices.Insert(pathParts, 0, b) + } + return filepath.Join(pathParts...), nil +} + +func (p *Provisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) { + bin, err := decodeBinary(p.Uri()) + if err != nil { + return nil, err + } + + rawInput, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to encode json input: %w", err) + } + outputBuffer := new(bytes.Buffer) + + // if there is a arg, we mark it as "provision". + args := slices.Clone(p.Args) + for i, arg := range args { + if arg == "" { + args[i] = "provision" + } + } + + cmd := exec.CommandContext(ctx, bin, args...) + slog.Debug(fmt.Sprintf("Executing '%s %v' for command provisioner", bin, args)) + cmd.Stdin = bytes.NewReader(rawInput) + cmd.Stdout = outputBuffer + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to execute cmd provisioner: %w", err) + } + + var output provisioners.ProvisionOutput + dec := json.NewDecoder(bytes.NewReader(outputBuffer.Bytes())) + dec.DisallowUnknownFields() + if err := dec.Decode(&output); err != nil { + slog.Debug("Output from command provisioner:\n" + outputBuffer.String()) + return nil, fmt.Errorf("failed to decode output from cmd provisioner: %w", err) + } + + return &output, nil +} + +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") + } + + parts, err := url.Parse(p.ProvisionerUri) + if err != nil { + return nil, fmt.Errorf("failed to parse url: %w", err) + } else if parts.User != nil { + return nil, fmt.Errorf("cmd provisioner uri cannot contain user info") + } else if len(parts.Query()) != 0 { + return nil, fmt.Errorf("cmd provisioner uri cannot contain query params") + } else if parts.Port() != "" { + return nil, fmt.Errorf("cmd provisioner uri cannot contain a port") + } + + return p, nil +} diff --git a/internal/provisioners/cmdprov/commandprov_test.go b/internal/provisioners/cmdprov/commandprov_test.go new file mode 100644 index 0000000..1b1cccf --- /dev/null +++ b/internal/provisioners/cmdprov/commandprov_test.go @@ -0,0 +1,131 @@ +// Copyright 2024 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 cmdprov + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/score-spec/score-k8s/internal/provisioners" +) + +func TestParseUri_success(t *testing.T) { + for _, k := range []string{ + "cmd://python", + "cmd:///absolute/path/here", + "cmd://./relative/path", + "cmd://../parent/path", + "cmd://~/path", + } { + t.Run(k, func(t *testing.T) { + out, err := Parse(map[string]interface{}{"uri": k, "type": "foo"}) + if assert.NoError(t, err) { + assert.Equal(t, k, out.Uri()) + } + }) + } +} + +func TestParseUri_fail(t *testing.T) { + for k, v := range map[string]string{ + "": "uri not set", + "cmd://x:x": "failed to parse url: parse \"cmd://x:x\": invalid port \":x\" after host", + "cmd://something@foo": "cmd provisioner uri cannot contain user info", + "cmd://something:80": "cmd provisioner uri cannot contain a port", + "cmd://something?foo=x": "cmd provisioner uri cannot contain query params", + } { + t.Run(k, func(t *testing.T) { + _, err := Parse(map[string]interface{}{"uri": k, "type": "foo"}) + assert.EqualError(t, err, v) + }) + } +} + +func TestDecodeBinary_success(t *testing.T) { + dir, _ := os.Getwd() + gogo, _ := exec.LookPath("go") + for k, v := range map[string]string{ + "cmd://./thing": dir + "/thing", + "cmd://../thing/foo": filepath.Dir(dir) + "/thing/foo", + "cmd://~/path": os.Getenv("HOME") + "/path", + "cmd:///absolute/path": "/absolute/path", + "cmd://go": gogo, + } { + t.Run(k, func(t *testing.T) { + out, err := decodeBinary(k) + if assert.NoError(t, err) { + assert.Equal(t, v, out) + } + }) + } +} + +func TestDecodeBinary_fail(t *testing.T) { + for k, v := range map[string]string{ + "cmd://absolutely-unknown-score-compose-provisioner": "failed to find 'absolutely-unknown-score-compose-provisioner' on path: exec: \"absolutely-unknown-score-compose-provisioner\": executable file not found in $PATH", + "cmd://something/foo": "direct command reference cannot contain additional path parts", + } { + t.Run(k, func(t *testing.T) { + _, err := decodeBinary(k) + assert.EqualError(t, err, v) + }) + } +} + +func TestProvision_success(t *testing.T) { + p, err := Parse(map[string]interface{}{ + "uri": "cmd://sh", + "type": "thing", + "args": []string{"-c", "echo '{\"resource_outputs\":' `cat` '}'"}, + }) + require.NoError(t, err) + po, err := p.Provision(context.Background(), &provisioners.Input{ + ResourceUid: "thing.default#w.r", + }) + require.NoError(t, err) + assert.Equal(t, "thing.default#w.r", po.ResourceOutputs["resource_uid"]) +} + +func TestProvision_fail_command(t *testing.T) { + p, err := Parse(map[string]interface{}{ + "uri": "cmd://sh", + "type": "thing", + "args": []string{"-c", "false"}, + }) + require.NoError(t, err) + _, err = p.Provision(context.Background(), &provisioners.Input{ + ResourceUid: "thing.default#w.r", + }) + require.EqualError(t, err, "failed to execute cmd provisioner: exit status 1") +} + +func TestProvision_fail_decode(t *testing.T) { + p, err := Parse(map[string]interface{}{ + "uri": "cmd://sh", + "type": "thing", + "args": []string{"-c", "echo bananas"}, + }) + require.NoError(t, err) + _, err = p.Provision(context.Background(), &provisioners.Input{ + ResourceUid: "thing.default#w.r", + }) + require.EqualError(t, err, "failed to decode output from cmd provisioner: invalid character 'b' looking for beginning of value") +} diff --git a/internal/provisioners/default/zz-default.provisioners.yaml b/internal/provisioners/default/zz-default.provisioners.yaml index b7f13c6..9f286a2 100644 --- a/internal/provisioners/default/zz-default.provisioners.yaml +++ b/internal/provisioners/default/zz-default.provisioners.yaml @@ -1,4 +1,5 @@ -# This example provisioner is a fake resource type used to demonstrate the template provisioner mechanism. +# This example provisioner is a fake resource type used to demonstrate the template provisioner mechanism. The URI should +# be a unique indicator of the provisioner. The scheme indicates how it is executed. - uri: template://example-provisioners/example-provisioner # (Required) Which resource type to match type: example-provisioner-resource @@ -49,6 +50,16 @@ data: key: {{ .Init.key }} +# The 'cmd' scheme has a "host" + path component that indicates the path to the binary to execute. If the host starts +# with "." it is interpreted as a relative path, if it starts with "~" it resolves to the home directory. +- uri: cmd://bash#example-provisioner + type: example-provisioner-resource + class: default + id: specific + # (Optional) additional args that the binary gets run with + # If any of the args are '' it will be replaced with "provision" + args: ["-c", "echo '{\"resource_outputs\":{\"key\":\"value\"},\"manifests\":[]}'"] + # As an example we have a 'volume' type which returns an emptyDir volume. # In production or for real applications you may want to replace this with a provisioner for a tmpfs, host path, or # persistent volume and claims. diff --git a/internal/provisioners/loader/load.go b/internal/provisioners/loader/load.go index a030d17..fee0c9a 100644 --- a/internal/provisioners/loader/load.go +++ b/internal/provisioners/loader/load.go @@ -26,6 +26,7 @@ import ( "gopkg.in/yaml.v3" "github.com/score-spec/score-k8s/internal/provisioners" + "github.com/score-spec/score-k8s/internal/provisioners/cmdprov" "github.com/score-spec/score-k8s/internal/provisioners/templateprov" ) @@ -54,6 +55,13 @@ func LoadProvisioners(raw []byte) ([]provisioners.Provisioner, error) { slog.Debug(fmt.Sprintf("Loaded provisioner %s", p.Uri())) out = append(out, p) } + case "cmd": + if p, err := cmdprov.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) } diff --git a/internal/provisioners/loader/load_test.go b/internal/provisioners/loader/load_test.go index 3d5a88f..81b9b07 100644 --- a/internal/provisioners/loader/load_test.go +++ b/internal/provisioners/loader/load_test.go @@ -34,7 +34,7 @@ func TestLoadProvisioners(t *testing.T) { assert.Len(t, p, 0) }) - t.Run("nominal", func(t *testing.T) { + t.Run("nominal template", func(t *testing.T) { p, err := LoadProvisioners([]byte(` - uri: template://example type: thing @@ -49,6 +49,20 @@ func TestLoadProvisioners(t *testing.T) { assert.True(t, p[0].Match(framework.NewResourceUid("w", "r", "thing", nil, internal.Ref("specific")))) }) + t.Run("nominal cmd", func(t *testing.T) { + p, err := LoadProvisioners([]byte(` +- uri: cmd://my-binary + type: thing + class: default + id: specific + args: ["first", "second"] +`)) + require.NoError(t, err) + assert.Len(t, p, 1) + assert.Equal(t, "template://example", p[0].Uri()) + assert.True(t, p[0].Match(framework.NewResourceUid("w", "r", "thing", nil, internal.Ref("specific")))) + }) + t.Run("unknown schema", func(t *testing.T) { _, err := LoadProvisioners([]byte(` - uri: blah://example