Skip to content

Commit 22f3077

Browse files
committed
Merge branch 'feature/publish-realtime-progress' into 'develop'
Add real-time progress display for npm install in publish.py See merge request genaiic-reusable-assets/engagement-artifacts/genaiic-idp-accelerator!331
2 parents 7254f18 + a64d298 commit 22f3077

File tree

6 files changed

+209
-51
lines changed

6 files changed

+209
-51
lines changed

.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ developer_tests:
5151

5252
integration_tests:
5353
stage: integration_tests
54+
timeout: 2h
5455
# variables:
5556
# # In order to run tests in another account, add a AWS_CREDS_TARGET_ROLE variable to the Gitlab pipeline variables.
5657
# AWS_CREDS_TARGET_ROLE: ${AWS_CREDS_TARGET_ROLE}

docs/deployment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ You need to have the following packages installed on your computer:
3535
3. [sam (AWS SAM)](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
3636
4. python 3.11 or later
3737
5. A local Docker daemon
38-
6. Python packages for publish.py: `pip install boto3 rich typer PyYAML botocore setuptools`
38+
6. Python packages for publish.py: `pip install boto3 rich typer PyYAML botocore setuptools ruff`
3939
7. **Node.js 18+** and **npm** (required for UI validation in publish script)
4040

4141
For guidance on setting up a development environment, see:

publish.py

Lines changed: 198 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(self, verbose=False):
5858
self.cf_client = None
5959
self._is_lib_changed = False
6060
self.skip_validation = False
61+
self.lint_enabled = True
6162

6263
def clean_checksums(self):
6364
"""Delete all .checksum files in main, patterns, options, and lib directories"""
@@ -116,11 +117,77 @@ def log_error_details(self, component, error_output):
116117
f"[red]❌ {component} build failed (use --verbose for details)[/red]"
117118
)
118119

