Skip to content

Commit

Permalink
feat: implemented template provisioner type and file based loading
Browse files Browse the repository at this point in the history
  • Loading branch information
astromechza committed Mar 10, 2024
1 parent b39a115 commit 2bd0dae
Show file tree
Hide file tree
Showing 4 changed files with 380 additions and 0 deletions.
67 changes: 67 additions & 0 deletions internal/provisioners/loader/load.go
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
}
73 changes: 73 additions & 0 deletions internal/provisioners/loader/load_test.go
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)
}
164 changes: 164 additions & 0 deletions internal/provisioners/templateprov/template.go
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)
76 changes: 76 additions & 0 deletions internal/provisioners/templateprov/template_test.go
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)
}

0 comments on commit 2bd0dae

Please sign in to comment.