Skip to content

Commit 91b4374

Browse files
committed
feat: api: add a route to resolve a templating expression
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]>
1 parent 791d927 commit 91b4374

File tree

6 files changed

+309
-95
lines changed

6 files changed

+309
-95
lines changed

.golangci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ linters:
2828
- goimports
2929
- predeclared
3030
- gosec
31-
- golint
31+
- revive
3232
- nolintlint
3333
- unconvert
3434
- errcheck

api/api_test.go

+144-42
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import (
2626
"github.com/ovh/utask/engine"
2727
"github.com/ovh/utask/engine/input"
2828
"github.com/ovh/utask/engine/step"
29+
"github.com/ovh/utask/engine/step/condition"
2930
"github.com/ovh/utask/engine/step/executor"
31+
"github.com/ovh/utask/engine/values"
3032
"github.com/ovh/utask/models/task"
3133
"github.com/ovh/utask/models/tasktemplate"
3234
"github.com/ovh/utask/pkg/auth"
@@ -276,6 +278,91 @@ func TestPasswordInput(t *testing.T) {
276278
tester.Run()
277279
}
278280

281+
func TestResolutionResolveVar(t *testing.T) {
282+
tester := iffy.NewTester(t, hdl)
283+
284+
dbp, err := zesty.NewDBProvider(utask.DBName)
285+
if err != nil {
286+
t.Fatal(err)
287+
}
288+
289+
tmpl := clientErrorTemplate()
290+
291+
_, err = tasktemplate.LoadFromName(dbp, tmpl.Name)
292+
if err != nil {
293+
if !errors.IsNotFound(err) {
294+
t.Fatal(err)
295+
}
296+
if err := dbp.DB().Insert(&tmpl); err != nil {
297+
t.Fatal(err)
298+
}
299+
}
300+
301+
tester.AddCall("getTemplate", http.MethodGet, "/template/"+tmpl.Name, "").
302+
Headers(regularHeaders).
303+
Checkers(
304+
iffy.ExpectStatus(200),
305+
)
306+
307+
tester.AddCall("newTask", http.MethodPost, "/task", `{"template_name":"{{.getTemplate.name}}","input":{"id":"foobarbuzz"}}`).
308+
Headers(regularHeaders).
309+
Checkers(iffy.ExpectStatus(201))
310+
311+
tester.AddCall("createResolution", http.MethodPost, "/resolution", `{"task_id":"{{.newTask.id}}"}`).
312+
Headers(adminHeaders).
313+
Checkers(iffy.ExpectStatus(201))
314+
315+
tester.AddCall("runResolution", http.MethodPost, "/resolution/{{.createResolution.id}}/run", "").
316+
Headers(adminHeaders).
317+
Checkers(
318+
iffy.ExpectStatus(204),
319+
waitChecker(time.Second), // fugly... need to give resolution manager some time to asynchronously finish running
320+
)
321+
322+
tester.AddCall("getResolution", http.MethodGet, "/resolution/{{.createResolution.id}}", "").
323+
Headers(adminHeaders).
324+
Checkers(
325+
iffy.ExpectStatus(200),
326+
iffy.ExpectJSONBranch("state", "BLOCKED_BADREQUEST"),
327+
)
328+
329+
tester.AddCall("getResolvedValuesError", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{}`).
330+
Headers(adminHeaders).
331+
Checkers(
332+
iffy.ExpectStatus(400),
333+
)
334+
335+
tester.AddCall("getResolvedValues1", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }}.input.id}}"}`).
336+
Headers(adminHeaders).
337+
Checkers(
338+
iffy.ExpectStatus(200),
339+
iffy.ExpectJSONBranch("result", "foobarbuzz"),
340+
)
341+
342+
tester.AddCall("getResolvedValues2", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }} eval \"var1\" }}"}`).
343+
Headers(adminHeaders).
344+
Checkers(
345+
iffy.ExpectStatus(200),
346+
iffy.ExpectJSONBranch("result", "hello id foobarbuzz for bar and BROKEN_TEMPLATING"),
347+
)
348+
349+
tester.AddCall("getResolvedValues3", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }} eval \"var1\" }}","step_name":"step2"}`).
350+
Headers(adminHeaders).
351+
Checkers(
352+
iffy.ExpectStatus(200),
353+
iffy.ExpectJSONBranch("result", "hello id foobarbuzz for bar and CLIENT_ERROR"),
354+
)
355+
356+
tester.AddCall("getResolvedValues4", http.MethodPost, "/resolution/{{.createResolution.id}}/templating", `{"template_str":"{{ "{{" }} eval \"var2\" }}"}`).
357+
Headers(adminHeaders).
358+
Checkers(
359+
iffy.ExpectStatus(200),
360+
iffy.ExpectJSONBranch("result", "5"),
361+
)
362+
363+
tester.Run()
364+
}
365+
279366
func TestPagination(t *testing.T) {
280367
tester := iffy.NewTester(t, hdl)
281368

@@ -450,40 +537,6 @@ func waitChecker(dur time.Duration) iffy.Checker {
450537
}
451538
}
452539

