Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
84f8b62
Fix formatting in README.md description
ColourblindGuy Mar 24, 2026
1960e60
Change runner from 'garbanzo' to 'self-hosted'
ColourblindGuy Mar 24, 2026
35e0f91
Fix typo in README description
ColourblindGuy Mar 24, 2026
d72dc3f
Change deployment script from deploy_rt.py to deploy.py
ColourblindGuy Mar 24, 2026
61ccc39
Update remote path for MyApp.rtexe deployment
aascencio9 Mar 24, 2026
c48fd4a
Enable PowerShell script execution in workflow
aascencio9 Mar 24, 2026
493abf3
Remove PowerShell script enabling step
aascencio9 Mar 24, 2026
d6726e0
Switch from FTP to SCP for file upload
aascencio9 Mar 24, 2026
cd4b769
Add paramiko to dependencies installation
aascencio9 Mar 24, 2026
344e24b
Implement SSH reboot functionality
aascencio9 Mar 24, 2026
e6e7a82
Add sleep before waiting for target
aascencio9 Mar 24, 2026
53bf633
Implement SFTP directory upload and cleanup
aascencio9 Mar 24, 2026
66c4e85
Enhance deploy.py with logging and backup features
aascencio9 Mar 24, 2026
e318cf9
Create text.txt
aascencio9 Mar 24, 2026
539769b
Implement deploy_bin_folder for bin deployment
aascencio9 Mar 24, 2026
bfcb64f
Remove unnecessary backticks from deploy.py
aascencio9 Mar 24, 2026
1b68b0b
Implement remote backup and deployment enhancements
aascencio9 Mar 24, 2026
cfe887e
Enhance deploy.py with better logging and SSH handling
aascencio9 Mar 24, 2026
d9c6782
Add files via upload
aascencio9 Mar 24, 2026
a3017ec
Delete releases/MyApp.rtexe
aascencio9 Mar 24, 2026
77900cc
Delete releases/bin/MyApp.rtexe
aascencio9 Mar 24, 2026
0d13865
Delete releases/bin/MyApp.aliases
aascencio9 Mar 24, 2026
b600685
Merge pull request #3 from aascencio9/releases
ColourblindGuy Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-rt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
python-version: "3.11"

- name: Install dependencies
run: pip install requests
run: pip install requests paramiko

- name: Deploy RTEXE to target
env:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# rt_deploy
POC to automate the deploy of new versions of RTEXEs on RT Targets using Github runners (And source control for regressions)...
POC to automate the deploy of new versions of RTEXEs on RT Targets using Github runners (And source control for regressions).........
5 changes: 5 additions & 0 deletions releases/bin/startup.aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[My Computer]
My Computer="192.168.56.103"

[RT CompactRIO Target]
RT CompactRIO Target="localhost"
Binary file added releases/bin/startup.rtexe
Binary file not shown.
File renamed without changes.
318 changes: 276 additions & 42 deletions scripts/deploy.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,299 @@
import ftplib
import os
import sys
import time
import requests
import paramiko
from pathlib import Path
from datetime import datetime

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

RT_IP = os.environ["RT_TARGET_IP"]
RT_USER = os.environ["RT_FTP_USER"]
RT_PASS = os.environ["RT_FTP_PASS"]

RTEXE_LOCAL = Path("releases/MyApp.rtexe") # adjust to your file path
RTEXE_REMOTE = "/ni-rt/startup/MyApp.rtexe" # standard NI RT deploy path

def ftp_upload():
print(f"Connecting to {RT_IP} via FTP...")
with ftplib.FTP(RT_IP, RT_USER, RT_PASS) as ftp:
ftp.set_pasv(True)
with open(RTEXE_LOCAL, "rb") as f:
ftp.storbinary(f"STOR {RTEXE_REMOTE}", f)
print("Upload complete.")

