Skip to content

Commit

Permalink
feat: api: add a route to resolve a templating expression
Browse files Browse the repository at this point in the history
When debugging a failed task, we often need to resolve templated value
that we struggle to inspect. POST /resolution/:id/templating will
execute a templating expression given as input, and returns the resolved
expression as output. This route can only be used by admins.

Signed-off-by: Romain Beuque <[email protected]>
  • Loading branch information
rbeuque74 authored and rclsilver committed May 6, 2022
1 parent 7d2e130 commit 31a60a1
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 94 deletions.
99 changes: 57 additions & 42 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,40 +558,6 @@ func waitChecker(dur time.Duration) iffy.Checker {
}
}

func templatesWithInvalidInputs() []tasktemplate.TaskTemplate {
var tt []tasktemplate.TaskTemplate
for _, inp := range []input.Input{
{
Name: "input-with-redundant-regex",
LegalValues: []interface{}{"a", "b", "c"},
Regex: strPtr("^d.+$"),
},
{
Name: "input-with-bad-regex",
Regex: strPtr("^^[d.+$"),
},
{
Name: "input-with-bad-type",
Type: "bad-type",
},
{
Name: "input-with-bad-legal-values",
Type: "number",
LegalValues: []interface{}{"a", "b", "c"},
},
} {
tt = append(tt, tasktemplate.TaskTemplate{
Name: "invalid-template",
Description: "Invalid template",
TitleFormat: "Invalid template",
Inputs: []input.Input{
inp,
},
})
}
return tt
}

