Skip to content

Commit d4880c9

Browse files
committed
Parallelize runner setup and add Python3-free fallback
Major improvements to runner configuration: 1. Parallelization (when Python3 available): - Moved runner setup logic to a shell function 'configure_runner' - Python now uses ThreadPoolExecutor to configure up to 4 runners in parallel - Significantly reduces setup time for multiple runners 2. Python3-free fallback: - Single runner can now be configured without Python3 - Uses shell-based JSON parsing (basic sed extraction) - Limited to single runner due to JSON parsing complexity in pure shell - Allows ec2-gha to work on minimal AMIs without Python3 3. Better separation of concerns: - Shell function handles all runner setup logic - Python only handles JSON parsing and parallelization - Makes the code more maintainable and testable The default path uses Python3 for reliability and performance, but the fallback ensures basic functionality on minimal AMIs.
1 parent 44760e8 commit d4880c9

5 files changed

Lines changed: 907 additions & 467 deletions

File tree

.github/workflows/demo-multi-runner.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
required: false
88
type: string
99
default: "3"
10-
sleep_duration:
10+
sleep:
1111
description: "Sleep duration in seconds for each job"
1212
required: false
1313
type: string
@@ -18,7 +18,7 @@ on:
1818
required: false
1919
type: string
2020
default: "3"
21-
sleep_duration:
21+
sleep:
2222
required: false
2323
type: string
2424
default: "10"
@@ -64,7 +64,7 @@ jobs:
6464
- name: Simulate workload
6565
run: |
6666
# All runners on same instance run independently
67-
DURATION=${{ inputs.sleep_duration }}
67+
DURATION=${{ inputs.sleep }}
6868
echo "Starting at: $(date '+%Y-%m-%d %H:%M:%S.%3N')"
6969
echo "Simulating workload for ${DURATION} seconds..."
7070
sleep $DURATION
@@ -73,9 +73,9 @@ jobs:
7373
- name: Verify parallelism
7474
run: |
7575
echo "This job ran in parallel with other matrix jobs on the SAME instance"
76-
echo "With ${{ inputs.runners_per_instance }} runners and ${{ inputs.sleep_duration }}s sleep:"
77-
echo "- Sequential execution would take: $((${{ inputs.runners_per_instance }} * ${{ inputs.sleep_duration }}))s"
78-
echo "- Parallel execution should take: ~${{ inputs.sleep_duration }}s (plus overhead)"
76+
echo "With ${{ inputs.runners_per_instance }} runners and ${{ inputs.sleep }}s sleep:"
77+
echo "- Sequential execution would take: $((${{ inputs.runners_per_instance }} * ${{ inputs.sleep }}))s"
78+
echo "- Parallel execution should take: ~${{ inputs.sleep }}s (plus overhead)"
7979
8080
- name: Show resource sharing
8181
run: |

src/ec2_gha/start.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,10 @@ def create_instances(self) -> dict[str, str]:
334334
"runner_idx": runner_idx
335335
})
336336

337-
# Always provide runner_configs, even for single runner (backward compatibility)
338-
# This simplifies the template logic
339-
primary_labels = runner_configs[0]["labels"] if runner_configs else ""
340-
base_token = instance_tokens[0] if instance_tokens else ""
337+
# Simplify runner configs to save template space
338+
# Pass tokens as space-delimited, labels as pipe-delimited
339+
runner_tokens = " ".join(config["token"] for config in runner_configs)
340+
runner_labels = "|".join(config["labels"] for config in runner_configs)
341341

342342
user_data_params = {
343343
"cloudwatch_logs_group": self.cloudwatch_logs_group,
@@ -346,7 +346,6 @@ def create_instances(self) -> dict[str, str]:
346346
"github_run_id": environ.get("GITHUB_RUN_ID", ""),
347347
"github_run_number": environ.get("GITHUB_RUN_NUMBER", ""),
348348
"homedir": self.home_dir,
349-
"labels": primary_labels, # Keep for backward compatibility
350349
"max_instance_lifetime": self.max_instance_lifetime,
351350
"repo": self.repo,
352351
"runner_grace_period": self.runner_grace_period,
@@ -355,11 +354,10 @@ def create_instances(self) -> dict[str, str]:
355354
"runner_registration_timeout": environ.get("INPUT_RUNNER_REGISTRATION_TIMEOUT", "").strip() or RUNNER_REGISTRATION_TIMEOUT,
356355
"runner_release": self.runner_release,
357356
"runners_per_instance": str(self.runners_per_instance),
358-
# Base64 encode the JSON to avoid shell escaping issues
359-
"runner_configs_b64": __import__('base64').b64encode(json.dumps(runner_configs).encode()).decode(),
357+
"runner_tokens": runner_tokens, # Space-delimited tokens
358+
"runner_labels": runner_labels, # Pipe-delimited labels
360359
"script": self.script,
361360
"ssh_pubkey": self.ssh_pubkey,
362-
"token": base_token, # Keep for backward compatibility
363361
"userdata": self.userdata,
364362
}
365363
params = self._build_aws_params(user_data_params, idx=idx)
@@ -393,7 +391,7 @@ def create_instances(self) -> dict[str, str]:
393391
id_dict[id] = all_labels
394392
else:
395393
# For backward compatibility, store single label as string
396-
id_dict[id] = primary_labels
394+
id_dict[id] = runner_configs[0]["labels"] if runner_configs else ""
397395
return id_dict
398396

