diff --git a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/controller/CronOMJobReconciler.java b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/controller/CronOMJobReconciler.java index ca027787ec54..953c4b7f7700 100644 --- a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/controller/CronOMJobReconciler.java +++ b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/controller/CronOMJobReconciler.java @@ -43,6 +43,7 @@ import org.openmetadata.operator.model.CronOMJobStatus; import org.openmetadata.operator.model.OMJobResource; import org.openmetadata.operator.model.OMJobSpec; +import org.openmetadata.operator.util.KubernetesNameBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +52,7 @@ public class CronOMJobReconciler implements Reconciler, ErrorStatusHandler { private static final Logger LOG = LoggerFactory.getLogger(CronOMJobReconciler.class); + private static final int MAX_OMJOB_NAME_LENGTH = 63; private final Duration defaultRequeue; private final OperatorConfig config; @@ -166,28 +168,27 @@ public UpdateControl reconcile( private OMJobResource buildOMJob(CronOMJobResource cronOMJob, Instant scheduledTime) { String baseName = cronOMJob.getMetadata().getName(); - String name = baseName + "-" + scheduledTime.getEpochSecond(); - if (name.length() > 253) { - name = name.substring(0, 253); - } + String name = buildScheduledOMJobName(baseName, scheduledTime); final String finalName = name; // Make final for use in lambda // Generate unique run ID for this execution String runId = UUID.randomUUID().toString(); + HashMap labels = + cronOMJob.getMetadata().getLabels() != null + ? new HashMap<>(cronOMJob.getMetadata().getLabels()) + : new HashMap<>(); ObjectMeta metadata = new ObjectMetaBuilder() .withName(name) .withNamespace(cronOMJob.getMetadata().getNamespace()) - .withLabels(cronOMJob.getMetadata().getLabels()) + .withLabels(labels) .withAnnotations(cronOMJob.getMetadata().getAnnotations()) .build(); // Add run ID to labels for tracking - if (metadata.getLabels() != null) { - metadata.getLabels().put("app.kubernetes.io/run-id", runId); - } + metadata.getLabels().put("app.kubernetes.io/run-id", runId); OMJobResource omJob = new OMJobResource(); omJob.setMetadata(metadata); @@ -239,6 +240,17 @@ private OMJobResource buildOMJob(CronOMJobResource cronOMJob, Instant scheduledT return omJob; } + private String buildScheduledOMJobName(String baseName, Instant scheduledTime) { + String suffix = "-" + scheduledTime.getEpochSecond(); + int maxBaseLength = MAX_OMJOB_NAME_LENGTH - suffix.length(); + + if (maxBaseLength < 1) { + throw new IllegalArgumentException("Scheduled OMJob suffix leaves no room for base name"); + } + + return KubernetesNameBuilder.fitNameWithHash(baseName, maxBaseLength, "omjob") + suffix; + } + private OMJobSpec deepCopyOMJobSpec(OMJobSpec source, String runId, String jobName) { if (source == null) { return null; diff --git a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/service/PodManager.java b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/service/PodManager.java index 76c4f56a1ddb..4afa973058e9 100644 --- a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/service/PodManager.java +++ b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/service/PodManager.java @@ -37,6 +37,7 @@ import org.openmetadata.operator.model.OMJobSpec; import org.openmetadata.operator.model.OMJobStatus; import org.openmetadata.operator.util.EnvVarUtils; +import org.openmetadata.operator.util.KubernetesNameBuilder; import org.openmetadata.operator.util.LabelBuilder; import org.openmetadata.schema.entity.services.ingestionPipelines.PipelineStatusType; import org.slf4j.Logger; @@ -51,6 +52,7 @@ public class PodManager { private static final Logger LOG = LoggerFactory.getLogger(PodManager.class); private static final String PIPELINE_STATUS = "pipelineStatus"; + private static final int MAX_POD_NAME_LENGTH = 253; private final KubernetesClient client; @@ -425,11 +427,35 @@ private Optional findPod(OMJobResource omJob, Map selector) } private String generateMainPodName(OMJobResource omJob) { - return omJob.getMetadata().getName() + "-main"; + return buildPodName(omJob, "-main"); } private String generateExitHandlerPodName(OMJobResource omJob) { - return omJob.getMetadata().getName() + "-exit"; + return buildPodName(omJob, "-exit"); + } + + private String buildPodName(OMJobResource omJob, String suffix) { + // Kubernetes pod names can be up to 253 characters (DNS subdomain limit). + // The OMJob name label is derived from omJob.getMetadata().getName() + // and sanitized by LabelBuilder to satisfy the separate 63-character label limit. + // It is not derived from the generated pod name. + String baseName = omJob.getMetadata().getName(); + + // Account for suffix length when calculating max base name length. + int maxBaseLength = MAX_POD_NAME_LENGTH - suffix.length(); + String podName = + KubernetesNameBuilder.fitNameWithHash(baseName, maxBaseLength, "omjob") + suffix; + + // Log a warning if the original name was truncated to fit the DNS subdomain limit. + if (!podName.equals(omJob.getMetadata().getName() + suffix)) { + LOG.warn( + "Pod name for OMJob {} was truncated from {} to {} to comply with Kubernetes DNS name length limits", + omJob.getMetadata().getName(), + omJob.getMetadata().getName() + suffix, + podName); + } + + return podName; } private String determinePipelineStatus(OMJobResource omJob) { diff --git a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/HashUtils.java b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/HashUtils.java new file mode 100644 index 000000000000..c0c4d35cf705 --- /dev/null +++ b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/HashUtils.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.operator.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class HashUtils { + private static final int HASH_LENGTH = 6; + + private HashUtils() { + // Utility class + } + + /** + * Generates a 6-character SHA-256 hash of the input string for deterministic name + * truncation. + * + * @param input the string to hash + * @return a 6-character hex string + * @throws IllegalArgumentException if SHA-256 is not available + */ + public static String hash(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encoded = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte value : encoded) { + hex.append(String.format("%02x", value & 0xff)); + } + return hex.substring(0, HASH_LENGTH); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("SHA-256 algorithm not available", e); + } + } +} diff --git a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/KubernetesNameBuilder.java b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/KubernetesNameBuilder.java new file mode 100644 index 000000000000..ff447fffd2d0 --- /dev/null +++ b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/KubernetesNameBuilder.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.operator.util; + +/** + * Utility for building deterministic Kubernetes resource names within a length limit. + */ +public final class KubernetesNameBuilder { + + private static final int HASH_LENGTH = 6; + + private KubernetesNameBuilder() { + // Utility class + } + + public static String fitNameWithHash( + String baseName, int maxBaseLength, String fallbackBaseName) { + if (maxBaseLength < 1) { + throw new IllegalArgumentException( + "Kubernetes resource name must allow at least one character"); + } + + if (fallbackBaseName == null || fallbackBaseName.isEmpty()) { + throw new IllegalArgumentException("fallbackBaseName must not be null or empty"); + } + + String candidate = baseName == null ? "" : baseName; + if (candidate.length() > maxBaseLength) { + String hash = HashUtils.hash(candidate).substring(0, Math.min(HASH_LENGTH, maxBaseLength)); + int maxPrefixLength = maxBaseLength - hash.length() - 1; + + if (maxPrefixLength > 0) { + String prefix = trimTrailingSeparators(candidate.substring(0, maxPrefixLength)); + if (!prefix.isEmpty()) { + return prefix + "-" + hash; + } + + String fallbackPrefix = + trimTrailingSeparators( + fallbackBaseName.substring( + 0, Math.min(fallbackBaseName.length(), maxPrefixLength))); + if (!fallbackPrefix.isEmpty()) { + return fallbackPrefix + "-" + hash; + } + } + + return hash; + } + + if (candidate.isEmpty()) { + candidate = fallbackBaseName; + } + + if (candidate.length() > maxBaseLength) { + throw new IllegalStateException( + "Fallback name exceeds max base length (" + maxBaseLength + ")"); + } + + return candidate; + } + + private static String trimTrailingSeparators(String value) { + return value.replaceAll("[^a-z0-9]+$", ""); + } +} diff --git a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/LabelBuilder.java b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/LabelBuilder.java index 858f1d62860c..b553cc01909a 100644 --- a/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/LabelBuilder.java +++ b/openmetadata-k8s-operator/src/main/java/org/openmetadata/operator/util/LabelBuilder.java @@ -25,6 +25,8 @@ */ public class LabelBuilder { + private static final int MAX_LABEL_VALUE_LENGTH = 63; + // Standard Kubernetes labels public static final String LABEL_APP_NAME = "app.kubernetes.io/name"; public static final String LABEL_APP_COMPONENT = "app.kubernetes.io/component"; @@ -56,7 +58,7 @@ public static Map buildBaseLabels(OMJobResource omJob) { labels.put(LABEL_APP_NAME, APP_NAME); labels.put(LABEL_APP_COMPONENT, COMPONENT_INGESTION); labels.put(LABEL_APP_MANAGED_BY, MANAGED_BY_OMJOB_OPERATOR); - labels.put(LABEL_OMJOB_NAME, omJob.getMetadata().getName()); + labels.put(LABEL_OMJOB_NAME, buildOMJobNameLabelValue(omJob)); // Copy pipeline and run-id from OMJob labels String pipelineName = omJob.getPipelineName(); @@ -113,10 +115,18 @@ public static Map buildExitHandlerLabels(OMJobResource omJob) { */ public static Map buildPodSelector(OMJobResource omJob) { Map selector = new HashMap<>(); - selector.put(LABEL_OMJOB_NAME, omJob.getMetadata().getName()); + selector.put(LABEL_OMJOB_NAME, buildOMJobNameLabelValue(omJob)); + selector.put(LABEL_APP_MANAGED_BY, MANAGED_BY_OMJOB_OPERATOR); return selector; } + /** + * Build the OMJob name label value used by operator-managed pods. + */ + public static String buildOMJobNameLabelValue(OMJobResource omJob) { + return sanitizeLabelValue(omJob.getMetadata().getName()); + } + /** * Build selector for finding main pod */ @@ -143,10 +153,51 @@ public static String sanitizeLabelValue(String value) { return ""; } - // Replace invalid characters with hyphens and truncate to 63 chars - String sanitized = - value.replaceAll("[^a-zA-Z0-9\\-_.]", "-").replaceAll("-+", "-").replaceAll("^-|-$", ""); + // Replace invalid characters with hyphens and collapse repeated hyphens. + // We intentionally keep dots and underscores, as they are allowed in label values. + String sanitized = value.replaceAll("[^a-zA-Z0-9\\-_.]", "-").replaceAll("-+", "-"); + + if (sanitized.length() <= MAX_LABEL_VALUE_LENGTH) { + // Even for short values, ensure we respect the Kubernetes rule that + // label values must start and end with an alphanumeric character. + return ensureValidLabelValue(sanitized, value); + } + + // For long values, preserve uniqueness by appending a short hash while + // keeping the overall value within the 63-character limit. + String hash = HashUtils.hash(value); + int maxPrefixLength = MAX_LABEL_VALUE_LENGTH - hash.length() - 1; + String prefix = sanitized.substring(0, maxPrefixLength); + + String candidate = prefix + "-" + hash; + return ensureValidLabelValue(candidate, value); + } + + /** + * Ensure the label value starts and ends with an alphanumeric character. + * If trimming non-alphanumeric characters from the edges results in an + * empty string, fall back to a hash-based value derived from the original + * input so that the label remains valid and stable. + */ + private static String ensureValidLabelValue(String candidate, String original) { + String result = candidate.replaceAll("^[^a-zA-Z0-9]+", "").replaceAll("[^a-zA-Z0-9]+$", ""); + + if (!result.isEmpty() && result.length() <= MAX_LABEL_VALUE_LENGTH) { + return result; + } + + // Fallback: build a short, deterministic value based on a hash of the + // original input. This guarantees the output is non-empty, starts/ends + // with an alphanumeric character, and fits within the label limit. + int maxHashLength = Math.max(1, MAX_LABEL_VALUE_LENGTH - 3); // Reserve for "om-" + String fullHash = HashUtils.hash(original); + String hash = fullHash.substring(0, Math.min(maxHashLength, fullHash.length())); + String fallback = "om-" + hash; + + if (fallback.length() > MAX_LABEL_VALUE_LENGTH) { + fallback = fallback.substring(0, MAX_LABEL_VALUE_LENGTH); + } - return sanitized.length() > 63 ? sanitized.substring(0, 63) : sanitized; + return fallback; } } diff --git a/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/service/PodManagerTest.java b/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/service/PodManagerTest.java index da63d7754a62..ca3cd9bd0774 100644 --- a/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/service/PodManagerTest.java +++ b/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/service/PodManagerTest.java @@ -45,6 +45,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.openmetadata.operator.model.OMJobResource; import org.openmetadata.operator.model.OMJobSpec; +import org.openmetadata.operator.util.LabelBuilder; /** * Test suite for PodManager. @@ -247,6 +248,68 @@ void testContainerSecurityContextWithoutPodSpec() { assertTrue(securityContext.getCapabilities().getDrop().contains("ALL")); } + @Test + void testCreateMainPodWithLongOMJobNameUsesSafePodNameAndLabels() { + OMJobResource omJob = createOMJobWithEmptyValueFrom("a".repeat(253)); + + when(podOperations.inNamespace(anyString())).thenReturn(podOperations); + when(podOperations.resource(any(Pod.class))).thenReturn(podResource); + + Pod createdPod = + new PodBuilder() + .withNewMetadata() + .withName("safe-pod-main") + .withNamespace("test-namespace") + .endMetadata() + .build(); + + when(podResource.create()).thenReturn(createdPod); + + podManager.createMainPod(omJob); + + ArgumentCaptor podCaptor = ArgumentCaptor.forClass(Pod.class); + verify(podOperations).resource(podCaptor.capture()); + + Pod capturedPod = podCaptor.getValue(); + String podName = capturedPod.getMetadata().getName(); + assertTrue(podName.length() <= 253); + assertTrue(podName.endsWith("-main")); + + String omJobLabel = capturedPod.getMetadata().getLabels().get(LabelBuilder.LABEL_OMJOB_NAME); + assertEquals(63, omJobLabel.length()); + assertTrue(omJobLabel.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$")); + } + + @Test + void testCreateMainPodStripsTrailingDotAfterTruncation() { + OMJobResource omJob = createOMJobWithEmptyValueFrom("a".repeat(247) + ".suffix"); + + when(podOperations.inNamespace(anyString())).thenReturn(podOperations); + when(podOperations.resource(any(Pod.class))).thenReturn(podResource); + + Pod createdPod = + new PodBuilder() + .withNewMetadata() + .withName("safe-pod-main") + .withNamespace("test-namespace") + .endMetadata() + .build(); + + when(podResource.create()).thenReturn(createdPod); + + podManager.createMainPod(omJob); + + ArgumentCaptor podCaptor = ArgumentCaptor.forClass(Pod.class); + verify(podOperations).resource(podCaptor.capture()); + + String podName = podCaptor.getValue().getMetadata().getName(); + int baseNameLastCharIndex = podName.length() - "-main".length() - 1; + + assertTrue(podName.length() <= 253); + assertTrue(podName.endsWith("-main")); + assertTrue(Character.isLetterOrDigit(podName.charAt(baseNameLastCharIndex))); + } + @Test void testCreateMainPodWithTolerations() { OMJobResource omJob = createOMJobWithTolerations(); @@ -307,7 +370,6 @@ void testCreateMainPodWithoutTolerations() { verify(podOperations).resource(podCaptor.capture()); Pod capturedPod = podCaptor.getValue(); - // No tolerations set - should be null assertNull(capturedPod.getSpec().getTolerations()); } @@ -317,7 +379,6 @@ void testFindExitHandlerPodFallsBackToNameLookup() { OMJobResource omJob = createOMJobWithEmptyValueFrom(); omJob.getStatus().setExitHandlerPodName("test-omjob-exit"); - // Label-based search returns empty NonNamespaceOperation nsOps = mock(NonNamespaceOperation.class); when(podOperations.inNamespace(anyString())).thenReturn(nsOps); when(nsOps.withLabels(anyMap())).thenReturn(nsOps); @@ -326,7 +387,6 @@ void testFindExitHandlerPodFallsBackToNameLookup() { emptyList.setItems(List.of()); when(nsOps.list()).thenReturn(emptyList); - // Name-based lookup returns the pod Pod exitPod = new PodBuilder() .withNewMetadata() @@ -349,7 +409,6 @@ void testFindExitHandlerPodFallsBackToNameLookup() { @Test void testFindExitHandlerPodNoFallbackWithoutStatus() { OMJobResource omJob = createOMJobWithEmptyValueFrom(); - // No exitHandlerPodName set in status NonNamespaceOperation nsOps = mock(NonNamespaceOperation.class); when(podOperations.inNamespace(anyString())).thenReturn(nsOps); @@ -483,6 +542,10 @@ private OMJobResource createOMJobWithSecurityContext() { } private OMJobResource createOMJobWithEmptyValueFrom() { + return createOMJobWithEmptyValueFrom("test-omjob"); + } + + private OMJobResource createOMJobWithEmptyValueFrom(String omJobName) { // Create environment variables including one with empty valueFrom List envVars = Arrays.asList( @@ -518,7 +581,7 @@ private OMJobResource createOMJobWithEmptyValueFrom() { ObjectMeta metadata = new ObjectMetaBuilder() - .withName("test-omjob") + .withName(omJobName) .withNamespace("test-namespace") .withUid("test-uid") .withLabels( diff --git a/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/KubernetesNameBuilderTest.java b/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/KubernetesNameBuilderTest.java new file mode 100644 index 000000000000..ba9ecbed8b38 --- /dev/null +++ b/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/KubernetesNameBuilderTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.operator.unit; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.openmetadata.operator.util.KubernetesNameBuilder; + +class KubernetesNameBuilderTest { + + @Test + void testReturnsShortNameAsIs() { + assertEquals("test-omjob", KubernetesNameBuilder.fitNameWithHash("test-omjob", 20, "omjob")); + } + + @Test + void testTrimsTrailingSeparatorsAfterTruncation() { + String name = KubernetesNameBuilder.fitNameWithHash("a".repeat(51) + ".suffix", 52, "omjob"); + + assertTrue(name.matches("^[a-z0-9-]+$")); + assertFalse(name.endsWith("-")); + } + + @Test + void testKeepsTruncatedNamesUnique() { + String sharedPrefix = "a".repeat(52); + String firstName = + KubernetesNameBuilder.fitNameWithHash(sharedPrefix + "-first-scheduled-run", 52, "omjob"); + String secondName = + KubernetesNameBuilder.fitNameWithHash(sharedPrefix + "-second-scheduled-run", 52, "omjob"); + + assertEquals(52, firstName.length()); + assertEquals(52, secondName.length()); + assertNotEquals(firstName, secondName); + } + + @Test + void testUsesHashWhenTruncationRemovesEntirePrefix() { + String name = KubernetesNameBuilder.fitNameWithHash("....", 3, "omjob"); + + assertEquals(3, name.length()); + assertTrue(name.matches("^[a-f0-9]+$")); + } + + @Test + void testThrowsWhenFallbackStillExceedsAllowedLength() { + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> KubernetesNameBuilder.fitNameWithHash("", 4, "omjob")); + + assertTrue(exception.getMessage().contains("Fallback name exceeds max base length")); + } +} diff --git a/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/LabelBuilderTest.java b/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/LabelBuilderTest.java index 9cb575a27d36..12551f082b78 100644 --- a/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/LabelBuilderTest.java +++ b/openmetadata-k8s-operator/src/test/java/org/openmetadata/operator/unit/LabelBuilderTest.java @@ -64,7 +64,7 @@ void testBuildSelectors() { Map podSelector = LabelBuilder.buildPodSelector(omJob); assertEquals("test-omjob", podSelector.get("omjob.pipelines.openmetadata.org/name")); - assertEquals(1, podSelector.size()); + assertEquals(2, podSelector.size()); Map mainSelector = LabelBuilder.buildMainPodSelector(omJob); assertTrue(mainSelector.entrySet().containsAll(podSelector.entrySet())); @@ -75,6 +75,19 @@ void testBuildSelectors() { assertEquals("exit-handler", exitSelector.get("omjob.pipelines.openmetadata.org/pod-type")); } + @Test + void testBuildBaseLabelsSanitizesLongOMJobName() { + OMJobResource omJob = createTestOMJobWithName("a".repeat(70)); + + Map labels = LabelBuilder.buildBaseLabels(omJob); + Map selector = LabelBuilder.buildPodSelector(omJob); + + assertEquals(63, labels.get("omjob.pipelines.openmetadata.org/name").length()); + assertEquals( + labels.get("omjob.pipelines.openmetadata.org/name"), + selector.get("omjob.pipelines.openmetadata.org/name")); + } + @Test void testSanitizeLabelValue() { assertEquals("", LabelBuilder.sanitizeLabelValue(null)); @@ -88,14 +101,57 @@ void testSanitizeLabelValue() { String longValue = "a".repeat(70); String sanitized = LabelBuilder.sanitizeLabelValue(longValue); assertEquals(63, sanitized.length()); + assertTrue(sanitized.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$")); + + String truncatedWithTrailingDash = + LabelBuilder.sanitizeLabelValue("a".repeat(62) + "-invalid-suffix"); + assertFalse(truncatedWithTrailingDash.endsWith("-")); + } + + @Test + void testSanitizeLabelValueUsesFallbackForSeparatorOnlyInputs() { + String dashed = LabelBuilder.sanitizeLabelValue("----"); + String dotted = LabelBuilder.sanitizeLabelValue("..."); + String underscored = LabelBuilder.sanitizeLabelValue("__"); + + assertAll( + () -> assertFalse(dashed.isEmpty()), + () -> assertTrue(dashed.length() <= 63), + () -> assertTrue(dashed.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$")), + () -> assertFalse(dotted.isEmpty()), + () -> assertTrue(dotted.length() <= 63), + () -> assertTrue(dotted.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$")), + () -> assertFalse(underscored.isEmpty()), + () -> assertTrue(underscored.length() <= 63), + () -> assertTrue(underscored.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$"))); + } + + @Test + void testLabelUniqueness() { + String sharedPrefix = "job-run-" + "1234567890".repeat(6); + String v1 = sharedPrefix + "-abcdef"; + String v2 = sharedPrefix + "-ghijkl"; + + String l1 = LabelBuilder.sanitizeLabelValue(v1); + String l2 = LabelBuilder.sanitizeLabelValue(v2); + + assertEquals(63, l1.length()); + assertEquals(63, l2.length()); + assertTrue(l1.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$")); + assertTrue(l2.matches("^[a-zA-Z0-9].*[a-zA-Z0-9]$")); + assertNotEquals(l1, l2); } private OMJobResource createTestOMJob() { + return createTestOMJobWithName("test-omjob"); + } + + private OMJobResource createTestOMJobWithName(String name) { OMJobResource omJob = new OMJobResource(); ObjectMeta metadata = new ObjectMetaBuilder() - .withName("test-omjob") + .withName(name) .withNamespace("test-namespace") .withLabels( Map.of( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts index 86a42b9ccdb8..27924bfb0bae 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts @@ -228,18 +228,34 @@ export class TableClass extends EntityClass { } async create(apiContext: APIRequestContext) { - const serviceResponse = await apiContext.post( + // Create database service with 409 conflict handling for sharded test runs + let serviceResponse = await apiContext.post( '/api/v1/services/databaseServices', { data: this.service, } ); - if (!serviceResponse.ok()) { + + let service; + if (serviceResponse.status() === 409) { + // Service already exists, fetch it by name + const serviceName = this.service.name; + const getServiceResponse = await apiContext.get( + `/api/v1/services/databaseServices/name/${serviceName}` + ); + if (!getServiceResponse.ok()) { + throw new Error( + `TableClass: failed to fetch existing service "${serviceName}" (${getServiceResponse.status()}): ${await getServiceResponse.text()}` + ); + } + service = await getServiceResponse.json(); + } else if (!serviceResponse.ok()) { throw new Error( `TableClass: service create failed (${serviceResponse.status()}): ${await serviceResponse.text()}` ); + } else { + service = await serviceResponse.json(); } - const service = await serviceResponse.json(); const databaseResponse = await apiContext.post('/api/v1/databases', { data: { ...this.database, service: service.fullyQualifiedName },