func templateWithPasswordInput() tasktemplate.TaskTemplate {
return tasktemplate.TaskTemplate{
Name: "input-password",
Expand Down Expand Up @@ -706,6 +672,63 @@ func dummyTemplate() tasktemplate.TaskTemplate {
}
}

func clientErrorTemplate() tasktemplate.TaskTemplate {
return tasktemplate.TaskTemplate{
Name: "client-error-template",
Description: "does nothing",
TitleFormat: "this task does nothing at all",
Inputs: []input.Input{
{
Name: "id",
},
},
Variables: []values.Variable{
{
Name: "var1",
Value: "hello id {{.input.id }} for {{ .step.step1.output.foo }} and {{ .step.this.state | default \"BROKEN_TEMPLATING\" }}",
},
{
Name: "var2",
Expression: "var a = 3+2; a;",
},
},
Steps: map[string]*step.Step{
"step1": {
Action: executor.Executor{
Type: "echo",
Configuration: json.RawMessage(`{
"output": {"foo":"bar"}
}`),
},
},
"step2": {
Action: executor.Executor{
Type: "echo",
Configuration: json.RawMessage(`{
"output": {"foo":"bar"}
}`),
},
Dependencies: []string{"step1"},
Conditions: []*condition.Condition{
{
If: []*condition.Assert{
{
Expected: "1",
Value: "1",
Operator: "EQ",
},
},
Then: map[string]string{
"this": "CLIENT_ERROR",
},
Type: "skip",
},
},
},
},
}
}

func blockedHidden(name string, blocked, hidden bool) tasktemplate.TaskTemplate {
return tasktemplate.TaskTemplate{
Name: name,
Expand Down Expand Up @@ -759,12 +782,4 @@ func expectStringPresent(value string) iffy.Checker {
}
}

func marshalJSON(t *testing.T, i interface{}) string {
jsonBytes, err := json.Marshal(i)
if err != nil {
t.Fatal(err)
}
return string(jsonBytes)
}

func strPtr(s string) *string { return &s }
78 changes: 78 additions & 0 deletions api/handler/resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/loopfz/gadgeto/zesty"
"github.com/sirupsen/logrus"

"github.com/ovh/configstore"
"github.com/ovh/utask"
"github.com/ovh/utask/engine"
"github.com/ovh/utask/engine/step"
Expand Down Expand Up @@ -932,3 +933,80 @@ func UpdateResolutionStepState(c *gin.Context, in *updateResolutionStepStateIn)

return nil
}

type resolveTemplatingResolutionIn struct {
PublicID string `path:"id" validate:"required"`
TemplatingExpression string `json:"templating_expression" validate:"required"`
StepName string `json:"step_name"`
}

// ResolveTemplatingResolutionOut is the output of the HTTP route
// for ResolveTemplatingResolution
type ResolveTemplatingResolutionOut struct {
Result string `json:"result"`
Error *string `json:"error,omitempty"`
}

// ResolveTemplatingResolution will use µtask templating engine for a given resolution
// to validate a given template. Action is restricted to admin only, as it could be used
// to exfiltrate configuration.
func ResolveTemplatingResolution(c *gin.Context, in *resolveTemplatingResolutionIn) (*ResolveTemplatingResolutionOut, error) {
metadata.AddActionMetadata(c, metadata.ResolutionID, in.PublicID)

dbp, err := zesty.NewDBProvider(utask.DBName)
if err != nil {
return nil, err
}

r, err := resolution.LoadFromPublicID(dbp, in.PublicID)
if err != nil {
return nil, err
}

t, err := task.LoadFromID(dbp, r.TaskID)
if err != nil {
return nil, err
}

metadata.AddActionMetadata(c, metadata.TaskID, t.PublicID)

tt, err := tasktemplate.LoadFromID(dbp, t.TemplateID)
if err != nil {
return nil, err
}

metadata.AddActionMetadata(c, metadata.TemplateName, tt.Name)

admin := auth.IsAdmin(c) == nil

if !admin {
return nil, errors.Forbiddenf("You are not allowed to resolve resolution variables")
}

metadata.SetSUDO(c)

// provide the resolution with values
t.ExportTaskInfos(r.Values)
r.Values.SetInput(t.Input)
r.Values.SetResolverInput(r.ResolverInput)
r.Values.SetVariables(tt.Variables)

config, err := utask.GetTemplatingConfig(configstore.DefaultStore)
if err != nil {
return nil, err
}

r.Values.SetConfig(config)

output, err := r.Values.Apply(in.TemplatingExpression, nil, in.StepName)
if err != nil {
errStr := err.Error()
return &ResolveTemplatingResolutionOut{
Error: &errStr,
}, nil
}

return &ResolveTemplatingResolutionOut{
Result: string(output),
}, nil
}
8 changes: 8 additions & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,14 @@ func (s *Server) build(ctx context.Context) {
},
maintenanceMode,
tonic.Handler(handler.UpdateResolutionStepState, 204))
resolutionRoutes.POST("/resolution/:id/templating",
[]fizz.OperationOption{
fizz.ID("ResolveTemplatingResolution"),
fizz.Summary("Resolve templating of a resolution"),
fizz.Description("Resolve the templating of a string, within a task resolution. Admin users only."),
},
maintenanceMode,
tonic.Handler(handler.ResolveTemplatingResolution, 200))

// resolutionRoutes.POST("/resolution/:id/rollback",
// []fizz.OperationOption{
Expand Down
56 changes: 4 additions & 52 deletions engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ package engine
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"

"github.com/cenkalti/backoff"
"github.com/ghodss/yaml"
expbk "github.com/jpillora/backoff"
"github.com/juju/errors"
"github.com/loopfz/gadgeto/zesty"
Expand Down Expand Up @@ -64,38 +62,13 @@ func Init(ctx context.Context, wg *sync.WaitGroup, store *configstore.Store) err
if err != nil {
return err
}
// get all configuration items
itemList, err := store.GetItemList()
if err != nil {
return err
}
// Squash to ensure that secrets with lower priority
// are dismissed.
itemList = configstore.Filter().Squash().Apply(itemList)

