Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions pkg/deploysecrets/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const (
// ActionRequire: the property is absent and nothing can produce it; the
// operator supplies it.
ActionRequire Action = "require"
// ActionEmpty: the property is absent, nothing supplies it, and the
// environment declares in service-secrets.may-be-empty that its correct value
// here is empty; it is stored empty so the workload's secret materializes.
ActionEmpty Action = "empty"
// ActionUnverified: the remote key exists but was not read, so whether it
// holds the property is unknown. Source says what would happen if it does not.
ActionUnverified Action = "unverified"
Expand Down Expand Up @@ -273,7 +277,7 @@ func Build(ctx context.Context, in *Inputs) (*Plan, error) {
for _, property := range state.secret.Properties {
location := propertyLocation{state.secret.RemoteKey, property.Property}
propertyPlan, value, err := planConfigured(state, property, states, generators, generated, in.Configured,
aliases[location], rotating)
aliases[location], rotating, in.MayBeEmpty)
if err != nil {
return nil, err
}
Expand All @@ -282,7 +286,7 @@ func Build(ctx context.Context, in *Inputs) (*Plan, error) {
Source: "derived: the stored URL's password follows rotated " + strings.Join(rewrite.from, ", ")}
value = rewrite.value
}
if value != "" {
if value != "" || propertyPlan.Action == ActionEmpty {
changes[property.Property] = value
}
secretPlan.Properties = append(secretPlan.Properties, propertyPlan)
Expand Down Expand Up @@ -531,7 +535,7 @@ func refuseSharedSigningKey(state *remoteState, property gitops.RenderedSecretPr
// only consulted for a property nothing holds.
func planConfigured(state *remoteState, property gitops.RenderedSecretProperty, states []*remoteState,
generators map[string]environments.EnvironmentSecretGenerator, generated map[string]string,
configured map[string]ConfiguredValue, keys []string, rotating map[string]bool) (PropertyPlan, string, error) {
configured map[string]ConfiguredValue, keys []string, rotating map[string]bool, mayBeEmpty []string) (PropertyPlan, string, error) {
lookup := property
lookup.Keys = keys
declaredKey, generator, err := declaredGenerator(lookup, generators)
Expand Down Expand Up @@ -596,7 +600,7 @@ func planConfigured(state *remoteState, property gitops.RenderedSecretProperty,
return PropertyPlan{Property: property.Property, Keys: property.Keys, Action: ActionKeep, Source: "stored"}, "", nil
}
}
fallback, value, err := fallbackSource(lookup, generators, generated, configuredValue, hasConfigured)
fallback, value, err := fallbackSource(lookup, generators, generated, configuredValue, hasConfigured, mayBeEmpty)
if err != nil {
return PropertyPlan{}, "", err
}
Expand Down Expand Up @@ -636,7 +640,7 @@ func planConfigured(state *remoteState, property gitops.RenderedSecretProperty,
// A property read as two keys covered by two different generators has no answer:
// whichever won would be an accident of ordering, so it is refused.
func fallbackSource(property gitops.RenderedSecretProperty, generators map[string]environments.EnvironmentSecretGenerator,
generated map[string]string, configured ConfiguredValue, hasConfigured bool) (PropertyPlan, string, error) {
generated map[string]string, configured ConfiguredValue, hasConfigured bool, mayBeEmpty []string) (PropertyPlan, string, error) {
if hasConfigured {
return PropertyPlan{Property: property.Property, Keys: property.Keys, Action: ActionConfigured,
Source: "configured in " + configured.File}, configured.value, nil
Expand All @@ -645,6 +649,14 @@ func fallbackSource(property gitops.RenderedSecretProperty, generators map[strin
if err != nil {
return PropertyPlan{}, "", err
}
if declaredKey == "" && declaredEmpty(property, mayBeEmpty) {
// The declaration is the value: an environment that says a key is
// legitimately empty here has said what to store. Requiring it instead
// leaves the property absent, and the ExternalSecret reading it never
// materializes its Secret.
return PropertyPlan{Property: property.Property, Keys: property.Keys, Action: ActionEmpty,
Source: "service-secrets.may-be-empty declares " + strings.Join(property.Keys, ", ") + " empty here"}, "", nil
}
if declaredKey == "" {
return PropertyPlan{Property: property.Property, Keys: property.Keys, Action: ActionRequire,
Source: "no key holds it, no configuration file supplies it, and service-secrets.generate declares no generator for " +
Expand Down
55 changes: 55 additions & 0 deletions pkg/deploysecrets/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,61 @@ func TestBuildAcceptsAnEmptyPropertyTheEnvironmentDeclares(t *testing.T) {
}
}

// A key the environment declares empty and nothing holds is written empty, not
// required: the declaration says what the value is, and leaving the property
// absent keeps the ExternalSecret reading it from ever materializing.
func TestBuildWritesADeclaredEmptyPropertyNothingHolds(t *testing.T) {
rendered := fixture()
documents := seeded()
delete(documents["host-accounts"], clientSecret)
store := newFakeStore(documents)
ctx := context.Background()
plan, err := Build(ctx, &Inputs{Rendered: rendered,
Generators: generators(), Services: []string{"tasks/store", "tasks/worker"},
Store: store, ReadPayloads: true, MayBeEmpty: []string{clientSecret}})
if err != nil {
t.Fatal(err)
}
for _, required := range plan.Required() {
if strings.Contains(required, clientSecret) {
t.Fatalf("Required() names %s, want it planned empty: the environment declares it", clientSecret)
}
}
if _, err = plan.Apply(ctx, store, true); err != nil {
t.Fatal(err)
}
value, present := store.documents["host-accounts"][clientSecret]
if !present || value != "" {
t.Errorf("apply stored (%q, present=%v), want the property present and empty", value, present)
}
// A second plan keeps it: the stored empty is the declared one.
again, err := Build(ctx, &Inputs{Rendered: rendered,
Generators: generators(), Services: []string{"tasks/store", "tasks/worker"},
Store: store, ReadPayloads: true, MayBeEmpty: []string{clientSecret}})
if err != nil {
t.Fatal(err)
}
if slices.Contains(again.Changes(), "host-accounts") {
t.Errorf("a re-plan rewrites host-accounts, want the stored empty kept")
}
}

// Without the declaration the same absent property is still the operator's.
func TestBuildRequiresAnAbsentPropertyNotDeclaredEmpty(t *testing.T) {
rendered := fixture()
documents := seeded()
delete(documents["host-accounts"], clientSecret)
plan, err := Build(context.Background(), &Inputs{Rendered: rendered,
Generators: generators(), Services: []string{"tasks/store", "tasks/worker"},
Store: newFakeStore(documents), ReadPayloads: true})
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(plan.Required(), func(required string) bool { return strings.Contains(required, clientSecret) }) {
t.Errorf("Required() = %v, want the undeclared %s required", plan.Required(), clientSecret)
}
}

// Declaring one of a property's keys is not declaring the property: a property
// two services read under two keys is only safely empty when neither needs one.
func TestBuildStillRefusesWhenOnlySomeOfAPropertysKeysAreDeclared(t *testing.T) {
Expand Down
Loading