def reboot_target():
# NI Web-based Configuration and Monitoring (WBCM) REST API
url = f"http://{RT_IP}/nisysapi/server"
payload = {"Function": "Restart", "Params": {"objSelfURI": f"nisysapi://{RT_IP}"}}
print("Sending reboot command...")
BIN_LOCAL = Path("releases/bin")
BIN_REMOTE = "/home/lvuser/natinst/bin"
BACKUP_ROOT = "/home/lvuser/deploy_backups"


# ---------------------------------------------------------------------------
# Logging helper
# ---------------------------------------------------------------------------

def log(msg):
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")


# ---------------------------------------------------------------------------
# SSH helpers
# ---------------------------------------------------------------------------

def open_ssh():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(
RT_IP,
username=RT_USER,
password=RT_PASS,
look_for_keys=False,
allow_agent=False,
timeout=5
)
return ssh


# ---------------------------------------------------------------------------
# Remote directory helpers
# ---------------------------------------------------------------------------

def ensure_remote_dir(sftp, path):
"""Create remote directory tree if needed."""
if path == "/" or path == "":
return

parts = path.strip("/").split("/")
current = ""
for part in parts:
current = f"/{part}" if current == "" else f"{current}/{part}"
try:
sftp.stat(current)
except FileNotFoundError:
log(f"Creating remote directory: {current}")
sftp.mkdir(current)


# ---------------------------------------------------------------------------
# Recursive remote copy (remote -> remote)
# ---------------------------------------------------------------------------

def recursive_remote_copy(sftp, src, dst):
"""Copy a remote directory tree to another remote directory."""
ensure_remote_dir(sftp, dst)

for attr in sftp.listdir_attr(src):
src_item = f"{src}/{attr.filename}"
dst_item = f"{dst}/{attr.filename}"

is_dir = bool(attr.st_mode & 0o40000)

if is_dir:
recursive_remote_copy(sftp, src_item, dst_item)
else:
log(f"Backing up file: {src_item} -> {dst_item}")
with sftp.open(src_item, "rb") as fsrc:
data = fsrc.read()
with sftp.open(dst_item, "wb") as fdst:
fdst.write(data)


# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------

def backup_remote_bin():
ssh = open_ssh()
sftp = ssh.open_sftp()

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = f"{BACKUP_ROOT}/bin_{timestamp}"

log(f"Ensuring backup root exists: {BACKUP_ROOT}")
ensure_remote_dir(sftp, BACKUP_ROOT)

log(f"Creating backup folder: {backup_dir}")
ensure_remote_dir(sftp, backup_dir)

log("Starting recursive backup...")
recursive_remote_copy(sftp, BIN_REMOTE, backup_dir)

sftp.close()
ssh.close()

log(f"Backup completed: {backup_dir}")
return backup_dir


# ---------------------------------------------------------------------------
# Recursive delete
# ---------------------------------------------------------------------------

def clear_remote_folder(sftp, remote_path):
"""Delete all contents of remote folder."""
for attr in sftp.listdir_attr(remote_path):
path = f"{remote_path}/{attr.filename}"
is_dir = bool(attr.st_mode & 0o40000)

if is_dir:
clear_remote_folder(sftp, path)
log(f"Removing folder: {path}")
sftp.rmdir(path)
else:
log(f"Removing file: {path}")
sftp.remove(path)


# ---------------------------------------------------------------------------
# Upload local bin folder to remote
# ---------------------------------------------------------------------------

def upload_directory_sftp(sftp, local_path, remote_path):
ensure_remote_dir(sftp, remote_path)

for item in local_path.iterdir():
dst = f"{remote_path}/{item.name}"
if item.is_dir():
upload_directory_sftp(sftp, item, dst)
else:
log(f"Uploading file: {item} -> {dst}")
sftp.put(str(item), dst)


# ---------------------------------------------------------------------------
# Deploy bin folder (includes backup and rollback)
# ---------------------------------------------------------------------------

def deploy_bin_folder():
log(f"Deploying bin folder to {RT_IP}")

# 1. Backup
backup_dir = backup_remote_bin()

# 2. Upload new files
ssh = open_ssh()
sftp = ssh.open_sftp()