// drop those that shouldnt be available for task execution
// (don't let DB credentials leak, for instance...)
config, err := filteredConfig(itemList, cfg.ConcealedSecrets...)
if err != nil {
var engineCfg map[string]interface{}
if engineCfg, err = utask.GetTemplatingConfig(store); err != nil {
return err
}
// attempt to deserialize json formatted config items
// -> make it easier to access internal nodes/values when templating
eng.config = make(map[string]interface{})
for k, v := range config {
var i interface{}
if v != nil {
err := yaml.Unmarshal([]byte(*v), &i, func(dec *json.Decoder) *json.Decoder {
dec.UseNumber()
return dec
})
if err != nil {
eng.config[k] = v
} else {
eng.config[k] = i
}
}
}

eng.config = engineCfg

// channels for handling graceful shutdown
shutdownCtx = ctx
Expand Down Expand Up @@ -150,27 +123,6 @@ func Init(ctx context.Context, wg *sync.WaitGroup, store *configstore.Store) err
return nil
}

// filteredConfig takes a configstore item list, drops some items by key
// then reduces the result into a map of key->values
func filteredConfig(list *configstore.ItemList, dropAlias ...string) (map[string]*string, error) {
cfg := make(map[string]*string)
for _, i := range list.Items {
if !utils.ListContainsString(dropAlias, i.Key()) {
// assume only one value per alias
if _, ok := cfg[i.Key()]; !ok {
v, err := i.Value()
if err != nil {
return nil, err
}
if len(v) > 0 {
cfg[i.Key()] = &v
}
}
}
}
return cfg, nil
}

// GetEngine returns the singleton instance of Engine
func GetEngine() Engine {
return eng
Expand Down
74 changes: 74 additions & 0 deletions utask.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"time"

"github.com/ghodss/yaml"
"golang.org/x/sync/semaphore"

"github.com/ovh/configstore"
Expand Down Expand Up @@ -386,3 +387,76 @@ func Config(store *configstore.Store) (*Cfg, error) {

return global, nil
}

// GetTemplatingConfig returns the µTask configuration without sensible piece of configuration
// such as encryption key, ... to be used by templating functions.
func GetTemplatingConfig(store *configstore.Store) (map[string]interface{}, error) {
cfg, err := Config(store)
if err != nil {
return nil, err
}
// get all configuration items
itemList, err := store.GetItemList()
if err != nil {
return nil, err
}
// Squash to ensure that secrets with lower priority
// are dismissed.
itemList = configstore.Filter().Squash().Apply(itemList)

// drop those that shouldnt be available for task execution
// (don't let DB credentials leak, for instance...)
config, err := filteredConfig(itemList, cfg.ConcealedSecrets...)
if err != nil {
return nil, err
}
// attempt to deserialize json formatted config items
// -> make it easier to access internal nodes/values when templating
c := make(map[string]interface{})
for k, v := range config {
var i interface{}
if v != nil {
err := yaml.Unmarshal([]byte(*v), &i, func(dec *json.Decoder) *json.Decoder {
dec.UseNumber()
return dec
})
if err != nil {
c[k] = v
} else {
c[k] = i
}
}
}
return c, nil
}

// filteredConfig takes a configstore item list, drops some items by key
// then reduces the result into a map of key->values
func filteredConfig(list *configstore.ItemList, dropAlias ...string) (map[string]*string, error) {
cfg := make(map[string]*string)
for _, i := range list.Items {
if !listContainsString(dropAlias, i.Key()) {
// assume only one value per alias
if _, ok := cfg[i.Key()]; !ok {
v, err := i.Value()
if err != nil {
return nil, err
}
if len(v) > 0 {
cfg[i.Key()] = &v
}
}
}
}
return cfg, nil
}

// listContainsString asserts that a string slice contains a given string
func listContainsString(list []string, item string) bool {
for _, i := range list {
if i == item {
return true
}
}
return false
}

0 comments on commit 31a60a1

Please sign in to comment.