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 committed Dec 21, 2021
1 parent 791d927 commit 91b4374
Show file tree
Hide file tree
Showing 6 changed files with 309 additions and 95 deletions.
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ linters:
- goimports
- predeclared
- gosec
- golint
- revive
- nolintlint
- unconvert
- errcheck
Expand Down
186 changes: 144 additions & 42 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import (
"github.com/ovh/utask/engine"
"github.com/ovh/utask/engine/input"
"github.com/ovh/utask/engine/step"
"github.com/ovh/utask/engine/step/condition"
"github.com/ovh/utask/engine/step/executor"
"github.com/ovh/utask/engine/values"
"github.com/ovh/utask/models/task"
"github.com/ovh/utask/models/tasktemplate"
"github.com/ovh/utask/pkg/auth"
Expand Down Expand Up @@ -276,6 +278,91 @@ func TestPasswordInput(t *testing.T) {
tester.Run()
}

func TestResolutionResolveVar(t *testing.T) {
tester := iffy.NewTester(t, hdl)

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

tmpl := clientErrorTemplate()

_, err = tasktemplate.LoadFromName(dbp, tmpl.Name)
if err != nil {
if !errors.IsNotFound(err) {
t.Fatal(err)
}
if err := dbp.DB().Insert(&tmpl); err != nil {
t.Fatal(err)
}
}

tester.AddCall("getTemplate", http.MethodGet, "/template/"+tmpl.Name, "").
Headers(regularHeaders).
Checkers(
iffy.ExpectStatus(200),
)

tester.AddCall("newTask", http.MethodPost, "/task", `{"template_name":"{{.getTemplate.name}}","input":{"id":"foobarbuzz"}}`).
Headers(regularHeaders).
Checkers(iffy.ExpectStatus(201))

tester.AddCall("createResolution", http.MethodPost, "/resolution", `{"task_id":"{{.newTask.id}}"}`).
Headers(adminHeaders).
Checkers(iffy.ExpectStatus(201))

tester.AddCall("runResolution", http.MethodPost, "/resolution/{{.createResolution.id}}/run", "").
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(204),
waitChecker(time.Second), // fugly... need to give resolution manager some time to asynchronously finish running
)

tester.AddCall("getResolution", http.MethodGet, "/resolution/{{.createResolution.id}}", "").
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(200),
iffy.ExpectJSONBranch("state", "BLOCKED_BADREQUEST"),
)

tester.AddCall("getResolvedValuesError", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{}`).
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(400),
)

tester.AddCall("getResolvedValues1", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }}.input.id}}"}`).
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(200),
iffy.ExpectJSONBranch("result", "foobarbuzz"),
)

tester.AddCall("getResolvedValues2", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }} eval \"var1\" }}"}`).
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(200),
iffy.ExpectJSONBranch("result", "hello id foobarbuzz for bar and BROKEN_TEMPLATING"),
)

tester.AddCall("getResolvedValues3", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }} eval \"var1\" }}","step_name":"step2"}`).
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(200),
iffy.ExpectJSONBranch("result", "hello id foobarbuzz for bar and CLIENT_ERROR"),
)

tester.AddCall("getResolvedValues4", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }} eval \"var2\" }}"}`).
Headers(adminHeaders).
Checkers(
iffy.ExpectStatus(200),
iffy.ExpectJSONBranch("result", "5"),
)

tester.Run()
}

func TestPagination(t *testing.T) {
tester := iffy.NewTester(t, hdl)

Expand Down Expand Up @@ -450,40 +537,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 @@ -534,6 +587,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 @@ -587,12 +697,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 @@ -901,3 +902,80 @@ func UpdateResolutionStepState(c *gin.Context, in *updateResolutionStepStateIn)

return nil
}

type resolveTemplatingResolutionIn struct {
PublicID string `path:"id" validate:"required"`
TemplateStr string `json:"template_str" 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"`
}

// 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.TemplateStr, 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
Loading

0 comments on commit 91b4374

Please sign in to comment.