diff --git a/.gitea/workflows/mvs_delete.yaml b/.gitea/workflows/mvs_delete.yaml new file mode 100644 index 0000000..523f512 --- /dev/null +++ b/.gitea/workflows/mvs_delete.yaml @@ -0,0 +1,83 @@ +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: Prepare environment + 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 + 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." + 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.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.deleted.outputs.has_deletions == 'true' }} + run: | + echo "Deletion complete! Members removed from mainframe PDS." diff --git a/.gitea/workflows/mvs_job.yaml b/.gitea/workflows/mvs_submit.yaml similarity index 93% rename from .gitea/workflows/mvs_job.yaml rename to .gitea/workflows/mvs_submit.yaml index 94c4c21..640f4f4 100644 --- a/.gitea/workflows/mvs_job.yaml +++ b/.gitea/workflows/mvs_submit.yaml @@ -1,4 +1,4 @@ -name: MVS Upload & Execute +name: MVS Submit & Execute on: push: @@ -47,7 +47,7 @@ jobs: 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 changes found; running fallback find ===" @@ -56,16 +56,16 @@ jobs: echo "Fallback files (newest first): '${CHANGED_FILES}'" echo "=== Debug: Fallback complete ===" fi - + if [ -z "$CHANGED_FILES" ]; then echo "No 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") @@ -78,7 +78,7 @@ jobs: echo "=== Debug: Starting upload/submit ===" echo "File: ${{ steps.files.outputs.file }}" echo "Member: ${{ steps.files.outputs.member }}" - python3 mvs_job.py "${{ steps.files.outputs.file }}" "@05054.SRCLIB.C(${{ 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 }} @@ -88,7 +88,7 @@ jobs: if: ${{ steps.files.outputs.file != '' }} run: | echo "=== Waiting for job completion ===" - python3 poll_job.py "${{ steps.files.outputs.member }}" 120 + python3 scripts/poll_job.py "${{ steps.files.outputs.member }}" 120 echo "=== Job output retrieved ===" env: MVS_CONSOLE_URL: ${{ vars.MVS_CONSOLE_URL }} diff --git a/README.MD b/README.MD index fc0618f..8feb590 100644 --- a/README.MD +++ b/README.MD @@ -1,10 +1,165 @@ # TK5 C90 Projects -This is an attempt to use the local GCC compiler and modern development tools, -to learn C90 (well, C89) on the MVS 3.8j Turnkey system. +A modern development workflow for C90 programming on IBM MVS 3.8j TK5, combining contemporary tooling with mainframe execution. -Essentially, you work from your IDE as you normally would. However, when you push to -the repository, the build is done on the the TK5 system by sending the source -file and the job execution JCL to the card reader port on the TK5. +## Overview - \ No newline at end of file +This project enables C90 (ANSI C89) development for MVS 3.8j using modern IDEs and Git workflows. Source code is written locally, then automatically uploaded and compiled on the mainframe via CI/CD automation. + +## How It Works + +1. **Local Development**: Write C90 code in your IDE with modern editing features +2. **Git Push**: Commit and push changes to the repository +3. **Automated Workflow**: Gitea Actions triggers mainframe operations: + - Uploads source files to PDS members via netcat to the JES card reader + - Submits JCL jobs for compilation and execution + - Polls for job completion and retrieves output + - Archives job output as build artifacts (PDFs) +4. **Sync Management**: Deleted files are automatically removed from the mainframe PDS + +## Architecture + +### Directory Structure + +``` +. +├── .gitea/workflows/ # CI/CD workflow definitions +│ ├── mvs_submit.yaml # Upload and execute workflow +│ └── mvs_delete.yaml # PDS member deletion workflow +├── scripts/ # Python automation scripts +│ ├── submit_job.py # Upload source & submit JCL +│ ├── del_member.py # Delete PDS members +│ └── poll_job.py # Poll for job completion +├── src/ # C90 source files +├── jcl/ # JCL job control files +└── tmp/ # Temporary files for workflow execution +``` + +### Workflows + +**MVS Submit & Execute** (`mvs_submit.yaml`) +- **Triggers**: Push to master with changes in `src/` or `jcl/` +- **Actions**: + - Detects changed `.c` or `.bas` files + - Uploads source to `@05054.SRCLIB.C(MEMBER)` + - Submits corresponding JCL from `jcl/` directory + - Polls for job completion (120s timeout) + - Downloads and archives job output PDF + +**MVS Delete Members** (`mvs_delete.yaml`) +- **Triggers**: Push to master with deletions in `src/` or `jcl/`, or manual dispatch +- **Actions**: + - Detects deleted `.c` or `.bas` files + - Submits IEHPROGM jobs to delete corresponding PDS members + - Keeps mainframe PDS in sync with repository + +### Scripts + +**submit_job.py** +- Uploads source files to MVS PDS using IEBUPDTE +- Submits JCL jobs via netcat to port 3505 (JES card reader) +- Handles PDS member creation and updates + +**del_member.py** +- Deletes PDS members using IEHPROGM utility +- Maintains sync between repository and mainframe storage + +**poll_job.py** +- Monitors job completion via MVS console +- Retrieves job output from remote printout directory +- Downloads PDF output for archival + +## Requirements + +### Mainframe +- MVS 3.8j TK5 system accessible via network +- JES card reader port (default: 3505) +- User account with appropriate permissions + +### CI/CD Environment +- Gitea with Actions enabled +- Ubuntu runner with: + - `netcat-traditional` + - Python 3 with `requests` library + +### Configuration Variables + +Set in Gitea repository settings: + +**Variables:** +- `MVS_BATCH_PASSWORD`: Password for batch job submission +- `MVS_CONSOLE_URL`: URL for MVS console access +- `MVS_CONSOLE_USER`: Console username +- `LINODE_SSH_HOST`: SSH host for printout retrieval +- `LINODE_PRINTOUT_DIR`: Remote directory for job output + +**Secrets:** +- `MVS_CONSOLE_PASSWORD`: Console password + +## Usage + +### Adding New Programs + +1. Create source file in `src/` (e.g., `HELLO.c`) +2. Create corresponding JCL in `jcl/` (e.g., `HELLO.jcl`) +3. Commit and push: + ```bash + git add src/HELLO.c jcl/HELLO.jcl + git commit -m "Add HELLO program" + git push + ``` +4. Workflow automatically uploads and executes +5. Download job output from build artifacts + +### Removing Programs + +Simply delete the source file and push: +```bash +git rm src/OLDPROG.c +git commit -m "Remove OLDPROG" +git push +``` + +The deletion workflow automatically removes `@05054.SRCLIB.C(OLDPROG)` from the mainframe. + +### Manual Cleanup + +Trigger the deletion workflow manually via Gitea UI to clean up orphaned PDS members. + +## Technical Details + +### Upload Process + +Source files are uploaded using JCL with IEBUPDTE: +1. Python script generates upload JCL dynamically +2. JCL includes `./ ADD NAME=MEMBER` control statements +3. Source lines are padded to 80 characters (LRECL=80) +4. Comment lines starting with `//` or `/*` are stripped to avoid JCL conflicts +5. Submitted via netcat to JES internal reader + +### Deletion Process + +PDS members are deleted using IEHPROGM: +```jcl +//DELMEM EXEC PGM=IEHPROGM +//DD1 DD DSN=dataset,DISP=SHR +//SYSIN DD * + DELETE DSNAME=dataset,MEMBER=member +``` + +### Job Monitoring + +Polls MVS console for job completion, then retrieves PDF output via SSH from remote printout directory. + +## Development Notes + +This project serves as a learning environment for: +- C90/ANSI C89 programming +- MVS JCL and utilities (IEBUPDTE, IEHPROGM) +- Mainframe batch processing workflows +- CI/CD integration with legacy systems +- Network protocols for mainframe communication + +## License + +Educational/personal use project. diff --git a/scripts/del_member.py b/scripts/del_member.py new file mode 100644 index 0000000..898f9fd --- /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 * + DELETE 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/poll_job.py b/scripts/poll_job.py similarity index 100% rename from poll_job.py rename to scripts/poll_job.py diff --git a/mvs_job.py b/scripts/submit_job.py similarity index 100% rename from mvs_job.py rename to scripts/submit_job.py