453-
func templatesWithInvalidInputs() []tasktemplate.TaskTemplate {
454-
var tt []tasktemplate.TaskTemplate
455-
for _, inp := range []input.Input{
456-
{
457-
Name: "input-with-redundant-regex",
458-
LegalValues: []interface{}{"a", "b", "c"},
459-
Regex: strPtr("^d.+$"),
460-
},
461-
{
462-
Name: "input-with-bad-regex",
463-
Regex: strPtr("^^[d.+$"),
464-
},
465-
{
466-
Name: "input-with-bad-type",
467-
Type: "bad-type",
468-
},
469-
{
470-
Name: "input-with-bad-legal-values",
471-
Type: "number",
472-
LegalValues: []interface{}{"a", "b", "c"},
473-
},
474-
} {
475-
tt = append(tt, tasktemplate.TaskTemplate{
476-
Name: "invalid-template",
477-
Description: "Invalid template",
478-
TitleFormat: "Invalid template",
479-
Inputs: []input.Input{
480-
inp,
481-
},
482-
})
483-
}
484-
return tt
485-
}
486-
487540
func templateWithPasswordInput() tasktemplate.TaskTemplate {
488541
return tasktemplate.TaskTemplate{
489542
Name: "input-password",
@@ -534,6 +587,63 @@ func dummyTemplate() tasktemplate.TaskTemplate {
534587
}
535588
}
536589

590+
func clientErrorTemplate() tasktemplate.TaskTemplate {
591+
return tasktemplate.TaskTemplate{
592+
Name: "client-error-template",
593+
Description: "does nothing",
594+
TitleFormat: "this task does nothing at all",
595+
Inputs: []input.Input{
596+
{
597+
Name: "id",
598+
},
599+
},
600+
Variables: []values.Variable{
601+
{
602+
Name: "var1",
603+
Value: "hello id {{.input.id }} for {{ .step.step1.output.foo }} and {{ .step.this.state | default \"BROKEN_TEMPLATING\" }}",
604+
},
605+
{
606+
Name: "var2",
607+
Expression: "var a = 3+2; a;",
608+
},
609+
},
610+
Steps: map[string]*step.Step{
611+
"step1": {
612+
Action: executor.Executor{
613+
Type: "echo",
614+
Configuration: json.RawMessage(`{
615+
"output": {"foo":"bar"}
616+
}`),
617+
},
618+
},
619+
"step2": {
620+
Action: executor.Executor{
621+
Type: "echo",
622+
Configuration: json.RawMessage(`{
623+
"output": {"foo":"bar"}
624+
}`),
625+
},
626+
Dependencies: []string{"step1"},
627+
Conditions: []*condition.Condition{
628+
{
629+
If: []*condition.Assert{
630+
{
631+
Expected: "1",
632+
Value: "1",
633+
Operator: "EQ",
634+
},
635+
},
636+
Then: map[string]string{
637+
"this": "CLIENT_ERROR",
638+
},
639+
Type: "skip",
640+
},
641+
},
642+
},
643+
},
644+
}
645+
}
646+
537647
func blockedHidden(name string, blocked, hidden bool) tasktemplate.TaskTemplate {
538648
return tasktemplate.TaskTemplate{
539649
Name: name,
@@ -587,12 +697,4 @@ func expectStringPresent(value string) iffy.Checker {
587697
}
588698
}
589699

590-
func marshalJSON(t *testing.T, i interface{}) string {
591-
jsonBytes, err := json.Marshal(i)
592-
if err != nil {
593-
t.Fatal(err)
594-
}
595-
return string(jsonBytes)
596-
}
597-
598700
func strPtr(s string) *string { return &s }

api/handler/resolution.go

+78
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/loopfz/gadgeto/zesty"
1010
"github.com/sirupsen/logrus"
1111

12+
"github.com/ovh/configstore"
1213
"github.com/ovh/utask"
1314
"github.com/ovh/utask/engine"
1415
"github.com/ovh/utask/engine/step"
@@ -901,3 +902,80 @@ func UpdateResolutionStepState(c *gin.Context, in *updateResolutionStepStateIn)
901902

902903
return nil
903904
}
905+
906+
type resolveTemplatingResolutionIn struct {
907+
PublicID string `path:"id" validate:"required"`
908+
TemplateStr string `json:"template_str" validate:"required"`
909+
StepName string `json:"step_name"`
910+
}
911+
912+
// ResolveTemplatingResolutionOut is the output of the HTTP route
913+
// for ResolveTemplatingResolution
914+
type ResolveTemplatingResolutionOut struct {
915+
Result string `json:"result"`
916+
Error *string `json:"error"`
917+
}
918+
919+
// ResolveTemplatingResolution will use µtask templating engine for a given resolution
920+
// to validate a given template. Action is restricted to admin only, as it could be used
921+
// to exfiltrate configuration.
922+
func ResolveTemplatingResolution(c *gin.Context, in *resolveTemplatingResolutionIn) (*ResolveTemplatingResolutionOut, error) {
923+
metadata.AddActionMetadata(c, metadata.ResolutionID, in.PublicID)
924+
925+
dbp, err := zesty.NewDBProvider(utask.DBName)
926+
if err != nil {
927+
return nil, err
928+
}
929+
930+
r, err := resolution.LoadFromPublicID(dbp, in.PublicID)
931+
if err != nil {
932+
return nil, err
933+
}
934+
935+
t, err := task.LoadFromID(dbp, r.TaskID)
936+
if err != nil {
937+
return nil, err
938+
}
939+
940+
metadata.AddActionMetadata(c, metadata.TaskID, t.PublicID)
941+
942+
tt, err := tasktemplate.LoadFromID(dbp, t.TemplateID)
943+
if err != nil {
944+
return nil, err
945+
}
946+
947+
metadata.AddActionMetadata(c, metadata.TemplateName, tt.Name)
948+
949+
admin := auth.IsAdmin(c) == nil
950+
951+
if !admin {
952+
return nil, errors.Forbiddenf("You are not allowed to resolve resolution variables")
953+
}
954+
955+
metadata.SetSUDO(c)
956+
957+
// provide the resolution with values
958+
t.ExportTaskInfos(r.Values)
959+
r.Values.SetInput(t.Input)
960+
r.Values.SetResolverInput(r.ResolverInput)
961+
r.Values.SetVariables(tt.Variables)
962+
963+
config, err := utask.GetTemplatingConfig(configstore.DefaultStore)
964+
if err != nil {
965+
return nil, err
966+
}
967+
968+
r.Values.SetConfig(config)
969+
970+
output, err := r.Values.Apply(in.TemplateStr, nil, in.StepName)
971+
if err != nil {
972+
errStr := err.Error()
973+
return &ResolveTemplatingResolutionOut{
974+
Error: &errStr,
975+
}, nil
976+
}
977+
978+
return &ResolveTemplatingResolutionOut{
979+
Result: string(output),
980+
}, nil
981+
}

api/server.go

+8
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,14 @@ func (s *Server) build(ctx context.Context) {
401401
},
402402
maintenanceMode,
403403
tonic.Handler(handler.UpdateResolutionStepState, 204))
404+
resolutionRoutes.POST("/resolution/:id/templating",
405+
[]fizz.OperationOption{
406+
fizz.ID("ResolveTemplatingResolution"),
407+
fizz.Summary("Resolve templating of a resolution"),
408+
fizz.Description("Resolve the templating of a string, within a task resolution. Admin users only."),
409+
},
410+
maintenanceMode,
411+
tonic.Handler(handler.ResolveTemplatingResolution, 200))
404412

405413
// resolutionRoutes.POST("/resolution/:id/rollback",
406414
// []fizz.OperationOption{

0 commit comments

Comments
 (0)