Skip to content

Commit f8d9685

Browse files
jogroganclaudeCopilotryannedolan
authored
Extend pre-delete dep-guard to triggers (#221)
* feat: pre-delete dependency guard for DROP TABLE Refuses a DROP TABLE while an active Pipeline still references the resource (as either source or sink), so dropping the underlying Kafka topic / Venice store / MySQL table can't silently orphan a downstream pipeline. Validator framework, made Connection-aware: - Validated.validate(Issues, Connection) (was: validate(Issues)) - ValidatorProvider.validators(T, Connection) (was: validators(T)) - ValidationService.validate(T, Issues, Connection) - ValidationService.validateOrThrow(T, Connection) - ValidationService.validateOrThrow(Collection<T>, Connection) - ValidationService.validators(T, Connection) PendingDelete<T> wrapper (hoptimator-api): - Explicit "this is being deleted" signal so unrelated callers of validateOrThrow(source, connection) don't accidentally trigger pre-delete checks. - Carries an optional selfOwnerUid so cascade-deleted children can be excluded from the dependent set. K8s indexed lookup: - PipelineDependencyLabels stamps `depends-on-<slug>` labels on every Pipeline CRD at create time, naming each source/sink. The slug is a 16-char SHA-256 prefix of `<database>_<dot-joined-path>`; an annotation lists the full identifiers so a slug collision can be detected at check time. - PipelineDependencyChecker uses a server-indexed label-selector list + annotation cross-check + selfOwnerUid filter. - K8sPipelineDeployer threads sources/sink through and calls PipelineDependencyLabels.labelsFor / annotationFor at toK8sObject(). K8sPipelineBundle and K8sMaterializedViewDeployer pass the data through. Dispatch: - K8sValidatorProvider returns a K8sPipelineDependencyValidator for PendingDelete<Source>; registered via META-INF/services/com.linkedin.hoptimator.ValidatorProvider. - K8sPipelineDependencyValidator wraps PipelineDependencyChecker as a Validator. DROP TABLE wiring: - HoptimatorDdlExecutor calls ValidationService.validateOrThrow(new PendingDelete<>(source), connection) before DeploymentService.delete in the table branch. HoptimatorDdlUtils.removeTableFromSchema() is the symmetric inverse of registerTemporaryTableInSchema() for cleanup. Implementor side-effects (no behavior change): - KafkaDeployer / VeniceDeployer / MySqlDeployer no longer need a declarative DependencyGuarded marker — the guard fires from the validator framework before delete() is reached. - All existing Validated implementors (DefaultValidator, CompatibilityValidatorBase, AvroTableValidator, K8sViewTable) and ValidatorProvider implementors (DefaultValidatorProvider, CompatibilityValidatorProvider, AvroValidatorProvider) updated to the new signatures. Tests: PipelineDependencyLabelsTest, PipelineDependencyCheckerTest, K8sPipelineDeployerTest assertions for stamping, validator-framework test updates throughout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: support DROP TABLE for logical tables LogicalTableDeployer.delete() previously threw SQLFeatureNotSupported. Now implemented end-to-end as a per-tier sequence that mirrors what running DROP TABLE on each tier independently would do, plus the LogicalTable CRD removal at the top. Flow: 1. Per-tier pre-flight via the validator framework: ValidationService.validateOrThrow(new PendingDelete<>(tierSource, logicalTableUid), connection) — refuses the drop if any active external pipeline still references a tier resource. The selfOwnerUid is the LogicalTable CRD's UID so the implicit inter-tier pipelines (owned by the CRD, cascade-deleted with it) don't self-block. 2. Delete the LogicalTable CRD. K8s owner-ref cascade removes its owned Pipeline and TableTrigger CRDs. 3. Best-effort physical cleanup of each tier resource (Kafka topic, Venice store, ...). A failed tier delete logs a warning but does not abort — a stranded tier is recoverable; aborting mid-DROP isn't. 4. Per-tier schema cleanup: deregister the TemporaryTable entry in each tier schema only when its physical delete succeeded. Tests: - LogicalTableDeployerTest deleteRemovesCrdAndCleansUpTierResources, deletePropagatesCrdDeletionFailure, deleteSwallowsTierCleanupFailures. - logical-ddl.id integration test: DROP TABLE LOGICAL.testevent now succeeds and cascades the implicit nearline-to-online pipeline. - logical-offline-ddl.id companion update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: integration scenarios + cleanup test warnings kafka-ddl-create-table.id: cross-driver dependency-guard scenarios exercising the new pre-delete check end-to-end through the kafka driver — drop-table-while-pipeline-depends-on-it (source side and partial-view sink side). The bulk of the file count is mechanical noise reduction across existing test files: dropped unused imports, tightened generics on @SuppressWarnings, etc. — fallout from the warning_cleanup pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * update integration test * comment cleanups and add @nullable annotation * Migrate UID lookup to kind+name * Fix issue to preserve existing annotations * Add more test coverage * Split depends-on annotation into directional sources/sink Pipelines previously stamped a single `depends-on` annotation listing every source and sink undifferentiated. The dep-guard collision check worked on this, but it loses the source/sink direction information needed for visualization. Replace the unified annotation with two directional annotations: hoptimator.linkedin.com/depends-on-sources: <s1>,<s2>,... hoptimator.linkedin.com/depends-on-sink: <single sink> The dep-guard's annotationConfirms now reads sources or sink as the collision-guard set — same correctness guarantee, no semantic change for the dep-guard. The split unlocks directional rendering for the upcoming pipeline visualizer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add typed Source/Sink to Trigger and stamp depends-on labels Two related changes that bring triggers into the depends-on label index so the pre-delete dep-guard can find them. Trigger API: - Replace the old `(String database, List<String> path)` pair with a typed `Source source` field (existing hoptimator-api type). Drop the convenience getters database() / path() / table() / schema() — callers go through trigger.source().X() for symmetry with the new Sink. - Add an optional `Sink sink` field for bridging-tier triggers (LogicalTableDeployer's offline → online reverse-ETL flow), so the deployer can stamp a depends-on-sink annotation in addition to the source side. - Source is nullable for DROP / PAUSE / RESUME paths that only need the trigger name. K8sTriggerDeployer stamping: - On both the toK8sObject and the partial-update paths, stamp: depends-on-<sourceSlug>: <sourceIdentifier> annotation depends-on-sources: <sourceIdentifier> and when sink is set: depends-on-<sinkSlug>: <sinkIdentifier> annotation depends-on-sink: <sinkIdentifier> LogicalTableDeployer wiring: - Pass `offlineSource` directly as the trigger's Source and a Sink derived from `onlineSource` (when present) as the trigger's Sink. HoptimatorDdlExecutor: - Resolve the target table's database name the same way DROP TABLE resolves it (HoptimatorJdbcTable / TemporaryTable unwrap), so user-created `CREATE TRIGGER ... ON <schema>.<table>` triggers participate in the dep-guard. Without this, Trigger.source was null and K8sTriggerDeployer skipped label stamping — a trigger could outlive its source silently. Tests: - K8sTriggerDeployerTest gains updateStampsSinkLabelWhenTriggerCarriesASink pinning that a Trigger carrying a Sink stamps both source-side and sink-side depends-on labels on the partial-update path. - TriggerTest covers the new accessor shape (source(), sink(), null source for the bare-name case). - LogicalTableDeployerTest asserts trigger.source() / trigger.sink() instead of the removed convenience getters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Extend pre-delete dependency guard to TableTrigger CRDs Triggers carry the same depends-on-<slug> labels Pipelines do (stamped by K8sTriggerDeployer in the previous commit), but the dep-guard's PipelineDependencyChecker only consulted the Pipeline CRD list. That left a hole: a user could DROP TABLE on a source still referenced by a live trigger. PipelineDependencyChecker now runs the same label-selector + annotation-confirmation logic against TableTrigger CRDs as well. The inner loop is genericized over KubernetesObject; each blocker is tagged with its CRD kind (pipeline/foo, trigger/bar) so the error message points the user at what to unhook. Self-owner exclusion still applies — LogicalTable-owned triggers don't block their parent's cascade-delete. Coverage: - PipelineDependencyCheckerTest gets paired blocksOnExternalTrigger, skipsSelfOwnedTrigger, and errorMessageListsAllBlockersAcrossKinds cases that prove triggers participate alongside pipelines. - New k8s-trigger-validation.id integration scenario: CREATE TABLE → CREATE TRIGGER → DROP TABLE blocked → DROP TRIGGER → DROP TABLE succeeds. Mirrors the MV pattern the existing k8s-validation.id scenarios use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Generalize dependency labels/checker beyond pipelines PipelineDependency{Labels,Checker} and K8sPipelineDependencyValidator were specific to Pipeline CRDs in name only — TableTrigger CRDs now wear the same depends-on labels and annotations. Renamed to DependencyLabels, DependencyChecker, and K8sDependencyValidator. Collapsed the labels API: DependencyLabels now exposes a single stamp(V1ObjectMeta, Collection<Source>, Sink) that writes both the depends-on labels and the directional annotations. K8sTriggerDeployer had reimplemented this inline in two places; both call sites now collapse to one stamp() call. Also dropped the 5-arg Trigger constructor; callers must pass a sink (or null) explicitly. * refactoring * Allow multiple sources/sinks (future proofing) * Fix checkstyle --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ryannedolan <1387539+ryannedolan@users.noreply.github.com>
1 parent f8e29a2 commit f8d9685

27 files changed

Lines changed: 975 additions & 672 deletions

File tree

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,39 @@
11
package com.linkedin.hoptimator;
22

3-
import java.util.List;
3+
import javax.annotation.Nullable;
44
import java.util.Map;
55

66

77
public class Trigger implements Deployable {
88

99
public static final String PAUSED_OPTION = "paused";
10+
1011
private final String name;
1112
private final UserJob job;
12-
private final List<String> path;
1313
private final String cronSchedule;
1414
private final Map<String, String> options;
15+
private final Source source;
16+
private final Sink sink;
1517

16-
public Trigger(String name, UserJob job, List<String> path, String cronSchedule,
17-
Map<String, String> options) {
18+
/**
19+
* Contains an optional downstream sink for triggers that operate between a source
20+
* sink (think ETL/rETL).
21+
* TODO: need to collapse the "job.properties.online.table.name" logic into a sink for adhoc triggers
22+
*/
23+
public Trigger(String name, UserJob job, String cronSchedule, Map<String, String> options,
24+
Source source, @Nullable Sink sink) {
1825
this.name = name;
1926
this.job = job;
20-
this.path = path;
2127
this.cronSchedule = cronSchedule;
2228
this.options = options;
29+
this.source = source;
30+
this.sink = sink;
2331
}
2432

2533
public String name() {
2634
return name;
2735
}
2836

29-
public List<String> path() {
30-
return path;
31-
}
32-
3337
public UserJob job() {
3438
return job;
3539
}
@@ -38,27 +42,24 @@ public String cronSchedule() {
3842
return cronSchedule;
3943
}
4044

41-
public String table() {
42-
return path.get(path.size() - 1);
43-
}
44-
45-
/**
46-
* Returns the schema name if present.
47-
*/
48-
public String schema() {
49-
return path.size() >= 2 ? path.get(path.size() - 2) : null;
50-
}
51-
5245
public Map<String, String> options() {
5346
return options;
5447
}
5548

56-
private String pathString() {
57-
return String.join(".", path);
49+
/** Upstream source the trigger fires on, or {@code null} when only the name is known
50+
* (e.g. during DROP TRIGGER / PAUSE / RESUME, which only need to look up the existing CRD). */
51+
public Source source() {
52+
return source;
53+
}
54+
55+
/** Downstream sink the trigger's job writes to, or {@code null} when the trigger has no declared sink. */
56+
public @Nullable Sink sink() {
57+
return sink;
5858
}
5959

6060
@Override
6161
public String toString() {
62-
return "Trigger[" + name() + ", " + pathString() + "]";
62+
String path = source == null ? "<unbound>" : String.join(".", source.path());
63+
return "Trigger[" + name + ", " + path + "]";
6364
}
6465
}

hoptimator-api/src/test/java/com/linkedin/hoptimator/PendingDeleteTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ void toStringOmitsSelfOwnerWhenNull() {
6767
@Test
6868
void toStringOmitsSelfOwnerWhenOnlyOneFieldSet() {
6969
// The toString contract says self= appears only when both kind and name are non-null.
70-
// (The K8sPipelineDependencyChecker.isSelfOwned guard also requires both.)
70+
// (The DependencyChecker.isSelfOwned guard also requires both.)
7171
PendingDelete<String> kindOnly = new PendingDelete<>("t", "LogicalTable", null);
7272
assertFalse(kindOnly.toString().contains("self="));
7373

hoptimator-api/src/test/java/com/linkedin/hoptimator/TriggerTest.java

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import org.junit.jupiter.api.Test;
44

5+
import java.util.Collections;
56
import java.util.List;
67
import java.util.Map;
78

89
import static org.junit.jupiter.api.Assertions.assertEquals;
10+
import static org.junit.jupiter.api.Assertions.assertNotNull;
911
import static org.junit.jupiter.api.Assertions.assertNull;
1012

1113

@@ -15,26 +17,37 @@ class TriggerTest {
1517
void testAccessors() {
1618
UserJob userJob = new UserJob("ns", "jobName");
1719
Map<String, String> options = Map.of("paused", "true");
18-
Trigger trigger = new Trigger("myTrigger", userJob, List.of("schema", "table"), "0 * * * *", options);
20+
Source source = new Source("db", List.of("schema", "table"), Collections.emptyMap());
21+
Trigger trigger = new Trigger("myTrigger", userJob, "0 * * * *", options, source, null);
1922

2023
assertEquals("myTrigger", trigger.name());
2124
assertEquals(userJob, trigger.job());
22-
assertEquals(List.of("schema", "table"), trigger.path());
2325
assertEquals("0 * * * *", trigger.cronSchedule());
2426
assertEquals(options, trigger.options());
25-
assertEquals("table", trigger.table());
26-
assertEquals("schema", trigger.schema());
27+
assertNotNull(trigger.source());
28+
assertEquals(List.of("schema", "table"), trigger.source().path());
29+
assertEquals("table", trigger.source().table());
30+
assertEquals("schema", trigger.source().schema());
31+
assertNull(trigger.sink());
2732
}
2833

2934
@Test
30-
void testSchemaReturnsNullForSingleElementPath() {
31-
Trigger trigger = new Trigger("t", null, List.of("table"), null, Map.of());
32-
assertNull(trigger.schema());
35+
void testNullSourceAccessor() {
36+
Trigger trigger = new Trigger("t", null, null, Map.of(), null, null);
37+
assertNull(trigger.source());
38+
assertNull(trigger.sink());
3339
}
3440

3541
@Test
3642
void testToString() {
37-
Trigger trigger = new Trigger("myTrig", null, List.of("a", "b"), null, Map.of());
43+
Source source = new Source("db", List.of("a", "b"), Collections.emptyMap());
44+
Trigger trigger = new Trigger("myTrig", null, null, Map.of(), source, null);
3845
assertEquals("Trigger[myTrig, a.b]", trigger.toString());
3946
}
47+
48+
@Test
49+
void testToStringWithoutSource() {
50+
Trigger trigger = new Trigger("myTrig", null, null, Map.of(), null, null);
51+
assertEquals("Trigger[myTrig, <unbound>]", trigger.toString());
52+
}
4053
}

hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutor.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ public void execute(SqlCreateTrigger create, CalcitePrepare.Context context) {
229229
String cronSchedule = create.cron != null
230230
? ((SqlLiteral) create.cron).getValueAs(String.class) : null;
231231
UserJob job = new UserJob(jobNamespace, jobName);
232-
Trigger trigger = new Trigger(name, job, targetPath, cronSchedule, options);
232+
Source source = new Source(databaseOf(target), targetPath, Collections.emptyMap());
233+
Trigger trigger = new Trigger(name, job, cronSchedule, options, source, null);
233234

234235
Collection<Deployer> deployers = null;
235236
try {
@@ -255,6 +256,23 @@ public void execute(SqlCreateTrigger create, CalcitePrepare.Context context) {
255256
}
256257
}
257258

259+
/**
260+
* Best-effort lookup of the database name that owns a Calcite Table. Mirrors what the
261+
* {@code DROP TABLE} path does so triggers and tables agree on the same
262+
* {@code (database, path)} identifier.
263+
*/
264+
private static String databaseOf(Table target) {
265+
if (target instanceof HoptimatorJdbcTable) {
266+
HoptimatorJdbcSchema jdbcSchema =
267+
(HoptimatorJdbcSchema) ((HoptimatorJdbcTable) target).jdbcTable().jdbcSchema;
268+
return jdbcSchema.databaseName();
269+
}
270+
if (target instanceof TemporaryTable) {
271+
return ((TemporaryTable) target).databaseName();
272+
}
273+
return null;
274+
}
275+
258276
// N.B. originally copy-pasted from Apache Calcite
259277

260278
/** Executes a {@code CREATE TABLE} command. */
@@ -307,7 +325,7 @@ public void execute(SqlDropTrigger drop, CalcitePrepare.Context context) {
307325
}
308326
String name = drop.name.names.get(0);
309327

310-
Trigger trigger = new Trigger(name, null, new ArrayList<>(), null, new HashMap<>());
328+
Trigger trigger = new Trigger(name, null, null, new HashMap<>(), null, null);
311329

312330
Collection<Deployer> deployers = null;
313331
try {
@@ -344,7 +362,7 @@ private void updateTriggerPausedState(SqlNode sqlNode, SqlIdentifier triggerName
344362

345363
Map<String, String> options = new HashMap<>();
346364
options.put(Trigger.PAUSED_OPTION, String.valueOf(paused));
347-
Trigger trigger = new Trigger(name, null, new ArrayList<>(), null, options);
365+
Trigger trigger = new Trigger(name, null, null, options, null, null);
348366

349367
Collection<Deployer> deployers = null;
350368
try {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.linkedin.hoptimator.k8s;
2+
3+
import java.sql.SQLException;
4+
import java.util.ArrayList;
5+
import java.util.Collection;
6+
import java.util.List;
7+
8+
import io.kubernetes.client.common.KubernetesObject;
9+
import io.kubernetes.client.openapi.models.V1ObjectMeta;
10+
import io.kubernetes.client.openapi.models.V1OwnerReference;
11+
12+
import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
13+
import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineList;
14+
import com.linkedin.hoptimator.k8s.models.V1alpha1TableTrigger;
15+
import com.linkedin.hoptimator.k8s.models.V1alpha1TableTriggerList;
16+
17+
import javax.annotation.Nullable;
18+
19+
20+
/**
21+
* Checks whether any Pipeline or TableTrigger CRDs still depend on a resource a
22+
* {@link com.linkedin.hoptimator.Deployer} is about to delete.
23+
*
24+
* <p>Both CRDs carry the same {@code depends-on-<slug>} label and {@code depends-on-sources}/
25+
* {@code depends-on-sinks} annotations (stamped by {@link K8sPipelineDeployer} and
26+
* {@link K8sTriggerDeployer}), so the same lookup works for either: a label-selector list against
27+
* the CRD group is O(matches) on the wire, then each candidate is cross-checked against the union
28+
* of the source + sink annotations to rule out hash collisions in the slug and stale labels left
29+
* over from a prior version of the resource ({@link K8sApi#update}'s additive label merge can leak
30+
* old {@code depends-on-*} keys).
31+
*
32+
* <p>Resources owned (directly) by {@code (selfOwnerKind, selfOwnerName)} are excluded from the
33+
* blocker list: those will be cascade-deleted alongside the parent resource, so counting them as
34+
* external dependents would make composite deletes (e.g. {@code LogicalTableDeployer.delete()})
35+
* impossible.
36+
*/
37+
public final class DependencyChecker {
38+
39+
private DependencyChecker() {
40+
}
41+
42+
public static void assertNoExternalDependents(K8sContext context, String database,
43+
List<String> path, @Nullable String selfOwnerKind, @Nullable String selfOwnerName) throws SQLException {
44+
assertNoExternalDependents(
45+
new K8sApi<>(context, K8sApiEndpoints.PIPELINES),
46+
new K8sApi<>(context, K8sApiEndpoints.TABLE_TRIGGERS),
47+
database, path, selfOwnerKind, selfOwnerName);
48+
}
49+
50+
/** Variant that takes pre-built {@link K8sApi}s — used by tests to inject mocks. */
51+
static void assertNoExternalDependents(K8sApi<V1alpha1Pipeline, V1alpha1PipelineList> pipelineApi,
52+
K8sApi<V1alpha1TableTrigger, V1alpha1TableTriggerList> triggerApi,
53+
String database, List<String> path, @Nullable String selfOwnerKind,
54+
@Nullable String selfOwnerName) throws SQLException {
55+
56+
String labelKey = DependencyLabels.labelKey(database, path);
57+
String identifier = DependencyLabels.identifier(database, path);
58+
59+
List<String> blockers = new ArrayList<>();
60+
blockers.addAll(findBlockers(pipelineApi, labelKey, identifier, "pipeline",
61+
selfOwnerKind, selfOwnerName));
62+
blockers.addAll(findBlockers(triggerApi, labelKey, identifier, "trigger",
63+
selfOwnerKind, selfOwnerName));
64+
65+
if (!blockers.isEmpty()) {
66+
throw new SQLException(String.format(
67+
"Cannot delete %s — %d active dependent(s): %s",
68+
identifier, blockers.size(), String.join(", ", blockers)));
69+
}
70+
}
71+
72+
/**
73+
* Generic blocker enumeration: list resources of type {@code T} that carry the given
74+
* {@code labelKey}, confirm via the depends-on annotations, and exclude self-owned children.
75+
* The {@code kindLabel} is prefixed onto each blocker description so a unified error message
76+
* can attribute each entry to its CRD kind.
77+
*/
78+
private static <T extends KubernetesObject> List<String> findBlockers(K8sApi<T, ?> api,
79+
String labelKey, String identifier, String kindLabel,
80+
@Nullable String selfOwnerKind, @Nullable String selfOwnerName) throws SQLException {
81+
Collection<T> matches = api.select(labelKey);
82+
List<String> blockers = new ArrayList<>();
83+
for (T obj : matches) {
84+
V1ObjectMeta meta = obj.getMetadata();
85+
if (isSelfOwned(meta, selfOwnerKind, selfOwnerName)) {
86+
continue;
87+
}
88+
if (!annotationConfirms(meta, identifier)) {
89+
// Label matched but annotation doesn't — slug collision or stale label, skip it.
90+
continue;
91+
}
92+
blockers.add(kindLabel + "/" + describeBlocker(meta));
93+
}
94+
return blockers;
95+
}
96+
97+
private static boolean isSelfOwned(V1ObjectMeta meta, @Nullable String selfOwnerKind,
98+
@Nullable String selfOwnerName) {
99+
if (selfOwnerKind == null || selfOwnerName == null) {
100+
return false;
101+
}
102+
if (meta == null || meta.getOwnerReferences() == null) {
103+
return false;
104+
}
105+
for (V1OwnerReference owner : meta.getOwnerReferences()) {
106+
if (selfOwnerKind.equals(owner.getKind()) && selfOwnerName.equals(owner.getName())) {
107+
return true;
108+
}
109+
}
110+
return false;
111+
}
112+
113+
private static boolean annotationConfirms(V1ObjectMeta meta, String identifier) {
114+
if (meta == null || meta.getAnnotations() == null) {
115+
return true; // pre-labeling resource — conservatively trust the label match
116+
}
117+
String sourcesAnno = meta.getAnnotations().get(DependencyLabels.ANNOTATION_KEY_SOURCES);
118+
String sinksAnno = meta.getAnnotations().get(DependencyLabels.ANNOTATION_KEY_SINKS);
119+
if (sourcesAnno == null && sinksAnno == null) {
120+
return true; // same — no annotations to cross-check against
121+
}
122+
if (sourcesAnno != null && DependencyLabels.parseAnnotation(sourcesAnno).contains(identifier)) {
123+
return true;
124+
}
125+
return sinksAnno != null && DependencyLabels.parseAnnotation(sinksAnno).contains(identifier);
126+
}
127+
128+
/**
129+
* Builds a human-readable blocker description: the resource name, plus (when present) the top
130+
* ownerReference's {@code kind/name} so the user knows which higher-level resource owns it.
131+
*/
132+
private static String describeBlocker(V1ObjectMeta meta) {
133+
String name = meta == null ? "<unknown>" : meta.getName();
134+
String ownerSuffix = "";
135+
if (meta != null && meta.getOwnerReferences() != null && !meta.getOwnerReferences().isEmpty()) {
136+
V1OwnerReference owner = meta.getOwnerReferences().get(0);
137+
ownerSuffix = " (owned by " + owner.getKind() + "/" + owner.getName() + ")";
138+
}
139+
return name + ownerSuffix;
140+
}
141+
}

0 commit comments

Comments
 (0)