Skip to content

Commit

Permalink
feat: added cmd provisioner
Browse files Browse the repository at this point in the history
Signed-off-by: Ben Meier <[email protected]>
  • Loading branch information
astromechza committed May 10, 2024
1 parent b1cde5c commit 59fadcb
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 3 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
162 changes: 162 additions & 0 deletions internal/provisioners/cmdprov/commandprov.go
Original file line number Diff line number Diff line change
@@ -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 <mode> arg, we mark it as "provision".
args := slices.Clone(p.Args)
for i, arg := range args {
if arg == "<mode>" {
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
}
131 changes: 131 additions & 0 deletions internal/provisioners/cmdprov/commandprov_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
13 changes: 12 additions & 1 deletion internal/provisioners/default/zz-default.provisioners.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 '<mode>' 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.
Expand Down
8 changes: 8 additions & 0 deletions internal/provisioners/loader/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
16 changes: 15 additions & 1 deletion internal/provisioners/loader/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 59fadcb

Please sign in to comment.