-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implemented template provisioner type and file based loading
- Loading branch information
1 parent
b39a115
commit 2bd0dae
Showing
4 changed files
with
380 additions
and
0 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
package loader | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"net/url" | ||
"os" | ||
"path/filepath" | ||
"strings" | ||
|
||
"gopkg.in/yaml.v3" | ||
|
||
"github.com/score-spec/score-compose/internal/provisioners" | ||
"github.com/score-spec/score-compose/internal/provisioners/templateprov" | ||
) | ||
|
||
// LoadProvisioners loads a list of provisioners from the raw contents from a yaml file. | ||
func LoadProvisioners(raw []byte) ([]provisioners.Provisioner, error) { | ||
var intermediate []map[string]interface{} | ||
if err := yaml.NewDecoder(bytes.NewReader(raw)).Decode(&intermediate); err != nil { | ||
return nil, fmt.Errorf("failed to decode file: %w", err) | ||
} | ||
out := make([]provisioners.Provisioner, 0, len(intermediate)) | ||
for i, m := range intermediate { | ||
uri, _ := m["uri"].(string) | ||
u, err := url.Parse(uri) | ||
if err != nil { | ||
return nil, fmt.Errorf("%d: invalid uri '%s'", i, u) | ||
} else if u.Scheme == "" { | ||
return nil, fmt.Errorf("%d: missing uri schema '%s'", i, u) | ||
} | ||
switch u.Scheme { | ||
case "template": | ||
if p, err := templateprov.Parse(m); err != nil { | ||
return nil, fmt.Errorf("%d: %s: failed to parse: %w", i, uri, err) | ||
} else { | ||
out = append(out, p) | ||
} | ||
default: | ||
return nil, fmt.Errorf("%d: unsupported provisioner type '%s'", i, u.Scheme) | ||
} | ||
} | ||
return out, nil | ||
} | ||
|
||
// LoadProvisionersFromDirectory loads all providers we can find in files that end in the common suffix. | ||
func LoadProvisionersFromDirectory(path string, suffix string) ([]provisioners.Provisioner, error) { | ||
items, err := os.ReadDir(path) | ||
if err != nil { | ||
return nil, err | ||
} | ||
out := make([]provisioners.Provisioner, 0) | ||
for _, item := range items { | ||
if !item.IsDir() && strings.HasSuffix(item.Name(), suffix) { | ||
raw, err := os.ReadFile(filepath.Join(path, item.Name())) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to read '%s': %w", item.Name(), err) | ||
} | ||
p, err := LoadProvisioners(raw) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to load '%s': %w", item.Name(), err) | ||
} | ||
out = append(out, p...) | ||
} | ||
} | ||
return out, 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,73 @@ | ||
package loader | ||
|
||
import ( | ||
"os" | ||
"path/filepath" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/score-spec/score-compose/internal/project" | ||
"github.com/score-spec/score-compose/internal/util" | ||
) | ||
|
||
func TestLoadProvisioners(t *testing.T) { | ||
|
||
t.Run("empty", func(t *testing.T) { | ||
p, err := LoadProvisioners([]byte(`[]`)) | ||
require.NoError(t, err) | ||
assert.Len(t, p, 0) | ||
}) | ||
|
||
t.Run("nominal", func(t *testing.T) { | ||
p, err := LoadProvisioners([]byte(` | ||
- uri: template://example | ||
type: thing | ||
class: default | ||
id: specific | ||
state: | | ||
number: {{ 1 }} | ||
`)) | ||
require.NoError(t, err) | ||
assert.Len(t, p, 1) | ||
assert.Equal(t, "template://example", p[0].Uri()) | ||
assert.True(t, p[0].Match(project.NewResourceUid("w", "r", "thing", nil, util.Ref("specific")))) | ||
}) | ||
|
||
t.Run("unknown schema", func(t *testing.T) { | ||
_, err := LoadProvisioners([]byte(` | ||
- uri: blah://example | ||
type: thing | ||
`)) | ||
require.EqualError(t, err, "0: unsupported provisioner type 'blah'") | ||
}) | ||
|
||
t.Run("missing uri", func(t *testing.T) { | ||
_, err := LoadProvisioners([]byte(` | ||
- type: thing | ||
`)) | ||
require.EqualError(t, err, "0: missing uri schema ''") | ||
}) | ||
|
||
} | ||
|
||
func TestLoadProvisionersFromDirectory(t *testing.T) { | ||
td := t.TempDir() | ||
assert.NoError(t, os.WriteFile(filepath.Join(td, "00.p.yaml"), []byte(` | ||
- uri: template://example-a | ||
type: thing | ||
`), 0600)) | ||
assert.NoError(t, os.WriteFile(filepath.Join(td, "01.p.yaml"), []byte(` | ||
- uri: template://example-b | ||
type: thing | ||
`), 0600)) | ||
|
||
p, err := LoadProvisionersFromDirectory(td, ".p.yaml") | ||
require.NoError(t, err) | ||
uris := make([]string, len(p)) | ||
for i, prv := range p { | ||
uris[i] = prv.Uri() | ||
} | ||
assert.Equal(t, []string{"template://example-a", "template://example-b"}, uris) | ||
} |
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,164 @@ | ||
package templateprov | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"fmt" | ||
"html/template" | ||
|
||
compose "github.com/compose-spec/compose-go/v2/types" | ||
"gopkg.in/yaml.v3" | ||
|
||
"github.com/score-spec/score-compose/internal/project" | ||
"github.com/score-spec/score-compose/internal/provisioners" | ||
) | ||
|
||
type Provisioner struct { | ||
ProvisionerUri string `yaml:"uri"` | ||
ResType string `yaml:"type"` | ||
ResClass *string `yaml:"class,omitempty"` | ||
ResId *string `yaml:"id,omitempty"` | ||
|
||
InitTemplate string `yaml:"init,omitempty"` | ||
StateTemplate string `yaml:"state,omitempty"` | ||
OutputsTemplate string `yaml:"outputs,omitempty"` | ||
SharedStateTemplate string `yaml:"shared,omitempty"` | ||
|
||
RelativeDirectoriesTemplate string `yaml:"directories,omitempty"` | ||
RelativeFilesTemplate string `yaml:"files,omitempty"` | ||
|
||
ComposeNetworksTemplate string `yaml:"networks,omitempty"` | ||
ComposeVolumesTemplate string `yaml:"volumes,omitempty"` | ||
ComposeServicesTemplate string `yaml:"services,omitempty"` | ||
} | ||
|
||
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") | ||
} | ||
return p, nil | ||
} | ||
|
||
func (p *Provisioner) Uri() string { | ||
return p.ProvisionerUri | ||
} | ||
|
||
func (p *Provisioner) Match(resUid project.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 renderTemplateAndDecode(raw string, data interface{}, out interface{}) error { | ||
if raw == "" { | ||
return nil | ||
} | ||
prepared, err := template.New("").Parse(raw) | ||
if err != nil { | ||
return fmt.Errorf("failed to parse template: %w", err) | ||
} | ||
buff := new(bytes.Buffer) | ||
if err := prepared.Execute(buff, data); err != nil { | ||
return fmt.Errorf("failed to execute template: %w", err) | ||
} | ||
dec := yaml.NewDecoder(buff) | ||
dec.KnownFields(true) | ||
if err := dec.Decode(out); err != nil { | ||
return fmt.Errorf("failed to decode output: %w", err) | ||
} | ||
return nil | ||
} | ||
|
||
type Data struct { | ||
Uid string | ||
Type string | ||
Class string | ||
Id string | ||
Params map[string]interface{} | ||
Metadata map[string]interface{} | ||
|
||
Init map[string]interface{} | ||
State map[string]interface{} | ||
Shared map[string]interface{} | ||
} | ||
|
||
func (p *Provisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) { | ||
out := &provisioners.ProvisionOutput{} | ||
|
||
// The data payload that gets passed into each template | ||
data := Data{ | ||
Uid: input.ResourceUid, | ||
Type: input.ResourceType, | ||
Class: input.ResourceClass, | ||
Id: input.ResourceId, | ||
Params: input.ResourceParams, | ||
Metadata: input.ResourceMetadata, | ||
State: input.ResourceState, | ||
Shared: input.SharedState, | ||
} | ||
|
||
init := make(map[string]interface{}) | ||
if err := renderTemplateAndDecode(p.InitTemplate, &data, &init); err != nil { | ||
return nil, fmt.Errorf("init template failed: %w", err) | ||
} | ||
data.Init = init | ||
|
||
out.ResourceState = make(map[string]interface{}) | ||
if err := renderTemplateAndDecode(p.StateTemplate, &data, &out.ResourceState); err != nil { | ||
return nil, fmt.Errorf("state template failed: %w", err) | ||
} | ||
data.State = out.ResourceState | ||
|
||
out.ResourceOutputs = make(map[string]interface{}) | ||
if err := renderTemplateAndDecode(p.OutputsTemplate, &data, &out.ResourceOutputs); err != nil { | ||
return nil, fmt.Errorf("outputs template failed: %w", err) | ||
} | ||
|
||
out.SharedState = make(map[string]interface{}) | ||
if err := renderTemplateAndDecode(p.SharedStateTemplate, &data, &out.SharedState); err != nil { | ||
return nil, fmt.Errorf("shared template failed: %w", err) | ||
} | ||
|
||
out.RelativeDirectories = make(map[string]bool) | ||
if err := renderTemplateAndDecode(p.RelativeDirectoriesTemplate, &data, &out.RelativeDirectories); err != nil { | ||
return nil, fmt.Errorf("directories template failed: %w", err) | ||
} | ||
|
||
out.RelativeFileContents = make(map[string]*string) | ||
if err := renderTemplateAndDecode(p.RelativeFilesTemplate, &data, &out.RelativeFileContents); err != nil { | ||
return nil, fmt.Errorf("files template failed: %w", err) | ||
} | ||
|
||
out.ComposeNetworks = make(map[string]compose.NetworkConfig) | ||
if err := renderTemplateAndDecode(p.ComposeNetworksTemplate, &data, &out.ComposeNetworks); err != nil { | ||
return nil, fmt.Errorf("networks template failed: %w", err) | ||
} | ||
|
||
out.ComposeServices = make(map[string]compose.ServiceConfig) | ||
if err := renderTemplateAndDecode(p.ComposeServicesTemplate, &data, &out.ComposeServices); err != nil { | ||
return nil, fmt.Errorf("networks template failed: %w", err) | ||
} | ||
|
||
out.ComposeVolumes = make(map[string]compose.VolumeConfig) | ||
if err := renderTemplateAndDecode(p.ComposeVolumesTemplate, &data, &out.ComposeVolumes); err != nil { | ||
return nil, fmt.Errorf("volumes template failed: %w", err) | ||
} | ||
|
||
return out, nil | ||
} | ||
|
||
var _ provisioners.Provisioner = (*Provisioner)(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,76 @@ | ||
package templateprov | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
compose "github.com/compose-spec/compose-go/v2/types" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/score-spec/score-compose/internal/project" | ||
"github.com/score-spec/score-compose/internal/provisioners" | ||
"github.com/score-spec/score-compose/internal/util" | ||
) | ||
|
||
func TestProvision(t *testing.T) { | ||
td := t.TempDir() | ||
resUid := project.NewResourceUid("w", "r", "thing", nil, nil) | ||
p, err := Parse(map[string]interface{}{ | ||
"uri": "template://example", | ||
"type": resUid.Type(), | ||
"init": ` | ||
a: {{ .Uid }} | ||
b: {{ .Type }} | ||
`, | ||
"state": ` | ||
a: {{ .Init.a }} | ||
b: stuff | ||
`, | ||
"outputs": ` | ||
b: {{ .State.b }} | ||
`, | ||
"shared": ` | ||
c: 1 | ||
`, | ||
"directories": `{"blah": true}`, | ||
"files": `{"blah/foo": "content"}`, | ||
"networks": ` | ||
default: | ||
driver: default | ||
`, | ||
"volumes": ` | ||
some-vol: | ||
driver: default | ||
`, | ||
"services": ` | ||
some-svc: | ||
name: foo | ||
`, | ||
}) | ||
require.NoError(t, err) | ||
out, err := p.Provision(context.Background(), &provisioners.Input{ | ||
ResourceUid: string(resUid), | ||
ResourceType: resUid.Type(), | ||
ResourceClass: resUid.Class(), | ||
ResourceId: resUid.Id(), | ||
ResourceParams: map[string]interface{}{"pk": "pv"}, | ||
ResourceMetadata: map[string]interface{}{"mk": "mv"}, | ||
ResourceState: map[string]interface{}{"sk": "sv"}, | ||
SharedState: map[string]interface{}{"ssk": "ssv"}, | ||
MountDirectoryPath: td, | ||
}) | ||
require.NoError(t, err) | ||
require.NotNil(t, out) | ||
assert.Equal(t, map[string]interface{}{ | ||
"a": "thing.default#w.r", | ||
"b": "stuff", | ||
}, out.ResourceState) | ||
assert.Equal(t, map[string]interface{}{"b": "stuff"}, out.ResourceOutputs) | ||
assert.Equal(t, map[string]interface{}{"c": 1}, out.SharedState) | ||
assert.Equal(t, map[string]bool{"blah": true}, out.RelativeDirectories) | ||
assert.Equal(t, map[string]*string{"blah/foo": util.Ref("content")}, out.RelativeFileContents) | ||
assert.Equal(t, map[string]compose.NetworkConfig{"default": {Driver: "default"}}, out.ComposeNetworks) | ||
assert.Equal(t, map[string]compose.VolumeConfig{"some-vol": {Driver: "default"}}, out.ComposeVolumes) | ||
assert.Equal(t, map[string]compose.ServiceConfig{"some-svc": {Name: "foo"}}, out.ComposeServices) | ||
} |