try:
log("Clearing remote bin folder...")
clear_remote_folder(sftp, BIN_REMOTE)

log("Uploading new bin folder...")
upload_directory_sftp(sftp, BIN_LOCAL, BIN_REMOTE)

sftp.close()
ssh.close()
log("Upload completed successfully.")

return backup_dir

except Exception as e:
log(f"Upload failed: {e}")
log("Starting rollback...")

rollback_from_backup(backup_dir)
sys.exit(1)


# ---------------------------------------------------------------------------
# Rollback
# ---------------------------------------------------------------------------

def recursive_restore_from_backup(sftp, src, dst):
ensure_remote_dir(sftp, dst)

for attr in sftp.listdir_attr(src):
src_item = f"{src}/{attr.filename}"
dst_item = f"{dst}/{attr.filename}"
is_dir = bool(attr.st_mode & 0o40000)

if is_dir:
recursive_restore_from_backup(sftp, src_item, dst_item)
else:
log(f"Restoring file: {src_item} -> {dst_item}")
with sftp.open(src_item, "rb") as fsrc:
data = fsrc.read()
with sftp.open(dst_item, "wb") as fdst:
fdst.write(data)


def rollback_from_backup(backup_dir):
log("Rollback: restoring backup data")

ssh = open_ssh()
sftp = ssh.open_sftp()

clear_remote_folder(sftp, BIN_REMOTE)
recursive_restore_from_backup(sftp, backup_dir, BIN_REMOTE)

sftp.close()
ssh.close()
log("Rollback completed successfully.")


# ---------------------------------------------------------------------------
# Reboot + Wait Logic
# ---------------------------------------------------------------------------

def reboot_target_via_ssh():
log("Sending reboot command via SSH...")
ssh = open_ssh()
try:
requests.post(url, json=payload, timeout=5)
except requests.exceptions.ReadTimeout:
pass # timeout is expected — target is rebooting
ssh.exec_command("/sbin/reboot")
log("Reboot command sent.")
except Exception:
log("Reboot disconnect occurred. This is normal.")
finally:
ssh.close()


def wait_for_shutdown(timeout=30):
log("Waiting for target to shut down...")
deadline = time.time() + timeout

while time.time() < deadline:
try:
ssh = open_ssh()
ssh.close()
time.sleep(2)
except Exception:
log("Target is offline.")
return True

def wait_for_target(timeout=90):
print("Waiting for target to come back online...")
log("Warning: target never appeared to go offline.")
return False


def wait_for_boot(timeout=90):
log("Waiting for target to come online...")
deadline = time.time() + timeout

while time.time() < deadline:
try:
ftplib.FTP(RT_IP, RT_USER, RT_PASS).quit()
print("Target is back online.")
ssh = open_ssh()
ssh.close()
log("Target is online again.")
return True
except Exception:
time.sleep(5)
print("ERROR: Target did not come back within timeout.")
return False

def verify_version():
# Read a version file you write from your RT app, or check a known file timestamp
with ftplib.FTP(RT_IP, RT_USER, RT_PASS) as ftp:
files = ftp.nlst("/ni-rt/startup/")
if "MyApp.rtexe" in [f.split("/")[-1] for f in files]:
print("Verification passed: RTEXE present on target.")
return True
print("Verification FAILED: RTEXE not found on target.")
log("Error: target did not boot in time.")
return False


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

if __name__ == "__main__":
ftp_upload()
reboot_target()
ok = wait_for_target()
if not ok:
log("=== Starting RT Deployment ===")

try:
backup_dir = deploy_bin_folder()
except Exception as e:
log(f"Deployment failed before reboot: {e}")
sys.exit(1)
ok = verify_version()
if not ok:

reboot_target_via_ssh()

if not wait_for_shutdown():
log("Shutdown not confirmed. Rolling back.")
rollback_from_backup(backup_dir)
sys.exit(1)
print("Deployment successful.")

if not wait_for_boot():
log("Boot failure. Rolling back.")
rollback_from_backup(backup_dir)
sys.exit(1)

log("Deployment completed successfully.")
Loading