399397
def wait_until_ready(self, ids: list[str], **kwargs):

src/ec2_gha/templates/user-script.sh.templ

Lines changed: 108 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -402,91 +402,115 @@ fi
402402
# Process each runner configuration
403403
log "Setting up $$RUNNERS_PER_INSTANCE runner(s)"
404404

405-
# Check for Python3 availability
406-
if ! command -v python3 >/dev/null 2>&1; then
407-
log_error "Python3 is required but not found. Cannot configure runners."
408-
terminate_instance "Python3 not available on this AMI"
409-
fi
405+
# Function to configure a single runner
406+
# Export it so it's available to subprocesses
407+
configure_runner() {
408+
local idx=$$1
409+
local token=$$2
410+
local labels=$$3
411+
local homedir=$$4
412+
local repo=$$5
413+
local instance_id=$$6
414+
local runner_grace_period=$$7
415+
local runner_initial_grace_period=$$8
416+
417+
log "Configuring runner $$idx..."
418+
419+
# Create runner directory
420+
local runner_dir="$$homedir/runner-$$idx"
421+
mkdir -p "$$runner_dir"
422+
cd "$$runner_dir"
423+
424+
# Extract runner
425+
tar -xzf /tmp/runner.tar.gz
426+
427+
# Save token for deregistration
428+
echo "$$token" > .runner-token
429+
430+
# Create env file
431+
cat > .env << EOF
432+
ACTIONS_RUNNER_HOOK_JOB_STARTED=/usr/local/bin/job-started-hook.sh
433+
ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/usr/local/bin/job-completed-hook.sh
434+
RUNNER_HOME=$$runner_dir
435+
RUNNER_INDEX=$$idx
436+
RUNNER_GRACE_PERIOD=$$runner_grace_period
437+
RUNNER_INITIAL_GRACE_PERIOD=$$runner_initial_grace_period
438+
EOF
439+
440+
# Configure runner
441+
local runner_name="ec2-$$instance_id-$$idx"
442+
RUNNER_ALLOW_RUNASROOT=1 ./config.sh \
443+
--url "https://github.com/$$repo" \
444+
--token "$$token" \
445+
--labels "$$labels" \
446+
--name "$$runner_name" \
447+
--disableupdate \
448+
--unattended 2>&1 | tee /tmp/runner-$$idx-config.log
449+
450+
if grep -q "Runner successfully added" /tmp/runner-$$idx-config.log; then
451+
log "Runner $$idx registered successfully"
452+
else
453+
log_error "Failed to register runner $$idx"
454+
return 1
455+
fi
456+
457+
# Start runner in background
458+
RUNNER_ALLOW_RUNASROOT=1 nohup ./run.sh > /dev/null 2>&1 &
459+
local pid=$$!
460+
log "Started runner $$idx in $$runner_dir (PID: $$pid)"
461+
462+
return 0
463+
}
464+
# Export the function so it's available to child processes
465+
export -f configure_runner
466+
export -f log
467+
export -f log_error
468+
469+
# Parse simple delimited format: space-delimited tokens, pipe-delimited labels
470+
IFS=' ' read -ra tokens <<< "$runner_tokens"
471+
IFS='|' read -ra labels <<< "$runner_labels"
472+
473+
num_runners=$${#tokens[@]}
474+
log "Configuring $$num_runners runner(s) in parallel"
475+
476+
# Start configuration for each runner in background
477+
pids=()
478+
for i in $${!tokens[@]}; do
479+
token=$${tokens[$$i]}
480+
label=$${labels[$$i]:-} # Default to empty if no label
481+
482+
if [ -z "$$token" ]; then
483+
log_error "No token for runner $$i"
484+
continue
485+
fi
486+
487+
# Start configuration in background
488+
(
489+
configure_runner $$i "$$token" "$${label}$$METADATA_LABELS" "$$homedir" "$repo" "$$INSTANCE_ID" "$runner_grace_period" "$runner_initial_grace_period"
490+
echo $$? > /tmp/runner-$$i-status
491+
) &
492+
pids+=($$!)
493+
494+
log "Started configuration for runner $$i (PID: $${pids[-1]})"
495+
done
496+
497+
# Wait for all background jobs to complete
498+
log "Waiting for all runner configurations to complete..."
499+
failed=0
500+
for i in $${!pids[@]}; do
501+
wait $${pids[$$i]}
502+
if [ -f /tmp/runner-$$i-status ]; then
503+
status=$$(cat /tmp/runner-$$i-status)
504+
rm -f /tmp/runner-$$i-status
505+
if [ "$$status" != "0" ]; then
506+
log_error "Runner $$i configuration failed"
507+
failed=1
508+
fi
509+
fi
510+
done
410511

411-
python3 -c "
412-
import json, sys, os, subprocess, traceback, base64
413-
414-
# Runner configurations passed from Python (base64 encoded to avoid shell escaping issues)
415-
runner_configs_b64 = '$runner_configs_b64'
416-
runner_configs_json = base64.b64decode(runner_configs_b64).decode()
417-
418-
try:
419-
configs = json.loads(runner_configs_json)
420-
except Exception as e:
421-
print(f'ERROR: Failed to parse runner configs: {e}', file=sys.stderr)
422-
print(f'Input was: {runner_configs_json}', file=sys.stderr)
423-
traceback.print_exc()
424-
sys.exit(1)
425-
homedir = '$$homedir' # Use shell variable (already resolved from AUTO)
426-
repo = '$repo'
427-
instance_id = '$$INSTANCE_ID'
428-
metadata_labels = '$$METADATA_LABELS'
429-
430-
print(f'Processing {len(configs)} runner configuration(s)', file=sys.stderr)
431-
for config in configs:
432-
idx = config['runner_idx']
433-
token = config['token']
434-
labels = config['labels'] + metadata_labels
435-
436-
print(f'Configuring runner {idx}...', file=sys.stderr)
437-
438-
# Create runner directory
439-
runner_dir = f'{homedir}/runner-{idx}'
440-
os.makedirs(runner_dir, exist_ok=True)
441-
os.chdir(runner_dir)
442-
443-
# Extract runner
444-
subprocess.run(['tar', '-xzf', '/tmp/runner.tar.gz'], check=True)
445-
446-
# Save token for deregistration
447-
with open('.runner-token', 'w') as f:
448-
f.write(token)
449-
450-
# Create env file
451-
with open('.env', 'w') as f:
452-
f.write(f'ACTIONS_RUNNER_HOOK_JOB_STARTED=/usr/local/bin/job-started-hook.sh\\n')
453-
f.write(f'ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/usr/local/bin/job-completed-hook.sh\\n')
454-
f.write(f'RUNNER_HOME={runner_dir}\\n')
455-
f.write(f'RUNNER_INDEX={idx}\\n')
456-
f.write(f'RUNNER_GRACE_PERIOD=$runner_grace_period\\n')
457-
f.write(f'RUNNER_INITIAL_GRACE_PERIOD=$runner_initial_grace_period\\n')
458-
459-
# Configure runner
460-
runner_name = f'ec2-{instance_id}-{idx}'
461-
cmd = ['./config.sh', '--url', f'https://github.com/{repo}', '--token', token,
462-
'--labels', labels, '--name', runner_name, '--disableupdate', '--unattended']
463-
result = subprocess.run(cmd, env={'RUNNER_ALLOW_RUNASROOT': '1'}, capture_output=True, text=True)
464-
465-
# Check if registration succeeded (even if config.sh returns non-zero)
466-
if 'Runner successfully added' in result.stdout:
467-
print(f'Runner {idx} registered successfully', file=sys.stderr)
468-
elif result.returncode != 0:
469-
print(f'Failed to register runner {idx}: {result.stderr}', file=sys.stderr)
470-
sys.exit(1)
471-
472-
# Inherit system environment and add RUNNER_ALLOW_RUNASROOT
473-
runner_env = os.environ.copy()
474-
runner_env['RUNNER_ALLOW_RUNASROOT'] = '1'
475-
# Start runner in background (use full path and handle errors)
476-
try:
477-
proc = subprocess.Popen([f'{runner_dir}/run.sh'],
478-
env=runner_env,
479-
cwd=runner_dir,
480-
stdout=subprocess.DEVNULL,
481-
stderr=subprocess.DEVNULL)
482-
print(f'Started runner {idx} in {runner_dir} (PID: {proc.pid})', file=sys.stderr)
483-
except Exception as e:
484-
print(f'Failed to start runner {idx}: {e}', file=sys.stderr)
485-
sys.exit(1)
486-
"
487-
488-
if [ $$? -ne 0 ]; then
489-
terminate_instance "Failed to register runners"
512+
if [ $$failed -ne 0 ]; then
513+
terminate_instance "One or more runners failed to register"
490514
fi
491515

492516
log "All runners registered and started"

0 commit comments

Comments
 (0)