Skip to content

Commit dafce50

Browse files
committed
fix: harden PrefectAutomation controller (#294 follow-ups)
Follow-up hardening for the PrefectAutomation CRD merged in #294. None of these were blockers, but worth addressing before the CRD sees real use: - Self-heal when the automation is deleted out-of-band in Prefect: a 404 on update now clears the stored ID and recreates instead of looping on SyncError forever. - Propagate invalid JSON in action parameters/jobVariables instead of silently dropping it (matches the match/matchRelated paths). - Enforce deploymentId/deploymentName rules in Validate(), grounded in the Prefect API schema: the two are mutually exclusive, and for run/pause/resume-deployment a "selected" source (the default) requires exactly one target while "inferred" requires none. - Error on ambiguous deployment names in FindDeploymentByName rather than silently resolving to an arbitrary match. Deployment names are only unique per flow, so a bare name can legitimately match several. Flow-qualified references are a larger CRD change left as a follow-up. - Add the missing controller envtest suite plus unit coverage for the above. Claude-Session: https://claude.ai/code/session_01YXNFE2AvNggTqhD3oBWmMg
1 parent f6a6f9f commit dafce50

9 files changed

Lines changed: 762 additions & 7 deletions

api/v1/prefectautomation_types.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ type PrefectSequenceTrigger struct {
184184
Triggers []PrefectChildTrigger `json:"triggers"`
185185
}
186186

187+
// Deployment-target action types (their target is a deployment).
188+
const (
189+
ActionRunDeployment = "run-deployment"
190+
ActionPauseDeployment = "pause-deployment"
191+
ActionResumeDeployment = "resume-deployment"
192+
)
193+
187194
// PrefectAutomationAction matches Prefect's action union. `type` selects the
188195
// action; the remaining fields apply to the relevant action types.
189196
type PrefectAutomationAction struct {
@@ -326,6 +333,62 @@ func (a *PrefectAutomation) Validate() error {
326333
if set != 1 {
327334
return fmt.Errorf("exactly one of trigger.event, trigger.metric, trigger.compound, or trigger.sequence must be set (got %d)", set)
328335
}
336+
337+
for _, list := range []struct {
338+
field string
339+
actions []PrefectAutomationAction
340+
}{
341+
{"actions", a.Spec.Actions},
342+
{"actionsOnTrigger", a.Spec.ActionsOnTrigger},
343+
{"actionsOnResolve", a.Spec.ActionsOnResolve},
344+
} {
345+
for i := range list.actions {
346+
if err := validateAction(list.field, i, list.actions[i]); err != nil {
347+
return err
348+
}
349+
}
350+
}
351+
return nil
352+
}
353+
354+
// deploymentTargetActions are the action types whose target is a deployment.
355+
var deploymentTargetActions = map[string]struct{}{
356+
ActionRunDeployment: {},
357+
ActionPauseDeployment: {},
358+
ActionResumeDeployment: {},
359+
}
360+
361+
// validateAction enforces the deploymentId/deploymentName rules that mirror the
362+
// Prefect API: the two fields are two ways to supply the same deployment_id (so
363+
// never both), and for deployment-target actions the presence of a target is
364+
// governed by `source` ("selected" requires exactly one, "inferred" requires
365+
// neither). Source defaults to "selected" per the API.
366+
func validateAction(field string, i int, action PrefectAutomationAction) error {
367+
hasID := action.DeploymentID != nil && *action.DeploymentID != ""
368+
hasName := action.DeploymentName != nil && *action.DeploymentName != ""
369+
370+
if hasID && hasName {
371+
return fmt.Errorf("%s[%d] (%s): deploymentId and deploymentName are mutually exclusive", field, i, action.Type)
372+
}
373+
374+
if _, isDeploymentAction := deploymentTargetActions[action.Type]; !isDeploymentAction {
375+
return nil
376+
}
377+
378+
source := "selected"
379+
if action.Source != nil && *action.Source != "" {
380+
source = *action.Source
381+
}
382+
switch source {
383+
case "inferred":
384+
if hasID || hasName {
385+
return fmt.Errorf("%s[%d] (%s): deploymentId/deploymentName must not be set when source is 'inferred'", field, i, action.Type)
386+
}
387+
default: // "selected"
388+
if !hasID && !hasName {
389+
return fmt.Errorf("%s[%d] (%s): exactly one of deploymentId or deploymentName must be set when source is 'selected'", field, i, action.Type)
390+
}
391+
}
329392
return nil
330393
}
331394

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1
18+
19+
import (
20+
. "github.com/onsi/ginkgo/v2"
21+
. "github.com/onsi/gomega"
22+
)
23+
24+
var _ = Describe("PrefectAutomation Validate", func() {
25+
// eventTrigger is a minimal valid trigger so tests can focus on action rules.
26+
eventTrigger := PrefectAutomationTrigger{
27+
Event: &PrefectEventTrigger{Posture: "Reactive"},
28+
}
29+
30+
automationWithActions := func(actions ...PrefectAutomationAction) *PrefectAutomation {
31+
return &PrefectAutomation{
32+
Spec: PrefectAutomationSpec{
33+
Name: "a",
34+
Trigger: eventTrigger,
35+
Actions: actions,
36+
},
37+
}
38+
}
39+
40+
Describe("trigger union", func() {
41+
It("accepts exactly one trigger member", func() {
42+
a := &PrefectAutomation{Spec: PrefectAutomationSpec{Name: "a", Trigger: eventTrigger}}
43+
Expect(a.Validate()).To(Succeed())
44+
})
45+
46+
It("rejects zero trigger members", func() {
47+
a := &PrefectAutomation{Spec: PrefectAutomationSpec{Name: "a"}}
48+
Expect(a.Validate()).To(HaveOccurred())
49+
})
50+
51+
It("rejects more than one trigger member", func() {
52+
a := &PrefectAutomation{Spec: PrefectAutomationSpec{
53+
Name: "a",
54+
Trigger: PrefectAutomationTrigger{
55+
Event: &PrefectEventTrigger{Posture: "Reactive"},
56+
Metric: &PrefectMetricTrigger{},
57+
},
58+
}}
59+
Expect(a.Validate()).To(HaveOccurred())
60+
})
61+
})
62+
63+
Describe("deployment target actions", func() {
64+
It("rejects setting both deploymentId and deploymentName", func() {
65+
a := automationWithActions(PrefectAutomationAction{
66+
Type: "run-deployment",
67+
Source: new("selected"),
68+
DeploymentID: new("dep-id"),
69+
DeploymentName: new("dep-name"),
70+
})
71+
err := a.Validate()
72+
Expect(err).To(HaveOccurred())
73+
Expect(err.Error()).To(ContainSubstring("mutually exclusive"))
74+
})
75+
76+
It("rejects both even for non-deployment action types", func() {
77+
a := automationWithActions(PrefectAutomationAction{
78+
Type: "pause-automation",
79+
DeploymentID: new("dep-id"),
80+
DeploymentName: new("dep-name"),
81+
})
82+
Expect(a.Validate()).To(HaveOccurred())
83+
})
84+
85+
It("requires a target when source is selected", func() {
86+
a := automationWithActions(PrefectAutomationAction{
87+
Type: "run-deployment",
88+
Source: new("selected"),
89+
})
90+
err := a.Validate()
91+
Expect(err).To(HaveOccurred())
92+
Expect(err.Error()).To(ContainSubstring("selected"))
93+
})
94+
95+
It("requires a target when source is unset (defaults to selected)", func() {
96+
a := automationWithActions(PrefectAutomationAction{
97+
Type: "run-deployment",
98+
})
99+
Expect(a.Validate()).To(HaveOccurred())
100+
})
101+
102+
It("accepts a selected action with deploymentId only", func() {
103+
a := automationWithActions(PrefectAutomationAction{
104+
Type: "run-deployment",
105+
Source: new("selected"),
106+
DeploymentID: new("dep-id"),
107+
})
108+
Expect(a.Validate()).To(Succeed())
109+
})
110+
111+
It("accepts a selected action with deploymentName only", func() {
112+
a := automationWithActions(PrefectAutomationAction{
113+
Type: "pause-deployment",
114+
Source: new("selected"),
115+
DeploymentName: new("dep-name"),
116+
})
117+
Expect(a.Validate()).To(Succeed())
118+
})
119+
120+
It("rejects a target when source is inferred", func() {
121+
a := automationWithActions(PrefectAutomationAction{
122+
Type: "run-deployment",
123+
Source: new("inferred"),
124+
DeploymentID: new("dep-id"),
125+
})
126+
err := a.Validate()
127+
Expect(err).To(HaveOccurred())
128+
Expect(err.Error()).To(ContainSubstring("inferred"))
129+
})
130+
131+
It("accepts an inferred action with no target", func() {
132+
a := automationWithActions(PrefectAutomationAction{
133+
Type: "resume-deployment",
134+
Source: new("inferred"),
135+
})
136+
Expect(a.Validate()).To(Succeed())
137+
})
138+
139+
It("validates actions across all three lists", func() {
140+
a := &PrefectAutomation{Spec: PrefectAutomationSpec{
141+
Name: "a",
142+
Trigger: eventTrigger,
143+
ActionsOnResolve: []PrefectAutomationAction{
144+
{Type: "run-deployment", Source: new("selected")},
145+
},
146+
}}
147+
err := a.Validate()
148+
Expect(err).To(HaveOccurred())
149+
Expect(err.Error()).To(ContainSubstring("actionsOnResolve"))
150+
})
151+
})
152+
153+
Describe("non-deployment actions", func() {
154+
It("does not require a deployment target", func() {
155+
a := automationWithActions(PrefectAutomationAction{
156+
Type: "send-notification",
157+
Subject: new("hi"),
158+
})
159+
Expect(a.Validate()).To(Succeed())
160+
})
161+
})
162+
})

internal/controller/prefectautomation_controller.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package controller
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"time"
2324

@@ -171,6 +172,18 @@ func (r *PrefectAutomationReconciler) syncWithPrefect(ctx context.Context, autom
171172
var result *prefect.Automation
172173
if automation.Status.Id != nil && *automation.Status.Id != "" {
173174
result, err = prefectClient.UpdateAutomation(ctx, *automation.Status.Id, automationSpec)
175+
// If the automation was deleted out-of-band, clear the stale ID and
176+
// requeue so the next pass recreates it instead of looping on SyncError.
177+
if errors.Is(err, prefect.ErrAutomationNotFound) {
178+
log.Info("Automation no longer exists in Prefect, recreating", "automation", automation.Name, "prefectId", *automation.Status.Id)
179+
automation.Status.Id = nil
180+
r.setCondition(automation, PrefectAutomationConditionSynced, metav1.ConditionFalse, "Recreating", "Automation was deleted in Prefect; recreating")
181+
automation.Status.Ready = false
182+
if updateErr := r.Status().Update(ctx, automation); updateErr != nil {
183+
log.Error(updateErr, "Failed to update automation status", "automation", automation.Name)
184+
}
185+
return ctrl.Result{RequeueAfter: time.Second}, nil
186+
}
174187
} else {
175188
result, err = prefectClient.CreateAutomation(ctx, automationSpec)
176189
}

0 commit comments

Comments
 (0)