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

provider rotation #432

Merged
merged 30 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8c4a065
initial rotate verb
nyobe Jan 15, 2025
4010ab8
example
nyobe Jan 15, 2025
a1f4ec9
fn::rotate
nyobe Jan 15, 2025
4fa0993
rotate scoped to specific paths
nyobe Jan 15, 2025
4e22ea2
move patch handling to eval pkg
nyobe Jan 15, 2025
65b0277
separate rotator inputs and state
nyobe Jan 16, 2025
54d9977
need to add RotatorLoaders to Check and Open too
nyobe Jan 16, 2025
889c6f0
license heading
nyobe Jan 16, 2025
e183ea7
fix rotator.Open not getting called when not rotating
nyobe Jan 16, 2025
d17fcda
fix rotator loaders need to be present for opening imports
nyobe Jan 17, 2025
4769a69
initial snapshot test
nyobe Jan 17, 2025
14d609a
test valueToSecretJSON
nyobe Jan 18, 2025
6042f21
lint
nyobe Jan 18, 2025
5910dc7
changelog
nyobe Jan 21, 2025
1922995
re-align rotator and provider interfaces?
nyobe Jan 21, 2025
9648bb6
add rotating flag instead of overloading rotatePaths
nyobe Jan 21, 2025
e3f06d7
fix evaluating state input against correct schema
nyobe Jan 21, 2025
a208a03
drop support for rotating individual paths for now
nyobe Jan 21, 2025
740e999
merge provider and rotator interfaces, pass state param to open
nyobe Jan 22, 2025
5048c2f
Revert "merge provider and rotator interfaces, pass state param to open"
nyobe Jan 22, 2025
dc86285
split provider and rotator interfaces, extend ProviderLoader to load …
nyobe Jan 22, 2025
1a386a8
pass inputs to Rotator.Open
nyobe Jan 22, 2025
53ccc3c
rename fn::rotate provider -> rotator
nyobe Jan 22, 2025
befdf5f
rename ApplyPatches -> ApplyValuePatches
nyobe Jan 22, 2025
53dc66c
do not intermix state and inputs for shortform fn::rotate
nyobe Jan 22, 2025
58732fa
Update eval/eval.go
nyobe Jan 22, 2025
1b0e0dd
Rotator.Rotate should receive execution context
nyobe Jan 22, 2025
38343da
Update eval/eval.go
nyobe Jan 23, 2025
b3fd85d
Revert "rename fn::rotate provider -> rotator"
nyobe Jan 23, 2025
d5f036c
clarify docstring
nyobe Jan 23, 2025
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
114 changes: 114 additions & 0 deletions ast/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"

"github.com/hashicorp/hcl/v2"
Expand Down Expand Up @@ -434,6 +435,41 @@ func Open(provider string, inputs *ObjectExpr) *OpenExpr {
}
}

// RotateExpr is a type of OpenExpr that supports a rotate operation.
type RotateExpr struct {
builtinNode

Provider *StringExpr
Inputs Expr
State *ObjectExpr
}

func RotateSyntax(node *syntax.ObjectNode, name *StringExpr, args Expr, provider *StringExpr, inputs Expr, state *ObjectExpr) *RotateExpr {
return &RotateExpr{
builtinNode: builtin(node, name, args),
Provider: provider,
Inputs: inputs,
State: state,
}
}

func Rotate(provider string, inputs, state *ObjectExpr) *RotateExpr {
name, providerX := String("fn::rotate"), String(provider)

entries := []ObjectProperty{
{Key: String("provider"), Value: providerX},
nyobe marked this conversation as resolved.
Show resolved Hide resolved
{Key: String("inputs"), Value: inputs},
{Key: String("state"), Value: state},
}

return &RotateExpr{
builtinNode: builtin(nil, name, Object(entries...)),
Provider: providerX,
Inputs: inputs,
State: state,
}
}

// ToJSON returns the underlying structure as a json string.
type ToJSONExpr struct {
builtinNode
Expand Down Expand Up @@ -607,6 +643,8 @@ func tryParseFunction(node *syntax.ObjectNode) (Expr, syntax.Diagnostics, bool)
parse = parseJoin
case "fn::open":
parse = parseOpen
case "fn::rotate":
parse = parseRotate
case "fn::secret":
parse = parseSecret
case "fn::toBase64":
Expand All @@ -620,6 +658,10 @@ func tryParseFunction(node *syntax.ObjectNode) (Expr, syntax.Diagnostics, bool)
parse = parseShortOpen
break
}
if strings.HasPrefix(kvp.Key.Value(), "fn::rotate::") {
parse = parseShortRotate
break
}

