initial commit; duping the c90 project
This commit is contained in:
commit
b770b1c174
109
.gitea/workflows/mvs_delete.yaml
Normal file
109
.gitea/workflows/mvs_delete.yaml
Normal file
@ -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."
|
||||||
138
.gitea/workflows/mvs_submit.yaml
Normal file
138
.gitea/workflows/mvs_submit.yaml
Normal file
@ -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."
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
poetry.lock
|
||||||
|
|
||||||
5
jcl/TEMPLATE.jcl
Normal file
5
jcl/TEMPLATE.jcl
Normal file
@ -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})'
|
||||||
|
//
|
||||||
69
newjob.sh
Executable file
69
newjob.sh
Executable file
@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to create a new C project with JCL and source file
|
||||||
|
# Usage: ./newjob.sh <NAME>
|
||||||
|
# NAME must be 8 characters or less (MVS member name restriction)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <NAME>"
|
||||||
|
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"
|
||||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@ -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"
|
||||||
96
scripts/del_member.py
Normal file
96
scripts/del_member.py
Normal file
@ -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 <pds_destination> [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))
|
||||||
218
scripts/poll_job.py
Executable file
218
scripts/poll_job.py
Executable file
@ -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 <jobname> [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))
|
||||||
210
scripts/submit_job.py
Executable file
210
scripts/submit_job.py
Executable file
@ -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 <local_source_file> [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/<basename>.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)
|
||||||
Loading…
Reference in New Issue
Block a user