diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index ed1d4f89..e9e5b0ad 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -2,8 +2,8 @@ - Fix diagnostic messages when updating environment with invalid definition [#422](https://github.com/pulumi/esc/pull/422) +- Introduce support for rotating static credentials via `fn::rotate` providers [432](https://github.com/pulumi/esc/pull/432) ### Bug Fixes ### Breaking changes - diff --git a/analysis/common_test.go b/analysis/common_test.go index c21d8c13..3e6dfd27 100644 --- a/analysis/common_test.go +++ b/analysis/common_test.go @@ -71,6 +71,10 @@ func (testProviders) LoadProvider(ctx context.Context, name string) (esc.Provide return nil, fmt.Errorf("unknown provider %q", name) } +func (testProviders) LoadRotator(ctx context.Context, name string) (esc.Rotator, error) { + return nil, fmt.Errorf("unknown rotator %q", name) +} + type testEnvironments struct{} func (testEnvironments) LoadEnvironment(ctx context.Context, name string) ([]byte, eval.Decrypter, error) { diff --git a/ast/expr.go b/ast/expr.go index 71b7c326..62a8deec 100644 --- a/ast/expr.go +++ b/ast/expr.go @@ -434,6 +434,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}, + {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 @@ -607,6 +642,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": @@ -620,6 +657,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(), @@ -696,6 +737,86 @@ 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 { + 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::")) + + obj, ok := args.(*ObjectExpr) + if !ok { + diags := syntax.Diagnostics{ExprError(args, "the argument to fn::rotate must be an object containing 'inputs' and 'state'")} + return RotateSyntax(node, name, args, nil, nil, nil), diags + } + + var 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 "inputs": + inputs = kvp.Value + case "state": + stateExpr = kvp.Value + } + } + + if inputs == nil { + diags.Extend(ExprError(obj, "missing provider inputs ('inputs')")) + } + + state, ok := stateExpr.(*ObjectExpr) + if !ok && state != nil { + diags.Extend(ExprError(stateExpr, "rotation state must be an object literal")) + } + + 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 { diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go index 87674f71..53ec1a49 100644 --- a/cmd/esc/cli/cli_test.go +++ b/cmd/esc/cli/cli_test.go @@ -215,6 +215,10 @@ func (testProviders) LoadProvider(ctx context.Context, name string) (esc.Provide return nil, fmt.Errorf("unknown provider %q", name) } +func (testProviders) LoadRotator(ctx context.Context, name string) (esc.Rotator, error) { + return nil, fmt.Errorf("unknown rotator %q", name) +} + type rot128 struct{} func (rot128) Encrypt(_ context.Context, plaintext []byte) ([]byte, error) { diff --git a/eval/eval.go b/eval/eval.go index 31945a0c..b95c88e8 100644 --- a/eval/eval.go +++ b/eval/eval.go @@ -37,6 +37,8 @@ import ( type ProviderLoader interface { // LoadProvider loads the provider with the given name. LoadProvider(ctx context.Context, name string) (esc.Provider, error) + // LoadRotator loads the rotator with the given name. + LoadRotator(ctx context.Context, name string) (esc.Rotator, error) } // An EnvironmentLoader provides the environment evaluator the capability to load imported environment definitions. @@ -83,7 +85,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, false, name, env, decrypter, providers, environments, execContext, true) + return opened, diags } // CheckEnvironment symbolically evaluates the given environment. Calls to fn::open are not invoked, and instead @@ -98,13 +101,29 @@ 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, false, name, env, decrypter, providers, environments, execContext, showSecrets) + return checked, diags +} + +// RotateEnvironment evaluates the given environment and invokes provider rotate methods. +// The updated rotation state is returned with a set of patches to be written back to the environment. +func RotateEnvironment( + ctx context.Context, + name string, + env *ast.EnvironmentDecl, + decrypter Decrypter, + providers ProviderLoader, + environments EnvironmentLoader, + execContext *esc.ExecContext, +) (*esc.Environment, []*Patch, syntax.Diagnostics) { + return evalEnvironment(ctx, false, true, name, env, decrypter, providers, environments, execContext, true) } // evalEnvironment evaluates an environment and exports the result of evaluation. func evalEnvironment( ctx context.Context, validating bool, + rotating bool, name string, env *ast.EnvironmentDecl, decrypter Decrypter, @@ -112,12 +131,12 @@ func evalEnvironment( envs EnvironmentLoader, execContext *esc.ExecContext, showSecrets bool, -) (*esc.Environment, syntax.Diagnostics) { +) (*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, rotating, name, env, decrypter, providers, envs, map[string]*imported{}, execContext, showSecrets) v, diags := ec.evaluate() s := schema.Never().Schema() @@ -139,7 +158,7 @@ func evalEnvironment( Properties: v.export(name).Value.(map[string]esc.Value), Schema: s, ExecutionContext: executionContext, - }, diags + }, ec.patchOutputs, diags } type imported struct { @@ -151,6 +170,7 @@ type imported struct { type evalContext struct { ctx context.Context // the cancellation context for evaluation validating bool // true if we are only checking the environment + rotating bool // true if we are invoking rotators showSecrets bool // true if secrets should be decrypted during validation name string // the name of the environment env *ast.EnvironmentDecl // the root of the environment AST @@ -165,12 +185,15 @@ type evalContext struct { root *expr // the root expression base *value // the base value + patchOutputs []*Patch // updated rotation state generated during evaluation, to be written back to the environment definition + diags syntax.Diagnostics // diagnostics generated during evaluation } func newEvalContext( ctx context.Context, validating bool, + rotating bool, name string, env *ast.EnvironmentDecl, decrypter Decrypter, @@ -183,6 +206,7 @@ func newEvalContext( return &evalContext{ ctx: ctx, validating: validating, + rotating: rotating, showSecrets: showSecrets, name: name, env: env, @@ -302,6 +326,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)} @@ -462,7 +496,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) + // we only want to rotate the root environment, so set rotating flag to false when evaluating imports + imp := newEvalContext(e.ctx, e.validating, false, name, env, dec, e.providers, e.environments, e.imports, e.execContext, e.showSecrets) v, diags := imp.evaluate() e.diags.Extend(diags...) @@ -526,6 +561,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: @@ -932,6 +969,87 @@ 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.providers.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 || err != nil { + v.unknown = true + return v + } + + // if rotating, invoke prior to open + if e.rotating { + newState, err := rotator.Rotate( + 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(), "rotate: %s", err.Error()) + v.unknown = true + return v + } + + // todo: validate newState conforms to state schema + + e.patchOutputs = append(e.patchOutputs, &Patch{ + // rotation output is written back to the fn's `state` input + DocPath: util.JoinKey(x.path, repr.node.Name().GetValue()) + ".state", + 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} diff --git a/eval/eval_test.go b/eval/eval_test.go index 22dc8039..5bab3137 100644 --- a/eval/eval_test.go +++ b/eval/eval_test.go @@ -22,6 +22,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" "testing" @@ -136,6 +137,33 @@ func (testProvider) Open(ctx context.Context, inputs map[string]esc.Value, conte return esc.NewValue(inputs), nil } +type swapRotator struct{} + +func (swapRotator) Schema() (*schema.Schema, *schema.Schema, *schema.Schema) { + inputSchema := schema.Always() + stateSchema := schema.Record(schema.BuilderMap{ + "a": schema.String(), + "b": schema.String(), + }).Schema() + outputSchema := schema.Record(schema.BuilderMap{ + "a": schema.String(), + "b": schema.String(), + }).Schema() + return inputSchema, stateSchema, outputSchema +} + +func (swapRotator) Open(ctx context.Context, inputs, state map[string]esc.Value, context esc.EnvExecContext) (esc.Value, error) { + return esc.NewValue(state), nil +} + +func (swapRotator) Rotate(ctx context.Context, inputs, state map[string]esc.Value, context esc.EnvExecContext) (esc.Value, error) { + newState := esc.NewValue(map[string]esc.Value{ + "a": state["b"], + "b": state["a"], + }) + return newState, nil +} + type testProviders struct { benchDelay time.Duration } @@ -154,6 +182,14 @@ func (tp testProviders) LoadProvider(ctx context.Context, name string) (esc.Prov return nil, fmt.Errorf("unknown provider %q", name) } +func (testProviders) LoadRotator(ctx context.Context, name string) (esc.Rotator, error) { + switch name { + case "swap": + return swapRotator{}, nil + } + return nil, fmt.Errorf("unknown rotator %q", name) +} + type testEnvironments struct { root string } @@ -245,6 +281,7 @@ func TestEval(t *testing.T) { type testOverrides struct { ShowSecrets bool `json:"showSecrets,omitempty"` RootEnvironment string `json:"rootEnvironment,omitempty"` + Rotate bool `json:"rotate,omitempty"` } type expectedData struct { @@ -256,6 +293,10 @@ func TestEval(t *testing.T) { Eval *esc.Environment `json:"eval,omitempty"` EvalJSONRedacted any `json:"evalJsonRedacted,omitempty"` EvalJSONRevealed any `json:"evalJSONRevealed,omitempty"` + RotateDiags syntax.Diagnostics `json:"rotateDiags,omitempty"` + Rotate *esc.Environment `json:"rotate,omitempty"` + RotateJSON any `json:"rotateJson,omitempty"` + RotatePatches []*Patch `json:"rotatePatches,omitempty"` } path := filepath.Join("testdata", "eval") @@ -295,6 +336,7 @@ func TestEval(t *testing.T) { environmentName = overrides.RootEnvironment } showSecrets := overrides.ShowSecrets + doRotate := overrides.Rotate if accept() { env, loadDiags, err := LoadYAMLBytes(environmentName, envBytes) @@ -309,9 +351,18 @@ func TestEval(t *testing.T) { &testEnvironments{basePath}, execContext) sortEnvironmentDiagnostics(evalDiags) + var rotated *esc.Environment + var patches []*Patch + var rotateDiags syntax.Diagnostics + if doRotate { + rotated, patches, rotateDiags = RotateEnvironment(context.Background(), environmentName, env, rot128{}, testProviders{}, + &testEnvironments{basePath}, execContext) + } + var checkJSON any var evalJSONRedacted any var evalJSONRevealed any + var rotateJSON any if check != nil { check = normalize(t, check) checkJSON = esc.NewValue(check.Properties).ToJSON(true) @@ -321,6 +372,10 @@ func TestEval(t *testing.T) { evalJSONRedacted = esc.NewValue(actual.Properties).ToJSON(true) evalJSONRevealed = esc.NewValue(actual.Properties).ToJSON(false) } + if rotated != nil { + rotated = normalize(t, rotated) + rotateJSON = esc.NewValue(rotated.Properties).ToJSON(true) + } bytes, err := json.MarshalIndent(expectedData{ LoadDiags: loadDiags, @@ -331,6 +386,10 @@ func TestEval(t *testing.T) { EvalJSONRedacted: evalJSONRedacted, EvalJSONRevealed: evalJSONRevealed, CheckJSON: checkJSON, + RotateDiags: rotateDiags, + Rotate: rotated, + RotateJSON: rotateJSON, + RotatePatches: patches, }, "", " ") bytes = append(bytes, '\n') require.NoError(t, err) @@ -364,9 +423,26 @@ func TestEval(t *testing.T) { sortEnvironmentDiagnostics(diags) require.Equal(t, expected.EvalDiags, diags) + var rotated *esc.Environment + if doRotate { + rotated_, patches, diags := RotateEnvironment(context.Background(), environmentName, env, rot128{}, testProviders{}, + &testEnvironments{basePath}, execContext) + + sortEnvironmentDiagnostics(diags) + require.Equal(t, expected.RotateDiags, diags) + + slices.SortFunc(patches, func(a, b *Patch) int { + return strings.Compare(a.DocPath, b.DocPath) + }) + require.Equal(t, expected.RotatePatches, patches) + + rotated = rotated_ + } + // work around a schema comparison issue due to the 'compiled' field by roundtripping through JSON check = normalize(t, check) actual = normalize(t, actual) + rotated = normalize(t, rotated) // work around a comparison issue when comparing nil slices/maps against zero-length slices/maps if actual != nil { @@ -389,8 +465,18 @@ func TestEval(t *testing.T) { t.Logf("check: %v", string(bytes)) } + if rotated != nil { + rotateJSON := esc.NewValue(check.Properties).ToJSON(true) + assert.Equal(t, expected.CheckJSON, rotateJSON) + + bytes, err := json.MarshalIndent(rotateJSON, "", " ") + require.NoError(t, err) + t.Logf("rotate: %v", string(bytes)) + } + assert.Equal(t, expected.Check, check) assert.Equal(t, expected.Eval, actual) + assert.Equal(t, expected.Rotate, rotated) }) } } diff --git a/eval/expr.go b/eval/expr.go index f13b1148..e54feee7 100644 --- a/eval/expr.go +++ b/eval/expr.go @@ -16,7 +16,6 @@ package eval import ( "fmt" - "github.com/hashicorp/hcl/v2" "github.com/pulumi/esc" "github.com/pulumi/esc/ast" @@ -204,6 +203,41 @@ func (x *expr) export(environment string) esc.Expr { Arg: repr.inputs.export(environment), } } + case *rotateExpr: + name := repr.node.Name().Value + if name == "fn::rotate" { + ex.Builtin = &esc.BuiltinExpr{ + Name: name, + NameRange: convertRange(repr.node.Name().Syntax().Syntax().Range(), environment), + ArgSchema: schema.Record(schema.SchemaMap{ + "provider": schema.String().Schema(), + "inputs": repr.inputSchema, + "state": repr.stateSchema, + }).Schema(), + Arg: esc.Expr{ + Object: map[string]esc.Expr{ + "provider": repr.provider.export(environment), + "inputs": repr.inputs.export(environment), + "state": repr.state.export(environment), + }, + }, + } + } else { + ex.Builtin = &esc.BuiltinExpr{ + Name: name, + NameRange: convertRange(repr.node.Name().Syntax().Syntax().Range(), environment), + ArgSchema: schema.Record(schema.SchemaMap{ + "inputs": repr.inputSchema, + "state": repr.stateSchema, + }).Schema(), + Arg: esc.Expr{ + Object: map[string]esc.Expr{ + "inputs": repr.inputs.export(environment), + "state": repr.state.export(environment), + }, + }, + } + } case *secretExpr: var arg esc.Expr if repr.plaintext != nil { @@ -371,6 +405,22 @@ func (x *openExpr) syntax() ast.Expr { return x.node } +// rotateExpr represents a call to the fn::rotate builtin. +type rotateExpr struct { + node *ast.RotateExpr + + provider *expr + inputs *expr + state *expr + + inputSchema *schema.Schema + stateSchema *schema.Schema +} + +func (x *rotateExpr) syntax() ast.Expr { + return x.node +} + // toJSONExpr represents a call to the fn::toJSON builtin. type toJSONExpr struct { node *ast.ToJSONExpr diff --git a/eval/patch.go b/eval/patch.go new file mode 100644 index 00000000..59f6464c --- /dev/null +++ b/eval/patch.go @@ -0,0 +1,92 @@ +// Copyright 2025, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package eval + +import ( + "github.com/pulumi/esc" + "github.com/pulumi/esc/syntax/encoding" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "gopkg.in/yaml.v3" +) + +// Patch represents a value that should be written back to the environment at the given path. +type Patch struct { + DocPath string + Replacement esc.Value +} + +// ApplyValuePatches applies a set of patches values to an environment definition. +// If patch values contain secret values, they will be wrapped with fn::secret. +func ApplyValuePatches(source []byte, patches []*Patch) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(source, &doc); err != nil { + return nil, err + } + + for _, patch := range patches { + path, err := resource.ParsePropertyPath("values." + patch.DocPath) + if err != nil { + return nil, err + } + + // convert the esc.Value into a yaml node that can be set on the environment + replacement := valueToSecretJSON(patch.Replacement) + bytes, err := yaml.Marshal(replacement) + if err != nil { + return nil, err + } + var yamlValue yaml.Node + if err := yaml.Unmarshal(bytes, &yamlValue); err != nil { + return nil, err + } + yamlValue = *yamlValue.Content[0] + + _, err = encoding.YAMLSyntax{Node: &doc}.Set(nil, path, yamlValue) + if err != nil { + return nil, err + } + } + + return yaml.Marshal(doc.Content[0]) +} + +// valueToSecretJSON converts a Value into a plain-old-JSON value, but secret values are wrapped with fn::secret +func valueToSecretJSON(v esc.Value) any { + ret := func() any { + switch pv := v.Value.(type) { + case []esc.Value: + a := make([]any, len(pv)) + for i, v := range pv { + a[i] = valueToSecretJSON(v) + } + return a + case map[string]esc.Value: + m := make(map[string]any, len(pv)) + for k, v := range pv { + m[k] = valueToSecretJSON(v) + } + return m + default: + return pv + } + }() + // wrap secret values + if v.Secret { + return map[string]any{ + "fn::secret": ret, + } + } + return ret +} diff --git a/eval/patch_test.go b/eval/patch_test.go new file mode 100644 index 00000000..f94567c9 --- /dev/null +++ b/eval/patch_test.go @@ -0,0 +1,39 @@ +// Copyright 2025, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package eval + +import ( + "github.com/pulumi/esc" + "github.com/stretchr/testify/require" + "testing" +) + +func TestValueToSecretJSON(t *testing.T) { + t.Run("nested secrets", func(t *testing.T) { + actual := valueToSecretJSON(esc.NewValue(map[string]esc.Value{ + "foo": esc.NewValue(map[string]esc.Value{ + "bar": esc.NewSecret("secret"), + }), + })) + expected := map[string]any{ + "foo": map[string]any{ + "bar": map[string]any{ + "fn::secret": "secret", + }, + }, + } + require.Equal(t, expected, actual) + }) +} diff --git a/eval/rotate_example_test.go b/eval/rotate_example_test.go new file mode 100644 index 00000000..4be47b0c --- /dev/null +++ b/eval/rotate_example_test.go @@ -0,0 +1,79 @@ +// Copyright 2025, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package eval + +import ( + "context" + "fmt" + "github.com/pulumi/esc" +) + +func ExampleRotate() { + const def = ` +values: + a: + a: + fn::rotate: + provider: swap + inputs: + foo: bar + state: + a: a + b: b + b: + - c: + fn::rotate::swap: + inputs: + foo: bar + state: + a: + fn::secret: a + b: b +` + env, _, _ := LoadYAMLBytes("", []byte(def)) + + // rotate the environment + execContext, _ := esc.NewExecContext(nil) + _, patches, _ := RotateEnvironment(context.Background(), "", env, rot128{}, testProviders{}, &testEnvironments{}, execContext) + + // writeback state patches + updated, _ := ApplyValuePatches([]byte(def), patches) + + // encrypt secret values + encryptedYaml, _ := EncryptSecrets(context.Background(), "", updated, rot128{}) + + fmt.Println(string(encryptedYaml)) + // Output: + // values: + // a: + // a: + // fn::rotate: + // provider: swap + // inputs: + // foo: bar + // state: + // a: b + // b: a + // b: + // - c: + // fn::rotate::swap: + // inputs: + // foo: bar + // state: + // a: b + // b: + // fn::secret: + // ciphertext: ZXNjeAAAAAHhQRt8TQ== +} diff --git a/eval/testdata/eval/rotate/env.yaml b/eval/testdata/eval/rotate/env.yaml new file mode 100644 index 00000000..cb8231e9 --- /dev/null +++ b/eval/testdata/eval/rotate/env.yaml @@ -0,0 +1,17 @@ +values: + full: + fn::rotate: + provider: swap + inputs: {} + state: + a: bar1 + b: bar2 + short: + fn::rotate::swap: + inputs: {} + state: + a: bar1 + b: bar2 + + reference: + test: ${full.a} ${short.b} \ No newline at end of file diff --git a/eval/testdata/eval/rotate/expected.json b/eval/testdata/eval/rotate/expected.json new file mode 100644 index 00000000..2a1ee0ea --- /dev/null +++ b/eval/testdata/eval/rotate/expected.json @@ -0,0 +1,3161 @@ +{ + "check": { + "exprs": { + "full": { + "range": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "builtin": { + "name": "fn::rotate", + "nameRange": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 3, + "column": 15, + "byte": 30 + } + }, + "argSchema": { + "properties": { + "inputs": true, + "provider": { + "type": "string" + }, + "state": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "inputs", + "provider", + "state" + ] + }, + "arg": { + "range": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + }, + "object": { + "inputs": { + "range": { + "environment": "rotate", + "begin": { + "line": 5, + "column": 15, + "byte": 67 + }, + "end": { + "line": 5, + "column": 15, + "byte": 67 + } + }, + "schema": { + "type": "object" + } + }, + "provider": { + "range": { + "environment": "rotate", + "begin": { + "line": 4, + "column": 17, + "byte": 48 + }, + "end": { + "line": 4, + "column": 21, + "byte": 52 + } + }, + "schema": { + "type": "string", + "const": "swap" + }, + "literal": "swap" + }, + "state": { + "range": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 9, + "byte": 91 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "keyRanges": { + "a": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 9, + "byte": 91 + }, + "end": { + "line": 7, + "column": 10, + "byte": 92 + } + }, + "b": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 9, + "byte": 107 + }, + "end": { + "line": 8, + "column": 10, + "byte": 108 + } + } + }, + "object": { + "a": { + "range": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 12, + "byte": 94 + }, + "end": { + "line": 7, + "column": 16, + "byte": 98 + } + }, + "schema": { + "type": "string", + "const": "bar1" + }, + "literal": "bar1" + }, + "b": { + "range": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 12, + "byte": 110 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "type": "string", + "const": "bar2" + }, + "literal": "bar2" + } + } + } + } + } + } + }, + "reference": { + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + }, + "schema": { + "properties": { + "test": { + "type": "string" + } + }, + "type": "object", + "required": [ + "test" + ] + }, + "keyRanges": { + "test": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 9, + "byte": 0 + } + } + }, + "object": { + "test": { + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + }, + "schema": { + "type": "string" + }, + "interpolate": [ + { + "value": [ + { + "key": "full", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 13, + "byte": 2 + }, + "end": { + "line": 17, + "column": 17, + "byte": 6 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + }, + { + "key": "a", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 17, + "byte": 6 + }, + "end": { + "line": 17, + "column": 19, + "byte": 8 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + ] + }, + { + "text": " ", + "value": [ + { + "key": "short", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 23, + "byte": 12 + }, + "end": { + "line": 17, + "column": 28, + "byte": 17 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + }, + { + "key": "b", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 28, + "byte": 17 + }, + "end": { + "line": 17, + "column": 30, + "byte": 19 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + ] + } + ] + } + } + }, + "short": { + "range": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "builtin": { + "name": "fn::rotate::swap", + "nameRange": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 10, + "column": 21, + "byte": 144 + } + }, + "argSchema": { + "properties": { + "inputs": true, + "state": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "inputs", + "state" + ] + }, + "arg": { + "range": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + }, + "object": { + "inputs": { + "range": { + "environment": "rotate", + "begin": { + "line": 11, + "column": 15, + "byte": 160 + }, + "end": { + "line": 11, + "column": 15, + "byte": 160 + } + }, + "schema": { + "type": "object" + } + }, + "state": { + "range": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 9, + "byte": 184 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "keyRanges": { + "a": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 9, + "byte": 184 + }, + "end": { + "line": 13, + "column": 10, + "byte": 185 + } + }, + "b": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 9, + "byte": 200 + }, + "end": { + "line": 14, + "column": 10, + "byte": 201 + } + } + }, + "object": { + "a": { + "range": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 12, + "byte": 187 + }, + "end": { + "line": 13, + "column": 16, + "byte": 191 + } + }, + "schema": { + "type": "string", + "const": "bar1" + }, + "literal": "bar1" + }, + "b": { + "range": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 12, + "byte": 203 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "type": "string", + "const": "bar2" + }, + "literal": "bar2" + } + } + } + } + } + } + } + }, + "properties": { + "full": { + "unknown": true, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + }, + "reference": { + "value": { + "test": { + "value": "[unknown]", + "unknown": true, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + }, + "short": { + "unknown": true, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + } + }, + "schema": { + "properties": { + "full": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "reference": { + "properties": { + "test": { + "type": "string" + } + }, + "type": "object", + "required": [ + "test" + ] + }, + "short": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "full", + "reference", + "short" + ] + }, + "executionContext": { + "properties": { + "currentEnvironment": { + "value": { + "name": { + "value": "rotate", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + }, + "pulumi": { + "value": { + "user": { + "value": { + "id": { + "value": "USER_123", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + }, + "rootEnvironment": { + "value": { + "name": { + "value": "rotate", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "schema": { + "properties": { + "currentEnvironment": { + "properties": { + "name": { + "type": "string", + "const": "rotate" + } + }, + "type": "object", + "required": [ + "name" + ] + }, + "pulumi": { + "properties": { + "user": { + "properties": { + "id": { + "type": "string", + "const": "USER_123" + } + }, + "type": "object", + "required": [ + "id" + ] + } + }, + "type": "object", + "required": [ + "user" + ] + }, + "rootEnvironment": { + "properties": { + "name": { + "type": "string", + "const": "rotate" + } + }, + "type": "object", + "required": [ + "name" + ] + } + }, + "type": "object", + "required": [ + "currentEnvironment", + "pulumi", + "rootEnvironment" + ] + } + } + }, + "checkJson": { + "full": "[unknown]", + "reference": { + "test": "[unknown]" + }, + "short": "[unknown]" + }, + "eval": { + "exprs": { + "full": { + "range": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "builtin": { + "name": "fn::rotate", + "nameRange": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 3, + "column": 15, + "byte": 30 + } + }, + "argSchema": { + "properties": { + "inputs": true, + "provider": { + "type": "string" + }, + "state": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "inputs", + "provider", + "state" + ] + }, + "arg": { + "range": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + }, + "object": { + "inputs": { + "range": { + "environment": "rotate", + "begin": { + "line": 5, + "column": 15, + "byte": 67 + }, + "end": { + "line": 5, + "column": 15, + "byte": 67 + } + }, + "schema": { + "type": "object" + } + }, + "provider": { + "range": { + "environment": "rotate", + "begin": { + "line": 4, + "column": 17, + "byte": 48 + }, + "end": { + "line": 4, + "column": 21, + "byte": 52 + } + }, + "schema": { + "type": "string", + "const": "swap" + }, + "literal": "swap" + }, + "state": { + "range": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 9, + "byte": 91 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "keyRanges": { + "a": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 9, + "byte": 91 + }, + "end": { + "line": 7, + "column": 10, + "byte": 92 + } + }, + "b": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 9, + "byte": 107 + }, + "end": { + "line": 8, + "column": 10, + "byte": 108 + } + } + }, + "object": { + "a": { + "range": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 12, + "byte": 94 + }, + "end": { + "line": 7, + "column": 16, + "byte": 98 + } + }, + "schema": { + "type": "string", + "const": "bar1" + }, + "literal": "bar1" + }, + "b": { + "range": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 12, + "byte": 110 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "type": "string", + "const": "bar2" + }, + "literal": "bar2" + } + } + } + } + } + } + }, + "reference": { + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + }, + "schema": { + "properties": { + "test": { + "type": "string" + } + }, + "type": "object", + "required": [ + "test" + ] + }, + "keyRanges": { + "test": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 9, + "byte": 0 + } + } + }, + "object": { + "test": { + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + }, + "schema": { + "type": "string" + }, + "interpolate": [ + { + "value": [ + { + "key": "full", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 13, + "byte": 2 + }, + "end": { + "line": 17, + "column": 17, + "byte": 6 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + }, + { + "key": "a", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 17, + "byte": 6 + }, + "end": { + "line": 17, + "column": 19, + "byte": 8 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + ] + }, + { + "text": " ", + "value": [ + { + "key": "short", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 23, + "byte": 12 + }, + "end": { + "line": 17, + "column": 28, + "byte": 17 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + }, + { + "key": "b", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 28, + "byte": 17 + }, + "end": { + "line": 17, + "column": 30, + "byte": 19 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + ] + } + ] + } + } + }, + "short": { + "range": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "builtin": { + "name": "fn::rotate::swap", + "nameRange": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 10, + "column": 21, + "byte": 144 + } + }, + "argSchema": { + "properties": { + "inputs": true, + "state": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "inputs", + "state" + ] + }, + "arg": { + "range": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + }, + "object": { + "inputs": { + "range": { + "environment": "rotate", + "begin": { + "line": 11, + "column": 15, + "byte": 160 + }, + "end": { + "line": 11, + "column": 15, + "byte": 160 + } + }, + "schema": { + "type": "object" + } + }, + "state": { + "range": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 9, + "byte": 184 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "keyRanges": { + "a": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 9, + "byte": 184 + }, + "end": { + "line": 13, + "column": 10, + "byte": 185 + } + }, + "b": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 9, + "byte": 200 + }, + "end": { + "line": 14, + "column": 10, + "byte": 201 + } + } + }, + "object": { + "a": { + "range": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 12, + "byte": 187 + }, + "end": { + "line": 13, + "column": 16, + "byte": 191 + } + }, + "schema": { + "type": "string", + "const": "bar1" + }, + "literal": "bar1" + }, + "b": { + "range": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 12, + "byte": 203 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "type": "string", + "const": "bar2" + }, + "literal": "bar2" + } + } + } + } + } + } + } + }, + "properties": { + "full": { + "value": { + "a": { + "value": "bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + }, + "b": { + "value": "bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + }, + "reference": { + "value": { + "test": { + "value": "bar1 bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + }, + "short": { + "value": { + "a": { + "value": "bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + }, + "b": { + "value": "bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + } + }, + "schema": { + "properties": { + "full": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "reference": { + "properties": { + "test": { + "type": "string" + } + }, + "type": "object", + "required": [ + "test" + ] + }, + "short": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "full", + "reference", + "short" + ] + }, + "executionContext": { + "properties": { + "currentEnvironment": { + "value": { + "name": { + "value": "rotate", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + }, + "pulumi": { + "value": { + "user": { + "value": { + "id": { + "value": "USER_123", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + }, + "rootEnvironment": { + "value": { + "name": { + "value": "rotate", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "schema": { + "properties": { + "currentEnvironment": { + "properties": { + "name": { + "type": "string", + "const": "rotate" + } + }, + "type": "object", + "required": [ + "name" + ] + }, + "pulumi": { + "properties": { + "user": { + "properties": { + "id": { + "type": "string", + "const": "USER_123" + } + }, + "type": "object", + "required": [ + "id" + ] + } + }, + "type": "object", + "required": [ + "user" + ] + }, + "rootEnvironment": { + "properties": { + "name": { + "type": "string", + "const": "rotate" + } + }, + "type": "object", + "required": [ + "name" + ] + } + }, + "type": "object", + "required": [ + "currentEnvironment", + "pulumi", + "rootEnvironment" + ] + } + } + }, + "evalJsonRedacted": { + "full": { + "a": "bar1", + "b": "bar2" + }, + "reference": { + "test": "bar1 bar2" + }, + "short": { + "a": "bar1", + "b": "bar2" + } + }, + "evalJSONRevealed": { + "full": { + "a": "bar1", + "b": "bar2" + }, + "reference": { + "test": "bar1 bar2" + }, + "short": { + "a": "bar1", + "b": "bar2" + } + }, + "rotate": { + "exprs": { + "full": { + "range": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar2" + }, + "b": { + "type": "string", + "const": "bar1" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "builtin": { + "name": "fn::rotate", + "nameRange": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 3, + "column": 15, + "byte": 30 + } + }, + "argSchema": { + "properties": { + "inputs": true, + "provider": { + "type": "string" + }, + "state": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "inputs", + "provider", + "state" + ] + }, + "arg": { + "range": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + }, + "object": { + "inputs": { + "range": { + "environment": "rotate", + "begin": { + "line": 5, + "column": 15, + "byte": 67 + }, + "end": { + "line": 5, + "column": 15, + "byte": 67 + } + }, + "schema": { + "type": "object" + } + }, + "provider": { + "range": { + "environment": "rotate", + "begin": { + "line": 4, + "column": 17, + "byte": 48 + }, + "end": { + "line": 4, + "column": 21, + "byte": 52 + } + }, + "schema": { + "type": "string", + "const": "swap" + }, + "literal": "swap" + }, + "state": { + "range": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 9, + "byte": 91 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "keyRanges": { + "a": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 9, + "byte": 91 + }, + "end": { + "line": 7, + "column": 10, + "byte": 92 + } + }, + "b": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 9, + "byte": 107 + }, + "end": { + "line": 8, + "column": 10, + "byte": 108 + } + } + }, + "object": { + "a": { + "range": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 12, + "byte": 94 + }, + "end": { + "line": 7, + "column": 16, + "byte": 98 + } + }, + "schema": { + "type": "string", + "const": "bar1" + }, + "literal": "bar1" + }, + "b": { + "range": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 12, + "byte": 110 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + }, + "schema": { + "type": "string", + "const": "bar2" + }, + "literal": "bar2" + } + } + } + } + } + } + }, + "reference": { + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + }, + "schema": { + "properties": { + "test": { + "type": "string" + } + }, + "type": "object", + "required": [ + "test" + ] + }, + "keyRanges": { + "test": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 9, + "byte": 0 + } + } + }, + "object": { + "test": { + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + }, + "schema": { + "type": "string" + }, + "interpolate": [ + { + "value": [ + { + "key": "full", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 13, + "byte": 2 + }, + "end": { + "line": 17, + "column": 17, + "byte": 6 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + }, + { + "key": "a", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 17, + "byte": 6 + }, + "end": { + "line": 17, + "column": 19, + "byte": 8 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + ] + }, + { + "text": " ", + "value": [ + { + "key": "short", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 23, + "byte": 12 + }, + "end": { + "line": 17, + "column": 28, + "byte": 17 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + }, + { + "key": "b", + "range": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 28, + "byte": 17 + }, + "end": { + "line": 17, + "column": 30, + "byte": 19 + } + }, + "value": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + ] + } + ] + } + } + }, + "short": { + "range": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar2" + }, + "b": { + "type": "string", + "const": "bar1" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "builtin": { + "name": "fn::rotate::swap", + "nameRange": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 10, + "column": 21, + "byte": 144 + } + }, + "argSchema": { + "properties": { + "inputs": true, + "state": { + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "inputs", + "state" + ] + }, + "arg": { + "range": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + }, + "object": { + "inputs": { + "range": { + "environment": "rotate", + "begin": { + "line": 11, + "column": 15, + "byte": 160 + }, + "end": { + "line": 11, + "column": 15, + "byte": 160 + } + }, + "schema": { + "type": "object" + } + }, + "state": { + "range": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 9, + "byte": 184 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "properties": { + "a": { + "type": "string", + "const": "bar1" + }, + "b": { + "type": "string", + "const": "bar2" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "keyRanges": { + "a": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 9, + "byte": 184 + }, + "end": { + "line": 13, + "column": 10, + "byte": 185 + } + }, + "b": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 9, + "byte": 200 + }, + "end": { + "line": 14, + "column": 10, + "byte": 201 + } + } + }, + "object": { + "a": { + "range": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 12, + "byte": 187 + }, + "end": { + "line": 13, + "column": 16, + "byte": 191 + } + }, + "schema": { + "type": "string", + "const": "bar1" + }, + "literal": "bar1" + }, + "b": { + "range": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 12, + "byte": 203 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + }, + "schema": { + "type": "string", + "const": "bar2" + }, + "literal": "bar2" + } + } + } + } + } + } + } + }, + "properties": { + "full": { + "value": { + "a": { + "value": "bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + }, + "b": { + "value": "bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 3, + "column": 5, + "byte": 20 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + }, + "reference": { + "value": { + "test": { + "value": "bar2 bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 11, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 17, + "column": 5, + "byte": 0 + }, + "end": { + "line": 17, + "column": 31, + "byte": 0 + } + } + } + }, + "short": { + "value": { + "a": { + "value": "bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + }, + "b": { + "value": "bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 10, + "column": 5, + "byte": 128 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + } + }, + "schema": { + "properties": { + "full": { + "properties": { + "a": { + "type": "string", + "const": "bar2" + }, + "b": { + "type": "string", + "const": "bar1" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + }, + "reference": { + "properties": { + "test": { + "type": "string" + } + }, + "type": "object", + "required": [ + "test" + ] + }, + "short": { + "properties": { + "a": { + "type": "string", + "const": "bar2" + }, + "b": { + "type": "string", + "const": "bar1" + } + }, + "type": "object", + "required": [ + "a", + "b" + ] + } + }, + "type": "object", + "required": [ + "full", + "reference", + "short" + ] + }, + "executionContext": { + "properties": { + "currentEnvironment": { + "value": { + "name": { + "value": "rotate", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + }, + "pulumi": { + "value": { + "user": { + "value": { + "id": { + "value": "USER_123", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + }, + "rootEnvironment": { + "value": { + "name": { + "value": "rotate", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + "schema": { + "properties": { + "currentEnvironment": { + "properties": { + "name": { + "type": "string", + "const": "rotate" + } + }, + "type": "object", + "required": [ + "name" + ] + }, + "pulumi": { + "properties": { + "user": { + "properties": { + "id": { + "type": "string", + "const": "USER_123" + } + }, + "type": "object", + "required": [ + "id" + ] + } + }, + "type": "object", + "required": [ + "user" + ] + }, + "rootEnvironment": { + "properties": { + "name": { + "type": "string", + "const": "rotate" + } + }, + "type": "object", + "required": [ + "name" + ] + } + }, + "type": "object", + "required": [ + "currentEnvironment", + "pulumi", + "rootEnvironment" + ] + } + } + }, + "rotateJson": { + "full": { + "a": "bar2", + "b": "bar1" + }, + "reference": { + "test": "bar2 bar1" + }, + "short": { + "a": "bar2", + "b": "bar1" + } + }, + "rotatePatches": [ + { + "DocPath": "full[\"fn::rotate\"].state", + "Replacement": { + "value": { + "a": { + "value": "bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 8, + "column": 12, + "byte": 110 + }, + "end": { + "line": 8, + "column": 16, + "byte": 114 + } + } + } + }, + "b": { + "value": "bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 7, + "column": 12, + "byte": 94 + }, + "end": { + "line": 7, + "column": 16, + "byte": 98 + } + } + } + } + }, + "trace": { + "def": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + }, + { + "DocPath": "short[\"fn::rotate::swap\"].state", + "Replacement": { + "value": { + "a": { + "value": "bar2", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 14, + "column": 12, + "byte": 203 + }, + "end": { + "line": 14, + "column": 16, + "byte": 207 + } + } + } + }, + "b": { + "value": "bar1", + "trace": { + "def": { + "environment": "rotate", + "begin": { + "line": 13, + "column": 12, + "byte": 187 + }, + "end": { + "line": 13, + "column": 16, + "byte": 191 + } + } + } + } + }, + "trace": { + "def": { + "begin": { + "line": 0, + "column": 0, + "byte": 0 + }, + "end": { + "line": 0, + "column": 0, + "byte": 0 + } + } + } + } + } + ] +} diff --git a/eval/testdata/eval/rotate/overrides.json b/eval/testdata/eval/rotate/overrides.json new file mode 100644 index 00000000..23e284f0 --- /dev/null +++ b/eval/testdata/eval/rotate/overrides.json @@ -0,0 +1 @@ +{"rotate": true} diff --git a/provider.go b/provider.go index 153cbaa2..7caf062d 100644 --- a/provider.go +++ b/provider.go @@ -29,3 +29,16 @@ type Provider interface { // Open retrieves the provider's secrets. Open(ctx context.Context, inputs map[string]Value, executionContext EnvExecContext) (Value, error) } + +// A Rotator enables environments to rotate a secret. +// It is the responsibility of the caller to appropriately persist rotation state (e.g. by writing it back to the environment definition). +type Rotator interface { + // Schema returns the rotator's input, state, and output schemata. + Schema() (inputs, state, outputs *schema.Schema) + + // Open retrieves the rotator's secrets, using persisted state. + Open(ctx context.Context, inputs, state map[string]Value, executionContext EnvExecContext) (Value, error) + + // Rotate rotates the provider's secret, and returns the rotator's new state to be persisted. + Rotate(ctx context.Context, inputs, state map[string]Value, executionContext EnvExecContext) (Value, error) +}