Skip to content

Commit

Permalink
feat: added provisioner input, output, and apply
Browse files Browse the repository at this point in the history
Signed-off-by: Ben Meier <[email protected]>
  • Loading branch information
astromechza committed Mar 9, 2024
1 parent c8060fb commit b39a115
Show file tree
Hide file tree
Showing 5 changed files with 460 additions and 14 deletions.
62 changes: 48 additions & 14 deletions internal/command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ The Apache Software Foundation (http://www.apache.org/).
package command

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"os"
"sort"
"strings"
Expand All @@ -29,6 +29,7 @@ import (

"github.com/score-spec/score-compose/internal/compose"
"github.com/score-spec/score-compose/internal/project"
"github.com/score-spec/score-compose/internal/provisioners"
)

const (
Expand Down Expand Up @@ -188,7 +189,12 @@ func run(cmd *cobra.Command, args []string) error {
// Instead of actually calling the resource provisioning system, we skip it and fill in the supported resources
// ourselves.
vars := new(compose.EnvVarTracker)
state, err = fillInLegacyResourceOutputFunctions(spec.Metadata["name"].(string), state, vars)
provisionerList, err := buildLegacyProvisioners(spec.Metadata["name"].(string), state, vars)
if err != nil {
return err
}

state, err = provisioners.ProvisionResources(context.Background(), state, provisionerList, nil)
if err != nil {
return fmt.Errorf("failed to provision resources: %w", err)
}
Expand Down Expand Up @@ -269,27 +275,55 @@ func run(cmd *cobra.Command, args []string) error {
return nil
}

func fillInLegacyResourceOutputFunctions(workloadName string, state *project.State, evt *compose.EnvVarTracker) (*project.State, error) {
out := *state
out.Resources = maps.Clone(state.Resources)
func buildLegacyProvisioners(workloadName string, state *project.State, evt *compose.EnvVarTracker) ([]provisioners.Provisioner, error) {
out := make([]provisioners.Provisioner, 0)
for resName, res := range state.Workloads[workloadName].Spec.Resources {
resUid := project.NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
resState := state.Resources[resUid]
if resUid.Type() == "environment" {
if resUid.Class() != "default" {
return nil, fmt.Errorf("resources '%s': '%s.%s' is not supported in score-compose", resUid, resUid.Type(), resUid.Class())
}
resState.OutputLookupFunc = evt.LookupOutput
out = append(out, &legacyProvisioner{
ProvisionerUri: "builtin://environment",
MatchResourceUid: resUid,
OutputLookupFunc: evt.LookupOutput,
})
} else if resUid.Type() == "volume" && resUid.Class() == "default" {
resState.OutputLookupFunc = func(keys ...string) (interface{}, error) {
return nil, fmt.Errorf("resource has no outputs")
}
out = append(out, &legacyProvisioner{
ProvisionerUri: "builtin://legacy-volume",
MatchResourceUid: resUid,
OutputLookupFunc: func(keys ...string) (interface{}, error) {
return nil, fmt.Errorf("resource has no outputs")
},
})
} else {
slog.Warn(fmt.Sprintf("resources.%s: '%s.%s' is not directly supported in score-compose, references will be converted to environment variables", resName, resUid.Type(), resUid.Class()))
fake := evt.GenerateResource(resName)
resState.OutputLookupFunc = fake.LookupOutput
out = append(out, &legacyProvisioner{
ProvisionerUri: "builtin://legacy-var",
MatchResourceUid: resUid,
OutputLookupFunc: evt.GenerateResource(resName).LookupOutput,
})
}
out.Resources[resUid] = resState
}
return &out, nil
return out, nil
}

type legacyProvisioner struct {
ProvisionerUri string
MatchResourceUid project.ResourceUid
OutputLookupFunc project.OutputLookupFunc
}

func (l *legacyProvisioner) Uri() string {
return l.ProvisionerUri
}

func (l *legacyProvisioner) Match(resUid project.ResourceUid) bool {
return l.MatchResourceUid == resUid
}

func (l *legacyProvisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
return &provisioners.ProvisionOutput{
OutputLookupFunc: l.OutputLookupFunc,
}, nil
}
196 changes: 196 additions & 0 deletions internal/provisioners/core.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package provisioners

import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"os"
"path/filepath"
"slices"

compose "github.com/compose-spec/compose-go/v2/types"

"github.com/score-spec/score-compose/internal/project"
"github.com/score-spec/score-compose/internal/util"
)

// Input is the set of thins passed to the provisioner implementation. It provides context, previous state, and shared
// state used by all resources.
type Input struct {
// -- aspects from the resource declaration --

ResourceUid string `json:"resource_uid"`
ResourceType string `json:"resource_type"`
ResourceClass string `json:"resource_class"`
ResourceId string `json:"resource_id"`
ResourceParams map[string]interface{} `json:"resource_params"`
ResourceMetadata map[string]interface{} `json:"resource_metadata"`

// -- current state --

ResourceState map[string]interface{} `json:"resource_state"`
SharedState map[string]interface{} `json:"shared_state"`

// -- configuration --

MountDirectoryPath string `json:"mount_directory_path"`
}

// ProvisionOutput is the output returned from a provisioner implementation.
type ProvisionOutput struct {
ResourceState map[string]interface{} `json:"resource_state"`
ResourceOutputs map[string]interface{} `json:"resource_outputs"`
SharedState map[string]interface{} `json:"shared_state"`
RelativeDirectories map[string]bool `json:"relative_directories"`
RelativeFileContents map[string]*string `json:"relative_file_contents"`
ComposeNetworks map[string]compose.NetworkConfig `json:"compose_networks"`
ComposeVolumes map[string]compose.VolumeConfig `json:"compose_volumes"`
ComposeServices map[string]compose.ServiceConfig `json:"compose_services"`

// For testing and legacy reasons, built in provisioners can set a direct lookup function
OutputLookupFunc project.OutputLookupFunc `json:"-"`
}

type Provisioner interface {
Uri() string
Match(resUid project.ResourceUid) bool
Provision(ctx context.Context, input *Input) (*ProvisionOutput, error)
}

// ApplyToStateAndProject takes the outputs of a provisioning request and applies to the state, file tree, and docker
// compose project.
func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid project.ResourceUid, project *compose.Project) (*project.State, error) {
out := *state
out.Resources = maps.Clone(state.Resources)

existing, ok := out.Resources[resUid]
if !ok {
return nil, fmt.Errorf("failed to apply to state - unknown res uid")
}

// State must ALWAYS be updated. If we don't get state back, we assume it's now empty.
if po.ResourceState != nil {
existing.State = po.ResourceState
} else {
existing.State = make(map[string]interface{})
}

// Same with outputs, it must ALWAYS be updated.
if po.ResourceOutputs != nil {
existing.Outputs = po.ResourceOutputs
} else {
existing.Outputs = make(map[string]interface{})
}

if po.OutputLookupFunc != nil {
existing.OutputLookupFunc = po.OutputLookupFunc
}

if po.SharedState != nil {
out.SharedState = util.PatchMap(state.SharedState, po.SharedState)
}

for relativePath, b := range po.RelativeDirectories {
relativePath = filepath.Clean(relativePath)
if !filepath.IsLocal(relativePath) {
return nil, fmt.Errorf("failing to write non relative volume directory '%s'", relativePath)
}
dst := filepath.Join(state.MountsDirectory, relativePath)
if b {
slog.Debug(fmt.Sprintf("Ensuring mount directory '%s' exists", dst))
if err := os.MkdirAll(dst, 0755); err != nil && !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("failed to create volume directory '%s': %w", dst, err)
}
} else {
slog.Debug(fmt.Sprintf("Ensuring mount directory '%s' no longer exists", dst))
if err := os.RemoveAll(dst); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to delete volume directory '%s': %w", dst, err)
}
}
}

for relativePath, b := range po.RelativeFileContents {
relativePath = filepath.Clean(relativePath)
if !filepath.IsLocal(relativePath) {
return nil, fmt.Errorf("failing to write non relative volume directory '%s'", relativePath)
}
dst := filepath.Join(state.MountsDirectory, relativePath)
if b != nil {
slog.Debug(fmt.Sprintf("Ensuring mount file '%s' exists", dst))
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil && !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("failed to create directories for file '%s': %w", dst, err)
}
if err := os.WriteFile(dst, []byte(*b), 0644); err != nil {
return nil, fmt.Errorf("failed to write file '%s': %w", dst, err)
}
} else {
slog.Debug(fmt.Sprintf("Ensuring mount file '%s' no longer exists", dst))
if err := os.Remove(dst); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to delete file '%s': %w", dst, err)
}
}
}

