Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: api: add a route to resolve a templating expression #303

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, Component, Input } from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { BehaviorSubject } from "rxjs";
import Resolution from "../../@models/resolution.model";
import { ApiService } from "../../@services/api.service";

@Component({
selector: "lib-utask-resoution-expression",
templateUrl: "./resolution-expression.html",
styleUrls: ["./resolution-expression.sass"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ResolutionExpressionComponent {
private _resolution: Resolution;
private _steps$ = new BehaviorSubject<String[]>([]);
private _result$ = new BehaviorSubject<String | null>(null);
private _error$ = new BehaviorSubject<String | null>(null);

readonly formGroup: FormGroup;

readonly steps$ = this._steps$.asObservable();
readonly result$ = this._result$.asObservable();
readonly error$ = this._error$.asObservable();

@Input("resolution") set resolution(r: Resolution) {
if ((this._resolution = r)) {
this._steps$.next(Object.keys(r.steps));
} else {
this._steps$.next([]);
}
}

get resolution(): Resolution {
return this._resolution;
}

constructor(private _api: ApiService, _builder: FormBuilder) {
this.formGroup = _builder.group({
step: ["", [Validators.required]],
expression: ["", [Validators.required]],
});
}

reset(): void {
this.formGroup.reset();
this._result$.next(null);
this._error$.next(null);
}

submit(): void {
const { step, expression } = this.formGroup.value;

this._api.resolution
.templating(this._resolution, step, expression)
.subscribe(
(result) => {
if (result.error) {
this._result$.next(null);
this._error$.next(result.error);
} else {
this._result$.next(result.result);
this._error$.next(null);
}
},
(e) => {
this._result$.next(null);
this._error$.next(e.error.error);
}
);
}
}
Loading