This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new c1d17df7b48 Warn on suspicious Dag and task IDs at Java SDK build time
(#69937)
c1d17df7b48 is described below
commit c1d17df7b4823d4b6de71c37ca536464c1d75111
Author: Andrew Chang <[email protected]>
AuthorDate: Mon Oct 12 19:09:36 2026 +0800
Warn on suspicious Dag and task IDs at Java SDK build time (#69937)
Hard SDK validation can go stale when the server changes the allowed ID
rules, and the server already validates authoritatively, so the SDK only
warns. Per review, the check runs at the interface based syntax, which
both authoring styles compile down to: a checkAirflowBundle verification
task in the check lifecycle runs BundleInspector (the ADR-0003 shape)
against the assembled Bundle and prints a warning for every ID the
server would reject. Warnings never fail the build; a bundle that cannot
be built at all does.
---
.../apache/airflow/sdk/plugin/AirflowSdkPlugin.kt | 23 +++
.../airflow/sdk/plugin/AirflowSdkPluginTest.kt | 50 +++++++
.../org/apache/airflow/sdk/BundleInspector.kt | 113 ++++++++++++++
.../main/kotlin/org/apache/airflow/sdk/DagDef.kt | 10 +-
.../kotlin/org/apache/airflow/sdk/IdValidation.kt | 86 +++++++++++
.../org/apache/airflow/sdk/BundleInspectorTest.kt | 164 +++++++++++++++++++++
.../org/apache/airflow/sdk/IdValidationTest.kt | 105 +++++++++++++
7 files changed, 548 insertions(+), 3 deletions(-)
diff --git
a/java-sdk/plugin/src/main/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPlugin.kt
b/java-sdk/plugin/src/main/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPlugin.kt
index 5e3a41b5ce0..5b1141d22b0 100644
---
a/java-sdk/plugin/src/main/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPlugin.kt
+++
b/java-sdk/plugin/src/main/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPlugin.kt
@@ -26,6 +26,7 @@ import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.SourceSetContainer
import org.gradle.api.tasks.bundling.Jar
import org.gradle.jvm.toolchain.JavaToolchainService
@@ -94,6 +95,15 @@ abstract class AirflowBundleExtension {
* artifacts to `build/bundle/` that can be copied directly into the Dag bundle
* named by an Airflow Java coordinator's `task_handler_bundle_name`.
*
+ * It also registers a `checkAirflowBundle` verification task, wired into the
+ * `check` lifecycle. The task runs the SDK's `BundleInspector` against the
+ * compiled classes: it instantiates the configured `mainClass` as a
+ * `BundleBuilder`, builds the `Bundle`, and prints a warning for every Dag or
+ * task ID the Airflow server would reject. Warnings do not fail the build; a
+ * bundle that cannot be built (for example duplicate Dag IDs) does. Run it
+ * alone with `./gradlew checkAirflowBundle`, or skip it with
+ * `./gradlew check -x checkAirflowBundle`.
+ *
* By default, plugin `com.github.johnrengelman.shadow` is applied
automatically
* to enable fat JAR build. In this mode, one single JAR with user code and all
* dependencies is produced by the `bundle` task. An additional metadata entry
@@ -196,6 +206,19 @@ class AirflowSdkPlugin : Plugin<Project> {
}
}
+ val checkTask =
+ project.tasks.register("checkAirflowBundle", JavaExec::class.java) {
task ->
+ task.group = "verification"
+ task.description =
+ "Warns on Dag and task IDs in the assembled Bundle that the
Airflow server would reject."
+ task.dependsOn(project.tasks.named("classes"))
+ task.onlyIf { ext.mainClass.isPresent }
+ task.classpath(classFiles)
+ task.mainClass.set("org.apache.airflow.sdk.BundleInspector")
+ ext.mainClass.orNull?.let { task.args(it) }
+ }
+ project.tasks.named("check") { it.dependsOn(checkTask) }
+
fun packSourcesInto(jarTask: String) {
if (!ext.mainClass.isPresent) return
project.tasks.named(jarTask, Jar::class.java).configure { task ->
diff --git
a/java-sdk/plugin/src/test/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPluginTest.kt
b/java-sdk/plugin/src/test/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPluginTest.kt
index 3297ae940db..959c9e19a62 100644
---
a/java-sdk/plugin/src/test/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPluginTest.kt
+++
b/java-sdk/plugin/src/test/kotlin/org/apache/airflow/sdk/plugin/AirflowSdkPluginTest.kt
@@ -20,6 +20,10 @@
package org.apache.airflow.sdk.plugin
import groovy.json.JsonSlurper
+import org.gradle.api.Project
+import org.gradle.api.internal.project.ProjectInternal
+import org.gradle.api.tasks.JavaExec
+import org.gradle.testfixtures.ProjectBuilder
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
@@ -28,6 +32,7 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
@@ -362,4 +367,49 @@ class AirflowSdkPluginTest {
assertFalse(entries(jar).any { it.startsWith("META-INF/airflow") })
JarFile(jar).use {
assertNull(it.manifest.mainAttributes.getValue("Airflow-Java-SDK-Sources")) }
}
+
+ private fun buildProject(mainClass: String? =
"com.example.MyBundleBuilder"): Project {
+ val project = ProjectBuilder.builder().build()
+ project.plugins.apply("org.apache.airflow.sdk")
+ mainClass?.let {
+ project.extensions
+ .getByType(AirflowBundleExtension::class.java)
+ .mainClass
+ .set(it)
+ }
+ (project as ProjectInternal).evaluate()
+ return project
+ }
+
+ @Test
+ @DisplayName("checkAirflowBundle runs BundleInspector with the configured
main class")
+ fun shouldRegisterCheckAirflowBundle() {
+ val project = buildProject()
+
+ val task = project.tasks.getByName("checkAirflowBundle") as JavaExec
+
+ assertEquals("verification", task.group)
+ assertEquals("org.apache.airflow.sdk.BundleInspector",
task.mainClass.get())
+ assertEquals(listOf("com.example.MyBundleBuilder"), task.args)
+ }
+
+ @Test
+ @DisplayName("check depends on checkAirflowBundle")
+ fun shouldWireCheckLifecycle() {
+ val project = buildProject()
+
+ val check = project.tasks.getByName("check")
+
+ assertTrue(check.taskDependencies.getDependencies(check).any { it.name ==
"checkAirflowBundle" })
+ }
+
+ @Test
+ @DisplayName("without a mainClass the task gets no arguments")
+ fun shouldHaveNoArgumentsWithoutMainClass() {
+ val project = buildProject(mainClass = null)
+
+ val task = project.tasks.getByName("checkAirflowBundle") as JavaExec
+
+ assertEquals(emptyList<String>(), task.args)
+ }
}
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleInspector.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleInspector.kt
new file mode 100644
index 00000000000..ff23ecb01cd
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleInspector.kt
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.airflow.sdk
+
+import java.lang.reflect.InvocationTargetException
+import java.lang.reflect.Modifier
+
+/**
+ * Build-time introspection entry point for Dag bundles.
+ *
+ * Instantiates the [BundleBuilder] named by the single command-line argument,
+ * builds its [Bundle], and prints a warning to stderr for every Dag or task ID
+ * the Airflow server would reject. The Gradle plugin's `checkAirflowBundle`
+ * task runs this against the compiled classes, so bundle authors see the
+ * warnings at build time, before anything is deployed.
+ *
+ * Warnings never fail the process; a bundle that cannot be built at all (for
+ * example duplicate Dag IDs) does. A main class that is not an instantiable
+ * [BundleBuilder] is skipped with a note, since the bundle contract only
+ * requires a static `main` method.
+ */
+object BundleInspector {
+ @JvmStatic
+ fun main(args: Array<String>) {
+ require(args.size == 1) { "usage: BundleInspector <bundle-builder-class>" }
+ inspect(args[0], System.err)
+ }
+
+ internal fun inspect(
+ className: String,
+ output: Appendable,
+ ) {
+ val builder = findBundleBuilder(className)
+ if (builder == null) {
+ output.appendLine(
+ "note: $className is not an instantiable BundleBuilder; skipping the
Dag and task ID check",
+ )
+ return
+ }
+ val bundle = builder.build()
+ for (warning in IdValidation.findSuspiciousIds(bundle.dags.values)) {
+ output.appendLine("warning: ${warning.render()}")
+ }
+ }
+
+ /**
+ * Resolves [className] to a [BundleBuilder]: a Kotlin or Scala singleton
+ * (an `INSTANCE` or `MODULE$` static field, the latter living on the
+ * `$`-suffixed class scalac emits next to the forwarder class), or a public
+ * no-argument constructor. Returns null when the class has neither; a
+ * constructor that itself throws is a real error and propagates.
+ */
+ private fun findBundleBuilder(className: String): BundleBuilder? {
+ val classes = listOfNotNull(Class.forName(className),
loadClassOrNull(className + "$"))
+ classes.firstNotNullOfOrNull { findSingleton(it) }?.let { return it }
+ return classes.firstNotNullOfOrNull { constructOrNull(it) }
+ }
+
+ private fun loadClassOrNull(name: String): Class<*>? =
+ try {
+ Class.forName(name)
+ } catch (_: ClassNotFoundException) {
+ null
+ }
+
+ private fun findSingleton(clazz: Class<*>): BundleBuilder? =
+ listOf("INSTANCE", "MODULE$").firstNotNullOfOrNull { name ->
+ try {
+ clazz
+ .getDeclaredField(name)
+ .takeIf { Modifier.isStatic(it.modifiers) &&
Modifier.isPublic(it.modifiers) }
+ ?.get(null) as? BundleBuilder
+ } catch (_: NoSuchFieldException) {
+ null
+ } catch (_: IllegalAccessException) {
+ null
+ }
+ }
+
+ private fun constructOrNull(clazz: Class<*>): BundleBuilder? {
+ if (!BundleBuilder::class.java.isAssignableFrom(clazz)) return null
+ val constructor =
+ try {
+ clazz.getConstructor()
+ } catch (_: NoSuchMethodException) {
+ return null
+ }
+ return try {
+ constructor.newInstance() as BundleBuilder
+ } catch (_: InstantiationException) {
+ null
+ } catch (e: InvocationTargetException) {
+ throw e.cause ?: e
+ }
+ }
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
index 37015ca9197..4b275c4b7ea 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
@@ -42,13 +42,17 @@ import kotlin.Throws
* extract.before(dag.task("load", Load.class));
* ```
*
- * @param id Dag identifier. Must contain only ASCII alphanumeric characters,
- * dashes, dots, or underscores; must be unique within a [Bundle].
+ * Dag and task IDs are validated authoritatively by the Airflow server. This
+ * class accepts any ID; IDs the server would reject are reported as
+ * best-effort build-time warnings by the Gradle plugin's `checkAirflowBundle`
+ * task, which runs [BundleInspector] against the assembled [Bundle].
+ *
+ * @param id Dag identifier; must be unique within a [Bundle].
*
* @see Builder.Dag
*/
class DagDef(
- val id: String, // TODO: charset check?
+ val id: String,
) {
internal val tasks = linkedMapOf<String, TaskDef>()
internal val dagConfig = linkedMapOf<String, Any>()
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/IdValidation.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/IdValidation.kt
new file mode 100644
index 00000000000..fe0e1e0df36
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/IdValidation.kt
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.airflow.sdk
+
+/**
+ * Best-effort checks of Dag and task IDs against the rules the Airflow server
+ * enforces (`airflow.utils.helpers.validate_key`). Warn-only: the server
+ * validates authoritatively, and checks like the `..` one depend on server
+ * configuration this process cannot see. [BundleInspector] reports the
+ * findings at build time.
+ */
+internal object IdValidation {
+ private const val MAX_ID_LENGTH = 250
+ private val ID_REGEX = Regex("""^[\p{L}\p{N}_.-]+$""")
+
+ fun findSuspiciousIds(dags: Iterable<DagDef>): List<IdWarning> {
+ val warnings = mutableListOf<IdWarning>()
+
+ fun check(
+ label: String,
+ id: String,
+ arguments: Map<String, Any>,
+ ) {
+ val length = id.codePointCount(0, id.length)
+ if (length > MAX_ID_LENGTH) {
+ warnings +=
+ IdWarning(
+ "$label is longer than $MAX_ID_LENGTH characters; the Airflow
server will reject it",
+ arguments + ("length" to length),
+ )
+ }
+ if (!ID_REGEX.matches(id)) {
+ warnings +=
+ IdWarning(
+ "$label must be made of alphanumeric characters, dashes, dots, and
underscores; " +
+ "the Airflow server will reject it",
+ arguments,
+ )
+ } else if (id.contains("..")) {
+ warnings +=
+ IdWarning(
+ "$label contains '..'; the Airflow server will reject it " +
+ "unless [core] allow_double_dot_in_ids is enabled",
+ arguments,
+ )
+ }
+ }
+
+ for (dag in dags) {
+ check("Dag id", dag.id, mapOf("dag_id" to dag.id))
+ for (taskId in dag.tasks.keys) {
+ check("Task id", taskId, mapOf("dag_id" to dag.id, "task_id" to
taskId))
+ }
+ }
+ return warnings
+ }
+}
+
+internal data class IdWarning(
+ val message: String,
+ val arguments: Map<String, Any>,
+) {
+ fun render(): String =
+ "$message (${
+ arguments.entries.joinToString(", ") { (key, value) ->
+ if (value is String) "$key=\"$value\"" else "$key=$value"
+ }
+ })"
+}
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/BundleInspectorTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/BundleInspectorTest.kt
new file mode 100644
index 00000000000..5cf81fc531d
--- /dev/null
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/BundleInspectorTest.kt
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.airflow.sdk
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+private class NoOp : Task {
+ override fun execute(
+ context: Context,
+ client: Client,
+ ) = Unit
+}
+
+internal class SuspiciousBundleBuilder : BundleBuilder {
+ override fun getDags(): Iterable<DagDef> = listOf(DagDef("bad
dag").addTask("bad task", NoOp::class.java))
+}
+
+internal class ValidBundleBuilder : BundleBuilder {
+ override fun getDags(): Iterable<DagDef> =
listOf(DagDef("good_dag").addTask("good_task", NoOp::class.java))
+}
+
+internal class DuplicateDagBundleBuilder : BundleBuilder {
+ override fun getDags(): Iterable<DagDef> = listOf(DagDef("dag"),
DagDef("dag"))
+}
+
+internal class ThrowingBundleBuilder : BundleBuilder {
+ init {
+ error("boom")
+ }
+
+ override fun getDags(): Iterable<DagDef> = emptyList()
+}
+
+internal object SingletonBundleBuilder : BundleBuilder {
+ override fun getDags(): Iterable<DagDef> = listOf(DagDef("with space"))
+}
+
+internal class PrivateInstanceFieldBundleBuilder : BundleBuilder {
+ override fun getDags(): Iterable<DagDef> = listOf(DagDef("with space"))
+
+ companion object {
+ @Suppress("unused")
+ private val INSTANCE = Any()
+ }
+}
+
+internal class ScalaLikeBundleBuilder
+
+@Suppress("ktlint:standard:class-naming", "ClassName")
+internal class `ScalaLikeBundleBuilder$` : BundleBuilder {
+ override fun getDags(): Iterable<DagDef> = listOf(DagDef("with space"))
+
+ companion object {
+ @JvmField
+ val `MODULE$`: `ScalaLikeBundleBuilder$` = `ScalaLikeBundleBuilder$`()
+ }
+}
+
+private fun charsetWarningLine(arguments: String) =
+ "warning: Dag id must be made of alphanumeric characters, dashes, dots, and
underscores; " +
+ "the Airflow server will reject it ($arguments)\n"
+
+internal class BundleInspectorTest {
+ private fun inspect(className: String): String {
+ val output = StringBuilder()
+ BundleInspector.inspect(className, output)
+ return output.toString()
+ }
+
+ @Test
+ @DisplayName("prints a warning for every suspicious dag and task id")
+ fun shouldWarnOnSuspiciousIds() {
+ assertEquals(
+ charsetWarningLine("dag_id=\"bad dag\"") +
+ "warning: Task id must be made of alphanumeric characters, dashes,
dots, and underscores; " +
+ "the Airflow server will reject it (dag_id=\"bad dag\", task_id=\"bad
task\")\n",
+ inspect(SuspiciousBundleBuilder::class.java.name),
+ )
+ }
+
+ @Test
+ @DisplayName("prints nothing for a valid bundle")
+ fun shouldStaySilentOnValidBundle() {
+ assertEquals("", inspect(ValidBundleBuilder::class.java.name))
+ }
+
+ @Test
+ @DisplayName("resolves a Kotlin object builder through its INSTANCE field")
+ fun shouldResolveKotlinObject() {
+ assertEquals(
+ charsetWarningLine("dag_id=\"with space\""),
+ inspect(SingletonBundleBuilder::class.java.name),
+ )
+ }
+
+ @Test
+ @DisplayName("resolves a Scala-style object builder through MODULE$ on the
dollar class")
+ fun shouldResolveScalaObject() {
+ assertEquals(
+ charsetWarningLine("dag_id=\"with space\""),
+ inspect(ScalaLikeBundleBuilder::class.java.name),
+ )
+ }
+
+ @Test
+ @DisplayName("a private static INSTANCE field is ignored, not tripped over")
+ fun shouldIgnorePrivateInstanceField() {
+ assertEquals(
+ charsetWarningLine("dag_id=\"with space\""),
+ inspect(PrivateInstanceFieldBundleBuilder::class.java.name),
+ )
+ }
+
+ @Test
+ @DisplayName("notes and skips a main class that is not a BundleBuilder")
+ fun shouldSkipNonBuilder() {
+ assertEquals(
+ "note: java.lang.Object is not an instantiable BundleBuilder; skipping
the Dag and task ID check\n",
+ inspect("java.lang.Object"),
+ )
+ }
+
+ @Test
+ @DisplayName("duplicate dag ids fail the inspection")
+ fun shouldFailOnDuplicateDagIds() {
+ val error =
+ assertThrows(IllegalArgumentException::class.java) {
+ inspect(DuplicateDagBundleBuilder::class.java.name)
+ }
+
+ assertEquals("Dags in bundle have duplicate ID: dag", error.message)
+ }
+
+ @Test
+ @DisplayName("a builder whose constructor throws propagates the real error")
+ fun shouldPropagateConstructorFailure() {
+ val error =
+ assertThrows(IllegalStateException::class.java) {
+ inspect(ThrowingBundleBuilder::class.java.name)
+ }
+
+ assertEquals("boom", error.message)
+ }
+}
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/IdValidationTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/IdValidationTest.kt
new file mode 100644
index 00000000000..793d4e26ece
--- /dev/null
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/IdValidationTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.airflow.sdk
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+private fun dagTooLongWarning(
+ id: String,
+ length: Int,
+) = IdWarning(
+ "Dag id is longer than 250 characters; the Airflow server will reject it",
+ mapOf("dag_id" to id, "length" to length),
+)
+
+private fun dagCharsetWarning(id: String) =
+ IdWarning(
+ "Dag id must be made of alphanumeric characters, dashes, dots, and
underscores; " +
+ "the Airflow server will reject it",
+ mapOf("dag_id" to id),
+ )
+
+private fun dagDoubleDotWarning(id: String) =
+ IdWarning(
+ "Dag id contains '..'; the Airflow server will reject it " +
+ "unless [core] allow_double_dot_in_ids is enabled",
+ mapOf("dag_id" to id),
+ )
+
+internal class IdValidationTest {
+ private class NoOp : Task {
+ override fun execute(
+ context: Context,
+ client: Client,
+ ) = Unit
+ }
+
+ @Test
+ @DisplayName("dag id warnings — exact findings across every branch")
+ fun dagIdWarnings() {
+ val astral = "𠀀"
+ val tooLongAndInvalid = "a".repeat(250) + " b"
+ val cases: List<Pair<String, List<IdWarning>>> =
+ listOf(
+ "simple" to emptyList(),
+ "with-dash" to emptyList(),
+ "with.dot" to emptyList(),
+ "with_underscore" to emptyList(),
+ "0numeric" to emptyList(),
+ "café_dag" to emptyList(),
+ "任務" to emptyList(),
+ "a".repeat(250) to emptyList(),
+ astral.repeat(250) to emptyList(),
+ "a".repeat(251) to listOf(dagTooLongWarning("a".repeat(251), 251)),
+ "任".repeat(251) to listOf(dagTooLongWarning("任".repeat(251), 251)),
+ astral.repeat(251) to listOf(dagTooLongWarning(astral.repeat(251),
251)),
+ "with space" to listOf(dagCharsetWarning("with space")),
+ "with/slash" to listOf(dagCharsetWarning("with/slash")),
+ "with:colon" to listOf(dagCharsetWarning("with:colon")),
+ "with\ttab" to listOf(dagCharsetWarning("with\ttab")),
+ "a..b c" to listOf(dagCharsetWarning("a..b c")),
+ "a..b" to listOf(dagDoubleDotWarning("a..b")),
+ tooLongAndInvalid to
+ listOf(dagTooLongWarning(tooLongAndInvalid, 252),
dagCharsetWarning(tooLongAndInvalid)),
+ )
+ cases.forEach { (id, expected) ->
+ assertEquals(expected,
IdValidation.findSuspiciousIds(listOf(DagDef(id))), "id=$id")
+ }
+ }
+
+ @Test
+ @DisplayName("a task warning carries its dag id")
+ fun taskWarningCarriesDagId() {
+ val dag = DagDef("my_dag").addTask("bad task", NoOp::class.java)
+
+ assertEquals(
+ listOf(
+ IdWarning(
+ "Task id must be made of alphanumeric characters, dashes, dots, and
underscores; " +
+ "the Airflow server will reject it",
+ mapOf("dag_id" to "my_dag", "task_id" to "bad task"),
+ ),
+ ),
+ IdValidation.findSuspiciousIds(listOf(dag)),
+ )
+ }
+}