for networkName, network := range po.ComposeNetworks {
if project.Networks == nil {
project.Networks = make(compose.Networks)
}
project.Networks[networkName] = network
}
for volumeName, volume := range po.ComposeVolumes {
if project.Volumes == nil {
project.Volumes = make(compose.Volumes)
}
project.Volumes[volumeName] = volume
}
for serviceName, service := range po.ComposeServices {
if project.Services == nil {
project.Services = make(compose.Services)
}
project.Services[serviceName] = service
}

out.Resources[resUid] = existing
return &out, nil
}

func ProvisionResources(ctx context.Context, state *project.State, provisioners []Provisioner, composeProject *compose.Project) (*project.State, error) {
out := state

for resUid, resState := range state.Resources {
provisionerIndex := slices.IndexFunc(provisioners, func(provisioner Provisioner) bool {
return provisioner.Match(resUid)
})
if provisionerIndex < 0 {
return nil, fmt.Errorf("resource '%s' is not supported by any provisioner", resUid)
}
provisioner := provisioners[provisionerIndex]
if resState.ProvisionerUri != "" && resState.ProvisionerUri != provisioner.Uri() {
return nil, fmt.Errorf("resource '%s' was previously provisioned by a different provider - undefined behavior", resUid)
}

output, err := provisioner.Provision(ctx, &Input{
ResourceUid: string(resUid),
ResourceType: resUid.Type(),
ResourceClass: resUid.Class(),
ResourceId: resUid.Id(),
ResourceParams: resState.Params,
ResourceMetadata: resState.Metadata,
ResourceState: resState.State,
SharedState: out.SharedState,
MountDirectoryPath: state.MountsDirectory,
})
if err != nil {
return nil, fmt.Errorf("resource '%s': failed to provision: %w", resUid, err)
}

out, err = output.ApplyToStateAndProject(out, resUid, composeProject)
if err != nil {
return nil, fmt.Errorf("resource '%s': failed to apply outputs: %w", resUid, err)
}
}

return out, nil
}
99 changes: 99 additions & 0 deletions internal/provisioners/core_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package provisioners

