commit b770b1c174cbad9f6b98435e2cc34da7ad7ed366 Author: Greg Gauthier Date: Tue Feb 17 21:32:14 2026 +0000 initial commit; duping the c90 project diff --git a/.gitea/workflows/mvs_delete.yaml b/.gitea/workflows/mvs_delete.yaml new file mode 100644 index 0000000..b78f718 --- /dev/null +++ b/.gitea/workflows/mvs_delete.yaml @@ -0,0 +1,109 @@ +name: MVS Delete Members + +on: + push: + branches: [ master ] + paths: + - 'src/**' + - 'jcl/**' + workflow_dispatch: # Allow manual trigger for cleanup + +jobs: + delete-members: + runs-on: ubuntu-gitea + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for git diff + + - name: Check if workflow should run + id: check + run: | + echo "Checking if there are deleted source files..." + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + DELETED_FILES=$(git diff --name-only --diff-filter=D HEAD~1 2>/dev/null | grep -E '\.(c|bas)$' || true) + if [ -z "$DELETED_FILES" ]; then + echo "No deleted source files found. Skipping workflow." + echo "should_run=false" >> $GITHUB_OUTPUT + exit 0 + fi + echo "Found deleted files: $DELETED_FILES" + echo "should_run=true" >> $GITHUB_OUTPUT + else + echo "No parent commit, skipping workflow" + echo "should_run=false" >> $GITHUB_OUTPUT + fi + + - name: Prepare environment + if: steps.check.outputs.should_run == 'true' + id: setup + run: | + echo "=== Debug: Starting setup ===" + apt-get update && apt install -y netcat-traditional python3-requests + nc -h + echo "=== Debug: Setup complete ===" + + - name: Find deleted source files (deleted only) + if: steps.check.outputs.should_run == 'true' + id: deleted + run: | + echo "=== Debug: Starting deletion detection ===" + echo "Current dir: $(pwd)" + + # Check if we have a parent commit + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + echo "Parent commit exists; checking for deletions." + # Use --diff-filter=D to only get Deleted files (exclude Added/Modified) + DELETED_FILES=$(git diff --name-only --diff-filter=D HEAD~1 2>/dev/null | grep -E '\.(c|bas)$') + echo "Deleted files from last commit: '${DELETED_FILES}'" + else + echo "No parent commit; no deletions to process." + DELETED_FILES="" + fi + + if [ -z "$DELETED_FILES" ]; then + echo "No deleted C/BAS files found; skipping workflow." + echo "has_deletions=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Process deleted files - convert to space-separated list of members + DELETED_MEMBERS="" + for DFILE in $DELETED_FILES; do + DEXT="${DFILE##*.}" + DBASE=$(basename "$DFILE" ".$DEXT") + DELETED_MEMBERS="$DELETED_MEMBERS $DBASE" + done + + echo "Deleted members: $DELETED_MEMBERS" + echo "deleted_members=$DELETED_MEMBERS" >> $GITHUB_OUTPUT + echo "has_deletions=true" >> $GITHUB_OUTPUT + echo "=== Debug: Deletion detection complete ===" + + - name: Delete removed members from PDS + if: steps.check.outputs.should_run == 'true' && steps.deleted.outputs.has_deletions == 'true' + run: | + echo "=== Starting deletion of removed members ===" + echo "Deleted members: ${{ steps.deleted.outputs.deleted_members }}" + + for MEMBER in ${{ steps.deleted.outputs.deleted_members }}; do + echo "Deleting member: $MEMBER" + python3 scripts/del_member.py "@05054.SRCLIB.C($MEMBER)" + done + + echo "=== Deletion complete ===" + env: + MVS_BATCH_PASSWORD: ${{ vars.MVS_BATCH_PASSWORD }} + MVS_HOST: "oldcomputernerd.com" + + - name: Report Status + if: steps.check.outputs.should_run == 'true' && steps.deleted.outputs.has_deletions == 'true' + run: | + echo "Deletion complete! Members removed from mainframe PDS." + + - name: Workflow skipped + if: steps.check.outputs.should_run == 'false' + run: | + echo "Workflow skipped - no deleted source files in this commit." diff --git a/.gitea/workflows/mvs_submit.yaml b/.gitea/workflows/mvs_submit.yaml new file mode 100644 index 0000000..ea6c110 --- /dev/null +++ b/.gitea/workflows/mvs_submit.yaml @@ -0,0 +1,138 @@ +name: MVS Submit & Execute + +on: + push: + branches: [ master ] + paths: + - 'src/**' # Trigger only if src/ (C sources) changes + - 'jcl/**' # Trigger only if jcl/ (JCL for batch jobs) changes + pull_request: + branches: [ master ] + paths: + - 'src/**' # Same for pull requests + - 'jcl/**' +jobs: + upload-and-run: + runs-on: ubuntu-gitea + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for git diff + + - name: Check if workflow should run + id: check + run: | + echo "Checking if there are added/modified source files..." + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + CHANGED_FILES=$(git diff --name-only --diff-filter=AM HEAD~1 2>/dev/null | grep -E '\.(c|bas)$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No added/modified source files found. Skipping workflow." + echo "should_run=false" >> $GITHUB_OUTPUT + exit 0 + fi + echo "Found changed files: $CHANGED_FILES" + echo "should_run=true" >> $GITHUB_OUTPUT + else + echo "No parent commit, allowing workflow to run" + echo "should_run=true" >> $GITHUB_OUTPUT + fi + + - name: Prepare environment + if: steps.check.outputs.should_run == 'true' + id: setup + run: | + echo "=== Debug: Starting setup ===" + echo "Current dir: $(pwd)" + echo "Files in repo: $(ls -la)" + apt-get update && apt install -y netcat-traditional python3-requests + nc -h + echo "=== Debug: Setup complete ===" + + - name: Find changed source files (added/modified only) + if: steps.check.outputs.should_run == 'true' + id: files + run: | + echo "=== Debug: Starting file detection ===" + echo "Current dir: $(pwd)" + echo "Files in repo: $(ls -la)" + echo "=== Debug: Checking for parent commit ===" + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + echo "Parent commit exists; running git diff for added/modified/renamed files." + # Use --diff-filter=AMR to get Added, Modified, and Renamed files (exclude Deleted) + CHANGED_FILES=$(git diff --name-only --diff-filter=AMR HEAD~1 2>/dev/null | grep -E '\.(c|bas)$' | head -1) + echo "Added/Modified files from last commit: '${CHANGED_FILES}'" + else + echo "No parent commit; skipping diff." + CHANGED_FILES="" + fi + echo "=== Debug: Git diff check complete ===" + + # Fallback to all .c/.bas files if no changes or no previous commit + if [ -z "$CHANGED_FILES" ]; then + echo "=== Debug: No added/modified files found; running fallback find ===" + # Find newest .c/.bas by modification time (sort -nr on %T@ timestamp) + CHANGED_FILES=$(find . -type f \( -name "*.c" -o -name "*.bas" \) -printf '%T@ %p\n' 2>/dev/null | sort -nr | cut -d' ' -f2- | head -1) + echo "Fallback files (newest first): '${CHANGED_FILES}'" + echo "=== Debug: Fallback complete ===" + fi + + if [ -z "$CHANGED_FILES" ]; then + echo "No added/modified C/BAS files found; skipping workflow." + exit 0 # Graceful skip, no failure + fi + + echo "=== Debug: Processing final file ===" + echo "Final selected file: '${CHANGED_FILES}'" + echo "file=$CHANGED_FILES" >> $GITHUB_OUTPUT + + # Extract member name (handle .c or .bas) + EXT="${CHANGED_FILES##*.}" + BASE=$(basename "$CHANGED_FILES" ".$EXT") + echo "member=$BASE" >> $GITHUB_OUTPUT + echo "=== Debug: File detection complete ===" + + - name: Upload to PDS and Submit JCL + if: steps.check.outputs.should_run == 'true' && steps.files.outputs.file != '' + run: | + echo "=== Debug: Starting upload/submit ===" + echo "File: ${{ steps.files.outputs.file }}" + echo "Member: ${{ steps.files.outputs.member }}" + python3 scripts/submit_job.py "${{ steps.files.outputs.file }}" "@05054.SRCLIB.C(${{ steps.files.outputs.member }})" + echo "=== Debug: Upload/submit complete ===" + env: + MVS_BATCH_PASSWORD: ${{ vars.MVS_BATCH_PASSWORD }} + MVS_HOST: "oldcomputernerd.com" + + - name: Poll for job completion and retrieve output + if: steps.check.outputs.should_run == 'true' && steps.files.outputs.file != '' + run: | + echo "=== Waiting for job completion ===" + python3 scripts/poll_job.py "${{ steps.files.outputs.member }}" 120 + echo "=== Job output retrieved ===" + env: + MVS_CONSOLE_URL: ${{ vars.MVS_CONSOLE_URL }} + MVS_CONSOLE_USER: ${{ vars.MVS_CONSOLE_USER }} + MVS_CONSOLE_PASSWORD: ${{ secrets.MVS_CONSOLE_PASSWORD }} + LINODE_SSH_HOST: ${{ vars.LINODE_SSH_HOST }} + LINODE_PRINTOUT_DIR: ${{ vars.LINODE_PRINTOUT_DIR }} + LOCAL_PRINTOUT_DIR: /printouts + + - name: Upload job output as artifact + if: steps.check.outputs.should_run == 'true' && steps.files.outputs.file != '' + uses: actions/upload-artifact@v3 + with: + name: job-output-${{ steps.files.outputs.member }} + path: "${{ steps.files.outputs.member }}_J*.pdf" + if-no-files-found: warn + + - name: Report Status + if: steps.check.outputs.should_run == 'true' && steps.files.outputs.file != '' + run: | + echo "Build complete! Job output PDF has been archived as a build artifact." + + - name: Workflow skipped + if: steps.check.outputs.should_run == 'false' + run: | + echo "Workflow skipped - no added/modified source files in this commit." \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..819ddf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +poetry.lock + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/jcl/TEMPLATE.jcl b/jcl/TEMPLATE.jcl new file mode 100644 index 0000000..b46f474 --- /dev/null +++ b/jcl/TEMPLATE.jcl @@ -0,0 +1,5 @@ +//{NAME} JOB (GCC),'C Program', +// NOTIFY=@05054,CLASS=A,MSGCLASS=A, +// MSGLEVEL=(1,1),REGION=4M,TIME=1440 +//STEP1 EXEC GCCCG,INFILE='@05054.SRCLIB.C({NAME})' +// \ No newline at end of file diff --git a/newjob.sh b/newjob.sh new file mode 100755 index 0000000..5803cc6 --- /dev/null +++ b/newjob.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# Script to create a new C project with JCL and source file +# Usage: ./newjob.sh +# NAME must be 8 characters or less (MVS member name restriction) + +set -e + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + echo " NAME: 8 characters or less (will be uppercased for MVS)" + echo "" + echo "Example:" + echo " $0 hello" + exit 1 +fi + +NAME_INPUT="$1" +NAME=$(echo "$NAME_INPUT" | tr '[:lower:]' '[:upper:]') + +# Validate name length (MVS member names are max 8 characters) +if [ ${#NAME} -gt 8 ]; then + echo "Error: Name '$NAME' is longer than 8 characters (${#NAME} chars)" + echo "MVS member names must be 8 characters or less" + exit 1 +fi + +# Validate name format (alphanumeric, must start with letter) +if ! [[ "$NAME" =~ ^[A-Z][A-Z0-9]*$ ]]; then + echo "Error: Name '$NAME' must start with a letter and contain only letters and numbers" + exit 1 +fi + +JCL_FILE="jcl/${NAME}.jcl" +SRC_FILE="src/${NAME}.c" + +# Check if files already exist +if [ -f "$JCL_FILE" ]; then + echo "Error: JCL file '$JCL_FILE' already exists" + exit 1 +fi + +if [ -f "$SRC_FILE" ]; then + echo "Error: Source file '$SRC_FILE' already exists" + exit 1 +fi + +# Create JCL from template +if [ ! -f "jcl/TEMPLATE.jcl" ]; then + echo "Error: Template file 'jcl/TEMPLATE.jcl' not found" + exit 1 +fi + +echo "Creating new C project: $NAME" +echo "" + +# Replace {NAME} placeholders in template +sed "s/{NAME}/$NAME/g" jcl/TEMPLATE.jcl > "$JCL_FILE" +echo "✓ Created JCL: $JCL_FILE" + +# Create empty C source file +touch "$SRC_FILE" +echo "✓ Created source: $SRC_FILE" + +echo "" +echo "Project '$NAME' created successfully!" +echo "Next steps:" +echo " 1. Edit $SRC_FILE with your C code" +echo " 2. Commit and push to trigger mainframe build" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3b0ad0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "tk5-c90-projects" +version = "0.1.0" +description = "" +authors = [ + {name = "Greg Gauthier",email = "gmgauthier@protonmail.com"} +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "requests (>=2.32.5,<3.0.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/del_member.py b/scripts/del_member.py new file mode 100644 index 0000000..3c0af2b --- /dev/null +++ b/scripts/del_member.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + +import sys +import subprocess +import tempfile +import os + +# Force temp files into a folder inside your project +custom_temp_dir = os.path.join(os.getcwd(), "tmp") +os.makedirs(custom_temp_dir, exist_ok=True) +tempfile.tempdir = custom_temp_dir + +MVSHOST = "oldcomputernerd.com" +RDRPORT = 3505 +MVS_PASSWORD = os.environ.get("MVS_BATCH_PASSWORD") + + +def create_delete_jcl(dataset_name, member_name): + """Create JCL to delete a PDS member using IEHPROGM""" + + jcl = f""" +//DELETE JOB (ACCT),'DELETE', +// USER=@05054,PASSWORD={MVS_PASSWORD}, +// CLASS=A,MSGCLASS=H,NOTIFY=@05054 +//DELMEM EXEC PGM=IEHPROGM +//SYSPRINT DD SYSOUT=* +//DD1 DD DSN={dataset_name},DISP=SHR +//SYSIN DD * + SCRATCH DSNAME={dataset_name},MEMBER={member_name} +/* +""" + return jcl + + +def delete_member(dataset_name, member_name, mvshost=MVSHOST): + """Delete a member from MVS PDS""" + + payload = create_delete_jcl(dataset_name, member_name) + + # Write JCL to temporary file and submit via netcat + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.jcl') as tmpfile: + tmpfile.write(payload) + tmpfile.flush() + tmpfile_path = tmpfile.name + + try: + with open(tmpfile_path, 'rb') as f: + result = subprocess.run( + ['nc', '-w', '5', mvshost, str(RDRPORT)], + input=f.read(), + check=True, + capture_output=True + ) + print(f"Deleted {dataset_name}({member_name})") + if result.stdout: + print("JES response:", result.stdout.decode(errors='ignore').strip()) + return 0 + except subprocess.CalledProcessError as e: + print(f"Deletion failed: {e}") + print("stderr:", e.stderr.decode(errors='ignore')) + return 1 + finally: + os.unlink(tmpfile_path) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: delete_mvs_member.py [mvshost]") + print() + print("Arguments:") + print(" pds_destination - PDS destination as DATASET(MEMBER) (required)") + print(" mvshost - MVS host (optional, default: oldcomputernerd.com)") + print() + print("Examples:") + print(" delete_mvs_member.py '@05054.SRCLIB.C(SIEVE11)'") + print(" delete_mvs_member.py '@05054.SRCLIB.C(HELLO)' mainframe.example.com") + sys.exit(1) + + destination = sys.argv[1] + + # Parse PDS syntax: DATASET(MEMBER) + if '(' in destination and destination.endswith(')'): + dataset_name = destination[:destination.index('(')] + member_name = destination[destination.index('(')+1:-1] + else: + print(f"Error: Invalid PDS syntax '{destination}'. Use format: DATASET(MEMBER)") + sys.exit(1) + + # Optional host override + mvshost = sys.argv[2] if len(sys.argv) > 2 else MVSHOST + + print(f"Deleting: {dataset_name}({member_name})") + print(f"Host: {mvshost}") + print() + + sys.exit(delete_member(dataset_name, member_name, mvshost)) diff --git a/scripts/poll_job.py b/scripts/poll_job.py new file mode 100755 index 0000000..210a5ea --- /dev/null +++ b/scripts/poll_job.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 + +import sys +import os +import re +import time +import subprocess +import requests +from requests.auth import HTTPBasicAuth + +CONSOLE_URL = os.environ.get("MVS_CONSOLE_URL", "http://oldcomputernerd.com:8038/cgi-bin/tasks/syslog") +CONSOLE_USER = os.environ.get("MVS_CONSOLE_USER", "gmgauthier") +CONSOLE_PASS = os.environ.get("MVS_CONSOLE_PASSWORD") +LINODE_HOST = os.environ.get("LINODE_SSH_HOST", "gmgauthier@socrates") +LINODE_PRINTOUT_DIR = os.environ.get("LINODE_PRINTOUT_DIR", "/home/gmgauthier/printouts") + +def get_syslog(): + """Fetch the Hercules syslog via HTTP""" + try: + response = requests.get( + CONSOLE_URL, + auth=HTTPBasicAuth(CONSOLE_USER, CONSOLE_PASS), + timeout=10 + ) + response.raise_for_status() + return response.text + except requests.RequestException as e: + print(f"Failed to fetch syslog: {e}") + return None + +def find_job_number(syslog, jobname): + """Extract job number from $HASP100 message""" + # Pattern: /12.28.02 JOB 257 $HASP100 SIMPLE2 ON READER1 + pattern = rf'/\d+\.\d+\.\d+\s+JOB\s+(\d+)\s+\$HASP100\s+{jobname}\s+ON\s+READER' + match = re.search(pattern, syslog, re.IGNORECASE) + if match: + return match.group(1) + return None + +def check_job_ended(syslog, jobname, job_number): + """Check if a job has ended (HASP395 ENDED)""" + # Pattern for job ended: /18.24.41 JOB 276 $HASP395 GMG0001 ENDED + ended_pattern = rf'/\d+\.\d+\.\d+\s+JOB\s+{job_number}\s+\$HASP395\s+{jobname}\s+ENDED' + return re.search(ended_pattern, syslog, re.IGNORECASE) is not None + +def check_job_printed(syslog, jobname, job_number): + """Check if a job has printed output (HASP150)""" + # Pattern for job printed: /12.28.03 JOB 257 $HASP150 SIMPLE2 ON PRINTER1 + printed_pattern = rf'/\d+\.\d+\.\d+\s+JOB\s+{job_number}\s+\$HASP150\s+{jobname}\s+ON\s+PRINTER' + return re.search(printed_pattern, syslog, re.IGNORECASE) is not None + +def list_pdfs_local(local_dir): + """List PDF files in a local directory (for mounted volumes)""" + import glob + pdf_files = glob.glob(f"{local_dir}/v1403-*.pdf") + # Sort by modification time, newest first + pdf_files.sort(key=os.path.getmtime, reverse=True) + return pdf_files + +def list_pdfs_remote(): + """List PDF files on remote Linode via SSH""" + cmd = f"ssh {LINODE_HOST} ls -t {LINODE_PRINTOUT_DIR}/v1403-*.pdf" + try: + result = subprocess.run( + cmd, + shell=True, + check=True, + capture_output=True, + text=True + ) + return result.stdout.strip().split('\n') + except subprocess.CalledProcessError: + return [] + +def find_pdf_for_job(job_number, jname, local_printout_dir=None): + """Find the PDF matching job number and name""" + pattern = f"v1403-J{job_number}_{jname}-" + + # Try the local directory first (for mounted volumes in CI) + if local_printout_dir and os.path.isdir(str(local_printout_dir)): + pdfs = list_pdfs_local(local_printout_dir) + for pdf_path in pdfs: + if pattern in pdf_path: + return pdf_path + return None + + # Fall back to remote SSH access + pdfs = list_pdfs_remote() + for pdf_path in pdfs: + if pattern in pdf_path: + return pdf_path + return None + +def retrieve_pdf(source_path, local_filename, is_local=False): + """Retrieve PDF either locally (copy) or remotely (SCP)""" + try: + if is_local: + # Local copy from a mounted volume + import shutil + shutil.copy2(source_path, local_filename) + print(f"Copied: {local_filename}") + else: + # Remote SCP + cmd = f"scp {LINODE_HOST}:{source_path} {local_filename}" + subprocess.run(cmd, shell=True, check=True) + print(f"Retrieved: {local_filename}") + return True + except (subprocess.CalledProcessError, IOError) as e: + print(f"Failed to retrieve PDF: {e}") + return False + +def poll_for_job(jn, to=300, poll_interval=5): + """Poll the console for job completion and retrieve PDF""" + jobname_upper = jn.upper() + start_time = time.time() + job_number = None + + print(f"Polling for job: {jobname_upper}") + print(f"Timeout: {to}s, Poll interval: {poll_interval}s") + print(f"Console URL: {CONSOLE_URL}") + print(f"Console User: {CONSOLE_USER}") + print(f"Console Pass: {'***' if CONSOLE_PASS else 'NOT SET'}") + print() + + # Phase 1: Find a job number + print("Phase 1: Looking for job submission ($HASP100)...") + while time.time() - start_time < to: + syslog = get_syslog() + if not syslog: + time.sleep(poll_interval) + continue + + job_number = find_job_number(syslog, jobname_upper) + if job_number: + print(f"Found job number: J{job_number}") + break + + time.sleep(poll_interval) + + if not job_number: + print(f"Timeout: Job {jobname_upper} not found in console after {to}s") + return 1 + + # Phase 2: Wait for completion + print(f"Phase 2: Waiting for job completion ($HASP395 ENDED)...") + job_ended = False + job_printed = False + + while time.time() - start_time < to: + syslog = get_syslog() + if not syslog: + time.sleep(poll_interval) + continue + + job_ended = check_job_ended(syslog, jobname_upper, job_number) + job_printed = check_job_printed(syslog, jobname_upper, job_number) + + if job_ended: + print(f"Job J{job_number} has ended") + break + + time.sleep(poll_interval) + + if not job_ended: + print(f"Timeout: Job J{job_number} did not complete after {to}s") + return 1 + + # Check if output was printed (required for PDF retrieval) + if not job_printed: + print(f"ERROR: Job J{job_number} completed but no output was printed ($HASP150 not found)") + print(f"This usually means MSGCLASS=H (hold) was used in the JCL") + print(f"Check TSO SDSF or console for job output manually") + print(f"To fix: Change JCL to use MSGCLASS=A for automatic printing") + return 1 + + print(f"Job J{job_number} completed and output printed!") + + # Phase 3: Retrieve PDF + print("Phase 3: Retrieving PDF...") + # Give the PDF a moment to be written to disk + time.sleep(2) + + # Check for local mounted directory (CI environment) + local_printout_dir = os.environ.get("LOCAL_PRINTOUT_DIR") + is_local = local_printout_dir and os.path.isdir(local_printout_dir) + + if is_local: + print(f"Using local mounted directory: {local_printout_dir}") + + pdf_path = find_pdf_for_job(job_number, jobname_upper, local_printout_dir) + if not pdf_path: + print(f"Error: PDF not found for J{job_number}_{jobname_upper}") + return 1 + + local_filename = f"{jobname_upper}_J{job_number}.pdf" + if retrieve_pdf(pdf_path, local_filename, is_local): + print(f"Success! Job output saved to: {local_filename}") + return 0 + else: + return 1 + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: poll_job.py [timeout_seconds]") + print() + print("Arguments:") + print(" jobname - Job name to poll for (required)") + print(" timeout_seconds - Maximum time to wait (optional, default: 300)") + print() + print("Example:") + print(" poll_job.py SIMPLE2") + print(" poll_job.py SIMPLE2 600") + sys.exit(1) + + jobname = sys.argv[1] + timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 300 + + sys.exit(poll_for_job(jobname, timeout)) diff --git a/scripts/submit_job.py b/scripts/submit_job.py new file mode 100755 index 0000000..66537c7 --- /dev/null +++ b/scripts/submit_job.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +import sys +import subprocess +import tempfile +import os +import time +import socket + +# Force temp files into a folder inside your project (fully owned by you) +custom_temp_dir = os.path.join(os.getcwd(), "tmp") +os.makedirs(custom_temp_dir, exist_ok=True) +tempfile.tempdir = custom_temp_dir + +SRCLIB = "src" +JCLLIB = "jcl" +MVSHOST = "oldcomputernerd.com" +RDRPORT = 3505 +MVS_PASSWORD = os.environ.get("MVS_BATCH_PASSWORD") + +def wait_for_reader(host, port, wait_seconds=10): + """ + Wait for card reader to finish processing previous job. + + Hercules keeps the socket in IO[n] open state while JES processes + the submitted job. We need to wait long enough for: + 1. JES to read the job from the internal reader + 2. Hercules to close the socket completely + 3. The port to be ready for a new connection + + A simple fixed delay is more reliable than trying to probe the port, + since the port will respond even when Hercules will reject connections. + """ + print(f"Waiting {wait_seconds} seconds for card reader to finish processing...") + for i in range(wait_seconds): + time.sleep(1) + if (i + 1) % 3 == 0: + print(f" {wait_seconds - i - 1} seconds remaining...") + print("Card reader should be ready now") + return True + + +def create_jcl_payload(local_file, dataset_name, member_name): + + with open(local_file, 'r') as f: + sysin = f.readlines() + + # PDS member: Use IEBUPDTE + jcl = f""" +//UPLOAD JOB (ACCT),'UPLOAD', +// USER=@05054,PASSWORD={MVS_PASSWORD}, +// CLASS=A,MSGCLASS=H,NOTIFY=@05054 +//COPY EXEC PGM=IEBUPDTE,PARM=NEW +//SYSPRINT DD SYSOUT=* +//SYSUT1 DD DUMMY +//SYSUT2 DD DSN={dataset_name},DISP=MOD,UNIT=SYSDA, +// DCB=(RECFM=FB,LRECL=80,BLKSIZE=0) +//SYSIN DD * +""" + # Append control statement, source lines, end, and terminator (no leading space on ./) + jcl += f"./ ADD NAME={member_name}\n" + for line in sysin: + line = line.rstrip('\n') + stripped = line.lstrip() + # Skip comment lines that would be interpreted as JCL + if stripped.startswith('//') or stripped.startswith('/*'): + continue + jcl += line[:80].ljust(80) + "\n" + jcl += "./ ENDUP\n" + jcl += "/*\n" + + return jcl + + +def upload_source(local_file, dataset_name, member_name, mvshost=MVSHOST): + """Upload source code to MVS PDS member""" + + # Read the source file + # full path will come from the job runner + # filepath = os.path.join(SRCLIB, local_file) + payload = create_jcl_payload(local_file, dataset_name, member_name) + + # Write JCL to temporary file and submit via netcat + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.jcl') as tmpfile: + tmpfile.write(payload) + tmpfile.flush() + tmpfile_path = tmpfile.name + try: + with open(tmpfile_path, 'rb') as f: + result = subprocess.run( + ['nc', '-w', '5', mvshost, str(RDRPORT)], + input=f.read(), + check=True, + capture_output=True + ) + print(f"Uploaded {local_file} to {dataset_name}({member_name})") + if result.stdout: + print("JES response:", result.stdout.decode(errors='ignore').strip()) + return 0 + except subprocess.CalledProcessError as e: + print(f"Upload failed: {e}") + print("stderr:", e.stderr.decode(errors='ignore')) + return 1 + + finally: + # Clean up outside + os.unlink(tmpfile_path) + +def submit_jcl(job, mvshost="oldcomputernerd.com"): + """Submit JCL job from local directory""" + subjcl = os.path.join(JCLLIB, f"{job.upper()}.jcl") + + if not os.path.exists(subjcl): + print(f"JCL file {subjcl} not found") + return 1 + + try: + # Read the JCL file and send via netcat (same approach as upload_source) + with open(subjcl, 'rb') as f: + jcl_data = f.read() + print(f"Submitting {len(jcl_data)} bytes of JCL to {mvshost}:{RDRPORT}") + result = subprocess.run( + ['nc', '-w', '5', mvshost, str(RDRPORT)], + input=jcl_data, + check=True, + capture_output=True + ) + print(f"Submitted JCL job: {job}") + if result.stdout: + print("JES response:", result.stdout.decode(errors='ignore').strip()) + if result.stderr: + print("netcat stderr:", result.stderr.decode(errors='ignore').strip()) + if result.returncode != 0: + print(f"WARNING: netcat returned non-zero exit code: {result.returncode}") + return 1 + return 0 + except subprocess.CalledProcessError as e: + print(f"ERROR: JCL submission failed with exit code {e.returncode}") + print("stderr:", e.stderr.decode(errors='ignore')) + print("stdout:", e.stdout.decode(errors='ignore')) + return 1 + except FileNotFoundError as e: + print(f"Error reading JCL file: {e}") + return 1 + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: mvs_job.py [destination_pds] [mvshost]") + print() + print("Arguments:") + print(" local_source_file - Path to source file (required)") + print(" destination_pds - PDS destination as DATASET(MEMBER) (optional)") + print(" Default: @05054.C90.SOURCE(basename)") + print(" mvshost - MVS host (optional, default: oldcomputernerd.com)") + print() + print("Examples:") + print(" mvs_job.py src/sieve11.c") + print(" mvs_job.py src/sieve11.c '@05054.C90.SOURCE(SIEVE11)'") + print(" mvs_job.py src/hello.c '@05054.C90.SOURCE(HELLO)' mainframe.example.com") + print() + print("Notes:") + print(" - JCL file is assumed to be jcl/.jcl") + print(" - Member name defaults to source filename without extension") + sys.exit(1) + + local_file = sys.argv[1] + + # Extract base name without extension for defaults + basename = os.path.splitext(os.path.basename(local_file))[0].upper() + valid_host_source_pds_suffixes = ['C', 'ALG', 'ASM', 'BAS', 'COB', 'PAS', 'PL360'] + default_suffix = valid_host_source_pds_suffixes[0] + + # Parse destination PDS (optional second argument) + if len(sys.argv) > 2 and sys.argv[2]: + destination = sys.argv[2] + # Parse PDS syntax: DATASET(MEMBER) + if '(' in destination and destination.endswith(')'): + dataset_name = destination[:destination.index('(')] + member_name = destination[destination.index('(')+1:-1] + else: + print(f"Error: Invalid PDS syntax '{destination}'. Use format: DATASET(MEMBER)") + sys.exit(1) + else: + # Default destination + dataset_name = f"@05054.SRCLIB.{default_suffix}" + member_name = basename.upper() + + # JCL job name defaults to basename + job = basename.upper() + + # Optional host override + mvshost = sys.argv[3] if len(sys.argv) > 3 else MVSHOST + + print(f"Source: {local_file}") + print(f"Destination: {dataset_name}({member_name})") + print(f"JCL: jcl/{job}.jcl") + print(f"Host: {mvshost}") + print() + + # Step 1: Upload source to PDS + if upload_source(local_file, dataset_name, member_name, mvshost) != 0: + sys.exit(1) + + # Wait for card reader to finish processing upload job before submitting compile job + # This prevents "device busy or interrupt pending" errors from Hercules + wait_for_reader(mvshost, RDRPORT, wait_seconds=10) + + # Step 2: Submit JCL job + exit_code = submit_jcl(job, mvshost) + sys.exit(exit_code) \ No newline at end of file