From 68c0151989e45637cf917b0cfe57f5d9ac07c439 Mon Sep 17 00:00:00 2001 From: Diego Braga Date: Sun, 15 Nov 2026 16:41:01 +0100 Subject: [PATCH] feat(condition): CreateOutcomeUnknown, its own Ready reason for a create the runtime refuses to repeat (#93) When a create was started (krateo.io/external-create-pending set) and the existence check that would decide its outcome fails, the worker refuses to act rather than risk creating the resource twice. That refusal is right: azuredevops-provider-kog ran 10 forced status-write races against a real vendor and got 0 duplicates, one through this exact branch. But it set a bare Ready=False/Creating, which reads like a healthy create in progress. A consumer could not tell "converging" from "needs a human unless the next check succeeds", and the cause reached only Synced. Decision (Diego, 2026-11-15): a distinct reason, resolved by a human. Not provider-runtime's bounded recovery (a grace, then remove the marker and retry), which on a backend that cannot find what a lost create made would create a duplicate real resource. condition.ReasonCreateOutcomeUnknown = "CreateOutcomeUnknown" and condition.CreateOutcomeUnknown(message) (Ready, False). The worker writes it with the existence check's error and the remediation, beside the existing ReconcileError on Synced. It clears when a later existence check succeeds (retried on every resync, so a transient cause clears by itself) or when a human, having verified the external state, removes the marker. Announced to installer (their gate refuses an unclassified reason by design) and to oasgen-provider (who adds it to their condition fallback) before merge. rest-dynamic-controller's createHasBeenAttempted returns early on the annotation, so no coordinated release there (oasgen's retraction on the issue). Test: TestCreateIncomplete_ConditionCarriesTheReason (which drives the branch, with calibrations) now also asserts Ready itself is False/CreateOutcomeUnknown, carrying the cause and the remediation. Mutant (back to a bare Creating) killed. Unit (18 packages) and vet under every tag pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8HNbBzRpE6rUjMRzosAjG --- .../create_incomplete_reason_test.go | 10 +++++++++ pkg/controller/worker.go | 5 ++++- pkg/tools/unstructured/condition/condition.go | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) 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,