import (
"io/fs"
"os"
"path/filepath"
"slices"
"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/util"
)

func TestApplyToStateAndProject(t *testing.T) {
resUid := project.NewResourceUid("w", "r", "t", nil, nil)
startState := &project.State{
Resources: map[project.ResourceUid]project.ScoreResourceState{
resUid: {},
},
}

t.Run("set first provision with no outputs", func(t *testing.T) {
td := t.TempDir()
startState.MountsDirectory = td
composeProject := &compose.Project{}
output := &ProvisionOutput{}
afterState, err := output.ApplyToStateAndProject(startState, resUid, composeProject)
require.NoError(t, err)
assert.Equal(t, project.ScoreResourceState{
State: map[string]interface{}{},
Outputs: map[string]interface{}{},
}, afterState.Resources[resUid])
})

t.Run("set first provision with some outputs", func(t *testing.T) {
td := t.TempDir()
startState.MountsDirectory = td
composeProject := &compose.Project{}
output := &ProvisionOutput{
ResourceState: map[string]interface{}{"a": "b", "c": nil},
ResourceOutputs: map[string]interface{}{"x": "y"},
SharedState: map[string]interface{}{"i": "j", "k": nil},
RelativeDirectories: map[string]bool{
"one/two/three": true,
"four": false,
"five": true,
},
RelativeFileContents: map[string]*string{
"one/two/three/thing.txt": util.Ref("hello-world"),
"six/other.txt": util.Ref("blah"),
"something.md": nil,
},
ComposeNetworks: map[string]compose.NetworkConfig{
"some-network": {Name: "network"},
},
ComposeServices: map[string]compose.ServiceConfig{
"some-service": {Name: "service"},
},
ComposeVolumes: map[string]compose.VolumeConfig{
"some-volume": {Name: "volume"},
},
}
afterState, err := output.ApplyToStateAndProject(startState, resUid, composeProject)
require.NoError(t, err)
assert.Equal(t, project.ScoreResourceState{
State: map[string]interface{}{"a": "b", "c": nil},
Outputs: map[string]interface{}{"x": "y"},
}, afterState.Resources[resUid])
assert.Equal(t, map[string]interface{}{"i": "j"}, afterState.SharedState)
assert.Len(t, composeProject.Networks, 1)
assert.Len(t, composeProject.Volumes, 1)
assert.Len(t, composeProject.Services, 1)
paths := make([]string, 0)
_ = filepath.WalkDir(td, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
if items, _ := os.ReadDir(path); len(items) > 0 {
return nil
}
path, _ = filepath.Rel(td, path)
paths = append(paths, path+"/")
} else {
path, _ = filepath.Rel(td, path)
paths = append(paths, path)
}
return nil
})
slices.Sort(paths)
assert.Equal(t, []string{
"five/",
"one/two/three/thing.txt",
"six/other.txt",
}, paths)
})

}
Loading

0 comments on commit b39a115

Please sign in to comment.