Skip to content

Commit 505d9e4

Browse files
authored
Auto-compile UI assets on Breeze start-airflow command (apache#57219)
* Compile assets when installing airflow from Github Branch in Breeze * Only install pnpm * Fix main UI compliation * Generalize compile_ui_assets for simple_auth_manager ui * Remove "no assets compiled" warning * Fix test due to compile_ui_assets not found
1 parent 1039a46 commit 505d9e4

File tree

1 file changed

+256
-12
lines changed

1 file changed

+256
-12
lines changed

scripts/in_container/install_airflow_and_providers.py

Lines changed: 256 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,33 @@
2121

2222
import os
2323
import re
24+
import shutil
2425
import sys
26+
from functools import cache
2527
from pathlib import Path
2628
from typing import NamedTuple
2729

2830
sys.path.insert(0, str(Path(__file__).parent.resolve()))
29-
from in_container_utils import AIRFLOW_CORE_SOURCES_PATH, AIRFLOW_DIST_PATH, click, console, run_command
31+
from in_container_utils import (
32+
AIRFLOW_CORE_SOURCES_PATH,
33+
AIRFLOW_DIST_PATH,
34+
AIRFLOW_ROOT_PATH,
35+
click,
36+
console,
37+
run_command,
38+
)
39+
40+
SOURCE_TARBALL = AIRFLOW_ROOT_PATH / ".build" / "airflow.tar.gz"
41+
EXTRACTED_SOURCE_DIR = AIRFLOW_ROOT_PATH / ".build" / "airflow_source"
42+
CORE_UI_DIST_PREFIX = "ui/dist"
43+
CORE_SOURCE_UI_PREFIX = "airflow-core/src/airflow/ui"
44+
CORE_SOURCE_UI_DIRECTORY = AIRFLOW_CORE_SOURCES_PATH / "airflow" / "ui"
45+
SIMPLE_AUTH_MANAGER_UI_DIST_PREFIX = "api_fastapi/auth/managers/simple/ui/dist"
46+
SIMPLE_AUTH_MANAGER_SOURCE_UI_PREFIX = "airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui"
47+
SIMPLE_AUTH_MANAGER_SOURCE_UI_DIRECTORY = (
48+
AIRFLOW_CORE_SOURCES_PATH / "airflow" / "api_fastapi" / "auth" / "managers" / "simple" / "ui"
49+
)
50+
INTERNAL_SERVER_ERROR = "500 Internal Server Error"
3051

3152

3253
def get_provider_name(package_name: str) -> str:
@@ -208,13 +229,30 @@ def get_providers_constraints_location(
208229
)
209230

210231

232+
@cache
233+
def get_airflow_installation_path() -> Path:
234+
"""Get the installation path of Airflow in the container.
235+
Will return somehow like `/usr/python/lib/python3.10/site-packages/airflow`.
236+
"""
237+
import importlib.util
238+
239+
spec = importlib.util.find_spec("airflow")
240+
if spec is None or spec.origin is None:
241+
console.print("[red]Airflow not found - cannot mount sources")
242+
sys.exit(1)
243+
244+
airflow_path = Path(spec.origin).parent
245+
return airflow_path
246+
247+
211248
class InstallationSpec(NamedTuple):
212249
airflow_distribution: str | None
213250
airflow_core_distribution: str | None
214251
airflow_constraints_location: str | None
215252
airflow_task_sdk_distribution: str | None
216253
airflow_ctl_distribution: str | None
217254
airflow_ctl_constraints_location: str | None
255+
compile_ui_assets: bool | None
218256
provider_distributions: list[str]
219257
provider_constraints_location: str | None
220258
pre_release: bool = os.environ.get("ALLOW_PRE_RELEASES", "false").lower() == "true"
@@ -313,6 +351,7 @@ def find_installation_spec(
313351
else:
314352
airflow_ctl_constraints_location = None
315353
airflow_ctl_distribution = airflow_ctl_spec
354+
compile_ui_assets = False
316355
elif use_airflow_version == "none" or use_airflow_version == "":
317356
console.print("\n[bright_blue]Skipping airflow package installation\n")
318357
airflow_distribution_spec = None
@@ -321,6 +360,7 @@ def find_installation_spec(
321360
airflow_task_sdk_distribution = None
322361
airflow_ctl_distribution = None
323362
airflow_ctl_constraints_location = None
363+
compile_ui_assets = False
324364
elif repo_match := re.match(GITHUB_REPO_BRANCH_PATTERN, use_airflow_version):
325365
owner, repo, branch = repo_match.groups()
326366
console.print(f"\nInstalling airflow from GitHub: {use_airflow_version}\n")
@@ -341,6 +381,7 @@ def find_installation_spec(
341381
github_repository=github_repository,
342382
python_version=python_version,
343383
)
384+
compile_ui_assets = True
344385
console.print(f"\nInstalling airflow task-sdk from GitHub {use_airflow_version}\n")
345386
airflow_task_sdk_distribution = f"apache-airflow-task-sdk @ {vcs_url}#subdirectory=task-sdk"
346387
airflow_constraints_location = get_airflow_constraints_location(
@@ -365,16 +406,13 @@ def find_installation_spec(
365406
github_repository=github_repository,
366407
python_version=python_version,
367408
)
368-
console.print(
369-
"[yellow]Note that installing airflow from branch has no assets compiled, so you will"
370-
"not be able to run UI (we might add asset compilation for this case later if needed)."
371-
)
372409
elif use_airflow_version in ["wheel", "sdist"] and not use_distributions_from_dist:
373410
console.print(
374411
"[red]USE_AIRFLOW_VERSION cannot be 'wheel' or 'sdist' without --use-distributions-from-dist"
375412
)
376413
sys.exit(1)
377414
else:
415+
compile_ui_assets = False
378416
console.print(f"\nInstalling airflow via apache-airflow=={use_airflow_version}")
379417
airflow_distribution_spec = f"apache-airflow{airflow_extras}=={use_airflow_version}"
380418
airflow_core_distribution_spec = (
@@ -427,6 +465,7 @@ def find_installation_spec(
427465
airflow_task_sdk_distribution=airflow_task_sdk_distribution,
428466
airflow_ctl_distribution=airflow_ctl_distribution,
429467
airflow_ctl_constraints_location=airflow_ctl_constraints_location,
468+
compile_ui_assets=compile_ui_assets,
430469
provider_distributions=provider_distributions_list,
431470
provider_constraints_location=get_providers_constraints_location(
432471
providers_constraints_mode=providers_constraints_mode,
@@ -443,6 +482,208 @@ def find_installation_spec(
443482
return installation_spec
444483

445484

485+
def download_airflow_source_tarball(installation_spec: InstallationSpec):
486+
"""Download Airflow source tarball from GitHub."""
487+
if not installation_spec.compile_ui_assets:
488+
console.print(
489+
"[bright_blue]Skipping downloading Airflow source tarball since UI assets compilation is disabled."
490+
)
491+
return
492+
493+
if not installation_spec.airflow_distribution:
494+
console.print("[yellow]No airflow distribution specified, cannot download source tarball.")
495+
return
496+
497+
if SOURCE_TARBALL.exists() and EXTRACTED_SOURCE_DIR.exists():
498+
console.print(
499+
"[bright_blue]Source tarball and extracted source directory already exist. Skipping download."
500+
)
501+
return
502+
503+
# Extract GitHub repository information from airflow_distribution
504+
# Expected format: "apache-airflow @ git+https://github.com/owner/repo.git@branch"
505+
airflow_dist = installation_spec.airflow_distribution
506+
git_url_match = re.search(r"git\+https://github\.com/([^/]+)/([^/]+)\.git@([^#\s]+)", airflow_dist)
507+
508+
if not git_url_match:
509+
console.print(f"[yellow]Cannot extract GitHub repository info from: {airflow_dist}")
510+
return
511+
512+
owner, repo, ref = git_url_match.groups()
513+
console.print(f"[bright_blue]Downloading source tarball from GitHub: {owner}/{repo}@{ref}")
514+
515+
# Create build directory if it doesn't exist
516+
SOURCE_TARBALL.parent.mkdir(parents=True, exist_ok=True)
517+
518+
# Download tarball from GitHub API if it doesn't exist
519+
if not SOURCE_TARBALL.exists():
520+
tarball_url = f"https://api.github.com/repos/{owner}/{repo}/tarball/{ref}"
521+
console.print(f"[bright_blue]Downloading from: {tarball_url}")
522+
523+
try:
524+
result = run_command(
525+
["curl", "-L", tarball_url, "-o", str(SOURCE_TARBALL)],
526+
github_actions=False,
527+
shell=False,
528+
check=True,
529+
)
530+
531+
if result.returncode != 0:
532+
console.print(f"[red]Failed to download tarball: {result.stderr}")
533+
return
534+
except Exception as e:
535+
console.print(f"[red]Error downloading source tarball: {e}")
536+
return
537+
else:
538+
console.print(f"[bright_blue]Source tarball already exists at: {SOURCE_TARBALL}")
539+
540+
try:
541+
# Create temporary extraction directory
542+
if EXTRACTED_SOURCE_DIR.exists():
543+
shutil.rmtree(EXTRACTED_SOURCE_DIR)
544+
# make sure .build exists
545+
EXTRACTED_SOURCE_DIR.parent.mkdir(parents=True, exist_ok=True)
546+
547+
# Extract tarball
548+
console.print(f"[bright_blue]Extracting tarball to: {EXTRACTED_SOURCE_DIR}")
549+
result = run_command(
550+
["tar", "-xzf", str(SOURCE_TARBALL), "-C", str(EXTRACTED_SOURCE_DIR.parent)],
551+
github_actions=False,
552+
shell=False,
553+
check=True,
554+
)
555+
556+
if result.returncode != 0:
557+
console.print(f"[red]Failed to extract tarball: {result.stderr}")
558+
return
559+
560+
# Rename extracted directory to a known name
561+
extracted_dirs = list(EXTRACTED_SOURCE_DIR.parent.glob(f"{owner}-{repo}-*"))
562+
if not extracted_dirs:
563+
console.print("[red]No extracted directory found after tarball extraction.")
564+
return
565+
extracted_dir = extracted_dirs[0]
566+
extracted_dir.rename(EXTRACTED_SOURCE_DIR)
567+
console.print("[bright_blue]Source tarball downloaded and extracted successfully")
568+
569+
except Exception as e:
570+
console.print(f"[red]Error extracting source tarball: {e}")
571+
return
572+
573+
574+
def compile_ui_assets(
575+
installation_spec: InstallationSpec,
576+
source_prefix: str,
577+
source_ui_directory: Path,
578+
dist_prefix: str,
579+
):
580+
if not installation_spec.compile_ui_assets:
581+
console.print("[bright_blue]Skipping UI assets compilation")
582+
return
583+
584+
# Copy UI directories from extracted tarball source to core source directory
585+
extracted_ui_directory = EXTRACTED_SOURCE_DIR / source_prefix
586+
if extracted_ui_directory.exists():
587+
console.print(
588+
f"[bright_blue]Copying UI source from: {extracted_ui_directory} to: {source_ui_directory}"
589+
)
590+
if source_ui_directory.exists():
591+
shutil.rmtree(source_ui_directory)
592+
source_ui_directory.parent.mkdir(parents=True, exist_ok=True)
593+
shutil.copytree(extracted_ui_directory, source_ui_directory)
594+
else:
595+
console.print(f"[yellow]Main UI directory not found at: {extracted_ui_directory}")
596+
597+
if not source_ui_directory.exists():
598+
console.print(
599+
f"[bright_blue]UI directory '{source_ui_directory}' still does not exist. Skipping UI assets compilation."
600+
)
601+
return
602+
603+
# check if UI assets need to be recompiled
604+
dist_directory = get_airflow_installation_path() / dist_prefix
605+
if dist_directory.exists():
606+
console.print(f"[bright_blue]Already compiled UI assets found in '{dist_directory}'")
607+
return
608+
console.print(f"[bright_blue]No compiled UI assets found in '{dist_directory}'")
609+
610+
# ensure dependencies for UI assets compilation
611+
need_pnpm = shutil.which("pnpm") is None
612+
if need_pnpm:
613+
console.print("[bright_blue]Installing pnpm directly from official setup script")
614+
run_command(
615+
[
616+
"bash",
617+
"-c",
618+
"curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs",
619+
],
620+
github_actions=False,
621+
shell=False,
622+
check=True,
623+
)
624+
run_command(["npm", "install", "-g", "pnpm"], github_actions=False, shell=False, check=True)
625+
626+
"""
627+
run_command(
628+
[
629+
"bash",
630+
"-c",
631+
'wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash -',
632+
],
633+
github_actions=False,
634+
shell=False,
635+
check=True,
636+
)
637+
console.print("[bright_blue]Setting up pnpm PATH")
638+
run_command(
639+
[
640+
"bash",
641+
"-c",
642+
'export PNPM_HOME="/root/.local/share/pnpm"; case ":$PATH:" in *":$PNPM_HOME:"*) ;; *) export PATH="$PNPM_HOME:$PATH" ;; esac',
643+
],
644+
github_actions=False,
645+
shell=False,
646+
check=True,
647+
)
648+
"""
649+
else:
650+
console.print("[bright_blue]pnpm already installed")
651+
652+
# TO avoid ` ELIFECYCLE  Command failed.` errors, we need to clear cache and node_modules
653+
run_command(
654+
["bash", "-c", "pnpm cache delete"],
655+
github_actions=False,
656+
shell=False,
657+
check=True,
658+
cwd=os.fspath(source_ui_directory),
659+
)
660+
shutil.rmtree(source_ui_directory / "node_modules", ignore_errors=True)
661+
662+
# install dependencies
663+
run_command(
664+
["bash", "-c", "pnpm install --frozen-lockfile -config.confirmModulesPurge=false"],
665+
github_actions=False,
666+
shell=False,
667+
check=True,
668+
cwd=os.fspath(source_ui_directory),
669+
)
670+
# compile UI assets
671+
run_command(
672+
["bash", "-c", "pnpm run build"],
673+
github_actions=False,
674+
shell=False,
675+
check=True,
676+
cwd=os.fspath(source_ui_directory),
677+
)
678+
# copy compiled assets to installation directory
679+
dist_source_directory = source_ui_directory / "dist"
680+
console.print(
681+
f"[bright_blue]Copying compiled UI assets from '{dist_source_directory}' to '{dist_directory}'"
682+
)
683+
shutil.copytree(dist_source_directory, dist_directory)
684+
console.print("[bright_blue]UI assets compiled successfully")
685+
686+
446687
ALLOWED_DISTRIBUTION_FORMAT = ["wheel", "sdist", "both"]
447688
ALLOWED_CONSTRAINTS_MODE = ["constraints-source-providers", "constraints", "constraints-no-providers"]
448689
ALLOWED_MOUNT_SOURCES = ["remove", "tests", "providers-and-tests", "selected"]
@@ -660,12 +901,6 @@ def install_airflow_and_providers(
660901
shell=True,
661902
check=False,
662903
)
663-
import importlib.util
664-
665-
spec = importlib.util.find_spec("airflow")
666-
if spec is None or spec.origin is None:
667-
console.print("[red]Airflow not found - cannot mount sources")
668-
sys.exit(1)
669904
from packaging.version import Version
670905

671906
from airflow import __version__
@@ -676,7 +911,7 @@ def install_airflow_and_providers(
676911
"[yellow]Patching airflow 2 installation "
677912
"in order to load providers from separate distributions.\n"
678913
)
679-
airflow_path = Path(spec.origin).parent
914+
airflow_path = get_airflow_installation_path()
680915
# Make sure old Airflow will include providers including common subfolder allow to extend loading
681916
# providers from the installed separate source packages
682917
console.print("[yellow]Uninstalling Airflow-3 only providers\n")
@@ -708,6 +943,15 @@ def install_airflow_and_providers(
708943
airflow_providers_common_init_py.parent.mkdir(exist_ok=True)
709944
airflow_providers_common_init_py.write_text(INIT_CONTENT + "\n")
710945

946+
# compile ui assets
947+
download_airflow_source_tarball(installation_spec)
948+
compile_ui_assets(installation_spec, CORE_SOURCE_UI_PREFIX, CORE_SOURCE_UI_DIRECTORY, CORE_UI_DIST_PREFIX)
949+
compile_ui_assets(
950+
installation_spec,
951+
SIMPLE_AUTH_MANAGER_SOURCE_UI_PREFIX,
952+
SIMPLE_AUTH_MANAGER_SOURCE_UI_DIRECTORY,
953+
SIMPLE_AUTH_MANAGER_UI_DIST_PREFIX,
954+
)
711955
console.print("\n[green]Done!")
712956

713957

0 commit comments

Comments
 (0)