119-
def run_subprocess_with_logging(self, cmd, component_name, cwd=None):
120+
def run_subprocess_with_logging(
121+
self, cmd, component_name, cwd=None, realtime=False
122+
):
120123
"""Run subprocess with standardized logging"""
121-
result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
122-
if result.returncode != 0:
123-
error_msg = f"""Command failed: {" ".join(cmd)}
124+
if realtime:
125+
# Real-time output for long-running processes like npm install
126+
self.console.print(f"[cyan]Running: {' '.join(cmd)}[/cyan]")
127+
128+
try:
129+
process = subprocess.Popen(
130+
cmd,
131+
stdout=subprocess.PIPE,
132+
stderr=subprocess.STDOUT,
133+
text=True,
134+
cwd=cwd,
135+
bufsize=1,
136+
universal_newlines=True,
137+
)
138+
139+
output_lines = []
140+
while True:
141+
output = process.stdout.readline()
142+
if output == "" and process.poll() is not None:
143+
break
144+
if output:
145+
line = output.strip()
146+
output_lines.append(line)
147+
# Show progress for npm commands
148+
if "npm" in " ".join(cmd):
149+
if any(
150+
keyword in line.lower()
151+
for keyword in [
152+
"downloading",
153+
"installing",
154+
"added",
155+
"updated",
156+
"audited",
157+
]
158+
):
159+
self.console.print(f"[dim] {line}[/dim]")
160+
elif "warn" in line.lower():
161+
self.console.print(f"[yellow] {line}[/yellow]")
162+
elif "error" in line.lower():
163+
self.console.print(f"[red] {line}[/red]")
164+
165+
return_code = process.poll()
166+
167+
if return_code != 0:
168+
error_msg = f"""Command failed: {" ".join(cmd)}
169+
Working directory: {cwd or os.getcwd()}
170+
Return code: {return_code}
171+
172+
OUTPUT:
173+
{chr(10).join(output_lines)}"""
174+
print(error_msg)
175+
self.log_error_details(component_name, error_msg)
176+
return False, error_msg
177+
178+
return True, None # Success, no result object needed for real-time
179+
180+
except Exception as e:
181+
error_msg = (
182+
f"Failed to execute command: {' '.join(cmd)}\nError: {str(e)}"
183+
)
184+
self.log_error_details(component_name, error_msg)
185+
return False, error_msg
186+
else:
187+
# Original behavior - capture all output
188+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
189+
if result.returncode != 0:
190+
error_msg = f"""Command failed: {" ".join(cmd)}
124191
Working directory: {cwd or os.getcwd()}
125192
Return code: {result.returncode}
126193
@@ -129,10 +196,10 @@ def run_subprocess_with_logging(self, cmd, component_name, cwd=None):
129196
130197
STDERR:
131198
{result.stderr}"""
132-
print(error_msg)
133-
self.log_error_details(component_name, error_msg)
134-
return False, error_msg
135-
return True, result
199+
print(error_msg)
200+
self.log_error_details(component_name, error_msg)
201+
return False, error_msg
202+
return True, result
136203

137204
def print_error_summary(self):
138205
"""Print summary of all build errors"""
@@ -159,7 +226,7 @@ def print_usage(self):
159226
"""Print usage information with Rich formatting"""
160227
self.console.print("\n[bold cyan]Usage:[/bold cyan]")
161228
self.console.print(
162-
" python3 publish.py <cfn_bucket_basename> <cfn_prefix> <region> [public] [--max-workers N] [--verbose] [--no-validate] [--clean-build]"
229+
" python3 publish.py <cfn_bucket_basename> <cfn_prefix> <region> [public] [--max-workers N] [--verbose] [--no-validate] [--clean-build] [--lint on|off]"
163230
)
164231

165232
self.console.print("\n[bold cyan]Parameters:[/bold cyan]")
@@ -186,6 +253,9 @@ def print_usage(self):
186253
self.console.print(
187254
" [yellow][--clean-build][/yellow]: Optional. Delete all .checksum files to force full rebuild"
188255
)
256+
self.console.print(
257+
" [yellow][--lint on|off][/yellow]: Optional. Enable/disable UI linting and build validation (default: on)"
258+
)
189259

190260
def check_parameters(self, args):
191261
"""Check and validate input parameters"""
@@ -248,6 +318,19 @@ def check_parameters(self, args):
248318
self.console.print(
249319
"[yellow]CloudFormation template validation will be skipped[/yellow]"
250320
)
321+
elif arg == "--lint":
322+
if i + 1 >= len(remaining_args):
323+
self.console.print(
324+
"[red]Error: --lint requires 'on' or 'off'[/red]"
325+
)
326+
self.print_usage()
327+
sys.exit(1)
328+
lint_value = remaining_args[i + 1].lower()
329+
if lint_value not in ["on", "off"]:
330+
self.console.print("[red]Error: --lint must be 'on' or 'off'[/red]")
331+
self.print_usage()
332+
sys.exit(1)
333+
self.lint_enabled = lint_value == "on"
251334
elif arg == "--clean-build":
252335
self.clean_checksums()
253336
else:
@@ -1104,6 +1187,50 @@ def upload_config_library(self):
11041187
f"[green]Configuration library uploaded to s3://{self.bucket}/{self.prefix_and_version}/config_library[/green]"
11051188
)
11061189

1190+
def ui_changed(self):
1191+
"""Check if UI has changed based on zipfile hash, returns (changed, zipfile_path)"""
1192+
ui_hash = self.compute_ui_hash()
1193+
zipfile_name = f"src-{ui_hash[:16]}.zip"
1194+
zipfile_path = os.path.join(".aws-sam", zipfile_name)
1195+
1196+
existing_zipfiles = (
1197+
[
1198+
f
1199+
for f in os.listdir(".aws-sam")
1200+
if f.startswith("src-") and f.endswith(".zip")
1201+
]
1202+
if os.path.exists(".aws-sam")
1203+
else []
1204+
)
1205+
1206+
if zipfile_name not in existing_zipfiles:
1207+
# Remove old zipfiles
1208+
for old_zip in existing_zipfiles:
1209+
old_path = os.path.join(".aws-sam", old_zip)
1210+
if os.path.exists(old_path):
1211+
os.remove(old_path)
1212+
return True, zipfile_path
1213+
1214+
return not os.path.exists(zipfile_path), zipfile_path
1215+
1216+
def start_ui_validation_parallel(self):
1217+
"""Start UI validation in parallel if needed, returns (future, executor)"""
1218+
if not self.lint_enabled or not os.path.exists("src/ui"):
1219+
return None, None
1220+
1221+
changed, _ = self.ui_changed()
1222+
if not changed:
1223+
return None, None
1224+
1225+
import concurrent.futures
1226+
1227+
ui_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
1228+
ui_validation_future = ui_executor.submit(self.validate_ui_build)
1229+
self.console.print(
1230+
"[cyan]🔍 Starting UI validation in parallel with builds...[/cyan]"
1231+
)
1232+
return ui_validation_future, ui_executor
1233+
11071234
def compute_ui_hash(self):
11081235
"""Compute hash of UI folder contents"""
11091236
self.console.print("[cyan]Computing hash of ui folder contents[/cyan]")
@@ -1123,18 +1250,22 @@ def validate_ui_build(self):
11231250
return
11241251

11251252
# Run npm install first
1126-
self.log_verbose("Running npm install for UI dependencies...")
1253+
self.console.print(
1254+
"[cyan]📦 Installing UI dependencies (this may take a while)...[/cyan]"
1255+
)
11271256
success, result = self.run_subprocess_with_logging(
1128-
["npm", "install"], "UI npm install", ui_dir
1257+
["npm", "install"], "UI npm install", ui_dir, realtime=True
11291258
)
11301259

11311260
if not success:
11321261
raise Exception("npm install failed")
11331262

11341263
# Run npm run build to validate ESLint/Prettier
1135-
self.log_verbose("Running npm run build for UI validation...")
1264+
self.console.print(
1265+
"[cyan]🔨 Building UI (validating ESLint/Prettier)...[/cyan]"
1266+
)
11361267
success, result = self.run_subprocess_with_logging(
1137-
["npm", "run", "build"], "UI build validation", ui_dir
1268+
["npm", "run", "build"], "UI build validation", ui_dir, realtime=True
11381269
)
11391270

11401271
if not success:
@@ -1149,50 +1280,24 @@ def validate_ui_build(self):
11491280

11501281
def package_ui(self):
11511282
"""Package UI source code"""
1152-
ui_hash = self.compute_ui_hash()
1153-
zipfile_name = f"src-{ui_hash[:16]}.zip"
1154-
1155-
# Ensure .aws-sam directory exists
1156-
os.makedirs(".aws-sam", exist_ok=True)
1157-
1158-
# Check if we need to rebuild
1159-
existing_zipfiles = [
1160-
f
1161-
for f in os.listdir(".aws-sam")
1162-
if f.startswith("src-") and f.endswith(".zip")
1163-
]
1164-
1165-
if existing_zipfiles and existing_zipfiles[0] != zipfile_name:
1166-
self.console.print(
1167-
f"[yellow]WebUI zipfile name changed from {existing_zipfiles[0]} to {zipfile_name}, forcing rebuild[/yellow]"
1168-
)
1169-
# Remove old zipfile
1170-
for old_zip in existing_zipfiles:
1171-
old_path = os.path.join(".aws-sam", old_zip)
1172-
if os.path.exists(old_path):
1173-
os.remove(old_path)
1174-
1175-
zipfile_path = os.path.join(".aws-sam", zipfile_name)
1283+
_, zipfile_path = self.ui_changed()
11761284

11771285
if not os.path.exists(zipfile_path):
1178-
self.console.print("[bold cyan]PACKAGING src/ui[/bold cyan]")
1179-
self.console.print(f"[cyan]Zipping source to {zipfile_path}[/cyan]")
1180-
1286+
os.makedirs(".aws-sam", exist_ok=True)
11811287
with zipfile.ZipFile(zipfile_path, "w", zipfile.ZIP_DEFLATED) as zipf:
11821288
ui_dir = "src/ui"
11831289
exclude_dirs = {"node_modules", "build", ".aws-sam"}
11841290
for root, dirs, files in os.walk(ui_dir):
1185-
# Exclude specified directories from zipping
11861291
dirs[:] = [d for d in dirs if d not in exclude_dirs]
11871292
for file in files:
1188-
# Skip .env files
11891293
if file == ".env" or file.startswith(".env."):
11901294
continue
11911295
file_path = os.path.join(root, file)
11921296
arcname = os.path.relpath(file_path, ui_dir)
11931297
zipf.write(file_path, arcname)
11941298

11951299
# Check if file exists in S3 and upload if needed
1300+
zipfile_name = os.path.basename(zipfile_path)
11961301
s3_key = f"{self.prefix_and_version}/{zipfile_name}"
11971302
try:
11981303
self.s3_client.head_object(Bucket=self.bucket, Key=s3_key)
@@ -1259,10 +1364,6 @@ def build_main_template(self, webui_zipfile, components_needing_rebuild):
12591364
# Main template needs rebuilding, if any component needs rebuilding
12601365
if components_needing_rebuild:
12611366
self.console.print("[yellow]Main template needs rebuilding[/yellow]")
1262-
1263-
# Validate UI build before rebuilding
1264-
self.validate_ui_build()
1265-
12661367
# Validate Python syntax in src directory before building
12671368
if not self._validate_python_syntax("src"):
12681369
raise Exception("Python syntax validation failed")
@@ -1687,6 +1788,32 @@ def _validate_python_syntax(self, directory):
16871788
return False
16881789
return True
16891790

1791+
def _validate_python_linting(self):
1792+
"""Validate Python linting"""
1793+
if not self.lint_enabled:
1794+
return True
1795+
1796+
self.console.print("[cyan]🔍 Running Python linting...[/cyan]")
1797+
1798+
# Run ruff check (same as GitLab CI lint-cicd)
1799+
result = subprocess.run(["ruff", "check"], capture_output=True, text=True)
1800+
if result.returncode != 0:
1801+
self.console.print("[red]❌ Ruff linting failed![/red]")
1802+
self.console.print(result.stdout, style="red", markup=False)
1803+
return False
1804+
1805+
# Run ruff format check (same as GitLab CI lint-cicd)
1806+
result = subprocess.run(
1807+
["ruff", "format", "--check"], capture_output=True, text=True
1808+
)
1809+
if result.returncode != 0:
1810+
self.console.print("[red]❌ Code formatting check failed![/red]")
1811+
self.console.print(result.stdout, style="red", markup=False)
1812+
return False
1813+
1814+
self.console.print("[green]✅ Python linting passed[/green]")
1815+
return True
1816+
16901817
def build_lib_package(self):
16911818
"""Build lib package with syntax validation"""
16921819
try:
@@ -1856,12 +1983,19 @@ def run(self, args):
18561983
# Check prerequisites
18571984
self.check_prerequisites()
18581985

1986+
# Validate Python linting if enabled
1987+
if not self._validate_python_linting():
1988+
raise Exception("Python linting validation failed")
1989+
18591990
# Set up S3 bucket
18601991
self.setup_artifacts_bucket()
18611992

18621993
# Perform smart rebuild detection and cache management
18631994
components_needing_rebuild = self.smart_rebuild_detection()
18641995

1996+
# Start UI validation early in parallel
1997+
ui_validation_future, ui_executor = self.start_ui_validation_parallel()
1998+
18651999
# clear component cache
18662000
for comp_info in components_needing_rebuild:
18672001
if comp_info["component"] != "lib": # lib doesnt have sam build
@@ -1934,7 +2068,24 @@ def run(self, args):
19342068
# Upload configuration library
19352069
self.upload_config_library()
19362070

1937-
# Package UI
2071+
# Wait for UI validation to complete if it was started
2072+
if ui_validation_future:
2073+
try:
2074+
self.console.print(
2075+
"[cyan]⏳ Waiting for UI validation to complete...[/cyan]"
2076+
)
2077+
ui_validation_future.result()
2078+
self.console.print(
2079+
"[green]✅ UI validation completed successfully[/green]"
2080+
)
2081+
except Exception as e:
2082+
self.console.print("[red]❌ UI validation failed:[/red]")
2083+
self.console.print(str(e), style="red", markup=False)
2084+
sys.exit(1)
2085+
finally:
2086+
ui_executor.shutdown(wait=True)
2087+
2088+
# Package UI and start validation in parallel if needed
19382089
webui_zipfile = self.package_ui()
19392090

19402091
# Build main template

0 commit comments

Comments
 (0)