if strings.HasPrefix(strings.ToLower(kvp.Key.Value()), "fn::") {
diags = append(diags, syntax.Error(kvp.Key.Syntax().Range(),
Expand Down Expand Up @@ -696,6 +738,78 @@ func parseShortOpen(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr,
return OpenSyntax(node, name, args, provider, args), nil
}

func parseRotate(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, syntax.Diagnostics) {
obj, ok := args.(*ObjectExpr)
if !ok {
diags := syntax.Diagnostics{ExprError(args, "the argument to fn::rotate must be an object containing 'provider', 'inputs' and 'state'")}
return RotateSyntax(node, name, args, nil, nil, nil), diags
}

var providerExpr, inputs, stateExpr Expr
var diags syntax.Diagnostics

for i := 0; i < len(obj.Entries); i++ {
kvp := obj.Entries[i]
key := kvp.Key
switch key.GetValue() {
case "provider":
providerExpr = kvp.Value
case "inputs":
inputs = kvp.Value
case "state":
stateExpr = kvp.Value
}
}

provider, ok := providerExpr.(*StringExpr)
if !ok {
if providerExpr == nil {
diags.Extend(ExprError(obj, "missing provider name ('provider')"))
} else {
diags.Extend(ExprError(providerExpr, "provider name must be a string literal"))
}
}

if inputs == nil {
diags.Extend(ExprError(obj, "missing provider inputs ('inputs')"))
}

state, ok := stateExpr.(*ObjectExpr)
if !ok && state != nil {
nyobe marked this conversation as resolved.
Show resolved Hide resolved
diags.Extend(ExprError(stateExpr, "rotation state must be an object literal"))
}

return RotateSyntax(node, name, obj, provider, inputs, state), diags
}

func parseShortRotate(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, syntax.Diagnostics) {
kvp := node.Index(0)
provider := StringSyntaxValue(name.Syntax().(*syntax.StringNode), strings.TrimPrefix(kvp.Key.Value(), "fn::rotate::"))
nyobe marked this conversation as resolved.
Show resolved Hide resolved

inputs, ok := args.(*ObjectExpr)
if !ok {
diags := syntax.Diagnostics{ExprError(args, "provider inputs must be an object")}
return RotateSyntax(node, name, args, nil, nil, nil), diags
}

// hoist 'state' key out of inputs
var stateExpr Expr
if i := slices.IndexFunc(inputs.Entries, func(kvp ObjectProperty) bool {
return kvp.Key.GetValue() == "state"
}); i != -1 {
stateExpr = inputs.Entries[i].Value
inputs.Entries = slices.Delete(inputs.Entries, i, i+1)
}

state, ok := stateExpr.(*ObjectExpr)
if !ok && state != nil {
diags := syntax.Diagnostics{ExprError(stateExpr, "rotation state must be an object literal")}
return RotateSyntax(node, name, args, nil, nil, nil), diags
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to check, we are ok if there is no initial state set on the rotator? (This makes sense but I vaguely recall us mentioning that users would set at least current on this when adding a new rotator)

Copy link
Contributor Author

@nyobe nyobe Jan 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it depends on the Rotator- the schema can be used to enforce it being present or optional. But I think it's also fine to make the key required 🤷‍♀️


return RotateSyntax(node, name, args, provider, inputs, state), nil
}

func parseJoin(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, syntax.Diagnostics) {
list, ok := args.(*ArrayExpr)
if !ok || len(list.Elements) != 2 {
Expand Down
145 changes: 138 additions & 7 deletions eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ type ProviderLoader interface {
LoadProvider(ctx context.Context, name string) (esc.Provider, error)
}

// A RotatorLoader provides the environment evaluator the capability to load rotators.
type RotatorLoader interface {
// LoadRotator loads the rotator with the given name.
LoadRotator(ctx context.Context, name string) (esc.Rotator, error)
}

nyobe marked this conversation as resolved.
Show resolved Hide resolved
// An EnvironmentLoader provides the environment evaluator the capability to load imported environment definitions.
type EnvironmentLoader interface {
// LoadEnvironment loads the definition for the environment with the given name.
Expand Down Expand Up @@ -83,7 +89,8 @@ func EvalEnvironment(
environments EnvironmentLoader,
execContext *esc.ExecContext,
) (*esc.Environment, syntax.Diagnostics) {
return evalEnvironment(ctx, false, name, env, decrypter, providers, environments, execContext, true)
opened, _, diags := evalEnvironment(ctx, false, name, env, decrypter, providers, nil, environments, execContext, true, nil)
return opened, diags
}

// CheckEnvironment symbolically evaluates the given environment. Calls to fn::open are not invoked, and instead
Expand All @@ -98,7 +105,28 @@ func CheckEnvironment(
execContext *esc.ExecContext,
showSecrets bool,
) (*esc.Environment, syntax.Diagnostics) {
return evalEnvironment(ctx, true, name, env, decrypter, providers, environments, execContext, showSecrets)
checked, _, diags := evalEnvironment(ctx, true, name, env, decrypter, providers, nil, environments, execContext, showSecrets, nil)
nyobe marked this conversation as resolved.
Show resolved Hide resolved
return checked, diags
}

// RotateEnvironment evaluates the given environment and invokes provider rotate methods.
// The updated rotation state is returned a set of patches to be written back to the environment.
nyobe marked this conversation as resolved.
Show resolved Hide resolved
func RotateEnvironment(
ctx context.Context,
name string,
env *ast.EnvironmentDecl,
decrypter Decrypter,
providers ProviderLoader,
rotators RotatorLoader,
environments EnvironmentLoader,
execContext *esc.ExecContext,
paths []string,
) (*esc.Environment, []*Patch, syntax.Diagnostics) {
rotatePaths := map[string]bool{}
for _, path := range paths {
rotatePaths[path] = true
}
return evalEnvironment(ctx, false, name, env, decrypter, providers, rotators, environments, execContext, true, rotatePaths)
}

// evalEnvironment evaluates an environment and exports the result of evaluation.
Expand All @@ -109,15 +137,17 @@ func evalEnvironment(
env *ast.EnvironmentDecl,
decrypter Decrypter,
providers ProviderLoader,
rotators RotatorLoader,
envs EnvironmentLoader,
execContext *esc.ExecContext,
showSecrets bool,
) (*esc.Environment, syntax.Diagnostics) {
rotatePaths map[string]bool,
nyobe marked this conversation as resolved.
Show resolved Hide resolved
) (*esc.Environment, []*Patch, syntax.Diagnostics) {
if env == nil || (len(env.Values.GetEntries()) == 0 && len(env.Imports.GetElements()) == 0) {
return nil, nil
return nil, nil, nil
}

ec := newEvalContext(ctx, validating, name, env, decrypter, providers, envs, map[string]*imported{}, execContext, showSecrets)
ec := newEvalContext(ctx, validating, name, env, decrypter, providers, rotators, envs, map[string]*imported{}, execContext, showSecrets, rotatePaths)
v, diags := ec.evaluate()

s := schema.Never().Schema()
Expand All @@ -139,7 +169,7 @@ func evalEnvironment(
Properties: v.export(name).Value.(map[string]esc.Value),
Schema: s,
ExecutionContext: executionContext,
}, diags
}, ec.patchOutputs, diags
}

type imported struct {
Expand All @@ -156,6 +186,7 @@ type evalContext struct {
env *ast.EnvironmentDecl // the root of the environment AST
decrypter Decrypter // the decrypter to use for the environment
providers ProviderLoader // the provider loader to use
rotators RotatorLoader // the rotator loader to use
environments EnvironmentLoader // the environment loader to use
imports map[string]*imported // the shared set of imported environments
execContext *esc.ExecContext // evaluation context used for interpolation
Expand All @@ -165,6 +196,9 @@ type evalContext struct {
root *expr // the root expression
base *value // the base value

rotatePaths map[string]bool // when non-nil, specifies providers to rotate. if empty, the full environment is rotated.
nyobe marked this conversation as resolved.
Show resolved Hide resolved
patchOutputs []*Patch // updated rotation state to be written back to the environment definition

diags syntax.Diagnostics // diagnostics generated during evaluation
}

Expand All @@ -175,10 +209,12 @@ func newEvalContext(
env *ast.EnvironmentDecl,
decrypter Decrypter,
providers ProviderLoader,
rotators RotatorLoader,
environments EnvironmentLoader,
imports map[string]*imported,
execContext *esc.ExecContext,
showSecrets bool,
rotatePaths map[string]bool,
) *evalContext {
return &evalContext{
ctx: ctx,
Expand All @@ -188,9 +224,11 @@ func newEvalContext(
env: env,
decrypter: decrypter,
providers: providers,
rotators: rotators,
environments: environments,
imports: imports,
execContext: execContext.CopyForEnv(name),
rotatePaths: rotatePaths,
}
}

Expand Down Expand Up @@ -302,6 +340,16 @@ func declare[Expr exprNode](e *evalContext, path string, x Expr, base *value) *e
inputSchema: schema.Always().Schema(),
}
return newExpr(path, repr, schema.Always().Schema(), base)
case *ast.RotateExpr:
repr := &rotateExpr{
node: x,
provider: declare(e, "", x.Provider, nil),
inputs: declare(e, "", x.Inputs, nil),
state: declare(e, "", x.State, nil),
inputSchema: schema.Always().Schema(),
stateSchema: schema.Always().Schema(),
}
return newExpr(path, repr, schema.Always().Schema(), base)
case *ast.SecretExpr:
if x.Plaintext != nil {
repr := &secretExpr{node: x, plaintext: declare(e, "", x.Plaintext, nil)}
Expand Down Expand Up @@ -462,7 +510,8 @@ func (e *evalContext) evaluateImport(myImports map[string]*value, decl *ast.Impo
return
}

imp := newEvalContext(e.ctx, e.validating, name, env, dec, e.providers, e.environments, e.imports, e.execContext, e.showSecrets)
// only rotate root environment
imp := newEvalContext(e.ctx, e.validating, name, env, dec, e.providers, nil, e.environments, e.imports, e.execContext, e.showSecrets, nil)
v, diags := imp.evaluate()
e.diags.Extend(diags...)

Expand Down Expand Up @@ -526,6 +575,8 @@ func (e *evalContext) evaluateExpr(x *expr) *value {
val = e.evaluateBuiltinJoin(x, repr)
case *openExpr:
val = e.evaluateBuiltinOpen(x, repr)
case *rotateExpr:
val = e.evaluateBuiltinRotate(x, repr)
case *secretExpr:
val = e.evaluateBuiltinSecret(x, repr)
case *toBase64Expr:
Expand Down Expand Up @@ -932,6 +983,86 @@ func (e *evalContext) evaluateBuiltinOpen(x *expr, repr *openExpr) *value {
return unexport(output, x)
}

// evaluateBuiltinOpen evaluates a call to the fn::rotate builtin.
func (e *evalContext) evaluateBuiltinRotate(x *expr, repr *rotateExpr) *value {
v := &value{def: x}

// Can happen if there are parse errors.
if repr.node.Provider == nil {
v.schema = schema.Always()
v.unknown = true
return v
}

rotator, err := e.rotators.LoadRotator(e.ctx, repr.node.Provider.GetValue())
if err != nil {
e.errorf(repr.syntax(), "%v", err)
} else {
inputSchema, stateSchema, outputSchema := rotator.Schema()
if err := inputSchema.Compile(); err != nil {
e.errorf(repr.syntax(), "internal error: invalid input schema (%v)", err)
} else {
repr.inputSchema = inputSchema
}
if err := stateSchema.Compile(); err != nil {
e.errorf(repr.syntax(), "internal error: invalid state schema (%v)", err)
} else {
repr.stateSchema = stateSchema
}
if err := outputSchema.Compile(); err != nil {
e.errorf(repr.syntax(), "internal error: invalid schema (%v)", err)
} else {
x.schema = outputSchema
}
}
v.schema = x.schema

inputs, inputsOK := e.evaluateTypedExpr(repr.inputs, repr.inputSchema)
state, stateOK := e.evaluateTypedExpr(repr.state, repr.stateSchema)
if !inputsOK || inputs.containsUnknowns() || !stateOK || state.containsUnknowns() || e.validating || e.rotatePaths == nil || err != nil {
nyobe marked this conversation as resolved.
Show resolved Hide resolved
v.unknown = true
return v
}

// if rotating, invoke prior to open
if len(e.rotatePaths) == 0 || e.rotatePaths[x.path] {
newState, err := rotator.Rotate(
e.ctx,
inputs.export("").Value.(map[string]esc.Value),
state.export("").Value.(map[string]esc.Value),
Comment on lines +1017 to +1018
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my own knowledge, what does export("") do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is supposed to be the environment name which I think is just used for annotating source ranges?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep that's right

)
if err != nil {
e.errorf(repr.syntax(), "rotate: %s", err.Error())
v.unknown = true
return v
}

// todo: validate newState conforms to state schema
nyobe marked this conversation as resolved.
Show resolved Hide resolved

e.patchOutputs = append(e.patchOutputs, &Patch{
// rotation output is written back to the fn's `state` input
DocPath: x.path + "." + repr.node.Name().GetValue() + ".state",
nyobe marked this conversation as resolved.
Show resolved Hide resolved
Replacement: newState,
})

// pass the updated state to open, as if it were already persisted
state = unexport(newState, x)
}

output, err := rotator.Open(
e.ctx,
inputs.export("").Value.(map[string]esc.Value),
state.export("").Value.(map[string]esc.Value),
e.execContext,
)
if err != nil {
e.errorf(repr.syntax(), "%s", err.Error())
v.unknown = true
return v
}
return unexport(output, x)
}

// evaluateBuiltinJoin evaluates a call to the fn::join builtin.
func (e *evalContext) evaluateBuiltinJoin(x *expr, repr *joinExpr) *value {
v := &value{def: x, schema: x.schema}
Expand Down
Loading
Loading