From 2cbce963b723d7be26ffce6ef8b54fc44aae37f6 Mon Sep 17 00:00:00 2001 From: James Shubin Date: Tue, 28 Nov 2023 13:49:31 -0500 Subject: [PATCH] engine: resources, lang: funcs, parser: Add panic magic It's valuable to check your runtime values and to shut down the entire engine in case something doesn't match. This patch adds some magic plumbing to support a "panic" mechanism. A new "panic" statement gets transparently converted into a panic function and panic resource. The former errors if the input is not empty. The latter must be present to consume the value, but doesn't actually do anything. --- engine/resources/panic.go | 114 ++++++++++++++++++ examples/lang/panic/main.mcl | 8 ++ examples/lang/panic0.mcl | 18 +++ lang/ast/structs.go | 76 +++++++++--- lang/funcs/core/panic_func.go | 46 +++++++ lang/interfaces/const.go | 6 + lang/interpret_test.go | 31 ++++- lang/interpret_test/TestAstFunc2/panic0.txtar | 6 + lang/interpret_test/TestAstFunc2/panic1.txtar | 7 ++ lang/interpret_test/TestAstFunc2/panic2.txtar | 6 + lang/parser/lexer.nex | 5 + lang/parser/parser.y | 22 ++++ 12 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 engine/resources/panic.go create mode 100644 examples/lang/panic/main.mcl create mode 100644 examples/lang/panic0.mcl create mode 100644 lang/funcs/core/panic_func.go create mode 100644 lang/interpret_test/TestAstFunc2/panic0.txtar create mode 100644 lang/interpret_test/TestAstFunc2/panic1.txtar create mode 100644 lang/interpret_test/TestAstFunc2/panic2.txtar diff --git a/engine/resources/panic.go b/engine/resources/panic.go new file mode 100644 index 00000000..793ceb42 --- /dev/null +++ b/engine/resources/panic.go @@ -0,0 +1,114 @@ +// Mgmt +// Copyright (C) 2013-2023+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package resources + +import ( + "context" + "fmt" + + "github.com/purpleidea/mgmt/engine" + "github.com/purpleidea/mgmt/engine/traits" + "github.com/purpleidea/mgmt/lang/interfaces" +) + +func init() { + // It starts with an underscore so a user can't add it manually. + engine.RegisterResource(interfaces.PanicResKind, func() engine.Res { return &PanicRes{} }) +} + +// PanicRes is a no-op resource that does nothing as quietly as possible. One of +// these will be added the graph if you use the panic function. (Even when it is +// in a non-panic mode.) This is possibly the simplest resource that exists, and +// in fact, everytime it is used, it will always have the same "name" value. It +// is only used so that there is a valid destination for the panic function. +type PanicRes struct { + traits.Base // add the base methods without re-implementation + + init *engine.Init +} + +// Default returns some sensible defaults for this resource. +func (obj *PanicRes) Default() engine.Res { + return &PanicRes{} +} + +// Validate if the params passed in are valid data. +func (obj *PanicRes) Validate() error { + return nil +} + +// Init runs some startup code for this resource. +func (obj *PanicRes) Init(init *engine.Init) error { + obj.init = init // save for later + + return nil +} + +// Cleanup is run by the engine to clean up after the resource is done. +func (obj *PanicRes) Cleanup() error { + return nil +} + +// Watch is the primary listener for this resource and it outputs events. +func (obj *PanicRes) Watch(ctx context.Context) error { + obj.init.Running() // when started, notify engine that we're running + + select { + case <-ctx.Done(): // closed by the engine to signal shutdown + } + + //obj.init.Event() // notify engine of an event (this can block) + + return nil +} + +// CheckApply method for Panic resource. Does nothing, returns happy! +func (obj *PanicRes) CheckApply(context.Context, bool) (bool, error) { + return true, nil // state is always okay +} + +// Cmp compares two resources and returns an error if they are not equivalent. +func (obj *PanicRes) Cmp(r engine.Res) error { + // we can only compare PanicRes to others of the same resource kind + _, ok := r.(*PanicRes) + if !ok { + return fmt.Errorf("not a %s", obj.Kind()) + } + + return nil +} + +// UnmarshalYAML is the custom unmarshal handler for this struct. It is +// primarily useful for setting the defaults. +func (obj *PanicRes) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawRes PanicRes // indirection to avoid infinite recursion + + def := obj.Default() // get the default + res, ok := def.(*PanicRes) // put in the right format + if !ok { + return fmt.Errorf("could not convert to PanicRes") + } + raw := rawRes(*res) // convert; the defaults go here + + if err := unmarshal(&raw); err != nil { + return err + } + + *obj = PanicRes(raw) // restore from indirection with type conversion! + return nil +} diff --git a/examples/lang/panic/main.mcl b/examples/lang/panic/main.mcl new file mode 100644 index 00000000..93ca063e --- /dev/null +++ b/examples/lang/panic/main.mcl @@ -0,0 +1,8 @@ +class foo() { + panic("fail3") # should panic + panic("fail4") # should panic + + test "nested-test" { + anotherstr => "hello!\n", + } +} diff --git a/examples/lang/panic0.mcl b/examples/lang/panic0.mcl new file mode 100644 index 00000000..e5bbc116 --- /dev/null +++ b/examples/lang/panic0.mcl @@ -0,0 +1,18 @@ +import "panic/" as nested # local, relative module to prove it can nest + +panic("") # should NOT panic +panic("") # should NOT panic +panic("fail1") # should panic +panic("fail2") # should panic + +include nested.foo() + +test "test" { + anotherstr => "hello!\n", +} + +# this is what we're simulating: +#$_panic1 = panic("whatever1") # this is a function +#_panic $_panic1 {} # this is a resource +#$_panic2 = panic("whatever2") +#_panic $_panic2 {} diff --git a/lang/ast/structs.go b/lang/ast/structs.go index 051a9f17..f9991aea 100644 --- a/lang/ast/structs.go +++ b/lang/ast/structs.go @@ -291,6 +291,8 @@ type StmtRes struct { Name interfaces.Expr // unique name for the res of this kind namePtr interfaces.Func // ptr for table lookup Contents []StmtResContents // list of fields/edges in parsed order + + allowUnderscores bool // secret flag to bypass some validation } // String returns a short representation of this statement. @@ -319,7 +321,10 @@ func (obj *StmtRes) Apply(fn func(interfaces.Node) error) error { // Init initializes this branch of the AST, and returns an error if it fails to // validate. func (obj *StmtRes) Init(data *interfaces.Data) error { - if strings.Contains(obj.Kind, "_") { + + isPanic := obj.allowUnderscores && obj.Kind == interfaces.PanicResKind + + if strings.Contains(obj.Kind, "_") && !isPanic { return fmt.Errorf("kind must not contain underscores") } @@ -379,10 +384,11 @@ func (obj *StmtRes) Interpolate() (interfaces.Stmt, error) { } return &StmtRes{ - data: obj.data, - Kind: obj.Kind, - Name: name, - Contents: contents, + data: obj.data, + Kind: obj.Kind, + Name: name, + Contents: contents, + allowUnderscores: obj.allowUnderscores, }, nil } @@ -419,10 +425,11 @@ func (obj *StmtRes) Copy() (interfaces.Stmt, error) { return obj, nil } return &StmtRes{ - data: obj.data, - Kind: obj.Kind, - Name: name, - Contents: contents, + data: obj.data, + Kind: obj.Kind, + Name: name, + Contents: contents, + allowUnderscores: obj.allowUnderscores, }, nil } @@ -2833,6 +2840,8 @@ type StmtProg struct { importProgs []*StmtProg // list of child programs after running SetScope importFiles []string // list of files seen during the SetScope import + panicCounter uint // number of possible different panics + Body []interfaces.Stmt } @@ -2887,6 +2896,36 @@ func (obj *StmtProg) Interpolate() (interfaces.Stmt, error) { return nil, err } body = append(body, interpolated) + + // If we have the magic bind statement, then add the res. + // NOTE: We could have used a custom StmtPanic instead here... + if bind, ok := interpolated.(*StmtBind); ok && bind.Ident == interfaces.PanicVarName { + // TODO: does it still work if we have multiple StmtProg's? + obj.panicCounter++ + name := fmt.Sprintf("%s%d", interfaces.PanicVarName, obj.panicCounter) + bind.Ident = name // change name to magic name + exprVar := &ExprVar{ + Name: name, // use magic name to match + + allowUnderscores: true, // allow our magic name + } + if err := exprVar.Init(obj.data); err != nil { + return nil, errwrap.Wrapf(err, "special panic ExprVar Init error during interpolate") + } + stmtRes := &StmtRes{ + Kind: interfaces.PanicResKind, // special resource kind + Name: exprVar, + Contents: []StmtResContents{}, + + allowUnderscores: true, // allow our magic kind + } + if err := stmtRes.Init(obj.data); err != nil { + return nil, errwrap.Wrapf(err, "special panic StmtRes Init error during interpolate") + } + + body = append(body, stmtRes) + continue + } } return &StmtProg{ data: obj.data, @@ -8250,6 +8289,8 @@ type ExprVar struct { typ *types.Type Name string // name of the variable + + allowUnderscores bool // secret flag to bypass some validation } // String returns a short representation of this expression. @@ -8265,6 +8306,9 @@ func (obj *ExprVar) Apply(fn func(interfaces.Node) error) error { return fn(obj) // Init initializes this branch of the AST, and returns an error if it fails to // validate. func (obj *ExprVar) Init(*interfaces.Data) error { + if obj.allowUnderscores && strings.HasPrefix(obj.Name, interfaces.PanicVarName) { + return nil + } return langutil.ValidateVarName(obj.Name) } @@ -8275,9 +8319,10 @@ func (obj *ExprVar) Init(*interfaces.Data) error { // support variable, variables or anything crazy like that. func (obj *ExprVar) Interpolate() (interfaces.Expr, error) { return &ExprVar{ - scope: obj.scope, - typ: obj.typ, - Name: obj.Name, + scope: obj.scope, + typ: obj.typ, + Name: obj.Name, + allowUnderscores: obj.allowUnderscores, }, nil } @@ -8288,9 +8333,10 @@ func (obj *ExprVar) Interpolate() (interfaces.Expr, error) { // and they won't be able to have different values. func (obj *ExprVar) Copy() (interfaces.Expr, error) { return &ExprVar{ - scope: obj.scope, - typ: obj.typ, - Name: obj.Name, + scope: obj.scope, + typ: obj.typ, + Name: obj.Name, + allowUnderscores: obj.allowUnderscores, }, nil } diff --git a/lang/funcs/core/panic_func.go b/lang/funcs/core/panic_func.go new file mode 100644 index 00000000..a79c863b --- /dev/null +++ b/lang/funcs/core/panic_func.go @@ -0,0 +1,46 @@ +// Mgmt +// Copyright (C) 2013-2023+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package core + +import ( + "fmt" + + "github.com/purpleidea/mgmt/lang/funcs/simple" + "github.com/purpleidea/mgmt/lang/types" +) + +func init() { + simple.Register("panic", &types.FuncValue{ + T: types.NewType("func(x str) str"), + V: Panic, + }) +} + +// Panic returns an error when it receives a non-empty string. The error should +// cause the function engine to shutdown. +func Panic(input []types.Value) (types.Value, error) { + if s := input[0].Str(); s != "" { + // This StrValue not really used here, since we error... + return &types.StrValue{ + V: s, + }, fmt.Errorf("panic occurred: %s", s) + } + return &types.StrValue{ + V: "panic", // name can't be empty + }, nil +} diff --git a/lang/interfaces/const.go b/lang/interfaces/const.go index 3b425c08..7026ca4e 100644 --- a/lang/interfaces/const.go +++ b/lang/interfaces/const.go @@ -25,4 +25,10 @@ const ( // VarPrefix is the prefix character that precedes the variables // identifier. For example, `$foo` or for a lambda, `$fn(42)`. VarPrefix = "$" + + // PanicResKind is the kind string used for the panic resource. + PanicResKind = "_panic" + + // PanicVarName is the magic name used for the panic output var. + PanicVarName = "_panic" ) diff --git a/lang/interpret_test.go b/lang/interpret_test.go index a860632d..107916b0 100644 --- a/lang/interpret_test.go +++ b/lang/interpret_test.go @@ -514,6 +514,7 @@ func TestAstFunc2(t *testing.T) { const magicErrorSetScope = "errSetScope: " const magicErrorUnify = "errUnify: " const magicErrorGraph = "errGraph: " + const magicErrorStream = "errStream: " const magicErrorInterpret = "errInterpret: " const magicErrorAutoEdge = "errAutoEdge: " const magicEmpty = "# empty!" @@ -650,6 +651,7 @@ func TestAstFunc2(t *testing.T) { failSetScope := false failUnify := false failGraph := false + failStream := false failInterpret := false failAutoEdge := false if strings.HasPrefix(expstr, magicError) { @@ -686,6 +688,11 @@ func TestAstFunc2(t *testing.T) { expstr = errStr failGraph = true } + if strings.HasPrefix(expstr, magicErrorStream) { + errStr = strings.TrimPrefix(expstr, magicErrorStream) + expstr = errStr + failStream = true + } if strings.HasPrefix(expstr, magicErrorInterpret) { errStr = strings.TrimPrefix(expstr, magicErrorInterpret) expstr = errStr @@ -1124,8 +1131,28 @@ func TestAstFunc2(t *testing.T) { return } if err != nil { - t.Errorf("test #%d: FAIL", index) - t.Errorf("test #%d: stream errored: %+v", index, err) + if (!fail || !failStream) && err != nil { + t.Errorf("test #%d: FAIL", index) + t.Errorf("test #%d: stream errored: %+v", index, err) + return + } + if failStream && err != nil { + t.Logf("test #%d: stream errored: %+v", index, err) + // Stream errors often have pointers in them, so don't compare for now. + //s := err.Error() // convert to string + //if s != expstr { + // t.Errorf("test #%d: FAIL", index) + // t.Errorf("test #%d: expected different error", index) + // t.Logf("test #%d: err: %s", index, s) + // t.Logf("test #%d: exp: %s", index, expstr) + //} + return + } + if failStream && err == nil { + t.Errorf("test #%d: FAIL", index) + t.Errorf("test #%d: stream passed, expected fail", index) + return + } return } t.Logf("test #%d: got stream event!", index) diff --git a/lang/interpret_test/TestAstFunc2/panic0.txtar b/lang/interpret_test/TestAstFunc2/panic0.txtar new file mode 100644 index 00000000..6235f0e0 --- /dev/null +++ b/lang/interpret_test/TestAstFunc2/panic0.txtar @@ -0,0 +1,6 @@ +-- main.mcl -- +# This should not panic. +panic("") +panic("") +-- OUTPUT -- +Vertex: _panic[panic] diff --git a/lang/interpret_test/TestAstFunc2/panic1.txtar b/lang/interpret_test/TestAstFunc2/panic1.txtar new file mode 100644 index 00000000..946a03cc --- /dev/null +++ b/lang/interpret_test/TestAstFunc2/panic1.txtar @@ -0,0 +1,7 @@ +-- main.mcl -- +# This should not panic. +panic("") +test "hello" {} +-- OUTPUT -- +Vertex: _panic[panic] +Vertex: test[hello] diff --git a/lang/interpret_test/TestAstFunc2/panic2.txtar b/lang/interpret_test/TestAstFunc2/panic2.txtar new file mode 100644 index 00000000..137f52d9 --- /dev/null +++ b/lang/interpret_test/TestAstFunc2/panic2.txtar @@ -0,0 +1,6 @@ +-- main.mcl -- +# This should panic! +panic("please panic") +test "hello" {} +-- OUTPUT -- +# err: errStream: func `panic @ 0x0000000000` stopped before it was loaded diff --git a/lang/parser/lexer.nex b/lang/parser/lexer.nex index 13251819..bf603aa7 100644 --- a/lang/parser/lexer.nex +++ b/lang/parser/lexer.nex @@ -231,6 +231,11 @@ } return BOOL } +/panic/ { + yylex.pos(lval) // our pos + lval.str = yylex.Text() + return PANIC_IDENTIFIER + } /"(\\.|[^"])*"/ { // This matches any number of the bracketed patterns // that are surrounded by the two quotes on each side. diff --git a/lang/parser/parser.y b/lang/parser/parser.y index 17923073..1a7e7062 100644 --- a/lang/parser/parser.y +++ b/lang/parser/parser.y @@ -92,6 +92,7 @@ func init() { %token CLASS_IDENTIFIER INCLUDE_IDENTIFIER %token IMPORT_IDENTIFIER AS_IDENTIFIER %token COMMENT ERROR +%token PANIC_IDENTIFIER // precedence table // "Operator precedence is determined by the line ordering of the declarations; @@ -169,6 +170,11 @@ stmt: posLast(yylex, yyDollar) // our pos $$.stmt = $1.stmt } +| panic + { + posLast(yylex, yyDollar) // our pos + $$.stmt = $1.stmt + } | resource { posLast(yylex, yyDollar) // our pos @@ -919,6 +925,22 @@ bind: } } ; +panic: + // panic("some error") + PANIC_IDENTIFIER OPEN_PAREN call_args CLOSE_PAREN + { + posLast(yylex, yyDollar) // our pos + call := &ast.ExprCall{ + Name: $1.str, + Args: $3.exprs, + //Var: false, // default + } + $$.stmt = &ast.StmtBind{ + Ident: interfaces.PanicVarName, // make up a placeholder var + Value: call, + } + } +; /* TODO: do we want to include this? // resource bind rbind: