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 259fe2d3e80 Warn on suspicious Dag and task IDs at TS SDK build time
(#70993)
259fe2d3e80 is described below
commit 259fe2d3e80f1ca4871f090fded5cd5025eb1cdf
Author: Andrew Chang <[email protected]>
AuthorDate: Thu Aug 20 07:29:54 2026 +0400
Warn on suspicious Dag and task IDs at TS SDK build time (#70993)
---
ts-sdk/src/cli/pack.ts | 8 +-
ts-sdk/src/cli/validate.ts | 66 ++++++++++++++
ts-sdk/src/coordinator/manifest.ts | 23 +----
ts-sdk/tests/cli/pack.test.ts | 46 +++++-----
ts-sdk/tests/cli/validate.test.ts | 101 ++++++++++++++++++++++
ts-sdk/tests/coordinator/runtime-manifest.test.ts | 50 ++++-------
6 files changed, 211 insertions(+), 83 deletions(-)
diff --git a/ts-sdk/src/cli/pack.ts b/ts-sdk/src/cli/pack.ts
index 3268ec9aab6..0642f0e13d3 100644
--- a/ts-sdk/src/cli/pack.ts
+++ b/ts-sdk/src/cli/pack.ts
@@ -21,9 +21,9 @@
// artifact NodeCoordinator consumes — `bundle.mjs` with the airflow
// metadata embedded as a leading `//# airflowMetadata=<base64>` comment.
//
-// Mirrors airflow-go-pack: build first, then run the built bundle with
-// --airflow-metadata so the manifest comes from the bundle's own task
-// registry and schema version, never from a hand-written sidecar.
+// Build first, then run the built bundle with --airflow-metadata so the
+// manifest comes from the bundle's own task registry and schema version,
+// never from a hand-written sidecar.
import { execFileSync } from "node:child_process";
import { readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -34,6 +34,7 @@ import {
AIRFLOW_METADATA_SENTINEL,
type BundleManifest,
} from "../coordinator/manifest.js";
+import { warnOnSuspiciousIds } from "./validate.js";
const AIRFLOW_BUNDLE_METADATA_VERSION = "1.0";
const BUNDLE_FILENAME = "bundle.mjs";
@@ -243,6 +244,7 @@ export async function runPack(argv: readonly string[]):
Promise<void> {
process.stderr.write(`warning: dag ${JSON.stringify(dagId)} has no
tasks\n`);
}
}
+ warnOnSuspiciousIds(manifest.dags);
const metadataYaml = renderMetadataYaml({
airflow_bundle_metadata_version: AIRFLOW_BUNDLE_METADATA_VERSION,
diff --git a/ts-sdk/src/cli/validate.ts b/ts-sdk/src/cli/validate.ts
new file mode 100644
index 00000000000..91fc6e35482
--- /dev/null
+++ b/ts-sdk/src/cli/validate.ts
@@ -0,0 +1,66 @@
+/*!
+ * 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.
+ */
+
+import type { BundleManifest } from "../coordinator/manifest.js";
+
+const MAX_ID_LENGTH = 250;
+const ID_REGEX = /^[\p{L}\p{N}_.-]+$/u;
+
+type WarnFn = (message: string) => void;
+
+/**
+ * Check every dag and task id in the manifest against the rules the Airflow
+ * server enforces (`airflow.utils.helpers.validate_key`). Best-effort and
+ * warn-only: the server validates authoritatively, and checks like the `..`
+ * one depend on server configuration the packer cannot see.
+ */
+export function warnOnSuspiciousIds(
+ dags: BundleManifest["dags"],
+ warn: WarnFn = (message) => process.stderr.write(`${message}\n`),
+): void {
+ for (const dagId of Object.keys(dags).sort()) {
+ warnOnSuspiciousId(`dag id ${JSON.stringify(dagId)}`, dagId, warn);
+ for (const taskId of dags[dagId]!.tasks) {
+ warnOnSuspiciousId(
+ `task id ${JSON.stringify(taskId)} in dag ${JSON.stringify(dagId)}`,
+ taskId,
+ warn,
+ );
+ }
+ }
+}
+
+function warnOnSuspiciousId(label: string, id: string, warn: WarnFn): void {
+ // Count code points, not UTF-16 units, to match the server-side len().
+ const length = [...id].length;
+ if (length > MAX_ID_LENGTH) {
+ warn(
+ `warning: ${label} is longer than ${MAX_ID_LENGTH} characters
(${length}); the Airflow server will reject it`,
+ );
+ }
+ if (!ID_REGEX.test(id)) {
+ warn(
+ `warning: ${label} must be made of alphanumeric characters, dashes,
dots, and underscores; the Airflow server will reject it`,
+ );
+ } else if (id.includes("..")) {
+ warn(
+ `warning: ${label} contains '..'; the Airflow server will reject it
unless [core] allow_double_dot_in_ids is enabled`,
+ );
+ }
+}
diff --git a/ts-sdk/src/coordinator/manifest.ts
b/ts-sdk/src/coordinator/manifest.ts
index 6baca7f4d7b..145cd350167 100644
--- a/ts-sdk/src/coordinator/manifest.ts
+++ b/ts-sdk/src/coordinator/manifest.ts
@@ -25,24 +25,6 @@ export const AIRFLOW_METADATA_FLAG = "--airflow-metadata";
/** Marks the manifest line on stdout, which import-time logging may also
reach. */
export const AIRFLOW_METADATA_SENTINEL = "__AIRFLOW_METADATA__ ";
-// Mirrors the Python task-SDK KEY_REGEX and validate_key in
airflow.sdk.definitions._internal.node.
-// Checked here rather than in Dag()/task() registration: that runs on every
-// bundle module load, including at task-execution-runtime startup, whereas
-// this only runs once, when the bundle is packed.
-const KEY_REGEX = /^[\p{L}\p{N}_.-]+$/u;
-const MAX_KEY_LENGTH = 250;
-
-function validateKey(label: string, value: string): void {
- if (typeof value !== "string" || !KEY_REGEX.test(value)) {
- throw new Error(
- `${label} must be made of alphanumeric characters, dashes, dots, and
underscores`,
- );
- }
- if (value.length > MAX_KEY_LENGTH) {
- throw new Error(`${label} must be less than ${MAX_KEY_LENGTH} characters,
not ${value.length}`);
- }
-}
-
/** Bundle manifest fields only the built bundle itself knows: the schema
* version it was compiled against and the Dag/task pairs it registered.
* Registered Dags without tasks appear with an empty `tasks` list so
@@ -56,9 +38,8 @@ export interface BundleManifest {
export function buildBundleManifest(registry: DagRegistry): BundleManifest {
const dags: BundleManifest["dags"] = {};
for (const { dagId, tasks } of listRegistryDags(registry)) {
- validateKey(`Dag "${dagId}"`, dagId);
- for (const taskId of tasks) {
- validateKey(`Task "${taskId}" of Dag "${dagId}"`, taskId);
+ if (typeof dagId !== "string") {
+ throw new Error("Dag ID must be a string");
}
Object.defineProperty(dags, dagId, {
configurable: true,
diff --git a/ts-sdk/tests/cli/pack.test.ts b/ts-sdk/tests/cli/pack.test.ts
index 6de652e7f28..75b2ccb5a45 100644
--- a/ts-sdk/tests/cli/pack.test.ts
+++ b/ts-sdk/tests/cli/pack.test.ts
@@ -211,38 +211,34 @@ describe("runPack", () => {
dagId: "bad id!",
taskId: "valid_task",
expected:
- 'Error: Dag "bad id!" must be made of alphanumeric characters, dashes,
dots, and underscores',
+ 'warning: dag id "bad id!" must be made of alphanumeric characters,
dashes, dots, and underscores; the Airflow server will reject it\n',
},
{
label: "task",
dagId: "valid_dag",
taskId: "bad id!",
expected:
- 'Error: Task "bad id!" of Dag "valid_dag" must be made of alphanumeric
characters, dashes, dots, and underscores',
+ 'warning: task id "bad id!" in dag "valid_dag" must be made of
alphanumeric characters, dashes, dots, and underscores; the Airflow server will
reject it\n',
},
- ])(
- "reports an invalid $label ID without a staging-bundle stack",
- async ({ dagId, taskId, expected }) => {
- outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
- const entry = path.join(outdir, "invalid-id-entry.ts");
- writeFileSync(
- entry,
- [
- `import { Dag, DagRegistry, serveDags } from
${JSON.stringify(SDK_INDEX)};`,
- `const invalidDag = new Dag(${JSON.stringify(dagId)});`,
- `invalidDag.task(${JSON.stringify(taskId)}, async () => undefined);`,
- "await serveDags(new DagRegistry(invalidDag));",
- ].join("\n"),
- );
-
- await expect(runPack([entry, "--outdir",
outdir])).rejects.toHaveProperty(
- "message",
- expected,
- );
- expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(false);
- expect(existsSync(path.join(outdir,
"bundle.pack-staging.mjs"))).toBe(false);
- },
- );
+ ])("warns on a suspicious $label ID but still packs", async ({ dagId,
taskId, expected }) => {
+ outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
+ const entry = path.join(outdir, "suspicious-id-entry.ts");
+ writeFileSync(
+ entry,
+ [
+ `import { Dag, DagRegistry, serveDags } from
${JSON.stringify(SDK_INDEX)};`,
+ `const suspiciousDag = new Dag(${JSON.stringify(dagId)});`,
+ `suspiciousDag.task(${JSON.stringify(taskId)}, async () =>
undefined);`,
+ "await serveDags(new DagRegistry(suspiciousDag));",
+ ].join("\n"),
+ );
+ const stderr = captureStderr();
+
+ await runPack([entry, "--outdir", outdir]);
+
+ expect(stderr()).toContain(expected);
+ expect(existsSync(path.join(outdir, "bundle.mjs"))).toBe(true);
+ });
it("reports the last error from a failed bundle", async () => {
outdir = mkdtempSync(path.join(tmpdir(), "ts-pack-"));
diff --git a/ts-sdk/tests/cli/validate.test.ts
b/ts-sdk/tests/cli/validate.test.ts
new file mode 100644
index 00000000000..f97dba81d4c
--- /dev/null
+++ b/ts-sdk/tests/cli/validate.test.ts
@@ -0,0 +1,101 @@
+/*!
+ * 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.
+ */
+
+import { describe, expect, it } from "vitest";
+
+import { warnOnSuspiciousIds } from "../../src/cli/validate.js";
+import type { BundleManifest } from "../../src/coordinator/manifest.js";
+
+// U+20000, a CJK letter outside the Basic Multilingual Plane: one code point
+// but two UTF-16 units, so it separates code-point from .length counting.
+const ASTRAL_LETTER = "𠀀";
+
+function warningsFor(dags: BundleManifest["dags"]): string[] {
+ const warnings: string[] = [];
+ warnOnSuspiciousIds(dags, (message) => warnings.push(message));
+ return warnings;
+}
+
+function dagCharsetWarning(id: string): string {
+ return `warning: dag id ${JSON.stringify(id)} must be made of alphanumeric
characters, dashes, dots, and underscores; the Airflow server will reject it`;
+}
+
+describe("warnOnSuspiciousIds", () => {
+ it.each([
+ "simple",
+ "with-dash",
+ "with.dot",
+ "with_underscore",
+ "0numeric",
+ "café_dag",
+ "任務",
+ "a".repeat(250),
+ "任".repeat(250),
+ ASTRAL_LETTER.repeat(250),
+ ])("does not warn on valid id %j", (id) => {
+ expect(warningsFor({ [id]: { tasks: [id] } })).toEqual([]);
+ });
+
+ it.each(["a".repeat(251), "任".repeat(251), ASTRAL_LETTER.repeat(251)])(
+ "warns on an id longer than 250 code points: %j",
+ (id) => {
+ expect(warningsFor({ [id]: { tasks: [] } })).toEqual([
+ `warning: dag id ${JSON.stringify(id)} is longer than 250 characters
(251); the Airflow server will reject it`,
+ ]);
+ },
+ );
+
+ // "a..b c" also locks the else-if: a charset failure suppresses the '..'
warning.
+ it.each(["", "with space", "with/slash", "with:colon", "with\ttab", "a..b
c"])(
+ "warns on an id with invalid characters: %j",
+ (id) => {
+ expect(warningsFor({ [id]: { tasks: [] }
})).toEqual([dagCharsetWarning(id)]);
+ },
+ );
+
+ it("warns on an id containing double dots", () => {
+ expect(warningsFor({ "a..b": { tasks: [] } })).toEqual([
+ `warning: dag id "a..b" contains '..'; the Airflow server will reject it
unless [core] allow_double_dot_in_ids is enabled`,
+ ]);
+ });
+
+ it("warns twice on an id that is both too long and invalid", () => {
+ const id = "a".repeat(250) + " b";
+ expect(warningsFor({ [id]: { tasks: [] } })).toEqual([
+ `warning: dag id ${JSON.stringify(id)} is longer than 250 characters
(252); the Airflow server will reject it`,
+ dagCharsetWarning(id),
+ ]);
+ });
+
+ it("sorts dag ids for stable output", () => {
+ const warnings = warningsFor({
+ "delta d": { tasks: [] },
+ "alpha d": { tasks: [] },
+ "charlie d": { tasks: [] },
+ "bravo d": { tasks: [] },
+ });
+ expect(warnings).toEqual(["alpha d", "bravo d", "charlie d", "delta
d"].map(dagCharsetWarning));
+ });
+
+ it("names the owning dag in a task id warning", () => {
+ expect(warningsFor({ my_dag: { tasks: ["bad task"] } })).toEqual([
+ `warning: task id "bad task" in dag "my_dag" must be made of
alphanumeric characters, dashes, dots, and underscores; the Airflow server will
reject it`,
+ ]);
+ });
+});
diff --git a/ts-sdk/tests/coordinator/runtime-manifest.test.ts
b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
index 9c8beec94e1..f1e4ea76b17 100644
--- a/ts-sdk/tests/coordinator/runtime-manifest.test.ts
+++ b/ts-sdk/tests/coordinator/runtime-manifest.test.ts
@@ -65,48 +65,30 @@ describe("buildBundleManifest", () => {
expect(Object.keys(buildBundleManifest(registry).dags)).toEqual(["dag_a"]);
});
- it("rejects an empty dagId", () => {
- const registry = new DagRegistry(buildDag(""));
- expect(() => buildBundleManifest(registry)).toThrowError(/must be made of
alphanumeric/);
- });
-
- it("rejects an empty taskId", () => {
- const registry = new DagRegistry(buildDag("example_dag", ""));
- expect(() => buildBundleManifest(registry)).toThrowError(/must be made of
alphanumeric/);
- });
-
- it.each([" ", "\t", "my dag", "a/b", "task@1"])(
- "rejects a dagId with characters no Python dag_id allows: %j",
+ // The server would reject these ids. The manifest keeps them and
+ // airflow-ts-pack warns at build time instead of failing the pack.
+ it.each(["", " ", "\t", "my dag", "a/b", "task@1", "d".repeat(251)])(
+ "keeps a dagId the server would reject visible in the manifest: %j",
(dagId) => {
- const registry = new DagRegistry(buildDag(dagId));
- expect(() => buildBundleManifest(registry)).toThrowError(/must be made
of alphanumeric/);
+ const manifest = buildBundleManifest(new DagRegistry(buildDag(dagId,
"t1")));
+ expect(manifest.dags[dagId]).toEqual({ tasks: ["t1"] });
},
);
- it.each([" ", "\t", "my task", "a/b", "task@1"])(
- "rejects a taskId with characters no Python task_id allows: %j",
+ // An empty taskId is absent: the bundle schema requires task ids to be
+ // non-empty strings, so airflow-ts-pack rejects it instead of warning.
+ it.each([" ", "\t", "my task", "a/b", "task@1", "t".repeat(251)])(
+ "keeps a taskId the server would reject visible in the manifest: %j",
(taskId) => {
- const registry = new DagRegistry(buildDag("example_dag", taskId));
- expect(() => buildBundleManifest(registry)).toThrowError(/must be made
of alphanumeric/);
+ const manifest = buildBundleManifest(new
DagRegistry(buildDag("example_dag", taskId)));
+ expect(manifest.dags["example_dag"]).toEqual({ tasks: [taskId] });
},
);
- it("rejects a dagId longer than 250 characters", () => {
- const registry = new DagRegistry(buildDag("d".repeat(251)));
- expect(() => buildBundleManifest(registry)).toThrowError(
- /must be less than 250 characters, not 251/,
- );
- });
-
- it("rejects a taskId longer than 250 characters", () => {
- const registry = new DagRegistry(buildDag("example_dag", "t".repeat(251)));
- expect(() => buildBundleManifest(registry)).toThrowError(
- /must be less than 250 characters, not 251/,
- );
- });
-
- it("does not validate key format when Dags are only registered, not packed",
() => {
- expect(() => new DagRegistry(buildDag("bad dag id", "bad task
id"))).not.toThrow();
+ it("rejects a non-string dagId before object-key coercion hides it", () => {
+ const dag = new Dag(123 as unknown as string);
+ dag.task("t1", async () => undefined);
+ expect(() => buildBundleManifest(new DagRegistry(dag))).toThrowError(/Dag
ID must be a string/);
});
});