-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Ben Meier <[email protected]>
- Loading branch information
1 parent
b1cde5c
commit 59fadcb
Showing
6 changed files
with
329 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters