diff --git a/pkg/controller/create_incomplete_reason_test.go b/pkg/controller/create_incomplete_reason_test.go index 85103d8..564f2a1 100644 --- a/pkg/controller/create_incomplete_reason_test.go +++ b/pkg/controller/create_incomplete_reason_test.go @@ -27,6 +27,7 @@ package controller import ( "context" "errors" + "github.com/krateo-platformops/unstructured-runtime/pkg/tools/unstructured/condition" "strings" "testing" "time" @@ -117,4 +118,13 @@ func TestCreateIncomplete_ConditionCarriesTheReason(t *testing.T) { require.Contains(t, joined, "remove the", "the remediation advice was lost: the message must carry BOTH what to do and why, "+ "conditions were: "+joined) + + // #93: READY itself names the refusal, with the cause and the remediation. It used to be a bare + // Creating, indistinguishable from a healthy create in progress, with the cause reachable only + // on Synced. + ready := unstructuredtools.GetCondition(got, condition.TypeReady, condition.ReasonCreateOutcomeUnknown) + require.NotNil(t, ready, "Ready must be False/CreateOutcomeUnknown; conditions were: "+joined) + require.Equal(t, metav1.ConditionFalse, ready.Status) + require.Contains(t, ready.Message, reason, "Ready's message must carry the existence check's error") + require.Contains(t, ready.Message, "remove the", "Ready's message must carry the remediation") } diff --git a/pkg/controller/worker.go b/pkg/controller/worker.go index a18dcda..be8144c 100644 --- a/pkg/controller/worker.go +++ b/pkg/controller/worker.go @@ -637,7 +637,10 @@ func (c *Controller) processItem(ctx context.Context, obj interface{}) (err erro c.recordEvent(el, event.Warning(reasonCannotInitialize, actionProcessEvent, wrapped)) err = unstructuredtools.SetConditions(el, - condition.Creating(), + // #93: its own reason, carrying the cause and the remediation. A bare Creating read + // like a healthy create in progress, while this refusal lasts until the existence + // check succeeds or a human clears the marker. + condition.CreateOutcomeUnknown(wrapped.Error()), condition.ReconcileError(wrapped), ) if err != nil { diff --git a/pkg/tools/unstructured/condition/condition.go b/pkg/tools/unstructured/condition/condition.go index af80e43..279da2c 100644 --- a/pkg/tools/unstructured/condition/condition.go +++ b/pkg/tools/unstructured/condition/condition.go @@ -54,6 +54,15 @@ const ( // PAUSED, so the value is the last one observed and is not being refreshed. Status stays True — // a paused resource is still available, it just is not converging (core-provider#241). ReasonReconcilePausedReady string = "ReconcilePaused" + // ReasonCreateOutcomeUnknown: a create was started and its outcome cannot be determined (the + // krateo.io/external-create-pending marker is set and the existence check failed), so the + // runtime refuses to act rather than risk creating the resource twice (unstructured-runtime#93). + // It was a bare Creating, which reads like a healthy create in progress. The message carries the + // existence check's error and the remediation. It clears when a later existence check succeeds + // (retried on every resync; a transient cause clears by itself) or when a human, having verified + // the external state, removes the marker; a permanent cause (a kind with no read verb, say) + // needs the human. + ReasonCreateOutcomeUnknown string = "CreateOutcomeUnknown" ) // Reasons a resource is or is not synced. @@ -188,6 +197,18 @@ func ObserveFailed(message string) metav1.Condition { } } +// CreateOutcomeUnknown is Ready=False/CreateOutcomeUnknown with message: see +// ReasonCreateOutcomeUnknown. +func CreateOutcomeUnknown(message string) metav1.Condition { + return metav1.Condition{ + Type: TypeReady, + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: ReasonCreateOutcomeUnknown, + Message: message, + } +} + func FailWithReason(reason string) metav1.Condition { return metav1.Condition{ Type: TypeReady,