Compare commits
22 Commits
development
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e50a5c2e05 | |||
| 41942eb9c9 | |||
| afa8eedc17 | |||
| 3f4e1425a2 | |||
| f637c53930 | |||
| 814ea84098 | |||
| 32d027fcc9 | |||
| 31b01c5c0c | |||
| 75bd96098a | |||
| 9ea54cfc60 | |||
| b728fe84f2 | |||
| 9bef455732 | |||
| 1ebf3f1435 | |||
| b82c9af8c4 | |||
| f4e87910ab | |||
| b8b585d242 | |||
| b38067dd8c | |||
| ee6a150e3e | |||
| b30c85dead | |||
| 9f112e0903 | |||
| 95f132bdf8 | |||
| 6fe5db1adc |
+26
@@ -0,0 +1,26 @@
|
||||
# PyInstaller
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Credentials (never commit these)
|
||||
credentials
|
||||
*.credentials
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
CRON_SCRIPTS = REPO_ROOT / "personal" / "cron_scripts"
|
||||
|
||||
BUILD_SCRIPTS = [
|
||||
CRON_SCRIPTS / "build_multi_powercycle.py",
|
||||
CRON_SCRIPTS / "build_set_fanspeed.py",
|
||||
CRON_SCRIPTS / "build_ilo_status.py",
|
||||
CRON_SCRIPTS / "build_ilo_powercycle.py",
|
||||
CRON_SCRIPTS / "build_ipmi_poweron.py",
|
||||
CRON_SCRIPTS / "build_ipmi_poweroff.py",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
for script in BUILD_SCRIPTS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Building: {script.name}")
|
||||
print(f"{'='*60}")
|
||||
result = subprocess.run([sys.executable, str(script)], cwd=REPO_ROOT)
|
||||
if result.returncode != 0:
|
||||
failures.append(script.name)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if failures:
|
||||
print(f"FAILED builds ({len(failures)}/{len(BUILD_SCRIPTS)}):")
|
||||
for name in failures:
|
||||
print(f" - {name}")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
print(f"All {len(BUILD_SCRIPTS)} builds completed successfully.")
|
||||
print(f"Executables are in: {REPO_ROOT / 'dist'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
[credentials]
|
||||
username=<username>
|
||||
password=<password>
|
||||
domain=<domain>
|
||||
|
||||
[ipmi_credentials]
|
||||
username=<username>
|
||||
password=<password>
|
||||
IPMIEK=0000000000000000000000000000000000000000
|
||||
|
||||
[ilo_credentials]
|
||||
username=<username>
|
||||
password=<password>
|
||||
@@ -0,0 +1,6 @@
|
||||
[ipmi]
|
||||
#list of IPMI hosts to power cycle
|
||||
|
||||
[ilo]
|
||||
#list of iLO hosts to power cycle
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
set_password.py — Encrypt and store a password in any section of credentials.ini
|
||||
|
||||
Usage:
|
||||
python set_password.py # interactive prompts
|
||||
python set_password.py -s ipmi_credentials # pre-select section
|
||||
python set_password.py --credentials /path/to/credentials.ini
|
||||
python set_password.py --keyfile /path/to/secret.key
|
||||
|
||||
The first run generates a secret.key file next to credentials.ini.
|
||||
Keep that key file secure (chmod 600); without it, passwords cannot be decrypted.
|
||||
|
||||
Decrypting at runtime (in other scripts):
|
||||
from cryptography.fernet import Fernet
|
||||
key = open("secret.key", "rb").read()
|
||||
fernet = Fernet(key)
|
||||
plaintext = fernet.decrypt(encrypted_value.encode()).decode()
|
||||
"""
|
||||
import argparse
|
||||
import configparser
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
except ImportError:
|
||||
sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography")
|
||||
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_CREDENTIALS = os.path.join(SCRIPT_DIR, "credentials.ini")
|
||||
DEFAULT_KEYFILE = os.path.join(SCRIPT_DIR, "secret.key")
|
||||
|
||||
|
||||
def load_or_create_key(keyfile: str) -> bytes:
|
||||
"""Load an existing Fernet key or generate and save a new one."""
|
||||
if os.path.exists(keyfile):
|
||||
with open(keyfile, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
key = Fernet.generate_key()
|
||||
with open(keyfile, "wb") as fh:
|
||||
fh.write(key)
|
||||
# Restrict permissions on Unix-like systems
|
||||
try:
|
||||
os.chmod(keyfile, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"New encryption key created: {keyfile}")
|
||||
print("Keep this file secure — it is required to decrypt passwords.")
|
||||
return key
|
||||
|
||||
|
||||
def encrypt_password(plaintext: str, fernet: Fernet) -> str:
|
||||
return fernet.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Encrypt and store a password in credentials.ini"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--credentials",
|
||||
default=DEFAULT_CREDENTIALS,
|
||||
help=f"Path to credentials file (default: {DEFAULT_CREDENTIALS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keyfile",
|
||||
default=DEFAULT_KEYFILE,
|
||||
help=f"Path to encryption key file (default: {DEFAULT_KEYFILE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--section",
|
||||
help="Section to update (skips interactive section prompt)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
|
||||
if not os.path.exists(args.credentials):
|
||||
print(f"Credentials file not found. Creating: {args.credentials}")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.credentials)), exist_ok=True)
|
||||
# Seed with the standard sections and placeholder values
|
||||
config["credentials"] = {"username": "", "password": "", "domain": ""}
|
||||
config["ipmi_credentials"] = {
|
||||
"username": "",
|
||||
"password": "",
|
||||
"IPMIEK": "0000000000000000000000000000000000000000",
|
||||
}
|
||||
config["ilo_credentials"] = {"username": "", "password": ""}
|
||||
with open(args.credentials, "w") as fh:
|
||||
config.write(fh)
|
||||
try:
|
||||
os.chmod(args.credentials, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"Created {args.credentials} — fill in usernames and re-run to set passwords.")
|
||||
else:
|
||||
config.read(args.credentials)
|
||||
|
||||
sections = config.sections()
|
||||
|
||||
if not sections:
|
||||
sys.exit(f"Error: no sections found in {args.credentials}")
|
||||
|
||||
# Resolve section
|
||||
if args.section:
|
||||
section = args.section
|
||||
if section not in sections:
|
||||
sys.exit(
|
||||
f"Error: section [{section}] not found in {args.credentials}\n"
|
||||
f"Available sections: {', '.join(sections)}"
|
||||
)
|
||||
else:
|
||||
print("Available sections:")
|
||||
for i, s in enumerate(sections, 1):
|
||||
print(f" {i}. {s}")
|
||||
choice = input("Select section number: ").strip()
|
||||
try:
|
||||
section = sections[int(choice) - 1]
|
||||
except (ValueError, IndexError):
|
||||
sys.exit("Error: invalid selection")
|
||||
|
||||
# Prompt for new password (hidden input, confirmed)
|
||||
while True:
|
||||
password = getpass.getpass(f"New password for [{section}]: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if password == confirm:
|
||||
break
|
||||
print("Passwords do not match. Try again.")
|
||||
|
||||
# Load/create key and encrypt
|
||||
key = load_or_create_key(args.keyfile)
|
||||
fernet = Fernet(key)
|
||||
encrypted = encrypt_password(password, fernet)
|
||||
|
||||
config.set(section, "password", encrypted)
|
||||
|
||||
with open(args.credentials, "w") as fh:
|
||||
config.write(fh)
|
||||
|
||||
print(f"Password updated and encrypted in [{section}] of {args.credentials}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,642 @@
|
||||
mqtt:
|
||||
enabled: false
|
||||
|
||||
detectors:
|
||||
coral:
|
||||
type: edgetpu
|
||||
device: pci
|
||||
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-nvidia
|
||||
|
||||
|
||||
record:
|
||||
enabled: true
|
||||
alerts:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
detections:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
|
||||
continuous:
|
||||
days: 0
|
||||
motion:
|
||||
days: 3
|
||||
go2rtc:
|
||||
ffmpeg:
|
||||
bin: ffmpeg
|
||||
# Nvidia NVDEC hardware decode templates (used when #hardware is in stream URL)
|
||||
h264: "-hwaccel cuda -hwaccel_output_format cuda -c:v h264_cuvid {input} -c:v copy -f rtsp {output}"
|
||||
h265: "-hwaccel cuda -hwaccel_output_format cuda -c:v hevc_cuvid {input} -c:v copy -f rtsp {output}"
|
||||
streams:
|
||||
# Backbasement:
|
||||
# - ffmpeg:rtsp://tpadmin:Optimus0329@192.168.128.222:554/live/ch0#video=copy#audio=copy#audio=aac#hardware
|
||||
# SubBackBasement:
|
||||
# - ffmpeg:rtsp://tpadmin:Optimus0329@192.168.128.222:554/live/ch1#video=copy#audio=copy#audio=aac#hardware
|
||||
# FrontBasement:
|
||||
# - ffmpeg:rtsp://tpadmin:Optimus0329@192.168.128.223:554/live/ch0?token=e6cf2225e8bf5fb028543898fab9a545#video=copy#audio=copy#audio=aac#hardware
|
||||
# SubFrontBasement:
|
||||
# - ffmpeg:rtsp://tpadmin:Optimus0329@192.168.128.223:554/live/ch1?token=e6cf2225e8bf5fb028543898fab9a545#video=copy#audio=copy#audio=aac#hardware
|
||||
# Eningeering:
|
||||
# - ffmpeg:rtsp://tpadmin:Optimus0329@192.168.128.221:554/live/ch0?token=e6cf2225e8bf5fb028543898fab9a545#video=copy#audio=copy#audio=aac#hardware
|
||||
# SubEngineering:
|
||||
# - ffmpeg:rtsp://tpadmin:Optimus0329@192.168.128.221:554/live/ch1?token=e6cf2225e8bf5fb028543898fab9a545#video=copy#audio=copy#audio=aac#hardware
|
||||
Driveway:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch12_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubDriveway:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch12_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
Porch:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch8_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubPorch:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch8_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
FenceGate:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch1_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubFenceGate:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch1_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
GardenArea:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch2_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubGardenArea:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch2_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
Backyard1:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch3_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubBackyard1:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch3_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
Backyard2:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch4_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubBackyard2:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch4_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
Backyard3:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch0_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubBackyard3:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch0_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
Backyard4:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch10_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubBackyard4:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch10_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
Backyard5:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch7_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubBackyard5:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch7_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
PatioArea:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch6_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubPatioArea:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch6_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
PoolArea:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch9_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubPoolArea:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch9_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SideHouse:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch11_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubSideHouse:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch11_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
BackSlider:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch5_0.264#video=copy#audio=copy#audio=aac#hardware
|
||||
SubBackSlider:
|
||||
- ffmpeg:rtsp://admin:Optimus0329@192.168.128.11:80/ch5_1.264#video=copy#audio=copy#audio=aac#hardware
|
||||
|
||||
|
||||
cameras:
|
||||
# name_of_your_camera: # <------ Name the camera
|
||||
# enabled: True
|
||||
# ffmpeg:
|
||||
# inputs:
|
||||
# - path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection
|
||||
# roles:
|
||||
# - detect
|
||||
# detect:
|
||||
# enabled: False # <---- disable detection until you have a working camera feed
|
||||
# width: 1280
|
||||
# height: 720
|
||||
|
||||
# back_basement:
|
||||
# enabled: true
|
||||
# ffmpeg:
|
||||
# inputs:
|
||||
# # - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/BackBasement
|
||||
# #input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
# - path: rtsp://127.0.0.1:8554/SubBackBasement
|
||||
# #input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - detect
|
||||
# detect:
|
||||
# enabled: true # <---- disable detection until you have a working camera feed
|
||||
# width: 1280
|
||||
# height: 720
|
||||
# fps: 5
|
||||
# motion:
|
||||
# enabled: true
|
||||
# threshold: 30
|
||||
# contour_area: 30
|
||||
# mask: 0.683,0.006,0.683,0.056,0.766,0.055,0.996,0.054,0.996,0.006
|
||||
# improve_contrast: true
|
||||
# snapshots:
|
||||
# enabled: true
|
||||
# timestamp: true
|
||||
# bounding_box: true
|
||||
# retain:
|
||||
# objects:
|
||||
# person: 7
|
||||
# front_basement:
|
||||
# enabled: true
|
||||
# ffmpeg:
|
||||
# inputs:
|
||||
# # - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/FrontBasement
|
||||
# #input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
# - path: rtsp://127.0.0.1:8554/SubFrontBasement
|
||||
# #input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - detect
|
||||
# detect:
|
||||
# enabled: true # <---- disable detection until you have a working camera feed
|
||||
# width: 1280
|
||||
# height: 720
|
||||
# fps: 5
|
||||
# motion:
|
||||
# enabled: true
|
||||
# threshold: 30
|
||||
# contour_area: 30
|
||||
# mask: 0.723,0,0.724,0.05,1,0.048,0.999,0.001
|
||||
# improve_contrast: true
|
||||
# snapshots:
|
||||
# enabled: true
|
||||
# timestamp: true
|
||||
# bounding_box: true
|
||||
# retain:
|
||||
# objects:
|
||||
# person: 7
|
||||
# record:
|
||||
# enabled: false
|
||||
# engineering:
|
||||
# enabled: true
|
||||
# ffmpeg:
|
||||
# inputs:
|
||||
# # - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Engineering
|
||||
# #input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
# - path: rtsp://127.0.0.1:8554/SubEngineering
|
||||
# #input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - detect
|
||||
# detect:
|
||||
# enabled: true # <---- disable detection until you have a working camera feed
|
||||
# width: 1280
|
||||
# height: 720
|
||||
# fps: 5
|
||||
# motion:
|
||||
# enabled: true
|
||||
# threshold: 14
|
||||
# contour_area: 15
|
||||
# improve_contrast: true
|
||||
# snapshots:
|
||||
# enabled: true
|
||||
# timestamp: true
|
||||
# bounding_box: true
|
||||
# retain:
|
||||
# objects:
|
||||
# person: 7
|
||||
# record:
|
||||
# enabled: false
|
||||
driveway:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Driveway
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubDriveway
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
zones:
|
||||
drivewayview:
|
||||
coordinates: 0,0.373,0,1,1,1,1,0.381,0.47,0.343,0.134,0.352
|
||||
loitering_time: 0
|
||||
friendly_name: DrivewayView
|
||||
porch:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Porch
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubPorch
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
fence_gate:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/FenceGate
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubFenceGate
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
zones:
|
||||
fencegate:
|
||||
coordinates:
|
||||
0,0.395,0.318,0.259,0.598,0.175,0.916,0.237,1,0.296,1,1,0.003,0.996
|
||||
loitering_time: 0
|
||||
friendly_name: FenceGate
|
||||
garden_area:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/GardenArea
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubGardenArea
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
backyard_1:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Backyard1
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubBackyard1
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
zones:
|
||||
backyard1:
|
||||
coordinates:
|
||||
0.001,0.274,0.124,0.162,0.228,0.143,0.382,0.131,0.555,0.133,0.714,0.16,0.854,0.196,0.998,0.253,0.998,0.996,0,0.992
|
||||
loitering_time: 0
|
||||
friendly_name: Backyard1
|
||||
backyard_2:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Backyard2
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubBackyard2
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
zones:
|
||||
backyard2:
|
||||
coordinates:
|
||||
0.002,0.147,0.086,0.106,0.172,0.091,0.271,0.078,0.386,0.077,0.499,0.077,0.601,0.086,0.698,0.099,0.787,0.117,0.859,0.087,0.995,0.155,0.998,0.992,0.001,0.995
|
||||
loitering_time: 0
|
||||
friendly_name: Backyard2
|
||||
backyard_3:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Backyard3
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubBackyard3
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
zones:
|
||||
backyard3:
|
||||
coordinates:
|
||||
0.001,0.337,0.166,0.332,0.327,0.342,0.419,0.344,0.51,0.355,0.603,0.363,0.692,0.374,0.771,0.381,0.848,0.389,0.951,0.405,0.997,0.423,0.998,0.996,0.003,0.994
|
||||
loitering_time: 0
|
||||
friendly_name: Backyard3
|
||||
motion:
|
||||
mask: 0.79,0.539,0.845,0.541,0.82,0.858,0.772,0.844
|
||||
backyard_4:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Backyard4
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubBackyard4
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
motion:
|
||||
mask:
|
||||
- 0.866,0.001,0.866,0.209,1,0.202,1,0
|
||||
- 0.001,0,0.249,0.001,0.249,0.045,0.002,0.044
|
||||
threshold: 60
|
||||
contour_area: 10
|
||||
improve_contrast: true
|
||||
backyard_5:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/Backyard5
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubBackyard5
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
motion:
|
||||
mask:
|
||||
- 0.029,0.31,0.13,0.305,0.134,0.54,0.032,0.544
|
||||
- 0.004,0.002,0.004,0.044,0.245,0.044,0.244,0
|
||||
threshold: 60
|
||||
contour_area: 10
|
||||
improve_contrast: true
|
||||
zones:
|
||||
backyard5:
|
||||
coordinates:
|
||||
0.001,0.305,0.043,0.264,0.113,0.194,0.19,0.128,0.271,0.109,0.371,0.089,0.525,0.057,0.709,0.057,0.89,0.076,0.997,0.122,0.997,0.995,0.002,0.998
|
||||
loitering_time: 0
|
||||
friendly_name: Backyard5
|
||||
patio_area:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/PatioArea
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubPatioArea
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
pool_area:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/PoolArea
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubPoolArea
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
motion:
|
||||
mask:
|
||||
0.43,0.146,0.704,0.194,0.532,0.808,0.199,0.704,0.002,0.559,0.252,0.282
|
||||
back_slider:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/BackSlider
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubBackSlider
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
side_house:
|
||||
enabled: true
|
||||
ffmpeg:
|
||||
inputs:
|
||||
# - path: rtsp://tpadmin:Optimus0329@192.168.128.225/stream1
|
||||
# - path: rtsp://127.0.0.1:8554/SideHouse
|
||||
# input_args: preset-rtsp-restream
|
||||
# roles:
|
||||
# - record
|
||||
- path: rtsp://127.0.0.1:8554/SubSideHouse
|
||||
input_args: preset-rtsp-restream
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
detect:
|
||||
enabled: true # <---- disable detection until you have a working camera feed
|
||||
width: 1280
|
||||
height: 720
|
||||
fps: 5
|
||||
snapshots:
|
||||
enabled: true
|
||||
timestamp: true
|
||||
bounding_box: true
|
||||
retain:
|
||||
objects:
|
||||
person: 7
|
||||
record:
|
||||
enabled: true
|
||||
motion:
|
||||
threshold: 60
|
||||
contour_area: 40
|
||||
improve_contrast: true
|
||||
mask:
|
||||
0.001,0.859,0.085,0.717,0.203,0.637,0.383,0.736,0.65,0.421,0.642,0.997,0.003,0.999
|
||||
zones:
|
||||
sidehouse:
|
||||
coordinates: 0.661,0.001,0.662,0.996,1,1,1,0
|
||||
loitering_time: 0
|
||||
friendly_name: SideHouse
|
||||
objects:
|
||||
filters:
|
||||
person: {}
|
||||
version: 0.17-0
|
||||
@@ -0,0 +1,43 @@
|
||||
api:
|
||||
listen: ":1984"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
format: "color"
|
||||
output: "file:go2rtc.log"
|
||||
|
||||
rtsp:
|
||||
listen: ":8554" # RTSP Server TCP port, default - 8554
|
||||
#username: "admin" # optional, default - disabled
|
||||
#password: "pass" # optional, default - disabled
|
||||
#default_query: "video=h264" # optional, default codecs filters
|
||||
|
||||
ffmpeg:
|
||||
bin: ffmpeg
|
||||
# Nvidia NVDEC hardware decode templates (used when #hardware is in stream URL)
|
||||
h264: "-hwaccel cuda -hwaccel_output_format cuda -c:v h264_cuvid {input} -c:v copy -f rtsp {output}"
|
||||
h265: "-hwaccel cuda -hwaccel_output_format cuda -c:v hevc_cuvid {input} -c:v copy -f rtsp {output}"
|
||||
|
||||
streams:
|
||||
porch_steps: rtsp://admin:Optimus0329@192.168.128.11:80/ch8_0.264#video=h264#hardware=cuda
|
||||
driveway: rtsp://admin:Optimus0329@192.168.128.11:80/ch12_0.264#video=h264#hardware=cuda
|
||||
fence_gate: rtsp://admin:Optimus0329@192.168.128.11:80/ch1_0.264#video=h264#hardware=cuda
|
||||
shed_area: rtsp://admin:Optimus0329@192.168.128.11:80/ch13_0.264#video=h264#hardware=cuda
|
||||
garden_area: rtsp://admin:Optimus0329@192.168.128.11:80/ch2_0.264#video=h264#hardware=cuda
|
||||
backyard_1: rtsp://admin:Optimus0329@192.168.128.11:80/ch3_0.264#video=h264#hardware=cuda
|
||||
backyard_2: rtsp://admin:Optimus0329@192.168.128.11:80/ch4_0.264#video=h264#hardware=cuda
|
||||
backyard_3: rtsp://admin:Optimus0329@192.168.128.11:80/ch0_0.264#video=h264#hardware=cuda
|
||||
backyard_4: rtsp://admin:Optimus0329@192.168.128.11:80/ch10_0.264#video=h264#hardware=cuda
|
||||
backyard_5: rtsp://admin:Optimus0329@192.168.128.11:80/ch7_0.264#video=h264#hardware=cuda
|
||||
inside_garage: rtsp://admin:Optimus0329@192.168.128.11:80/ch6_0.264#video=h264#hardware=cuda
|
||||
pool_area: rtsp://admin:Optimus0329@192.168.128.11:80/ch9_0.264#video=h264#hardware=cuda
|
||||
back_slider: rtsp://admin:Optimus0329@192.168.128.11:80/ch5_0.264#video=h264#hardware=cuda
|
||||
side_house: rtsp://admin:Optimus0329@192.168.128.11:80/ch11_0.264#video=h264#hardware=cuda
|
||||
patio_area: rtsp://admin:Optimus0329@192.168.128.11:80/ch14_0.264
|
||||
engineering: rtsp://admin:Optimus0329@192.168.128.65:1554/live/ch0
|
||||
front_basement: rtsp://admin:Optimus0329@192.168.128.223:554/live/ch0
|
||||
back_basement: rtsp://admin:Optimus0329@192.168.128.95:554/live/ch0
|
||||
back_basement2: rtsp://admin:Optimus0329@192.168.128.183:554/live/ch0
|
||||
|
||||
webrtc:
|
||||
listen: ":8555"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import sys
|
||||
import urllib3
|
||||
import redfish
|
||||
from datetime import datetime
|
||||
|
||||
def log(message, is_error=False):
|
||||
"""Print message with timestamp"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if is_error:
|
||||
print(f"[{timestamp}] {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[{timestamp}] {message}")
|
||||
|
||||
# Suppress SSL warnings for self-signed iLO certificates
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Power off an HPE server via iLO Redfish")
|
||||
parser.add_argument("hostname", help="iLO hostname or IP address")
|
||||
parser.add_argument("-c", "--credentials", default="/etc/ilo_credentials",
|
||||
help="Path to credentials file (default: /etc/ilo_credentials)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read credentials from file (chmod 600 recommended: sudo chmod 600 /etc/ilo_credentials)
|
||||
# File format:
|
||||
# [ilo]
|
||||
# username = root
|
||||
# password = yourpassword
|
||||
cred_path = args.credentials
|
||||
if not os.path.exists(cred_path):
|
||||
log(f"Error: credentials file not found: {cred_path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(cred_path)
|
||||
try:
|
||||
LOGIN_ACCOUNT = config.get("ilo_credentials", "username", fallback="root")
|
||||
LOGIN_PASSWORD = config.get("ilo_credentials", "password")
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
log(f"Error reading credentials file: {e}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
BASE_URL = f"https://{args.hostname}"
|
||||
|
||||
REDFISHOBJ = redfish.RedfishClient(
|
||||
base_url=BASE_URL,
|
||||
username=LOGIN_ACCOUNT,
|
||||
password=LOGIN_PASSWORD,
|
||||
)
|
||||
REDFISHOBJ.login(auth="session")
|
||||
|
||||
# Discover the correct reset action URI from the system resource
|
||||
sys_response = REDFISHOBJ.get("/redfish/v1/Systems/1/")
|
||||
reset_uri = sys_response.dict["Actions"]["#ComputerSystem.Reset"]["target"]
|
||||
|
||||
body = {"ResetType": "PushPowerButton"} # iLO 4: PushPowerButton = graceful shutdown; use "ForceOff" for hard power cut
|
||||
response = REDFISHOBJ.post(reset_uri, body=body)
|
||||
|
||||
log(f"Status: {response.status}")
|
||||
if response.status >= 400:
|
||||
log(f"Error: {response.read}", is_error=True)
|
||||
|
||||
REDFISHOBJ.logout()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import sys
|
||||
import urllib3
|
||||
import redfish
|
||||
from datetime import datetime
|
||||
|
||||
def log(message, is_error=False):
|
||||
"""Print message with timestamp"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if is_error:
|
||||
print(f"[{timestamp}] {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[{timestamp}] {message}")
|
||||
|
||||
# Suppress SSL warnings for self-signed iLO certificates
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Power off an HPE server via iLO Redfish")
|
||||
parser.add_argument("hostname", help="iLO hostname or IP address")
|
||||
parser.add_argument("-c", "--credentials", default="/etc/ilo_credentials",
|
||||
help="Path to credentials file (default: /etc/ilo_credentials)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read credentials from file (chmod 600 recommended: sudo chmod 600 /etc/ilo_credentials)
|
||||
# File format:
|
||||
# [ilo]
|
||||
# username = root
|
||||
# password = yourpassword
|
||||
cred_path = args.credentials
|
||||
if not os.path.exists(cred_path):
|
||||
log(f"Error: credentials file not found: {cred_path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(cred_path)
|
||||
try:
|
||||
LOGIN_ACCOUNT = config.get("ilo", "username", fallback="root")
|
||||
LOGIN_PASSWORD = config.get("ilo", "password")
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
log(f"Error reading credentials file: {e}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
BASE_URL = f"https://{args.hostname}"
|
||||
|
||||
REDFISHOBJ = redfish.RedfishClient(
|
||||
base_url=BASE_URL,
|
||||
username=LOGIN_ACCOUNT,
|
||||
password=LOGIN_PASSWORD,
|
||||
)
|
||||
REDFISHOBJ.login(auth="session")
|
||||
|
||||
# Discover the correct reset action URI from the system resource
|
||||
sys_response = REDFISHOBJ.get("/redfish/v1/Systems/1/")
|
||||
reset_uri = sys_response.dict["Actions"]["#ComputerSystem.Reset"]["target"]
|
||||
|
||||
body = {"ResetType": "PushPowerButton"} # iLO 4: PushPowerButton = graceful shutdown; use "ForceOff" for hard power cut
|
||||
response = REDFISHOBJ.post(reset_uri, body=body)
|
||||
|
||||
log(f"Status: {response.status}")
|
||||
if response.status >= 400:
|
||||
log(f"Error: {response.read}", is_error=True)
|
||||
|
||||
REDFISHOBJ.logout()
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
# Examples:
|
||||
# ./ipmi_multipoweron.py host1 host2 host3
|
||||
# ./ipmi_multipoweron.py host1 host2 host3 -c /path/to/credentials
|
||||
# ./ipmi_multipoweron.py -f serverlist.txt
|
||||
# ./ipmi_multipoweron.py -f serverlist.txt -c /path/to/credentials
|
||||
|
||||
def ts_print(msg):
|
||||
"""Print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{now}] {msg}")
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
|
||||
|
||||
def find_credentials_file(provided_path):
|
||||
"""Find credentials file in standard locations if provided path doesn't exist."""
|
||||
# If provided path exists, use it
|
||||
if os.path.exists(provided_path):
|
||||
return provided_path
|
||||
|
||||
# Check standard locations
|
||||
standard_paths = [
|
||||
os.path.expanduser("~/credentials"),
|
||||
"/etc/credentials",
|
||||
]
|
||||
|
||||
for path in standard_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
# Return the provided path (will fail with proper error message later)
|
||||
return provided_path
|
||||
|
||||
|
||||
def load_credentials(path):
|
||||
cred_path = find_credentials_file(path)
|
||||
|
||||
if not os.path.exists(cred_path):
|
||||
ts_print(f"Error: credentials file not found: {cred_path}")
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(cred_path)
|
||||
|
||||
try:
|
||||
username = config.get("credentials", "username", fallback="root")
|
||||
password = config.get("credentials", "password")
|
||||
encryption_key = config.get("credentials", "encryption_key", fallback=DEFAULT_EK)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
ts_print(f"Error reading credentials file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
return username, password, encryption_key
|
||||
|
||||
|
||||
def power_on_host(host, ipmi_user, ipmi_pw, ipmi_ek):
|
||||
"""Power on a single host via IPMI. Returns True if successful."""
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", host,
|
||||
"-U", ipmi_user,
|
||||
"-P", ipmi_pw,
|
||||
"-y", ipmi_ek,
|
||||
"power", "on",
|
||||
]
|
||||
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
ts_print(f"ERROR: Failed to power on {host}: {proc.stderr.strip()}")
|
||||
return False
|
||||
else:
|
||||
ts_print(f"SUCCESS: {host} - {proc.stdout.strip()}")
|
||||
return True
|
||||
|
||||
|
||||
def load_servers_from_file(filepath):
|
||||
"""Load server list from a file (one server per line, lines starting with # are ignored)."""
|
||||
if not os.path.exists(filepath):
|
||||
ts_print(f"Error: server list file not found: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
servers = []
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if line and not line.startswith('#'):
|
||||
servers.append(line)
|
||||
except Exception as e:
|
||||
ts_print(f"Error reading server list file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power on to multiple servers")
|
||||
|
||||
# Either provide servers as arguments or a file
|
||||
group = p.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"servers",
|
||||
nargs="*",
|
||||
help="List of IPMI host addresses (space-separated)"
|
||||
)
|
||||
group.add_argument(
|
||||
"-f", "--file",
|
||||
help="Path to file containing list of servers (one per line)"
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"-c",
|
||||
"--credentials",
|
||||
default="credentials",
|
||||
help="Path to credentials file (default: credentials; also checks ~/ and /etc/ if not found)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
# Determine server list
|
||||
if args.file:
|
||||
servers = load_servers_from_file(args.file)
|
||||
else:
|
||||
servers = args.servers
|
||||
|
||||
if not servers:
|
||||
ts_print("Error: No servers specified")
|
||||
sys.exit(1)
|
||||
|
||||
ts_print(f"Starting power on sequence for {len(servers)} server(s)")
|
||||
|
||||
ipmi_user, ipmi_pw, ipmi_ek = load_credentials(args.credentials)
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for host in servers:
|
||||
if power_on_host(host, ipmi_user, ipmi_pw, ipmi_ek):
|
||||
success_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
ts_print(f"Completed: {success_count} successful, {failed_count} failed")
|
||||
|
||||
if failed_count > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
|
||||
def log(message, is_error=False):
|
||||
"""Print message with timestamp"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if is_error:
|
||||
print(f"[{timestamp}] {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[{timestamp}] {message}")
|
||||
|
||||
|
||||
def load_credentials(path):
|
||||
if not os.path.exists(path):
|
||||
log(f"Error: credentials file not found: {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
try:
|
||||
username = config.get("credentials", "username", fallback="root")
|
||||
password = config.get("credentials", "password")
|
||||
encryption_key = config.get("credentials", "encryption_key", fallback=DEFAULT_EK)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
log(f"Error reading credentials file: {e}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
return username, password, encryption_key
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power off")
|
||||
p.add_argument("-H", "--host", required=True, help="IPMI host address")
|
||||
p.add_argument(
|
||||
"-c",
|
||||
"--credentials",
|
||||
default="credentials",
|
||||
help="Path to credentials file (default: credentials)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
ipmi_user, ipmi_pw, ipmi_ek = load_credentials(args.credentials)
|
||||
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", args.host,
|
||||
"-U", ipmi_user,
|
||||
"-P", ipmi_pw,
|
||||
"-y", ipmi_ek,
|
||||
"power", "off",
|
||||
]
|
||||
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
log("ipmitool failed: " + proc.stderr.strip(), is_error=True)
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
log(proc.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
def ts_print(msg):
|
||||
"""ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"{now} {msg}")
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
|
||||
|
||||
def load_credentials(path):
|
||||
if not os.path.exists(path):
|
||||
print(f"Error: credentials file not found: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
try:
|
||||
username = config.get("ilo", "username", fallback="root")
|
||||
password = config.get("ilo", "password")
|
||||
encryption_key = config.get("ilo", "encryption_key", fallback=DEFAULT_EK)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
print(f"Error reading credentials file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return username, password, encryption_key
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power on")
|
||||
p.add_argument("-H", "--host", required=True, help="IPMI host address")
|
||||
p.add_argument(
|
||||
"-c",
|
||||
"--credentials",
|
||||
default="credentials",
|
||||
help="Path to credentials file (default: credentials)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
ipmi_user, ipmi_pw, ipmi_ek = load_credentials(args.credentials)
|
||||
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", args.host,
|
||||
"-U", ipmi_user,
|
||||
"-P", ipmi_pw,
|
||||
"-y", ipmi_ek,
|
||||
"power", "on",
|
||||
]
|
||||
print(f"Sending power on to {args.host}")
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
print("ipmitool failed:", proc.stderr.strip(), file=sys.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
print(proc.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
ENTRYPOINT = REPO_ROOT / "personal" / "tools" / "ilo_powercycle.py"
|
||||
DIST_DIR = REPO_ROOT / "dist"
|
||||
|
||||
|
||||
def main():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"ilo_powercycle",
|
||||
"--collect-submodules",
|
||||
"redfish",
|
||||
str(ENTRYPOINT),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(exc.returncode) from exc
|
||||
|
||||
output_name = "ilo_powercycle.exe" if sys.platform.startswith("win") else "ilo_powercycle"
|
||||
output_path = DIST_DIR / output_name
|
||||
print(f"Built executable: {output_path}")
|
||||
print("Build once on each target OS to get a native executable for that platform.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
ENTRYPOINT = SCRIPT_DIR / "ilo_status.py"
|
||||
DIST_DIR = REPO_ROOT / "dist"
|
||||
|
||||
|
||||
def main():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"ilo_status",
|
||||
"--collect-submodules",
|
||||
"redfish",
|
||||
str(ENTRYPOINT),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(exc.returncode) from exc
|
||||
|
||||
output_name = "ilo_status.exe" if sys.platform.startswith("win") else "ilo_status"
|
||||
output_path = DIST_DIR / output_name
|
||||
print(f"Built executable: {output_path}")
|
||||
print("Build once on each target OS to get a native executable for that platform.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
ENTRYPOINT = SCRIPT_DIR / "power" / "ipmi_poweroff.py"
|
||||
DIST_DIR = REPO_ROOT / "dist"
|
||||
|
||||
|
||||
def main():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"ipmi_poweroff",
|
||||
str(ENTRYPOINT),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(exc.returncode) from exc
|
||||
|
||||
output_name = "ipmi_poweroff.exe" if sys.platform.startswith("win") else "ipmi_poweroff"
|
||||
output_path = DIST_DIR / output_name
|
||||
print(f"Built executable: {output_path}")
|
||||
print("Build once on each target OS to get a native executable for that platform.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
ENTRYPOINT = SCRIPT_DIR / "power" / "ipmi_poweron.py"
|
||||
DIST_DIR = REPO_ROOT / "dist"
|
||||
|
||||
|
||||
def main():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"ipmi_poweron",
|
||||
str(ENTRYPOINT),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(exc.returncode) from exc
|
||||
|
||||
output_name = "ipmi_poweron.exe" if sys.platform.startswith("win") else "ipmi_poweron"
|
||||
output_path = DIST_DIR / output_name
|
||||
print(f"Built executable: {output_path}")
|
||||
print("Build once on each target OS to get a native executable for that platform.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
ENTRYPOINT = SCRIPT_DIR / "multi_powercycle.py"
|
||||
DIST_DIR = REPO_ROOT / "dist"
|
||||
|
||||
|
||||
def main():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"multi_powercycle",
|
||||
"--collect-submodules",
|
||||
"redfish",
|
||||
str(ENTRYPOINT),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(exc.returncode) from exc
|
||||
|
||||
output_name = "multi_powercycle.exe" if sys.platform.startswith("win") else "multi_powercycle"
|
||||
output_path = DIST_DIR / output_name
|
||||
print(f"Built executable: {output_path}")
|
||||
print("Build once on each target OS to get a native executable for that platform.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
ENTRYPOINT = SCRIPT_DIR / "fancontrol" / "set_fanspeed.py"
|
||||
DIST_DIR = REPO_ROOT / "dist"
|
||||
|
||||
|
||||
def main():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"set_fanspeed",
|
||||
str(ENTRYPOINT),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise SystemExit(exc.returncode) from exc
|
||||
|
||||
output_name = "set_fanspeed.exe" if sys.platform.startswith("win") else "set_fanspeed"
|
||||
output_path = DIST_DIR / output_name
|
||||
print(f"Built executable: {output_path}")
|
||||
print("Build once on each target OS to get a native executable for that platform.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
# Edit this file to introduce tasks to be run by cron.
|
||||
#
|
||||
# Each task to run has to be defined through a single line
|
||||
# indicating with different fields when the task will be run
|
||||
# and what command to run for the task
|
||||
#
|
||||
# To define the time you can provide concrete values for
|
||||
# minute (m), hour (h), day of month (dom), month (mon),
|
||||
# and day of week (dow) or use '*' in these fields (for 'any').
|
||||
#
|
||||
# Notice that tasks will be started based on the cron's system
|
||||
# daemon's notion of time and timezones.
|
||||
#
|
||||
# Output of the crontab jobs (including errors) is sent through
|
||||
# email to the user the crontab file belongs to (unless redirected).
|
||||
#
|
||||
# For example, you can run a backup of all your user accounts
|
||||
# at 5 a.m every week with:
|
||||
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
|
||||
#
|
||||
# For more information see the manual pages of crontab(5) and cron(8)
|
||||
#
|
||||
# m h dom mon dow command
|
||||
30 0 * * * /cron_jobs/power/ipmi_poweroff.py -H 192.168.129.240 >> /var/log/cron_scripts.log 2>&1
|
||||
30 0 * * * /cron_jobs/power/ipmi_poweroff.py -H 192.168.129.241 >> /var/log/cron_scripts.log 2>&1
|
||||
0 1 * * * /cron_jobs/power/ilo_poweroff.py -H 192.168.129.242 -a ForceOff --no-serverinfo >> /var/log/cron_scripts.log 2>&1
|
||||
0 5 * * * /cron_jobs/system/ilo_poweron.py -H 192.168.129.242 -a On --no-serverinfo >> /var/log/cron_scripts.log 2>&1
|
||||
10 5 * * * /cron_jobs/power/ipmi_poweron.py -H 192.168.129.240 >> /var/log/cron_scripts.log 2>&1
|
||||
10 5 * * * /cron_jobs/power/ipmi_poweron.py -H 192.168.129.241 >> /var/log/cron_scripts.log 2>&1
|
||||
35 0 * * * /cron_jobs/system/update_system.py >> /var/log/cron_scripts.log 2>&1
|
||||
*/3 * * * * /cron_jobs/fancontrol/set_fanspeed.py -H 192.168.129.240 >> /var/log/cron_scripts.log 2>&1
|
||||
*/3 * * * * /cron_jobs/fancontrol/set_fanspeed.py -H 192.168.129.241 >> /var/log/cron_scripts.log 2>&1
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import datetime
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
except ImportError:
|
||||
sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography")
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
DEFAULT_CREDENTIALS_FILE = "/root/cron_serverlists/credentials.ini"
|
||||
DEFAULT_KEYFILE_NAME = "secret.key"
|
||||
|
||||
# IPMI settings (loaded at runtime from credentials file)
|
||||
IPMIUSER = None
|
||||
IPMIPW = None
|
||||
IPMIEK = DEFAULT_EK
|
||||
|
||||
# Fan / temperature settings
|
||||
FANSPEED = 15 # percent
|
||||
MAXTEMP = 37 # Celsius
|
||||
|
||||
def build_ipmi_base():
|
||||
base = ["ipmitool", "-I", IPMIINTERFACE, "-H", IPMIHOST, "-U", IPMIUSER, "-P", IPMIPW, "-y", IPMIEK]
|
||||
if IPMICIPHER is not None:
|
||||
base += ["-C", str(IPMICIPHER)]
|
||||
return base
|
||||
|
||||
def has_ipmi_session_error(stderr_text):
|
||||
s = (stderr_text or "").lower()
|
||||
markers = [
|
||||
"unable to establish ipmi v2 / rmcp+ session",
|
||||
"rakp",
|
||||
"session timeout",
|
||||
"authentication type",
|
||||
"invalid user name",
|
||||
]
|
||||
return any(marker in s for marker in markers)
|
||||
|
||||
def run_ipmi(args):
|
||||
base = build_ipmi_base()
|
||||
cmd = base + args
|
||||
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if p.returncode != 0 or has_ipmi_session_error(p.stderr):
|
||||
raise RuntimeError(
|
||||
f"ipmitool failed: {' '.join(cmd)}\n"
|
||||
f"stdout: {(p.stdout or '').strip() or '<empty>'}\n"
|
||||
f"stderr: {(p.stderr or '').strip() or '<empty>'}"
|
||||
)
|
||||
return p.stdout
|
||||
|
||||
def run_ipmi_raw(args):
|
||||
base = build_ipmi_base()
|
||||
cmd = base + args
|
||||
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
return cmd, p.returncode, p.stdout, p.stderr
|
||||
|
||||
def log_systemd(tag, msg):
|
||||
try:
|
||||
subprocess.run(["systemd-cat", "-t", tag], input=msg + "\n", text=True, check=True)
|
||||
except Exception:
|
||||
# fallback to stdout
|
||||
ts_print(msg)
|
||||
|
||||
def ts_print(msg):
|
||||
"""ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"{now} {msg}")
|
||||
|
||||
|
||||
def is_fernet_token(value):
|
||||
return isinstance(value, str) and value.startswith("gAAAA")
|
||||
|
||||
|
||||
def load_fernet(keyfile):
|
||||
if not os.path.exists(keyfile):
|
||||
raise RuntimeError(f"key file not found: {keyfile}")
|
||||
|
||||
with open(keyfile, "rb") as fh:
|
||||
key = fh.read()
|
||||
|
||||
try:
|
||||
return Fernet(key)
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"invalid key format in key file: {keyfile}") from e
|
||||
|
||||
|
||||
def decrypt_password(value, fernet):
|
||||
# Support both encrypted and plaintext credentials for smoother migrations.
|
||||
if not is_fernet_token(value):
|
||||
return value
|
||||
|
||||
if fernet is None:
|
||||
raise RuntimeError(
|
||||
"encrypted password detected but key file was not found. "
|
||||
"Use --keyfile /path/to/secret.key"
|
||||
)
|
||||
|
||||
try:
|
||||
return fernet.decrypt(value.encode()).decode()
|
||||
except InvalidToken as e:
|
||||
raise RuntimeError("failed to decrypt password; key file may not match credentials file") from e
|
||||
|
||||
|
||||
def resolve_credentials_path(cli_path):
|
||||
if cli_path and os.path.exists(cli_path):
|
||||
return cli_path
|
||||
|
||||
candidates = []
|
||||
if cli_path:
|
||||
candidates.append(cli_path)
|
||||
|
||||
candidates.append(os.path.join(os.getcwd(), "credentials.ini"))
|
||||
if DEFAULT_CREDENTIALS_FILE not in candidates:
|
||||
candidates.append(DEFAULT_CREDENTIALS_FILE)
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError("credentials file not found. Looked in: " + ", ".join(candidates))
|
||||
|
||||
|
||||
def resolve_keyfile(cli_keyfile, credentials_path):
|
||||
if cli_keyfile:
|
||||
return cli_keyfile if os.path.exists(cli_keyfile) else None
|
||||
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(credentials_path), DEFAULT_KEYFILE_NAME),
|
||||
os.path.join(os.path.dirname(DEFAULT_CREDENTIALS_FILE), DEFAULT_KEYFILE_NAME),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_credentials(path, fernet):
|
||||
if not os.path.exists(path):
|
||||
raise RuntimeError(f"credentials file not found: {path}")
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
try:
|
||||
section = "ipmi_credentials"
|
||||
username = config.get(section, "username", fallback="root")
|
||||
encrypted_password = config.get(section, "password")
|
||||
password = decrypt_password(encrypted_password, fernet)
|
||||
encryption_key = config.get(
|
||||
section,
|
||||
"ipmiek",
|
||||
fallback=config.get(section, "encryption_key", fallback=DEFAULT_EK),
|
||||
)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
raise RuntimeError(f"error reading credentials file: {e}")
|
||||
|
||||
return username, password, encryption_key
|
||||
|
||||
def get_exhaust_temp():
|
||||
commands_to_try = [
|
||||
["sensor", "get", "Exhaust Temp"],
|
||||
["sdr", "type", "temperature"],
|
||||
["sensor", "list"],
|
||||
["sdr", "elist", "full"],
|
||||
]
|
||||
|
||||
debug_sections = []
|
||||
session_error_seen = False
|
||||
for args in commands_to_try:
|
||||
cmd, rc, out, err = run_ipmi_raw(args)
|
||||
if has_ipmi_session_error(err):
|
||||
session_error_seen = True
|
||||
cmd_text = " ".join(cmd)
|
||||
section = [
|
||||
f"Command: {cmd_text}",
|
||||
f"Return code: {rc}",
|
||||
"STDOUT:",
|
||||
out.strip() or "<empty>",
|
||||
"STDERR:",
|
||||
err.strip() or "<empty>",
|
||||
]
|
||||
debug_sections.append("\n".join(section))
|
||||
|
||||
# Parse explicit sensor-get output first.
|
||||
if args[:2] == ["sensor", "get"] and out:
|
||||
m = re.search(r"Sensor\s+Reading\s*:\s*(-?\d+(?:\.\d+)?)\b.*degrees\s*C", out, re.IGNORECASE)
|
||||
if m:
|
||||
return int(float(m.group(1)))
|
||||
|
||||
# Parse table outputs that have pipe-delimited sensor rows.
|
||||
for line in out.splitlines():
|
||||
parts = [p.strip() for p in line.split("|")]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
sensor_name = parts[0]
|
||||
normalized_name = " ".join(sensor_name.lower().split())
|
||||
if not (normalized_name == "exhaust temp" or normalized_name.startswith("exhaust temp ")):
|
||||
continue
|
||||
|
||||
for part in parts[1:]:
|
||||
m = re.search(r"(-?\d+(?:\.\d+)?)\s*degrees\s*C", part, re.IGNORECASE)
|
||||
if m:
|
||||
return int(float(m.group(1)))
|
||||
|
||||
if session_error_seen:
|
||||
raise RuntimeError(
|
||||
"Failed to establish an IPMI session. "
|
||||
"Check host/credentials, then try different options like "
|
||||
"--interface lan --cipher 3 or --interface lanplus --cipher 17.\n\n"
|
||||
+ "Debug output from attempted commands:\n\n"
|
||||
+ "\n\n---\n\n".join(debug_sections)
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
"Failed to read Exhaust Temp from ipmitool output.\n"
|
||||
+ "Debug output from attempted commands:\n\n"
|
||||
+ "\n\n---\n\n".join(debug_sections)
|
||||
)
|
||||
|
||||
def main():
|
||||
# parse CLI args (IPMI host can be overridden with -H/--host)
|
||||
parser = argparse.ArgumentParser(description="Set fan speed via IPMI")
|
||||
parser.add_argument("-H", "--host", required=True, help="IPMI host/IP")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--credentials",
|
||||
default=DEFAULT_CREDENTIALS_FILE,
|
||||
help="Path to credentials file (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keyfile",
|
||||
default=None,
|
||||
help="Path to Fernet key file for encrypted passwords (default: auto-detect next to credentials)",
|
||||
)
|
||||
parser.add_argument("--interface", default="lanplus", choices=["lanplus", "lan"], help="IPMI interface (default: %(default)s)")
|
||||
parser.add_argument("--cipher", type=int, default=None, help="Optional IPMI cipher suite ID (e.g. 3 or 17)")
|
||||
args = parser.parse_args()
|
||||
|
||||
global IPMIHOST, IPMIINTERFACE, IPMICIPHER, IPMIUSER, IPMIPW, IPMIEK
|
||||
# set globals so run_ipmi() uses provided connection settings and credentials
|
||||
IPMIHOST = args.host
|
||||
IPMIINTERFACE = args.interface
|
||||
IPMICIPHER = args.cipher
|
||||
credentials_path = resolve_credentials_path(args.credentials)
|
||||
keyfile_path = resolve_keyfile(args.keyfile, credentials_path)
|
||||
fernet = load_fernet(keyfile_path) if keyfile_path else None
|
||||
IPMIUSER, IPMIPW, IPMIEK = load_credentials(credentials_path, fernet)
|
||||
|
||||
ts_print(f"{IPMIHOST}: Fan speed check for host {IPMIHOST}")
|
||||
try:
|
||||
temp = get_exhaust_temp()
|
||||
ts_print(f"{IPMIHOST}: Exhaust temperature: {temp} C")
|
||||
except Exception as e:
|
||||
ts_print(f"{IPMIHOST}: Error reading temperature: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
speed_hex = format(FANSPEED, "x")
|
||||
|
||||
if temp > MAXTEMP:
|
||||
msg = f"{IPMIHOST}:Warning: Temperature is too high! Activating dynamic fan control! ({temp} C)"
|
||||
log_systemd("R710-IPMI-TEMP", msg)
|
||||
ts_print(msg)
|
||||
run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"])
|
||||
else:
|
||||
okmsg = f"{IPMIHOST}: Temperature is OK ({temp} C)"
|
||||
log_systemd("R710-IPMI-TEMP", okmsg)
|
||||
ts_print(okmsg)
|
||||
ts_print(f"{IPMIHOST}: Activating manual fan speeds! (requested %d%%)" % FANSPEED)
|
||||
run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
|
||||
run_ipmi(["raw", "0x30", "0x30", "0x02", "0xff", "0x" + speed_hex])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import sys
|
||||
import urllib3
|
||||
import redfish
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
except ImportError:
|
||||
sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography")
|
||||
|
||||
DEFAULT_CREDENTIALS_FILE = "/root/cron_serverlists/credentials.ini"
|
||||
DEFAULT_KEYFILE_NAME = "secret.key"
|
||||
|
||||
|
||||
def log(message, is_error=False):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
stream = sys.stderr if is_error else sys.stdout
|
||||
print(f"[{timestamp}] {message}", file=stream)
|
||||
|
||||
|
||||
def resolve_credentials_path(cli_path):
|
||||
"""Resolve credentials path. Prefer explicit CLI path, then search common locations."""
|
||||
if cli_path and os.path.exists(cli_path):
|
||||
return cli_path
|
||||
|
||||
candidates = []
|
||||
if cli_path:
|
||||
candidates.append(cli_path)
|
||||
candidates.append(os.path.join(os.getcwd(), "credentials.ini"))
|
||||
if DEFAULT_CREDENTIALS_FILE not in candidates:
|
||||
candidates.append(DEFAULT_CREDENTIALS_FILE)
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
log(
|
||||
"Error: credentials file not found. Looked in: " + ", ".join(candidates),
|
||||
is_error=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve_keyfile(cli_keyfile, credentials_path):
|
||||
"""Resolve keyfile path. Prefer explicit --keyfile, else search next to credentials file."""
|
||||
if cli_keyfile:
|
||||
return cli_keyfile if os.path.exists(cli_keyfile) else None
|
||||
|
||||
candidate = os.path.join(os.path.dirname(credentials_path), DEFAULT_KEYFILE_NAME)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_fernet(keyfile):
|
||||
"""Load Fernet instance from the key file created by set_password.py."""
|
||||
if not os.path.exists(keyfile):
|
||||
log(f"Error: key file not found: {keyfile}", is_error=True)
|
||||
sys.exit(1)
|
||||
with open(keyfile, "rb") as fh:
|
||||
key = fh.read()
|
||||
try:
|
||||
return Fernet(key)
|
||||
except ValueError:
|
||||
log(f"Error: invalid key format in key file: {keyfile}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def is_fernet_token(value):
|
||||
return isinstance(value, str) and value.startswith("gAAAA")
|
||||
|
||||
|
||||
def decrypt_password(encrypted_password, fernet):
|
||||
"""Decrypt password encrypted by set_password.py, or pass through plaintext."""
|
||||
if not is_fernet_token(encrypted_password):
|
||||
return encrypted_password
|
||||
if fernet is None:
|
||||
log(
|
||||
"Error: encrypted password detected but key file was not found. "
|
||||
"Use --keyfile /path/to/secret.key.",
|
||||
is_error=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
return fernet.decrypt(encrypted_password.encode()).decode()
|
||||
except InvalidToken:
|
||||
log("Error: failed to decrypt password. Check that --keyfile matches the credentials file.", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_ilo_credentials(path, fernet):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
section = "ilo_credentials"
|
||||
if not config.has_section(section):
|
||||
log(f"Error: missing [{section}] section in {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
username = config.get(section, "username", fallback="root")
|
||||
encrypted_password = config.get(section, "password", fallback=None)
|
||||
if not encrypted_password:
|
||||
log(f"Error: missing password in [{section}] section of {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
password = decrypt_password(encrypted_password, fernet)
|
||||
return username, password
|
||||
|
||||
|
||||
# Suppress SSL warnings for self-signed iLO certificates
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Get the power status of an HPE server via iLO Redfish")
|
||||
parser.add_argument("hostname", help="iLO hostname or IP address")
|
||||
parser.add_argument("-c", "--credentials", default=None,
|
||||
help="Path to credentials file (default: auto-detected credentials.ini)")
|
||||
parser.add_argument("--keyfile", default=None,
|
||||
help="Path to Fernet key file for decrypting passwords (default: auto-detected secret.key)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cred_path = resolve_credentials_path(args.credentials)
|
||||
keyfile = resolve_keyfile(args.keyfile, cred_path)
|
||||
fernet = load_fernet(keyfile) if keyfile else None
|
||||
|
||||
LOGIN_ACCOUNT, LOGIN_PASSWORD = load_ilo_credentials(cred_path, fernet)
|
||||
|
||||
BASE_URL = f"https://{args.hostname}"
|
||||
|
||||
REDFISHOBJ = redfish.RedfishClient(
|
||||
base_url=BASE_URL,
|
||||
username=LOGIN_ACCOUNT,
|
||||
password=LOGIN_PASSWORD,
|
||||
)
|
||||
REDFISHOBJ.login(auth="session")
|
||||
|
||||
sys_response = REDFISHOBJ.get("/redfish/v1/Systems/1/")
|
||||
power_state = sys_response.dict.get("PowerState", "Unknown")
|
||||
|
||||
log(f"Power State: {power_state}")
|
||||
|
||||
REDFISHOBJ.logout()
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import redfish
|
||||
import urllib3
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
except ImportError:
|
||||
sys.exit("Error: 'cryptography' package is required. Run: pip install cryptography")
|
||||
|
||||
DEFAULT_CREDENTIALS_FILE = "/root/cron_serverlists/credentials.ini"
|
||||
DEFAULT_KEYFILE_NAME = "/root/cron_serverlists/secret.key"
|
||||
DEFAULT_SERVER_LIST_FILE = "/root/cron_serverlists/serverlist.ini"
|
||||
|
||||
# Examples:
|
||||
# ./multi_powercycle.py on
|
||||
# ./multi_powercycle.py on --keyfile /root/cron_serverlists/secret.key
|
||||
# ./multi_powercycle.py off -f /root/cron_serverlists/serverlist.ini --keyfile /root/cron_serverlists/secret.key
|
||||
|
||||
# ./multi_powercycle.py on
|
||||
# ./multi_powercycle.py off
|
||||
# ./multi_powercycle.py on -f server_groups.txt
|
||||
#
|
||||
# File format:
|
||||
# [ipmi]
|
||||
# 192.168.129.240
|
||||
# 192.168.129.241
|
||||
#
|
||||
# [ilo]
|
||||
# 192.168.129.242
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
|
||||
|
||||
def load_fernet(keyfile: str):
|
||||
"""Load Fernet instance from the key file created by set_password.py."""
|
||||
if not os.path.exists(keyfile):
|
||||
log(f"Error: key file not found: {keyfile}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
with open(keyfile, "rb") as fh:
|
||||
key = fh.read()
|
||||
|
||||
try:
|
||||
return Fernet(key)
|
||||
except ValueError:
|
||||
log(f"Error: invalid key format in key file: {keyfile}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve_keyfile(cli_keyfile, ipmi_credentials_path, ilo_credentials_path):
|
||||
"""Resolve keyfile path. Prefer explicit --keyfile, else search next to credentials files."""
|
||||
if cli_keyfile:
|
||||
return cli_keyfile if os.path.exists(cli_keyfile) else None
|
||||
|
||||
candidates = []
|
||||
for cred_path in (ipmi_credentials_path, ilo_credentials_path):
|
||||
key_candidate = os.path.join(os.path.dirname(cred_path), DEFAULT_KEYFILE_NAME)
|
||||
if key_candidate not in candidates:
|
||||
candidates.append(key_candidate)
|
||||
|
||||
# Backward-compatible fallback
|
||||
legacy_default = os.path.join(os.path.dirname(DEFAULT_CREDENTIALS_FILE), DEFAULT_KEYFILE_NAME)
|
||||
if legacy_default not in candidates:
|
||||
candidates.append(legacy_default)
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_credentials_path(cli_path, server_file_path):
|
||||
"""Resolve credentials path. Prefer explicit CLI path, then search common locations."""
|
||||
if cli_path and os.path.exists(cli_path):
|
||||
return cli_path
|
||||
|
||||
candidates = []
|
||||
|
||||
if cli_path:
|
||||
candidates.append(cli_path)
|
||||
|
||||
# Credentials next to server list file is a common deployment pattern
|
||||
candidates.append(os.path.join(os.path.dirname(server_file_path), "credentials.ini"))
|
||||
|
||||
# Current working directory fallback
|
||||
candidates.append(os.path.join(os.getcwd(), "credentials.ini"))
|
||||
|
||||
# Legacy default fallback
|
||||
if DEFAULT_CREDENTIALS_FILE not in candidates:
|
||||
candidates.append(DEFAULT_CREDENTIALS_FILE)
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
log(
|
||||
"Error: credentials file not found. Looked in: " + ", ".join(candidates),
|
||||
is_error=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def is_fernet_token(value: str) -> bool:
|
||||
"""Best-effort check for Fernet token format from set_password.py."""
|
||||
return isinstance(value, str) and value.startswith("gAAAA")
|
||||
|
||||
|
||||
def decrypt_password(encrypted_password: str, fernet):
|
||||
"""Decrypt password encrypted by set_password.py, or pass through plaintext."""
|
||||
if not is_fernet_token(encrypted_password):
|
||||
return encrypted_password
|
||||
|
||||
if fernet is None:
|
||||
log(
|
||||
"Error: encrypted password detected but key file was not found. "
|
||||
"Use --keyfile /path/to/secret.key.",
|
||||
is_error=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
return fernet.decrypt(encrypted_password.encode()).decode()
|
||||
except InvalidToken:
|
||||
log("Error: failed to decrypt password. Check that --keyfile matches the credentials file.", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def log(message, is_error=False):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
stream = sys.stderr if is_error else sys.stdout
|
||||
print(f"[{timestamp}] {message}", file=stream)
|
||||
|
||||
|
||||
def parse_server_groups(path):
|
||||
if not os.path.exists(path):
|
||||
log(f"Error: server list file not found: {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
groups = {"ipmi": [], "ilo": []}
|
||||
current_group = None
|
||||
aliases = {
|
||||
"ipmi": "ipmi",
|
||||
"dell": "ipmi",
|
||||
"ilo": "ilo",
|
||||
}
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section_name = line[1:-1].strip().lower()
|
||||
current_group = aliases.get(section_name)
|
||||
if current_group is None:
|
||||
log(f"Error: unsupported server group [{section_name}] in {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
continue
|
||||
|
||||
if current_group is None:
|
||||
log(f"Error: host entry found before any section header in {path}: {line}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
groups[current_group].append(line)
|
||||
except OSError as exc:
|
||||
log(f"Error reading server list file: {exc}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def load_ipmi_credentials(path, fernet):
|
||||
if not os.path.exists(path):
|
||||
log(f"Error: IPMI credentials file not found: {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
section = "ipmi_credentials"
|
||||
if not config.has_section(section):
|
||||
log(f"Error reading IPMI credentials file: missing [{section}] section in {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
username = config.get(section, "username", fallback="root")
|
||||
encrypted_password = config.get(section, "password", fallback=None)
|
||||
encryption_key = config.get(
|
||||
section,
|
||||
"ipmiek",
|
||||
fallback=config.get(section, "encryption_key", fallback=DEFAULT_EK),
|
||||
)
|
||||
|
||||
if not encrypted_password:
|
||||
log(f"Error reading IPMI credentials file: missing password in [{section}] section of {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
password = decrypt_password(encrypted_password, fernet)
|
||||
return username, password, encryption_key
|
||||
|
||||
|
||||
def load_ilo_credentials(path, fernet):
|
||||
if not os.path.exists(path):
|
||||
log(f"Error: iLO credentials file not found: {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
section = "ilo_credentials"
|
||||
if not config.has_section(section):
|
||||
log(f"Error reading iLO credentials file: missing [{section}] section in {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
username = config.get(section, "username", fallback="root")
|
||||
encrypted_password = config.get(section, "password", fallback=None)
|
||||
if not encrypted_password:
|
||||
log(f"Error reading iLO credentials file: missing password in [{section}] section of {path}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
password = decrypt_password(encrypted_password, fernet)
|
||||
return username, password
|
||||
|
||||
|
||||
def ipmi_power(host, action, username, password, encryption_key):
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I",
|
||||
"lanplus",
|
||||
"-H",
|
||||
host,
|
||||
"-U",
|
||||
username,
|
||||
"-P",
|
||||
password,
|
||||
"-y",
|
||||
encryption_key,
|
||||
"power",
|
||||
action,
|
||||
]
|
||||
|
||||
log(f"Sending IPMI power {action} to {host}")
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.strip() or proc.stdout.strip() or "unknown error"
|
||||
log(f"IPMI {action} failed for {host}: {detail}", is_error=True)
|
||||
return False
|
||||
|
||||
detail = proc.stdout.strip() or f"power {action} request accepted"
|
||||
log(f"IPMI {action} succeeded for {host}: {detail}")
|
||||
return True
|
||||
|
||||
|
||||
def ilo_powercycle(host, username, password):
|
||||
base_url = f"https://{host}"
|
||||
client = redfish.RedfishClient(base_url=base_url, username=username, password=password)
|
||||
|
||||
try:
|
||||
client.login(auth="session")
|
||||
sys_response = client.get("/redfish/v1/Systems/1/")
|
||||
reset_uri = sys_response.dict["Actions"]["#ComputerSystem.Reset"]["target"]
|
||||
response = client.post(reset_uri, body={"ResetType": "PushPowerButton"})
|
||||
|
||||
log(f"iLO powercycle status for {host}: {response.status}")
|
||||
if response.status >= 400:
|
||||
log(f"iLO powercycle failed for {host}: {response.read}", is_error=True)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
log(f"iLO powercycle failed for {host}: {exc}", is_error=True)
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dispatch power operations to IPMI hosts and iLO hosts from a grouped server list"
|
||||
)
|
||||
parser.add_argument("action", choices=("on", "off"), help="Power action for IPMI hosts")
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--file",
|
||||
dest="server_file",
|
||||
default=DEFAULT_SERVER_LIST_FILE,
|
||||
help=f"Path to server list file (default: {DEFAULT_SERVER_LIST_FILE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ipmi-credentials",
|
||||
default=DEFAULT_CREDENTIALS_FILE,
|
||||
help=f"Path to IPMI credentials file (default: {DEFAULT_CREDENTIALS_FILE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ilo-credentials",
|
||||
default=DEFAULT_CREDENTIALS_FILE,
|
||||
help=f"Path to iLO credentials file (default: {DEFAULT_CREDENTIALS_FILE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keyfile",
|
||||
default=None,
|
||||
help="Path to Fernet key file created by set_password.py (default: auto-detect next to credentials file)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
args.ipmi_credentials = resolve_credentials_path(args.ipmi_credentials, args.server_file)
|
||||
args.ilo_credentials = resolve_credentials_path(args.ilo_credentials, args.server_file)
|
||||
|
||||
keyfile = resolve_keyfile(args.keyfile, args.ipmi_credentials, args.ilo_credentials)
|
||||
fernet = load_fernet(keyfile) if keyfile else None
|
||||
|
||||
groups = parse_server_groups(args.server_file)
|
||||
total_hosts = len(groups["ipmi"]) + len(groups["ilo"])
|
||||
if total_hosts == 0:
|
||||
log(f"Error: no servers found in {args.server_file}", is_error=True)
|
||||
sys.exit(1)
|
||||
|
||||
log(
|
||||
f"Starting run for {total_hosts} host(s): {len(groups['ipmi'])} IPMI host(s), {len(groups['ilo'])} iLO host(s)"
|
||||
)
|
||||
|
||||
ipmi_creds = None
|
||||
ilo_creds = None
|
||||
failures = 0
|
||||
|
||||
if groups["ipmi"]:
|
||||
ipmi_creds = load_ipmi_credentials(args.ipmi_credentials, fernet)
|
||||
for host in groups["ipmi"]:
|
||||
if not ipmi_power(host, args.action, *ipmi_creds):
|
||||
failures += 1
|
||||
|
||||
if groups["ilo"]:
|
||||
ilo_creds = load_ilo_credentials(args.ilo_credentials, fernet)
|
||||
for host in groups["ilo"]:
|
||||
if not ilo_powercycle(host, *ilo_creds):
|
||||
failures += 1
|
||||
|
||||
successes = total_hosts - failures
|
||||
log(f"Completed run: {successes} successful, {failures} failed")
|
||||
if failures:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
# Configuration
|
||||
|
||||
# If started by root (e.g., root crontab), re-run as postgres.
|
||||
if [ "$(id -u)" -eq 0 ] && [ "$(id -un)" != "postgres" ]; then
|
||||
if command -v runuser >/dev/null 2>&1; then
|
||||
exec runuser -u postgres -- "$0" "$@"
|
||||
fi
|
||||
|
||||
# Fallback for systems without runuser.
|
||||
exec su - postgres -s /bin/bash -c "$(printf '%q ' "$0" "$@")"
|
||||
fi
|
||||
|
||||
# Avoid permission warnings when root launches this script and postgres inherits /root as cwd.
|
||||
cd / || exit 1
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
||||
|
||||
: "${WAL_CLEANUP_ENABLED:=1}"
|
||||
WAL_ARCHIVE_DIR="/var/lib/postgresql_archive/18/main/pg_wal"
|
||||
WAL_MIN_AGE_DAYS=7
|
||||
|
||||
clean_walfiles() {
|
||||
local newest_backup=""
|
||||
local newest_backup_ts=""
|
||||
local newest_backup_dt=""
|
||||
local age_cutoff_ts=""
|
||||
local age_cutoff_dt=""
|
||||
local backup_ts=""
|
||||
local file_ts=""
|
||||
local deleted=0
|
||||
local backup_file=""
|
||||
local file=""
|
||||
local -a backup_files=()
|
||||
local -a files_to_delete=()
|
||||
|
||||
if [[ ! -d "$WAL_ARCHIVE_DIR" ]]; then
|
||||
log "Archive directory does not exist: $WAL_ARCHIVE_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! [[ "$WAL_MIN_AGE_DAYS" =~ ^[0-9]+$ ]]; then
|
||||
log "WAL_MIN_AGE_DAYS must be a non-negative integer" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mapfile -d '' backup_files < <(find "$WAL_ARCHIVE_DIR" -maxdepth 1 -type f -name '*.backup' -print0)
|
||||
|
||||
if [[ ${#backup_files[@]} -eq 0 ]]; then
|
||||
log "No .backup files found in $WAL_ARCHIVE_DIR; skipping WAL cleanup."
|
||||
return 0
|
||||
fi
|
||||
|
||||
for backup_file in "${backup_files[@]}"; do
|
||||
backup_ts=$(stat -c %Y "$backup_file")
|
||||
if [[ -z "$newest_backup_ts" || "$backup_ts" -gt "$newest_backup_ts" ]]; then
|
||||
newest_backup_ts="$backup_ts"
|
||||
newest_backup="$backup_file"
|
||||
fi
|
||||
done
|
||||
|
||||
age_cutoff_ts=$(date -d "$WAL_MIN_AGE_DAYS days ago" +%s)
|
||||
newest_backup_dt=$(date -d "@$newest_backup_ts" '+%Y-%m-%d %H:%M:%S')
|
||||
age_cutoff_dt=$(date -d "@$age_cutoff_ts" '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
log "WAL archive dir : $WAL_ARCHIVE_DIR"
|
||||
log "Newest .backup file : $newest_backup"
|
||||
log "Newest .backup mtime : $newest_backup_dt"
|
||||
log "WAL age cutoff : $age_cutoff_dt"
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
file_ts=$(stat -c %Y "$file")
|
||||
|
||||
# Delete only when both constraints are true.
|
||||
if [[ "$file_ts" -lt "$newest_backup_ts" && "$file_ts" -lt "$age_cutoff_ts" ]]; then
|
||||
files_to_delete+=("$file")
|
||||
fi
|
||||
done < <(find "$WAL_ARCHIVE_DIR" -maxdepth 1 -type f ! -name '*.backup' -print0)
|
||||
|
||||
# Also delete old .backup files (except the newest one) that exceed retention period
|
||||
for backup_file in "${backup_files[@]}"; do
|
||||
if [[ "$backup_file" != "$newest_backup" ]]; then
|
||||
backup_ts=$(stat -c %Y "$backup_file")
|
||||
if [[ "$backup_ts" -lt "$age_cutoff_ts" ]]; then
|
||||
files_to_delete+=("$backup_file")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#files_to_delete[@]} -eq 0 ]]; then
|
||||
log "No WAL files matched cleanup criteria."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "WAL files matching cleanup criteria (${#files_to_delete[@]}):"
|
||||
for file in "${files_to_delete[@]}"; do
|
||||
log " $file"
|
||||
done
|
||||
|
||||
for file in "${files_to_delete[@]}"; do
|
||||
rm -f -- "$file"
|
||||
((deleted += 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
log "Deleted $deleted WAL file(s)."
|
||||
}
|
||||
|
||||
log "Beginning base backup."
|
||||
BASEBACKUP_ROOT="/var/lib/postgresql_archive/pgbackups"
|
||||
BACKUP_DIR="/var/lib/postgresql_archive/pgbackups/basebackup"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
# Perform base backup with WAL streaming
|
||||
pg_basebackup -h localhost -U wjones -D "$BACKUP_DIR" -Fp -Xs -P
|
||||
# Optional: Compress
|
||||
BASEBACKUP_ARCHIVE="${BACKUP_DIR}_$(date +%Y%m%d_%H%M%S).tar.gz"
|
||||
tar -czf "$BASEBACKUP_ARCHIVE" -C "$(dirname "$BACKUP_DIR")" "$(basename "$BACKUP_DIR")"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
# Retention: Delete backups older than 7 days
|
||||
find "$BASEBACKUP_ROOT" -type f -name "*.tar.gz" -mtime +7 -delete
|
||||
|
||||
log "Base backup completed.Now backing up individual databases."
|
||||
|
||||
BACKUP_DIR="/var/lib/postgresql_archive/pg_dumpfiles"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
RETENTION_DAYS=7
|
||||
|
||||
# Ensure directory exists
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Get all non-system databases and back each one up.
|
||||
psql -U postgres -d postgres -Atc "SELECT datname FROM pg_database WHERE datistemplate = false AND datname <> 'postgres' ORDER BY datname" | while IFS= read -r DB_NAME; do
|
||||
pg_dump -U postgres -d "$DB_NAME" | gzip > "$BACKUP_DIR/${DB_NAME}_$TIMESTAMP.sql.gz"
|
||||
log "Backup completed: ${DB_NAME}_$TIMESTAMP.sql.gz"
|
||||
done
|
||||
|
||||
# Delete backups older than retention period
|
||||
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete
|
||||
|
||||
log "Individual database backups completed and old backups cleaned up."
|
||||
if [[ "$WAL_CLEANUP_ENABLED" -eq 1 ]]; then
|
||||
log "Starting WAL archive cleanup."
|
||||
clean_walfiles
|
||||
else
|
||||
log "WAL archive cleanup disabled; skipping final cleanup."
|
||||
fi
|
||||
log "Backup workflow completed."
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import getpass
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
def ts_print(msg):
|
||||
"""ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"{now} {msg}")
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Command failed: {' '.join(cmd)}", file=sys.stderr)
|
||||
sys.exit(e.returncode)
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Run iLO REST commands (login, serverinfo, reboot)")
|
||||
p.add_argument("-H", "--host", required=True, help="iLO host/IP")
|
||||
p.add_argument("-a", "--action", default="ForceOff", choices=["ForceOff", "GracefulRestart", "Reset"],
|
||||
help="reboot action to run (default: ForceOff)")
|
||||
p.add_argument("--no-serverinfo", action="store_true", help="skip running serverinfo")
|
||||
args = p.parse_args()
|
||||
|
||||
# login
|
||||
ts_print(f"Sending {args.action} to {args.host}")
|
||||
run(['ilorest', 'login', args.host, '-u', '<username>', '-p', '<password>'])
|
||||
# optional serverinfo
|
||||
if not args.no_serverinfo:
|
||||
run(['ilorest', 'serverinfo'])
|
||||
# power off / reboot action
|
||||
run(['ilorest', 'reboot', args.action])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import getpass
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
def ts_print(msg):
|
||||
"""ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"{now} {msg}")
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Command failed: {' '.join(cmd)}", file=sys.stderr)
|
||||
sys.exit(e.returncode)
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Login to iLO, show serverinfo and power on")
|
||||
p.add_argument("-H", "--host", required=True, help="iLO host/IP")
|
||||
p.add_argument("-a", "--action", default="On", choices=["On","Off","GracefulRestart","ForceOff","Reset"],
|
||||
help="power/reboot action to run (default: On)")
|
||||
p.add_argument("--no-serverinfo", action="store_true", help="skip running serverinfo")
|
||||
args = p.parse_args()
|
||||
|
||||
ts_print(f"Sending {args.action} to {args.host}")
|
||||
run(['ilorest', 'login', args.host, '-u', '<username>', '-p', '<password>'])
|
||||
if not args.no_serverinfo:
|
||||
run(['ilorest', 'serverinfo'])
|
||||
run(['ilorest', 'reboot', args.action])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
IPMIUSER = "<username>"
|
||||
IPMIPW = "<password>"
|
||||
IPMIEK = "0000000000000000000000000000000000000000"
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power off")
|
||||
p.add_argument("-H", "--host", required=True, help="IPMI host address")
|
||||
args = p.parse_args()
|
||||
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", args.host,
|
||||
"-U", IPMIUSER,
|
||||
"-P", IPMIPW,
|
||||
"-y", IPMIEK,
|
||||
"power", "off",
|
||||
]
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
print("ipmitool failed:", proc.stderr.strip(), file=sys.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
print(proc.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
def ts_print(msg):
|
||||
"""ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"{now} {msg}")
|
||||
|
||||
IPMIUSER = "<username>"
|
||||
IPMIPW = "<password>"
|
||||
IPMIEK = "0000000000000000000000000000000000000000"
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power off")
|
||||
p.add_argument("-H", "--host", required=True, help="IPMI host address")
|
||||
args = p.parse_args()
|
||||
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", args.host,
|
||||
"-U", IPMIUSER,
|
||||
"-P", IPMIPW,
|
||||
"-y", IPMIEK,
|
||||
"power", "on",
|
||||
]
|
||||
print(f"Sending power on to {args.host}")
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
print("ipmitool failed:", proc.stderr.strip(), file=sys.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
print(proc.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/python3
|
||||
import subprocess
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def generate_timestamp(fmt: Optional[str] = None, utc: bool = False) -> str:
|
||||
"""Return a timestamp string.
|
||||
|
||||
- If `fmt` is provided, return `datetime.strftime(fmt)`.
|
||||
- If `fmt` is None, return an ISO 8601 timestamp. When `utc` is True
|
||||
the timestamp ends with 'Z' (UTC). When `utc` is False it returns
|
||||
the local time ISO format.
|
||||
|
||||
Examples:
|
||||
- `generate_timestamp()` -> '2025-11-20T12:34:56Z'
|
||||
- `generate_timestamp("%Y-%m-%d %H:%M:%S")` -> '2025-11-20 12:34:56'
|
||||
"""
|
||||
if fmt:
|
||||
dt = datetime.now(timezone.utc) if utc else datetime.now()
|
||||
return dt.strftime(fmt)
|
||||
|
||||
dt = datetime.now(timezone.utc) if utc else datetime.now()
|
||||
if utc:
|
||||
# force trailing Z for UTC instead of +00:00
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
return dt.isoformat()
|
||||
|
||||
print(f"Starting system update at {generate_timestamp()}")
|
||||
# Update package lists
|
||||
print(f"{generate_timestamp()}: Updating package lists...")
|
||||
subprocess.run(['sudo', 'apt', 'update'], check=True)
|
||||
# Upgrade packages
|
||||
print(f"{generate_timestamp()}: Upgrading packages...")
|
||||
subprocess.run(['sudo', 'apt', 'upgrade', '-y'], check=True)
|
||||
|
||||
# Check if reboot is required
|
||||
if os.path.exists('/var/run/reboot-required'):
|
||||
print(f"{generate_timestamp()}: Reboot required. Rebooting now...")
|
||||
subprocess.run(['sudo', 'reboot'])
|
||||
else:
|
||||
print(f"{generate_timestamp()}: No reboot required.")
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ def get_time():
|
||||
|
||||
def client_shutdown(client_type, clientid):
|
||||
lxc_filepath = "/usr/bin/lxc-stop"
|
||||
qemu_filepath = f"/usr/sbin/qm shutdown {clientid}"
|
||||
qemu_filepath = "/usr/sbin/qm"
|
||||
try:
|
||||
print(f'Stopping {clientid}')
|
||||
if client_type == 'vm':
|
||||
process_id = os.spawnv(os.P_NOWAIT, qemu_filepath, ['--skip-lock', 'true'])
|
||||
process_id = os.spawnv(os.P_NOWAIT, qemu_filepath, ['shutdown', clientid])
|
||||
elif client_type == 'lxc':
|
||||
process_id = os.spawnv(os.P_NOWAIT, lxc_filepath, [client_type, clientid])
|
||||
print(process_id)
|
||||
@@ -37,7 +37,6 @@ def main():
|
||||
print(f'Start time: {timestamp}')
|
||||
for clientid in client_list:
|
||||
client_shutdown(client_type, clientid)
|
||||
sleep(1)
|
||||
else:
|
||||
print(f'Successfully stopped client list.')
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# IPMI IP address
|
||||
IPMIHOST=<hostname>
|
||||
# IPMI Username
|
||||
IPMIUSER=<username
|
||||
# IPMI Password
|
||||
IPMIPW=<password>
|
||||
# Your IPMI Encryption Key
|
||||
IPMIEK=0<IPMI Encryption Key>
|
||||
# Fan Speed / utilisation in percentage, for example 9 for 9% utilisation
|
||||
# Please note that each fan can have a different rpm and will not all be the same speed
|
||||
FANSPEED=10
|
||||
|
||||
# TEMPERATURE
|
||||
# Change this to the temperature in Celsius you are comfortable with.
|
||||
# If the temperature goes above the set degrees it will send raw IPMI command to enable dynamic fan control
|
||||
MAXTEMP=30
|
||||
|
||||
# This variable sends a IPMI command to get the temperature, and outputs it as two digits.
|
||||
# Do not edit unless you know what you do.
|
||||
# Side note, if you are running ipmitool on the system you are controlling, you don't need to specify -H,-U,-P - from the OS installed on the host, ipmitool is assumed permitted. You only need host/user/pass for remote access.
|
||||
TEMP=$(ipmitool -I lanplus -H $IPMIHOST -U $IPMIUSER -P $IPMIPW -y $IPMIEK sdr type temperature |grep Ambient |grep degrees |grep -Po '\d{2}' | tail -1)
|
||||
|
||||
# Dont edit this, this converts decimal to hex
|
||||
SPEEDHEX=$( printf "%x" $FANSPEED )
|
||||
|
||||
if [[ $TEMP > $MAXTEMP ]];
|
||||
then
|
||||
printf "Warning: Temperature is too high! Activating dynamic fan control! ($TEMP C)" | systemd-cat -t R710-IPMI-TEMP
|
||||
echo "Warning: Temperature is too high! Activating dynamic fan control! ($TEMP C)"
|
||||
# This sets the fans to auto mode, so the motherboard will set it to a speed that it will need do cool the server down
|
||||
ipmitool -I lanplus -H $IPMIHOST -U $IPMIUSER -P $IPMIPW -y $IPMIEK raw 0x30 0x30 0x01 0x01
|
||||
else
|
||||
printf "Temperature is OK ($TEMP C)" | systemd-cat -t R710-IPMI-TEMP
|
||||
printf "Activating manual fan speeds! (1560 RPM)"
|
||||
# This sets the fans to manual mode
|
||||
ipmitool -I lanplus -H $IPMIHOST -U $IPMIUSER -P $IPMIPW -y $IPMIEK raw 0x30 0x30 0x01 0x00
|
||||
# This is where we set the slower, quite speed
|
||||
ipmitool -I lanplus -H $IPMIHOST -U $IPMIUSER -P $IPMIPW -y $IPMIEK raw 0x30 0x30 0x02 0xff 0x$SPEEDHEX
|
||||
fi
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
ipmitool -I lanplus -H 192.168.128.223 -U root -P Optimus0329 -y 0000000000000000000000000000000000000000 power off
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
ipmitool -I lanplus -H 192.168.128.223 -U root -P Optimus0329 -y 0000000000000000000000000000000000000000 power on
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Client Type: $1"
|
||||
echo "Client IDs: $2"
|
||||
client_string=$2
|
||||
client_list=()
|
||||
|
||||
IFS=', ' read -a array <<< "${client_string}"; echo "${array[@]}"
|
||||
|
||||
for index in "${!array[@]}"
|
||||
do
|
||||
echo "Starting container: ${array[index]}"
|
||||
lxc-start ${array[index]}
|
||||
done
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Airsonic Advanced + PostgreSQL diagnostics
|
||||
# Focus: slow media scans, index efficiency, write pressure, and cache tuning signals.
|
||||
|
||||
PGHOST="${PGHOST:-localhost}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
PGDATABASE="${PGDATABASE:-airsonic}"
|
||||
OUT_FILE="${1:-airsonic_pg_diagnostics_$(date +%Y%m%d_%H%M%S).txt}"
|
||||
|
||||
PSQL=(psql -X -v ON_ERROR_STOP=1 -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE")
|
||||
|
||||
run_section() {
|
||||
local title="$1"
|
||||
local sql="$2"
|
||||
|
||||
{
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo "$title"
|
||||
echo "================================================================"
|
||||
} >> "$OUT_FILE"
|
||||
|
||||
if ! "${PSQL[@]}" -c "$sql" >> "$OUT_FILE" 2>&1; then
|
||||
echo "Query failed for section: $title" >> "$OUT_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
query_single_value() {
|
||||
local sql="$1"
|
||||
"${PSQL[@]}" -Atc "$sql" 2>/dev/null || true
|
||||
}
|
||||
|
||||
bytes_to_human() {
|
||||
local bytes="$1"
|
||||
if [[ -z "$bytes" || ! "$bytes" =~ ^[0-9]+$ ]]; then
|
||||
echo "unknown"
|
||||
return
|
||||
fi
|
||||
|
||||
local kib=$((1024))
|
||||
local mib=$((1024 * 1024))
|
||||
local gib=$((1024 * 1024 * 1024))
|
||||
|
||||
if (( bytes >= gib )); then
|
||||
awk -v b="$bytes" -v g="$gib" 'BEGIN { printf "%.2f GiB", b/g }'
|
||||
elif (( bytes >= mib )); then
|
||||
awk -v b="$bytes" -v m="$mib" 'BEGIN { printf "%.2f MiB", b/m }'
|
||||
elif (( bytes >= kib )); then
|
||||
awk -v b="$bytes" -v k="$kib" 'BEGIN { printf "%.2f KiB", b/k }'
|
||||
else
|
||||
echo "${bytes} B"
|
||||
fi
|
||||
}
|
||||
|
||||
{
|
||||
echo "Airsonic PostgreSQL Diagnostics"
|
||||
echo "Generated at: $(date -Iseconds)"
|
||||
echo "Target: host=$PGHOST port=$PGPORT db=$PGDATABASE user=$PGUSER"
|
||||
} > "$OUT_FILE"
|
||||
|
||||
run_section "1) Server identity and uptime" "
|
||||
SELECT version();
|
||||
SELECT now() AS current_time, pg_postmaster_start_time() AS postmaster_start_time;
|
||||
"
|
||||
|
||||
run_section "2) Key PostgreSQL tuning parameters" "
|
||||
SELECT name, setting, unit, short_desc
|
||||
FROM pg_settings
|
||||
WHERE name IN (
|
||||
'shared_buffers',
|
||||
'work_mem',
|
||||
'maintenance_work_mem',
|
||||
'effective_cache_size',
|
||||
'max_connections',
|
||||
'effective_io_concurrency',
|
||||
'random_page_cost',
|
||||
'seq_page_cost',
|
||||
'checkpoint_timeout',
|
||||
'checkpoint_completion_target',
|
||||
'autovacuum',
|
||||
'autovacuum_max_workers',
|
||||
'autovacuum_naptime'
|
||||
)
|
||||
ORDER BY name;
|
||||
"
|
||||
|
||||
run_section "3) Database cache hit ratio and temp usage" "
|
||||
SELECT
|
||||
datname,
|
||||
blks_read,
|
||||
blks_hit,
|
||||
ROUND(100.0 * blks_hit / NULLIF(blks_hit + blks_read, 0), 2) AS cache_hit_pct,
|
||||
temp_files,
|
||||
pg_size_pretty(temp_bytes) AS temp_bytes,
|
||||
deadlocks,
|
||||
stats_reset
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database();
|
||||
"
|
||||
|
||||
run_section "4) Most read tables and their index-vs-seq scan profile" "
|
||||
SELECT
|
||||
relname,
|
||||
seq_scan,
|
||||
idx_scan,
|
||||
n_live_tup,
|
||||
n_dead_tup,
|
||||
ROUND((100.0 * idx_scan / NULLIF(idx_scan + seq_scan, 0))::numeric, 2) AS index_scan_pct,
|
||||
ROUND((100.0 * heap_blks_hit / NULLIF(heap_blks_hit + heap_blks_read, 0))::numeric, 2) AS table_cache_hit_pct
|
||||
FROM pg_stat_user_tables t
|
||||
JOIN pg_statio_user_tables io USING (relid)
|
||||
ORDER BY (seq_scan + idx_scan) DESC NULLS LAST
|
||||
LIMIT 25;
|
||||
"
|
||||
|
||||
run_section "5) Potentially unused indexes (can slow writes during scans/indexing)" "
|
||||
SELECT
|
||||
s.schemaname,
|
||||
s.relname AS table_name,
|
||||
s.indexrelname AS index_name,
|
||||
s.idx_scan,
|
||||
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
|
||||
t.n_tup_ins + t.n_tup_upd + t.n_tup_del AS table_writes_since_reset
|
||||
FROM pg_stat_user_indexes s
|
||||
JOIN pg_index i ON i.indexrelid = s.indexrelid
|
||||
JOIN pg_stat_user_tables t ON t.relid = s.relid
|
||||
WHERE s.idx_scan = 0
|
||||
AND NOT i.indisunique
|
||||
AND NOT i.indisprimary
|
||||
ORDER BY pg_relation_size(s.indexrelid) DESC
|
||||
LIMIT 50;
|
||||
"
|
||||
|
||||
run_section "6) Write-heavy tables (hot spot during media metadata indexing)" "
|
||||
SELECT
|
||||
relname,
|
||||
n_tup_ins,
|
||||
n_tup_upd,
|
||||
n_tup_del,
|
||||
n_tup_hot_upd,
|
||||
n_dead_tup,
|
||||
vacuum_count,
|
||||
autovacuum_count,
|
||||
analyze_count,
|
||||
autoanalyze_count,
|
||||
COALESCE(last_autovacuum::text, 'never') AS last_autovacuum,
|
||||
COALESCE(last_autoanalyze::text, 'never') AS last_autoanalyze
|
||||
FROM pg_stat_user_tables
|
||||
ORDER BY (n_tup_ins + n_tup_upd + n_tup_del) DESC
|
||||
LIMIT 25;
|
||||
"
|
||||
|
||||
run_section "7) Largest relations (tables and indexes)" "
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
c.relname,
|
||||
c.relkind,
|
||||
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
AND c.relkind IN ('r', 'i')
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC
|
||||
LIMIT 30;
|
||||
"
|
||||
|
||||
if [[ "$(query_single_value "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements';")" == "1" ]]; then
|
||||
run_section "8) Slowest normalized SQL from pg_stat_statements" "
|
||||
SELECT
|
||||
calls,
|
||||
ROUND(total_exec_time::numeric, 2) AS total_exec_ms,
|
||||
ROUND((total_exec_time / NULLIF(calls, 0))::numeric, 2) AS mean_exec_ms,
|
||||
ROUND(rows::numeric / NULLIF(calls, 0), 2) AS avg_rows,
|
||||
shared_blks_read,
|
||||
shared_blks_hit,
|
||||
temp_blks_read,
|
||||
temp_blks_written,
|
||||
LEFT(query, 300) AS query_sample
|
||||
FROM pg_stat_statements
|
||||
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
ORDER BY mean_exec_ms DESC
|
||||
LIMIT 20;
|
||||
"
|
||||
else
|
||||
{
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo "8) Slowest normalized SQL from pg_stat_statements"
|
||||
echo "================================================================"
|
||||
echo "Extension pg_stat_statements is not installed."
|
||||
echo "Install with: CREATE EXTENSION pg_stat_statements;"
|
||||
} >> "$OUT_FILE"
|
||||
fi
|
||||
|
||||
# Heuristic recommendation summary
|
||||
CACHE_HIT_PCT="$(query_single_value "
|
||||
SELECT ROUND(100.0 * blks_hit / NULLIF(blks_hit + blks_read, 0), 2)
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database();")"
|
||||
|
||||
UNUSED_INDEX_COUNT="$(query_single_value "
|
||||
SELECT COUNT(*)
|
||||
FROM pg_stat_user_indexes s
|
||||
JOIN pg_index i ON i.indexrelid = s.indexrelid
|
||||
WHERE s.idx_scan = 0
|
||||
AND NOT i.indisunique
|
||||
AND NOT i.indisprimary;")"
|
||||
|
||||
SHARED_BUFFERS_BYTES="$(query_single_value "
|
||||
SELECT pg_size_bytes(setting || unit)
|
||||
FROM pg_settings
|
||||
WHERE name = 'shared_buffers';")"
|
||||
|
||||
TOTAL_RAM_KB=""
|
||||
if [[ -r /proc/meminfo ]]; then
|
||||
TOTAL_RAM_KB="$(awk '/MemTotal:/ {print $2}' /proc/meminfo)"
|
||||
fi
|
||||
|
||||
{
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo "9) Recommendations summary"
|
||||
echo "================================================================"
|
||||
|
||||
if [[ -n "$CACHE_HIT_PCT" ]]; then
|
||||
echo "Cache hit ratio: ${CACHE_HIT_PCT}%"
|
||||
awk -v hit="$CACHE_HIT_PCT" 'BEGIN {
|
||||
if (hit < 98.0) {
|
||||
print "- Action: cache hit ratio is low for media workloads. Increase shared_buffers/effective_cache_size and review slow seq scans."
|
||||
} else if (hit < 99.0) {
|
||||
print "- Action: cache hit ratio is acceptable but can likely improve for large libraries."
|
||||
} else {
|
||||
print "- Action: cache hit ratio looks healthy."
|
||||
}
|
||||
}'
|
||||
else
|
||||
echo "Cache hit ratio: unavailable"
|
||||
fi
|
||||
|
||||
if [[ -n "$UNUSED_INDEX_COUNT" ]]; then
|
||||
echo "Potentially unused non-unique indexes: $UNUSED_INDEX_COUNT"
|
||||
if [[ "$UNUSED_INDEX_COUNT" =~ ^[0-9]+$ ]] && (( UNUSED_INDEX_COUNT > 0 )); then
|
||||
echo "- Action: validate and drop truly unused indexes to reduce write overhead during scans/indexing."
|
||||
else
|
||||
echo "- Action: no obvious unused non-unique indexes from current stats."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$SHARED_BUFFERS_BYTES" ]]; then
|
||||
echo "shared_buffers: $(bytes_to_human "$SHARED_BUFFERS_BYTES")"
|
||||
if [[ -n "$TOTAL_RAM_KB" && "$TOTAL_RAM_KB" =~ ^[0-9]+$ ]]; then
|
||||
TOTAL_RAM_BYTES=$((TOTAL_RAM_KB * 1024))
|
||||
SHARED_BUFFER_PCT=$(awk -v s="$SHARED_BUFFERS_BYTES" -v t="$TOTAL_RAM_BYTES" 'BEGIN { printf "%.2f", (s/t)*100 }')
|
||||
echo "Estimated shared_buffers/system RAM: ${SHARED_BUFFER_PCT}%"
|
||||
awk -v p="$SHARED_BUFFER_PCT" 'BEGIN {
|
||||
if (p < 10.0) {
|
||||
print "- Action: shared_buffers appears low; typical starting point is ~15-25% of system RAM."
|
||||
} else if (p > 40.0) {
|
||||
print "- Action: shared_buffers appears high; ensure OS cache remains adequate."
|
||||
} else {
|
||||
print "- Action: shared_buffers proportion looks reasonable."
|
||||
}
|
||||
}'
|
||||
else
|
||||
echo "- Action: compare shared_buffers to system RAM manually (target often 15-25% for dedicated DB hosts)."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "- Action: for media scans, verify high-read tables use selective indexes and that autovacuum keeps dead tuples controlled."
|
||||
echo "- Action: rerun this script before and during a scan to compare write pressure and cache behavior over time."
|
||||
} >> "$OUT_FILE"
|
||||
|
||||
echo "Diagnostics complete. Report written to: $OUT_FILE"
|
||||
@@ -0,0 +1,536 @@
|
||||
#Requires -Version 7.0
|
||||
# =============================================================================
|
||||
# batch_rsync.ps1 — Parallel rsync launcher for Windows PowerShell
|
||||
# =============================================================================
|
||||
#
|
||||
# KEY IMPLEMENTATION NOTES
|
||||
# ------------------------
|
||||
#
|
||||
# Parallelism
|
||||
# Uses RunspacePool + [PowerShell]::Create() so multiple rsync processes
|
||||
# run concurrently without blocking the main thread. All runspace script
|
||||
# blocks are self-contained (no closures over outer variables).
|
||||
#
|
||||
# As-completed result collection
|
||||
# WaitHandle.WaitAny() polls the async handles so results are printed as
|
||||
# soon as each task finishes, matching the behaviour of Python's as_completed().
|
||||
#
|
||||
# --files-from (recursive-files mode)
|
||||
# rsync is fed a NUL-delimited temp file via --from0 --files-from=<tmpfile>
|
||||
# rather than stdin, because stdin is not reliably available inside a
|
||||
# runspace. The temp file is always cleaned up in a finally block.
|
||||
#
|
||||
# Path handling
|
||||
# All source paths are converted from Windows backslashes to forward slashes
|
||||
# before being passed to rsync, which is required for MSYS2/Cygwin rsync
|
||||
# to interpret them correctly (e.g. C:/data/... not C:\data\...).
|
||||
#
|
||||
# Argument passing to rsync
|
||||
# Uses ProcessStartInfo.ArgumentList (System.Collections.Generic.List<string>)
|
||||
# rather than a single argument string. This avoids quoting/escaping issues
|
||||
# with paths that contain spaces or special characters.
|
||||
# Requires .NET 5+ — hence the #Requires -Version 7.0 constraint.
|
||||
#
|
||||
# Auto-tune worker selection
|
||||
# Workers are derived from average file size and destination type (local vs
|
||||
# remote). Smaller files favour higher concurrency; large files and remote
|
||||
# destinations are throttled to avoid saturating the link or dest I/O.
|
||||
# The -Workers value is always treated as an upper bound.
|
||||
#
|
||||
# Size-balanced chunk partitioning (recursive-files mode)
|
||||
# Files are sorted largest-first and distributed across worker buckets with
|
||||
# a greedy least-loaded algorithm to minimise the slowest-worker stall.
|
||||
#
|
||||
# Remote destination detection
|
||||
# Heuristics check for rsync://, :: (daemon), Windows drive letters, UNC
|
||||
# paths, and user@host:/path patterns to decide whether the destination is
|
||||
# local or remote without requiring an actual connection.
|
||||
#
|
||||
# Verbose output
|
||||
# Uses PowerShell's built-in -Verbose switch (CmdletBinding) so there is no
|
||||
# custom flag. Pass -Verbose on the command line to enable detailed output.
|
||||
#
|
||||
# Requirements
|
||||
# - PowerShell 7.0+ (for ProcessStartInfo.ArgumentList via .NET 5+)
|
||||
# - rsync on PATH (MSYS2, Cygwin, or WSL interop on Windows)
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Parallel rsync launcher for Windows PowerShell.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs multiple rsync invocations in parallel using either top-level entries
|
||||
or balanced recursive file chunks. Requires rsync on PATH (e.g. via MSYS2
|
||||
or Cygwin on Windows).
|
||||
|
||||
Paths are converted to forward-slash notation for rsync compatibility.
|
||||
MSYS2 rsync accepts Windows absolute paths using forward slashes (C:/path/...).
|
||||
|
||||
.EXAMPLE
|
||||
# Basic sync (recursive-files mode, auto worker count):
|
||||
.\batch_rsync.ps1 C:\data user@host:/dest/
|
||||
|
||||
.EXAMPLE
|
||||
# Auto-tune workers based on file size and local vs remote destination:
|
||||
.\batch_rsync.ps1 C:\data user@host:/dest/ -AutoTune
|
||||
|
||||
.EXAMPLE
|
||||
# Limit to a specific number of parallel workers:
|
||||
.\batch_rsync.ps1 C:\data D:\backup -Workers 8
|
||||
|
||||
.EXAMPLE
|
||||
# Use top-level mode (one rsync process per immediate source entry):
|
||||
.\batch_rsync.ps1 C:\data D:\backup -Mode top-level
|
||||
|
||||
.EXAMPLE
|
||||
# Dry run (show what would be transferred without doing it):
|
||||
.\batch_rsync.ps1 C:\data user@host:/dest/ -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
# Verbose output (command templates, task previews, elapsed time):
|
||||
.\batch_rsync.ps1 C:\data D:\backup -Verbose
|
||||
|
||||
.EXAMPLE
|
||||
# Show rsync output for all tasks:
|
||||
.\batch_rsync.ps1 C:\data D:\backup -ShowRsyncOutput
|
||||
|
||||
.EXAMPLE
|
||||
# Show rsync output only when a task fails:
|
||||
.\batch_rsync.ps1 C:\data user@host:/dest/ -ShowRsyncOutputOnFailure
|
||||
|
||||
.EXAMPLE
|
||||
# Full-featured (auto-tune, verbose, show on failure):
|
||||
.\batch_rsync.ps1 C:\data user@host:/dest/ -AutoTune -Verbose -ShowRsyncOutputOnFailure
|
||||
|
||||
.EXAMPLE
|
||||
# Override rsync flags entirely:
|
||||
.\batch_rsync.ps1 C:\data D:\backup -RsyncArgs '-az --checksum'
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory, Position = 0)]
|
||||
[string]$Src,
|
||||
|
||||
[Parameter(Mandatory, Position = 1)]
|
||||
[string]$Dest,
|
||||
|
||||
[ValidateRange(1, [int]::MaxValue)]
|
||||
[int]$Workers = [Math]::Min(8, [Environment]::ProcessorCount),
|
||||
|
||||
[ValidateSet('recursive-files', 'top-level')]
|
||||
[string]$Mode = 'recursive-files',
|
||||
|
||||
[string]$RsyncArgs = '-a --partial --info=stats2',
|
||||
|
||||
[switch]$AutoTune,
|
||||
|
||||
[switch]$ShowRsyncOutput,
|
||||
|
||||
[switch]$ShowRsyncOutputOnFailure,
|
||||
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
#region Helper functions
|
||||
|
||||
function Format-Bytes {
|
||||
param([double]$Bytes)
|
||||
$units = @('B', 'KB', 'MB', 'GB', 'TB')
|
||||
$value = $Bytes
|
||||
foreach ($unit in $units) {
|
||||
if ($value -lt 1024 -or $unit -eq 'TB') {
|
||||
return '{0:F1}{1}' -f $value, $unit
|
||||
}
|
||||
$value /= 1024
|
||||
}
|
||||
return '{0:F1}B' -f $Bytes
|
||||
}
|
||||
|
||||
function Test-RemoteDestination {
|
||||
param([string]$Destination)
|
||||
if ($Destination -match '^rsync://') { return $true }
|
||||
if ($Destination -match '::') { return $true }
|
||||
# Windows drive letters and UNC paths are local
|
||||
if ($Destination -match '^[a-zA-Z]:[/\\]') { return $false }
|
||||
if ($Destination -match '^\\\\') { return $false }
|
||||
# user@host:/path or host:/path patterns are remote
|
||||
return $Destination -match '^[^:/\\]+@?[^:/\\]*:[^/]'
|
||||
}
|
||||
|
||||
function Get-AutoTunedWorkers {
|
||||
param(
|
||||
[int] $TotalUnits,
|
||||
[double]$AvgSizeBytes,
|
||||
[bool] $IsRemote,
|
||||
[int] $CpuCount,
|
||||
[int] $MaxWorkers
|
||||
)
|
||||
$cpu = [Math]::Max(1, $CpuCount)
|
||||
$MB1 = 1 * 1024 * 1024
|
||||
$MB32 = 32 * 1024 * 1024
|
||||
$MB256 = 256 * 1024 * 1024
|
||||
|
||||
if ($AvgSizeBytes -lt $MB1) {
|
||||
$base = [Math]::Min(32, $cpu * 4)
|
||||
} elseif ($AvgSizeBytes -lt $MB32) {
|
||||
$base = [Math]::Min(16, $cpu * 2)
|
||||
} elseif ($AvgSizeBytes -lt $MB256) {
|
||||
$base = [Math]::Min(8, $cpu)
|
||||
} else {
|
||||
$base = [Math]::Min(4, [Math]::Max(2, [int]($cpu / 2)))
|
||||
}
|
||||
|
||||
if ($IsRemote) {
|
||||
$base = [Math]::Max(2, [Math]::Min($base, 8))
|
||||
} else {
|
||||
$base = [Math]::Min($base + 2, 24)
|
||||
}
|
||||
|
||||
$tuned = [Math]::Max(1, [Math]::Min($base, $TotalUnits))
|
||||
return [Math]::Min($tuned, $MaxWorkers)
|
||||
}
|
||||
|
||||
function Get-RecursiveFileList {
|
||||
param([string]$SrcDir)
|
||||
$root = $SrcDir.TrimEnd('\', '/')
|
||||
Get-ChildItem -LiteralPath $root -Recurse -File |
|
||||
ForEach-Object { $_.FullName.Substring($root.Length).TrimStart('\', '/') }
|
||||
}
|
||||
|
||||
function Get-SizedFiles {
|
||||
param([string]$SrcDir, [string[]]$RelFiles)
|
||||
foreach ($rel in $RelFiles) {
|
||||
$abs = Join-Path $SrcDir $rel
|
||||
try { $size = (Get-Item -LiteralPath $abs -ErrorAction Stop).Length }
|
||||
catch { $size = 0 }
|
||||
[PSCustomObject]@{ Size = [long]$size; RelPath = $rel }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BalancedChunks {
|
||||
param($SizedFiles, [int]$Workers)
|
||||
$buckets = @(1..$Workers | ForEach-Object { [System.Collections.Generic.List[string]]::new() })
|
||||
$bucketSizes = [long[]]::new($Workers)
|
||||
|
||||
foreach ($entry in ($SizedFiles | Sort-Object -Property Size -Descending)) {
|
||||
$minIdx = 0
|
||||
for ($i = 1; $i -lt $Workers; $i++) {
|
||||
if ($bucketSizes[$i] -lt $bucketSizes[$minIdx]) { $minIdx = $i }
|
||||
}
|
||||
$buckets[$minIdx].Add($entry.RelPath)
|
||||
$bucketSizes[$minIdx] += $entry.Size
|
||||
}
|
||||
|
||||
$buckets | Where-Object { $_.Count -gt 0 }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Runspace script blocks
|
||||
# These blocks run inside isolated runspaces — all needed logic must be self-contained.
|
||||
|
||||
$rsyncChunkScriptBlock = {
|
||||
param(
|
||||
[string] $SrcDir,
|
||||
[string[]]$RelFiles,
|
||||
[string] $Dest,
|
||||
[string[]]$RsyncArgsList,
|
||||
[bool] $IsDryRun
|
||||
)
|
||||
|
||||
$filesFromTmp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
# Build a NUL-delimited file list using forward slashes for rsync
|
||||
$lines = $RelFiles | ForEach-Object { $_ -replace '\\', '/' }
|
||||
$allBytes = [System.Collections.Generic.List[byte]]::new()
|
||||
foreach ($line in $lines) {
|
||||
$allBytes.AddRange([System.Text.Encoding]::UTF8.GetBytes($line))
|
||||
$allBytes.Add([byte]0)
|
||||
}
|
||||
[System.IO.File]::WriteAllBytes($filesFromTmp, $allBytes.ToArray())
|
||||
|
||||
$srcRoot = ($SrcDir.TrimEnd('\', '/') -replace '\\', '/') + '/'
|
||||
|
||||
$cmdArgs = [System.Collections.Generic.List[string]]::new($RsyncArgsList)
|
||||
if ($IsDryRun) { $cmdArgs.Insert(0, '--dry-run') }
|
||||
$cmdArgs.AddRange([string[]]@('--from0', "--files-from=$filesFromTmp", $srcRoot, $Dest))
|
||||
|
||||
$psi = [System.Diagnostics.ProcessStartInfo]::new('rsync')
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
foreach ($arg in $cmdArgs) { $psi.ArgumentList.Add($arg) }
|
||||
|
||||
try {
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $proc.StandardOutput.ReadToEnd()
|
||||
$stderr = $proc.StandardError.ReadToEnd()
|
||||
$proc.WaitForExit()
|
||||
$exitCode = $proc.ExitCode
|
||||
} catch {
|
||||
$stdout = ''
|
||||
$stderr = "rsync not found on PATH: $($_.Exception.Message)"
|
||||
$exitCode = 127
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Label = "chunk($($RelFiles.Count) files)"
|
||||
ExitCode = $exitCode
|
||||
Stdout = $stdout
|
||||
Stderr = $stderr
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $filesFromTmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$rsyncItemScriptBlock = {
|
||||
param(
|
||||
[string] $Item,
|
||||
[string] $Dest,
|
||||
[string[]]$RsyncArgsList,
|
||||
[bool] $IsDryRun
|
||||
)
|
||||
|
||||
$srcPath = $Item -replace '\\', '/'
|
||||
|
||||
$cmdArgs = [System.Collections.Generic.List[string]]::new($RsyncArgsList)
|
||||
if ($IsDryRun) { $cmdArgs.Insert(0, '--dry-run') }
|
||||
$cmdArgs.Add($srcPath)
|
||||
$cmdArgs.Add($Dest)
|
||||
|
||||
$psi = [System.Diagnostics.ProcessStartInfo]::new('rsync')
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
foreach ($arg in $cmdArgs) { $psi.ArgumentList.Add($arg) }
|
||||
|
||||
try {
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $proc.StandardOutput.ReadToEnd()
|
||||
$stderr = $proc.StandardError.ReadToEnd()
|
||||
$proc.WaitForExit()
|
||||
$exitCode = $proc.ExitCode
|
||||
} catch {
|
||||
$stdout = ''
|
||||
$stderr = "rsync not found on PATH: $($_.Exception.Message)"
|
||||
$exitCode = 127
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Label = $Item
|
||||
ExitCode = $exitCode
|
||||
Stdout = $stdout
|
||||
Stderr = $stderr
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parallel job runner (as-completed using WaitHandle.WaitAny)
|
||||
|
||||
function Invoke-ParallelJobs {
|
||||
param(
|
||||
[object[]] $Jobs, # PSCustomObjects with .PS and .Handle
|
||||
[bool] $ShowOutput,
|
||||
[bool] $ShowOnFailOnly
|
||||
)
|
||||
|
||||
$results = [System.Collections.Generic.List[object]]::new()
|
||||
$pending = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($j in $Jobs) { $pending.Add($j) }
|
||||
|
||||
while ($pending.Count -gt 0) {
|
||||
$handles = @($pending | ForEach-Object { $_.Handle.AsyncWaitHandle })
|
||||
$idx = [System.Threading.WaitHandle]::WaitAny($handles)
|
||||
$job = $pending[$idx]
|
||||
$pending.RemoveAt($idx)
|
||||
|
||||
$r = $job.PS.EndInvoke($job.Handle)[0]
|
||||
$job.PS.Dispose()
|
||||
$results.Add($r)
|
||||
|
||||
$status = if ($r.ExitCode -eq 0) { 'OK' } else { "ERR($($r.ExitCode))" }
|
||||
Write-Host "[$status] $($r.Label)"
|
||||
|
||||
$printOutput = $ShowOutput -and (-not $ShowOnFailOnly -or $r.ExitCode -ne 0)
|
||||
if ($printOutput) {
|
||||
if ($r.Stdout.Trim()) {
|
||||
Write-Host "--- stdout: $($r.Label) ---"
|
||||
Write-Host $r.Stdout.TrimEnd()
|
||||
}
|
||||
if ($r.Stderr.Trim()) {
|
||||
Write-Warning "--- stderr: $($r.Label) ---`n$($r.Stderr.TrimEnd())"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main
|
||||
|
||||
$timer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$rsyncArgsList = $RsyncArgs -split '\s+' | Where-Object { $_ -ne '' }
|
||||
$srcPath = $Src.TrimEnd('\', '/')
|
||||
$isRemote = Test-RemoteDestination -Destination $Dest
|
||||
$cpuCount = [Environment]::ProcessorCount
|
||||
$showOutput = $ShowRsyncOutput -or $ShowRsyncOutputOnFailure
|
||||
$showOnFail = $ShowRsyncOutputOnFailure -and -not $ShowRsyncOutput
|
||||
|
||||
if ($ShowRsyncOutput -and $ShowRsyncOutputOnFailure) {
|
||||
Write-Warning 'Both -ShowRsyncOutput and -ShowRsyncOutputOnFailure set; showing output for all tasks.'
|
||||
}
|
||||
|
||||
$allResults = $null
|
||||
|
||||
if ($Mode -eq 'recursive-files' -and (Test-Path -LiteralPath $srcPath -PathType Container)) {
|
||||
|
||||
$relFiles = @(Get-RecursiveFileList -SrcDir $srcPath)
|
||||
if (-not $relFiles -or $relFiles.Count -eq 0) {
|
||||
Write-Error "No files found for: $Src"
|
||||
exit 2
|
||||
}
|
||||
|
||||
$sizedFiles = @(Get-SizedFiles -SrcDir $srcPath -RelFiles $relFiles)
|
||||
$totalBytes = ($sizedFiles | Measure-Object -Property Size -Sum).Sum
|
||||
$avgSize = if ($sizedFiles.Count -gt 0) { $totalBytes / $sizedFiles.Count } else { 0 }
|
||||
|
||||
if ($AutoTune) {
|
||||
$effectiveWorkers = Get-AutoTunedWorkers -TotalUnits $sizedFiles.Count -AvgSizeBytes $avgSize `
|
||||
-IsRemote $isRemote -CpuCount $cpuCount -MaxWorkers $Workers
|
||||
$destKind = if ($isRemote) { 'remote' } else { 'local' }
|
||||
Write-Host "Auto-tuned workers=$effectiveWorkers ($destKind dest, avg file $(Format-Bytes $avgSize))"
|
||||
} else {
|
||||
$effectiveWorkers = [Math]::Min($Workers, $sizedFiles.Count)
|
||||
}
|
||||
|
||||
$chunks = @(Get-BalancedChunks -SizedFiles $sizedFiles -Workers $effectiveWorkers)
|
||||
Write-Host "Starting $($chunks.Count) rsync chunk task(s) with $effectiveWorkers worker(s)..."
|
||||
|
||||
if ($VerbosePreference -ne 'SilentlyContinue') {
|
||||
$srcRoot = ($srcPath -replace '\\', '/') + '/'
|
||||
$previewArgs = (@('rsync') + $rsyncArgsList + @('--from0', '--files-from=<tmpfile>', $srcRoot, $Dest))
|
||||
if ($DryRun) { $previewArgs = @('rsync', '--dry-run') + $previewArgs[1..($previewArgs.Count-1)] }
|
||||
Write-Verbose "Command template: $($previewArgs -join ' ')"
|
||||
}
|
||||
|
||||
$pool = [RunspaceFactory]::CreateRunspacePool(1, $effectiveWorkers)
|
||||
$pool.Open()
|
||||
|
||||
$jobs = foreach ($chunk in $chunks) {
|
||||
$ps = [PowerShell]::Create()
|
||||
$ps.RunspacePool = $pool
|
||||
[void]$ps.AddScript($rsyncChunkScriptBlock)
|
||||
[void]$ps.AddParameter('SrcDir', $srcPath)
|
||||
[void]$ps.AddParameter('RelFiles', [string[]]$chunk)
|
||||
[void]$ps.AddParameter('Dest', $Dest)
|
||||
[void]$ps.AddParameter('RsyncArgsList', [string[]]$rsyncArgsList)
|
||||
[void]$ps.AddParameter('IsDryRun', $DryRun.IsPresent)
|
||||
[PSCustomObject]@{ PS = $ps; Handle = $ps.BeginInvoke() }
|
||||
}
|
||||
|
||||
$allResults = Invoke-ParallelJobs -Jobs $jobs -ShowOutput $showOutput -ShowOnFailOnly $showOnFail
|
||||
$pool.Close()
|
||||
$pool.Dispose()
|
||||
|
||||
} else {
|
||||
|
||||
if (Test-Path -LiteralPath $srcPath -PathType Container) {
|
||||
$items = @(Get-ChildItem -LiteralPath $srcPath | ForEach-Object { $_.FullName })
|
||||
} elseif ($srcPath -match '[*?]') {
|
||||
$items = @(Resolve-Path -Path $srcPath | ForEach-Object { $_.ProviderPath })
|
||||
} else {
|
||||
$items = @($srcPath)
|
||||
}
|
||||
|
||||
if (-not $items -or $items.Count -eq 0) {
|
||||
Write-Error "No files found for: $Src"
|
||||
exit 2
|
||||
}
|
||||
|
||||
$sizeSamples = foreach ($item in $items) {
|
||||
try {
|
||||
$fi = Get-Item -LiteralPath $item -ErrorAction Stop
|
||||
if ($fi.PSIsContainer) { [long](64 * 1024 * 1024) } else { $fi.Length }
|
||||
} catch { [long]0 }
|
||||
}
|
||||
$totalBytes = ($sizeSamples | Measure-Object -Sum).Sum
|
||||
$avgSize = if ($items.Count -gt 0) { $totalBytes / $items.Count } else { 0 }
|
||||
|
||||
if ($AutoTune) {
|
||||
$effectiveWorkers = Get-AutoTunedWorkers -TotalUnits $items.Count -AvgSizeBytes $avgSize `
|
||||
-IsRemote $isRemote -CpuCount $cpuCount -MaxWorkers $Workers
|
||||
$destKind = if ($isRemote) { 'remote' } else { 'local' }
|
||||
Write-Host "Auto-tuned workers=$effectiveWorkers ($destKind dest, avg unit $(Format-Bytes $avgSize))"
|
||||
} else {
|
||||
$effectiveWorkers = [Math]::Min($Workers, $items.Count)
|
||||
}
|
||||
|
||||
Write-Host "Starting $($items.Count) rsync task(s) with $effectiveWorkers worker(s)..."
|
||||
|
||||
if ($VerbosePreference -ne 'SilentlyContinue') {
|
||||
Write-Verbose 'Task list preview:'
|
||||
$previewItems = $items | Select-Object -First 10
|
||||
for ($i = 0; $i -lt $previewItems.Count; $i++) {
|
||||
Write-Verbose (' {0}. {1}' -f ($i + 1), $previewItems[$i])
|
||||
}
|
||||
if ($items.Count -gt 10) { Write-Verbose " ... and $($items.Count - 10) more" }
|
||||
}
|
||||
|
||||
$pool = [RunspaceFactory]::CreateRunspacePool(1, $effectiveWorkers)
|
||||
$pool.Open()
|
||||
|
||||
$jobs = foreach ($item in $items) {
|
||||
if ($VerbosePreference -ne 'SilentlyContinue') {
|
||||
$previewArgs = (@('rsync') + $rsyncArgsList + @(($item -replace '\\', '/'), $Dest))
|
||||
if ($DryRun) { $previewArgs = @('rsync', '--dry-run') + $previewArgs[1..($previewArgs.Count-1)] }
|
||||
Write-Verbose "Scheduling: $($previewArgs -join ' ')"
|
||||
}
|
||||
$ps = [PowerShell]::Create()
|
||||
$ps.RunspacePool = $pool
|
||||
[void]$ps.AddScript($rsyncItemScriptBlock)
|
||||
[void]$ps.AddParameter('Item', $item)
|
||||
[void]$ps.AddParameter('Dest', $Dest)
|
||||
[void]$ps.AddParameter('RsyncArgsList', [string[]]$rsyncArgsList)
|
||||
[void]$ps.AddParameter('IsDryRun', $DryRun.IsPresent)
|
||||
[PSCustomObject]@{ PS = $ps; Handle = $ps.BeginInvoke() }
|
||||
}
|
||||
|
||||
$allResults = Invoke-ParallelJobs -Jobs $jobs -ShowOutput $showOutput -ShowOnFailOnly $showOnFail
|
||||
$pool.Close()
|
||||
$pool.Dispose()
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
|
||||
$failed = @($allResults | Where-Object { $_.ExitCode -ne 0 })
|
||||
$elapsed = [Math]::Round($timer.Elapsed.TotalSeconds, 2)
|
||||
Write-Host "`nCompleted: $($allResults.Count) tasks, $($failed.Count) failed in ${elapsed}s."
|
||||
|
||||
if ($failed.Count -gt 0) {
|
||||
Write-Host 'Failures detail:'
|
||||
foreach ($r in $failed) {
|
||||
Write-Host "--- $($r.Label) (exit $($r.ExitCode)) ---"
|
||||
if ($r.Stdout.Trim()) { Write-Host $r.Stdout.TrimEnd() }
|
||||
if ($r.Stderr.Trim()) { Write-Host $r.Stderr.TrimEnd() }
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix the powerball CSV file format."""
|
||||
|
||||
import shutil
|
||||
|
||||
# Read the original file
|
||||
with open('powerball_numbers.csv', 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Create backup
|
||||
shutil.copy('powerball_numbers.csv', 'powerball_numbers.csv.backup')
|
||||
|
||||
# Open output file
|
||||
with open('powerball_numbers.csv', 'w') as f:
|
||||
# Write header
|
||||
f.write('Draw Date,Winning Numbers,Multiplier\n')
|
||||
|
||||
# Process each line
|
||||
for line in lines:
|
||||
parts = line.strip().split(',')
|
||||
|
||||
# Extract components
|
||||
month = parts[0]
|
||||
day_year = parts[1].split('/')
|
||||
day = day_year[0]
|
||||
year = day_year[1]
|
||||
|
||||
# Format date as MM/DD/YYYY
|
||||
date = f"{int(month):02d}/{int(day):02d}/{year}"
|
||||
|
||||
# Get the 6 winning numbers (5 white balls + 1 powerball)
|
||||
numbers = ' '.join(parts[2:8])
|
||||
|
||||
# Get the multiplier
|
||||
multiplier = parts[8]
|
||||
|
||||
# Write the formatted line
|
||||
f.write(f'{date},{numbers},{multiplier}\n')
|
||||
|
||||
print("✓ File fixed! Created backup at powerball_numbers.csv.backup")
|
||||
print("\nFirst few lines of fixed file:")
|
||||
with open('powerball_numbers.csv', 'r') as f:
|
||||
for i, line in enumerate(f):
|
||||
if i < 5:
|
||||
print(line.strip())
|
||||
else:
|
||||
break
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import sys
|
||||
import urllib3
|
||||
import redfish
|
||||
|
||||
# Suppress SSL warnings for self-signed iLO certificates
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Power off an HPE server via iLO Redfish")
|
||||
parser.add_argument("hostname", help="iLO hostname or IP address")
|
||||
parser.add_argument("-c", "--credentials", default="/etc/ilo_credentials",
|
||||
help="Path to credentials file (default: /etc/ilo_credentials)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read credentials from file (chmod 600 recommended: sudo chmod 600 /etc/ilo_credentials)
|
||||
# File format:
|
||||
# [ilo]
|
||||
# username = root
|
||||
# password = yourpassword
|
||||
cred_path = args.credentials
|
||||
if not os.path.exists(cred_path):
|
||||
print(f"Error: credentials file not found: {cred_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(cred_path)
|
||||
try:
|
||||
LOGIN_ACCOUNT = config.get("ilo", "username", fallback="root")
|
||||
LOGIN_PASSWORD = config.get("ilo", "password")
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
print(f"Error reading credentials file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
BASE_URL = f"https://{args.hostname}"
|
||||
|
||||
REDFISHOBJ = redfish.RedfishClient(
|
||||
base_url=BASE_URL,
|
||||
username=LOGIN_ACCOUNT,
|
||||
password=LOGIN_PASSWORD,
|
||||
)
|
||||
REDFISHOBJ.login(auth="session")
|
||||
|
||||
# Discover the correct reset action URI from the system resource
|
||||
sys_response = REDFISHOBJ.get("/redfish/v1/Systems/1/")
|
||||
reset_uri = sys_response.dict["Actions"]["#ComputerSystem.Reset"]["target"]
|
||||
|
||||
body = {"ResetType": "PushPowerButton"} # iLO 4: PushPowerButton = graceful shutdown; use "ForceOff" for hard power cut
|
||||
response = REDFISHOBJ.post(reset_uri, body=body)
|
||||
|
||||
print(f"Status: {response.status}")
|
||||
if response.status >= 400:
|
||||
print(f"Error: {response.read}")
|
||||
|
||||
REDFISHOBJ.logout()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
|
||||
|
||||
def load_credentials(path):
|
||||
if not os.path.exists(path):
|
||||
print(f"Error: credentials file not found: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
try:
|
||||
username = config.get("credentials", "username", fallback="root")
|
||||
password = config.get("credentials", "password")
|
||||
encryption_key = config.get("credentials", "encryption_key", fallback=DEFAULT_EK)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
print(f"Error reading credentials file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return username, password, encryption_key
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power off")
|
||||
p.add_argument("-H", "--host", required=True, help="IPMI host address")
|
||||
p.add_argument(
|
||||
"-c",
|
||||
"--credentials",
|
||||
default="credentials",
|
||||
help="Path to credentials file (default: credentials)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
ipmi_user, ipmi_pw, ipmi_ek = load_credentials(args.credentials)
|
||||
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", args.host,
|
||||
"-U", ipmi_user,
|
||||
"-P", ipmi_pw,
|
||||
"-y", ipmi_ek,
|
||||
"power", "off",
|
||||
]
|
||||
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
print("ipmitool failed:", proc.stderr.strip(), file=sys.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
print(proc.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
def ts_print(msg):
|
||||
"""ts_print message prefixed with a timestamp (YYYY-MM-DD HH:MM:SS)."""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"{now} {msg}")
|
||||
|
||||
DEFAULT_EK = "0000000000000000000000000000000000000000"
|
||||
|
||||
|
||||
def load_credentials(path):
|
||||
if not os.path.exists(path):
|
||||
print(f"Error: credentials file not found: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
|
||||
try:
|
||||
username = config.get("ilo", "username", fallback="root")
|
||||
password = config.get("ilo", "password")
|
||||
encryption_key = config.get("ilo", "encryption_key", fallback=DEFAULT_EK)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError) as e:
|
||||
print(f"Error reading credentials file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return username, password, encryption_key
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Send IPMI power on")
|
||||
p.add_argument("-H", "--host", required=True, help="IPMI host address")
|
||||
p.add_argument(
|
||||
"-c",
|
||||
"--credentials",
|
||||
default="credentials",
|
||||
help="Path to credentials file (default: credentials)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
ipmi_user, ipmi_pw, ipmi_ek = load_credentials(args.credentials)
|
||||
|
||||
cmd = [
|
||||
"ipmitool",
|
||||
"-I", "lanplus",
|
||||
"-H", args.host,
|
||||
"-U", ipmi_user,
|
||||
"-P", ipmi_pw,
|
||||
"-y", ipmi_ek,
|
||||
"power", "on",
|
||||
]
|
||||
print(f"Sending power on to {args.host}")
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if proc.returncode != 0:
|
||||
print("ipmitool failed:", proc.stderr.strip(), file=sys.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
print(proc.stdout.strip())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,268 @@
|
||||
param(
|
||||
[string]$PgHost = "localhost",
|
||||
[int]$PgPort = 5432,
|
||||
[string]$PgUser = "postgres",
|
||||
[string]$BenchDb = "pgbench",
|
||||
[int]$Scale = 50,
|
||||
[int]$Duration = 60,
|
||||
[string]$Clients = "1 4 8 16 32",
|
||||
[int]$Jobs = 4,
|
||||
[int]$WarmupSeconds = 15,
|
||||
[bool]$RunReadOnly = $true,
|
||||
[bool]$RunCustom = $false,
|
||||
[string]$CustomSqlFile = "pgbench_custom_workload.sql",
|
||||
[string]$CustomModeName = "custom_sql",
|
||||
[ValidateRange(1, 99)][int]$CustomReadPct = 80,
|
||||
[ValidateRange(1, 100)][int]$CustomHotPct = 90,
|
||||
[ValidateRange(1, 4)][int]$CustomTxnSize = 2,
|
||||
[ValidateRange(100, 100000000)][int]$CustomHotAccounts = 10000,
|
||||
[bool]$Reinit = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Require-Command {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "Missing required command: $Name"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PsqlScalar {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Database,
|
||||
[Parameter(Mandatory = $true)][string]$Sql
|
||||
)
|
||||
|
||||
$output = & psql -X -v ON_ERROR_STOP=1 -h $PgHost -p $PgPort -U $PgUser -d $Database -Atc $Sql
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "psql failed running SQL: $Sql"
|
||||
}
|
||||
|
||||
return ($output | Select-Object -First 1)
|
||||
}
|
||||
|
||||
function Invoke-PsqlNonQuery {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Database,
|
||||
[Parameter(Mandatory = $true)][string]$Sql
|
||||
)
|
||||
|
||||
& psql -X -v ON_ERROR_STOP=1 -h $PgHost -p $PgPort -U $PgUser -d $Database -c $Sql | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "psql failed running SQL: $Sql"
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-BenchmarkDatabase {
|
||||
$exists = Invoke-PsqlScalar -Database "postgres" -Sql "SELECT 1 FROM pg_database WHERE datname = '$BenchDb';"
|
||||
if ($exists -ne "1") {
|
||||
Write-Host "Creating benchmark database: $BenchDb"
|
||||
Invoke-PsqlNonQuery -Database "postgres" -Sql "CREATE DATABASE $BenchDb;"
|
||||
}
|
||||
}
|
||||
|
||||
function Needs-Init {
|
||||
$hasTable = Invoke-PsqlScalar -Database $BenchDb -Sql "SELECT to_regclass('public.pgbench_accounts') IS NOT NULL;"
|
||||
return $hasTable -ne "t"
|
||||
}
|
||||
|
||||
function Init-OrReinitData {
|
||||
if ($Reinit -or (Needs-Init)) {
|
||||
Write-Host "Initializing pgbench schema/data (scale=$Scale)"
|
||||
& pgbench -h $PgHost -p $PgPort -U $PgUser -i -s $Scale $BenchDb | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "pgbench initialization failed"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Using existing pgbench data in $BenchDb"
|
||||
}
|
||||
}
|
||||
|
||||
function Parse-Tps {
|
||||
param([Parameter(Mandatory = $true)][string]$Text)
|
||||
|
||||
$matches = [regex]::Matches($Text, "(?m)^tps\s*=\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
if ($matches.Count -gt 0) {
|
||||
return $matches[$matches.Count - 1].Groups[1].Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function Parse-AvgLatencyMs {
|
||||
param([Parameter(Mandatory = $true)][string]$Text)
|
||||
|
||||
$matches = [regex]::Matches($Text, "(?m)^latency average\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*ms")
|
||||
if ($matches.Count -gt 0) {
|
||||
return $matches[$matches.Count - 1].Groups[1].Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function Run-Case {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Mode,
|
||||
[Parameter(Mandatory = $true)][int]$ClientCount,
|
||||
[AllowEmptyCollection()][string[]]$ExtraArgs = @(),
|
||||
[Parameter(Mandatory = $true)][string]$ResultDir,
|
||||
[Parameter(Mandatory = $true)][string]$SummaryCsv
|
||||
)
|
||||
|
||||
$logFile = Join-Path $ResultDir ("{0}_c{1}.txt" -f $Mode, $ClientCount)
|
||||
$txLogPrefix = Join-Path $ResultDir ("{0}_c{1}_txlog" -f $Mode, $ClientCount)
|
||||
Write-Host "Running mode=$Mode, clients=$ClientCount, duration=${Duration}s"
|
||||
|
||||
# Warm cache and avoid measuring first-run effects.
|
||||
& pgbench -h $PgHost -p $PgPort -U $PgUser -n -M prepared -j $Jobs -c $ClientCount -T $WarmupSeconds @ExtraArgs $BenchDb | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Warm-up run failed for mode=$Mode clients=$ClientCount"
|
||||
}
|
||||
|
||||
$runOutput = & pgbench -h $PgHost -p $PgPort -U $PgUser -n -M prepared -j $Jobs -c $ClientCount -T $Duration -r -l --log-prefix $txLogPrefix @ExtraArgs $BenchDb 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Measured run failed for mode=$Mode clients=$ClientCount"
|
||||
}
|
||||
|
||||
$runOutput | Set-Content -Path $logFile -Encoding UTF8
|
||||
|
||||
$text = ($runOutput -join [Environment]::NewLine)
|
||||
$tps = Parse-Tps -Text $text
|
||||
$latencyMs = Parse-AvgLatencyMs -Text $text
|
||||
$percentiles = Get-PercentileLatency -LogPrefix $txLogPrefix
|
||||
|
||||
"$Mode,$ClientCount,$Duration,$Jobs,$tps,$latencyMs,$($percentiles.P95Ms),$($percentiles.P99Ms),$logFile" | Add-Content -Path $SummaryCsv
|
||||
}
|
||||
|
||||
function Get-PercentileLatency {
|
||||
param([Parameter(Mandatory = $true)][string]$LogPrefix)
|
||||
|
||||
$logFiles = Get-ChildItem -Path ($LogPrefix + "*") -File -ErrorAction SilentlyContinue
|
||||
if (-not $logFiles -or $logFiles.Count -eq 0) {
|
||||
return @{ P95Ms = ""; P99Ms = "" }
|
||||
}
|
||||
|
||||
# Keep memory bounded even for very large transaction logs.
|
||||
$maxSampleSize = 200000
|
||||
$reservoir = New-Object System.Collections.Generic.List[double]
|
||||
$reservoir.Capacity = $maxSampleSize
|
||||
$random = [System.Random]::new()
|
||||
$seen = 0
|
||||
|
||||
foreach ($file in $logFiles) {
|
||||
$reader = [System.IO.File]::OpenText($file.FullName)
|
||||
try {
|
||||
while (($line = $reader.ReadLine()) -ne $null) {
|
||||
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$tokens = $line -split "[\s,]+" | Where-Object { $_ -ne "" }
|
||||
if ($tokens.Count -ge 3 -and $tokens[2] -match "^[0-9]+(?:\.[0-9]+)?$") {
|
||||
$latUs = [double]$tokens[2]
|
||||
$seen++
|
||||
|
||||
if ($reservoir.Count -lt $maxSampleSize) {
|
||||
$reservoir.Add($latUs)
|
||||
}
|
||||
else {
|
||||
$idx = $random.Next(0, $seen)
|
||||
if ($idx -lt $maxSampleSize) {
|
||||
$reservoir[$idx] = $latUs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
if ($reservoir.Count -eq 0) {
|
||||
return @{ P95Ms = ""; P99Ms = "" }
|
||||
}
|
||||
|
||||
$sorted = $reservoir | Sort-Object
|
||||
$p95Us = Select-PercentileValue -SortedValues $sorted -Percentile 95
|
||||
$p99Us = Select-PercentileValue -SortedValues $sorted -Percentile 99
|
||||
|
||||
return @{
|
||||
P95Ms = [math]::Round(($p95Us / 1000.0), 3)
|
||||
P99Ms = [math]::Round(($p99Us / 1000.0), 3)
|
||||
}
|
||||
}
|
||||
|
||||
function Select-PercentileValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][double[]]$SortedValues,
|
||||
[Parameter(Mandatory = $true)][ValidateRange(1, 99)][int]$Percentile
|
||||
)
|
||||
|
||||
$n = $SortedValues.Count
|
||||
$rank = [math]::Ceiling(($Percentile / 100.0) * $n)
|
||||
if ($rank -lt 1) { $rank = 1 }
|
||||
if ($rank -gt $n) { $rank = $n }
|
||||
return $SortedValues[$rank - 1]
|
||||
}
|
||||
|
||||
function Resolve-CustomSqlPath {
|
||||
if ([System.IO.Path]::IsPathRooted($CustomSqlFile)) {
|
||||
return $CustomSqlFile
|
||||
}
|
||||
|
||||
return (Join-Path (Get-Location) $CustomSqlFile)
|
||||
}
|
||||
|
||||
Require-Command -Name "psql"
|
||||
Require-Command -Name "pgbench"
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$resultDir = Join-Path (Get-Location) ("pgbench_results_{0}" -f $timestamp)
|
||||
$summaryCsv = Join-Path $resultDir "summary.csv"
|
||||
|
||||
New-Item -Path $resultDir -ItemType Directory -Force | Out-Null
|
||||
"mode,clients,duration_seconds,jobs,tps,latency_ms,p95_ms,p99_ms,details_file" | Set-Content -Path $summaryCsv -Encoding UTF8
|
||||
|
||||
Ensure-BenchmarkDatabase
|
||||
Init-OrReinitData
|
||||
|
||||
$customSqlPath = ""
|
||||
if ($RunCustom) {
|
||||
$customSqlPath = Resolve-CustomSqlPath
|
||||
if (-not (Test-Path -Path $customSqlPath -PathType Leaf)) {
|
||||
throw "Custom SQL file not found: $customSqlPath"
|
||||
}
|
||||
}
|
||||
|
||||
$clientValues = $Clients -split "\s+" | Where-Object { $_ -match "^[0-9]+$" }
|
||||
if ($clientValues.Count -eq 0) {
|
||||
throw "No valid client values found. Example: -Clients '1 4 8 16 32'"
|
||||
}
|
||||
|
||||
foreach ($c in $clientValues) {
|
||||
$clientInt = [int]$c
|
||||
Run-Case -Mode "read_write" -ClientCount $clientInt -ExtraArgs @() -ResultDir $resultDir -SummaryCsv $summaryCsv
|
||||
|
||||
if ($RunReadOnly) {
|
||||
Run-Case -Mode "read_only" -ClientCount $clientInt -ExtraArgs @("-S") -ResultDir $resultDir -SummaryCsv $summaryCsv
|
||||
}
|
||||
|
||||
if ($RunCustom) {
|
||||
Run-Case -Mode $CustomModeName -ClientCount $clientInt -ExtraArgs @(
|
||||
"-D", "read_pct=$CustomReadPct",
|
||||
"-D", "hot_pct=$CustomHotPct",
|
||||
"-D", "txn_size=$CustomTxnSize",
|
||||
"-D", "hot_accounts=$CustomHotAccounts",
|
||||
"-f", $customSqlPath
|
||||
) -ResultDir $resultDir -SummaryCsv $summaryCsv
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Benchmark completed."
|
||||
Write-Host "Summary: $summaryCsv"
|
||||
Write-Host "Raw outputs: $resultDir"
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Repeatable pgbench benchmark harness.
|
||||
# Usage example:
|
||||
# PGHOST=localhost PGPORT=5432 PGUSER=postgres PGPASSWORD=secret \
|
||||
# ./pgbench_benchmark.sh
|
||||
#
|
||||
# Optional environment variables:
|
||||
# BENCH_DB=pgbench
|
||||
# SCALE=50
|
||||
# DURATION=60
|
||||
# CLIENTS="1 4 8 16 32"
|
||||
# JOBS=4
|
||||
# WARMUP_SECONDS=15
|
||||
# RUN_READ_ONLY=1
|
||||
# RUN_CUSTOM=0
|
||||
# CUSTOM_SQL_FILE=pgbench_custom_workload.sql
|
||||
# CUSTOM_MODE_NAME=custom_sql
|
||||
# CUSTOM_READ_PCT=80
|
||||
# CUSTOM_HOT_PCT=90
|
||||
# CUSTOM_TXN_SIZE=2
|
||||
# CUSTOM_HOT_ACCOUNTS=10000
|
||||
# REINIT=0
|
||||
|
||||
PGHOST="${PGHOST:-localhost}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
BENCH_DB="${BENCH_DB:-pgbench}"
|
||||
SCALE="${SCALE:-50}"
|
||||
DURATION="${DURATION:-60}"
|
||||
CLIENTS="${CLIENTS:-1 4 8 16 32}"
|
||||
JOBS="${JOBS:-4}"
|
||||
WARMUP_SECONDS="${WARMUP_SECONDS:-15}"
|
||||
RUN_READ_ONLY="${RUN_READ_ONLY:-1}"
|
||||
RUN_CUSTOM="${RUN_CUSTOM:-0}"
|
||||
CUSTOM_SQL_FILE="${CUSTOM_SQL_FILE:-pgbench_custom_workload.sql}"
|
||||
CUSTOM_MODE_NAME="${CUSTOM_MODE_NAME:-custom_sql}"
|
||||
CUSTOM_READ_PCT="${CUSTOM_READ_PCT:-80}"
|
||||
CUSTOM_HOT_PCT="${CUSTOM_HOT_PCT:-90}"
|
||||
CUSTOM_TXN_SIZE="${CUSTOM_TXN_SIZE:-2}"
|
||||
CUSTOM_HOT_ACCOUNTS="${CUSTOM_HOT_ACCOUNTS:-10000}"
|
||||
REINIT="${REINIT:-0}"
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
RESULT_DIR="pgbench_results_${TIMESTAMP}"
|
||||
SUMMARY_CSV="${RESULT_DIR}/summary.csv"
|
||||
|
||||
PSQL=(psql -X -v ON_ERROR_STOP=1 -h "$PGHOST" -p "$PGPORT" -U "$PGUSER")
|
||||
PGBENCH=(pgbench -h "$PGHOST" -p "$PGPORT" -U "$PGUSER")
|
||||
|
||||
require_command() {
|
||||
local cmd="$1"
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $cmd" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_database_if_missing() {
|
||||
local exists
|
||||
exists="$("${PSQL[@]}" -d postgres -Atc "SELECT 1 FROM pg_database WHERE datname = '${BENCH_DB}';" || true)"
|
||||
if [[ "$exists" != "1" ]]; then
|
||||
echo "Creating benchmark database: ${BENCH_DB}"
|
||||
"${PSQL[@]}" -d postgres -c "CREATE DATABASE ${BENCH_DB};"
|
||||
fi
|
||||
}
|
||||
|
||||
needs_init() {
|
||||
local rel
|
||||
rel="$("${PSQL[@]}" -d "$BENCH_DB" -Atc "SELECT to_regclass('public.pgbench_accounts') IS NOT NULL;")"
|
||||
[[ "$rel" != "t" ]]
|
||||
}
|
||||
|
||||
init_or_reinit_data() {
|
||||
if [[ "$REINIT" == "1" ]] || needs_init; then
|
||||
echo "Initializing pgbench schema/data (scale=${SCALE})"
|
||||
"${PGBENCH[@]}" -i -s "$SCALE" "$BENCH_DB"
|
||||
else
|
||||
echo "Using existing pgbench data in ${BENCH_DB}"
|
||||
fi
|
||||
}
|
||||
|
||||
extract_tps() {
|
||||
local output_file="$1"
|
||||
awk -F'= ' '/tps =/{print $2}' "$output_file" | awk '{print $1}' | tail -n 1
|
||||
}
|
||||
|
||||
extract_avg_latency() {
|
||||
local output_file="$1"
|
||||
awk -F'= ' '/latency average =/{print $2}' "$output_file" | awk '{print $1}' | tail -n 1
|
||||
}
|
||||
|
||||
select_percentile_ms_from_prefix() {
|
||||
local log_prefix="$1"
|
||||
local percentile="$2"
|
||||
local -a log_files
|
||||
local -a values_us
|
||||
local count rank value_us
|
||||
|
||||
shopt -s nullglob
|
||||
log_files=("${log_prefix}"*)
|
||||
shopt -u nullglob
|
||||
|
||||
if (( ${#log_files[@]} == 0 )); then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
mapfile -t values_us < <(awk 'NF >= 3 && $3 ~ /^[0-9]+(\.[0-9]+)?$/ {print $3}' "${log_files[@]}" | sort -n)
|
||||
count="${#values_us[@]}"
|
||||
if (( count == 0 )); then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
rank=$(( (percentile * count + 99) / 100 ))
|
||||
if (( rank < 1 )); then rank=1; fi
|
||||
if (( rank > count )); then rank=count; fi
|
||||
|
||||
value_us="${values_us[$((rank - 1))]}"
|
||||
awk -v us="$value_us" 'BEGIN { printf "%.3f", us/1000.0 }'
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local mode="$1"
|
||||
local clients="$2"
|
||||
shift 2
|
||||
local -a extra_args=("$@")
|
||||
local log_file="${RESULT_DIR}/${mode}_c${clients}.txt"
|
||||
local tx_log_prefix="${RESULT_DIR}/${mode}_c${clients}_txlog"
|
||||
|
||||
echo "Running mode=${mode}, clients=${clients}, duration=${DURATION}s"
|
||||
# Warm cache and avoid measuring first-run effects.
|
||||
"${PGBENCH[@]}" -n -M prepared -j "$JOBS" -c "$clients" -T "$WARMUP_SECONDS" "${extra_args[@]}" "$BENCH_DB" > /dev/null
|
||||
|
||||
"${PGBENCH[@]}" -n -M prepared -j "$JOBS" -c "$clients" -T "$DURATION" -r -l --log-prefix="$tx_log_prefix" "${extra_args[@]}" "$BENCH_DB" > "$log_file"
|
||||
|
||||
local tps latency p95 p99
|
||||
tps="$(extract_tps "$log_file")"
|
||||
latency="$(extract_avg_latency "$log_file")"
|
||||
p95="$(select_percentile_ms_from_prefix "$tx_log_prefix" 95)"
|
||||
p99="$(select_percentile_ms_from_prefix "$tx_log_prefix" 99)"
|
||||
|
||||
echo "${mode},${clients},${DURATION},${JOBS},${tps},${latency},${p95},${p99},${log_file}" >> "$SUMMARY_CSV"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_command psql
|
||||
require_command pgbench
|
||||
|
||||
mkdir -p "$RESULT_DIR"
|
||||
echo "mode,clients,duration_seconds,jobs,tps,latency_ms,p95_ms,p99_ms,details_file" > "$SUMMARY_CSV"
|
||||
|
||||
create_database_if_missing
|
||||
init_or_reinit_data
|
||||
|
||||
if [[ "$RUN_CUSTOM" == "1" ]]; then
|
||||
if [[ ! -f "$CUSTOM_SQL_FILE" ]]; then
|
||||
echo "Custom SQL file not found: $CUSTOM_SQL_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
for c in $CLIENTS; do
|
||||
run_case "read_write" "$c"
|
||||
|
||||
if [[ "$RUN_READ_ONLY" == "1" ]]; then
|
||||
run_case "read_only" "$c" "-S"
|
||||
fi
|
||||
|
||||
if [[ "$RUN_CUSTOM" == "1" ]]; then
|
||||
run_case "$CUSTOM_MODE_NAME" "$c" \
|
||||
"-D" "read_pct=${CUSTOM_READ_PCT}" \
|
||||
"-D" "hot_pct=${CUSTOM_HOT_PCT}" \
|
||||
"-D" "txn_size=${CUSTOM_TXN_SIZE}" \
|
||||
"-D" "hot_accounts=${CUSTOM_HOT_ACCOUNTS}" \
|
||||
"-f" "$CUSTOM_SQL_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Benchmark completed."
|
||||
echo "Summary: $SUMMARY_CSV"
|
||||
echo "Raw outputs: $RESULT_DIR"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export pgbench summary.csv into an HTML report with TPS and latency charts."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create an HTML chart report from pgbench summary.csv")
|
||||
parser.add_argument("summary_csv", help="Path to summary.csv generated by pgbench benchmark script")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
default=None,
|
||||
help="Output HTML path (default: <summary_dir>/pgbench_report.html)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_rows(path: Path) -> List[Dict[str, str]]:
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
expected = {"mode", "clients", "tps", "latency_ms"}
|
||||
if not expected.issubset(set(reader.fieldnames or [])):
|
||||
missing = sorted(expected - set(reader.fieldnames or []))
|
||||
raise ValueError(f"summary.csv missing required columns: {', '.join(missing)}")
|
||||
return list(reader)
|
||||
|
||||
|
||||
def parse_optional_float(raw: str) -> Optional[float]:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def build_series(rows: List[Dict[str, str]]) -> Dict[str, List[Dict[str, float]]]:
|
||||
series: Dict[str, List[Dict[str, float]]] = {}
|
||||
for row in rows:
|
||||
mode = row["mode"].strip()
|
||||
try:
|
||||
clients = int(row["clients"])
|
||||
tps = float(row["tps"])
|
||||
latency = float(row["latency_ms"])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
point: Dict[str, float] = {
|
||||
"clients": clients,
|
||||
"tps": tps,
|
||||
"latency_ms": latency,
|
||||
}
|
||||
|
||||
p95 = parse_optional_float(row.get("p95_ms", ""))
|
||||
p99 = parse_optional_float(row.get("p99_ms", ""))
|
||||
if p95 is not None:
|
||||
point["p95_ms"] = p95
|
||||
if p99 is not None:
|
||||
point["p99_ms"] = p99
|
||||
|
||||
series.setdefault(mode, []).append(point)
|
||||
|
||||
for mode in series:
|
||||
series[mode].sort(key=lambda item: item["clients"])
|
||||
|
||||
return series
|
||||
|
||||
|
||||
def html_report(title: str, data: Dict[str, List[Dict[str, float]]]) -> str:
|
||||
payload = json.dumps(data)
|
||||
return f"""<!doctype html>
|
||||
<html lang=\"en\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f7f7f2;
|
||||
--fg: #182020;
|
||||
--panel: #ffffff;
|
||||
--muted: #4f5b5b;
|
||||
--grid: #dde4e4;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: radial-gradient(circle at 8% 15%, #e7f6f2, var(--bg));
|
||||
color: var(--fg);
|
||||
}}
|
||||
.container {{
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}}
|
||||
.card {{
|
||||
background: var(--panel);
|
||||
border: 1px solid #e2ebeb;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: 0 10px 26px rgba(20, 30, 30, 0.06);
|
||||
}}
|
||||
h1 {{ margin: 0 0 8px 0; font-size: 1.6rem; }}
|
||||
p {{ margin: 0; color: var(--muted); }}
|
||||
canvas {{ width: 100%; height: 360px; display: block; }}
|
||||
.legend {{ display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }}
|
||||
.legend-item {{ display: inline-flex; align-items: center; gap: 8px; font-size: 0.9rem; color: var(--muted); }}
|
||||
.swatch {{ width: 14px; height: 14px; border-radius: 3px; display: inline-block; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=\"container\">
|
||||
<div class=\"card\">
|
||||
<h1>{title}</h1>
|
||||
<p>Generated from summary.csv. X-axis = clients. Lines are grouped by mode.</p>
|
||||
</div>
|
||||
|
||||
<div class=\"card\">
|
||||
<h2>TPS by Clients</h2>
|
||||
<canvas id=\"tpsChart\" width=\"1040\" height=\"360\"></canvas>
|
||||
<div id=\"legend\" class=\"legend\"></div>
|
||||
</div>
|
||||
|
||||
<div class=\"card\">
|
||||
<h2>Latency (ms) by Clients</h2>
|
||||
<canvas id=\"latencyChart\" width=\"1040\" height=\"360\"></canvas>
|
||||
</div>
|
||||
|
||||
<div class=\"card\">
|
||||
<h2>p95 Latency (ms) by Clients</h2>
|
||||
<canvas id=\"p95Chart\" width=\"1040\" height=\"360\"></canvas>
|
||||
</div>
|
||||
|
||||
<div class=\"card\">
|
||||
<h2>p99 Latency (ms) by Clients</h2>
|
||||
<canvas id=\"p99Chart\" width=\"1040\" height=\"360\"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const series = {payload};
|
||||
const palette = ["#0b6e4f", "#f26419", "#33658a", "#2f4858", "#5f0f40", "#9a031e", "#386641", "#6a4c93"];
|
||||
|
||||
function flattenValues(metric) {{
|
||||
const out = [];
|
||||
Object.values(series).forEach(items => items.forEach(i => {{
|
||||
const v = i[metric];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
||||
}}));
|
||||
return out;
|
||||
}}
|
||||
|
||||
function flattenClients() {{
|
||||
const out = [];
|
||||
Object.values(series).forEach(items => items.forEach(i => out.push(i.clients)));
|
||||
return out;
|
||||
}}
|
||||
|
||||
function drawChart(canvasId, metric, yLabel) {{
|
||||
const c = document.getElementById(canvasId);
|
||||
const ctx = c.getContext('2d');
|
||||
const W = c.width;
|
||||
const H = c.height;
|
||||
const m = {{ top: 22, right: 20, bottom: 44, left: 60 }};
|
||||
const x0 = m.left, y0 = H - m.bottom, x1 = W - m.right, y1 = m.top;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#182020';
|
||||
ctx.font = '13px Segoe UI, sans-serif';
|
||||
ctx.fillText(yLabel, 8, 16);
|
||||
|
||||
const clientsAll = flattenClients();
|
||||
const valuesAll = flattenValues(metric);
|
||||
if (!clientsAll.length || !valuesAll.length) {{
|
||||
ctx.fillText('No data in summary.csv for this metric', x0 + 10, y0 - 10);
|
||||
return;
|
||||
}}
|
||||
|
||||
const minX = Math.min(...clientsAll);
|
||||
const maxX = Math.max(...clientsAll);
|
||||
const minY = 0;
|
||||
const maxY = Math.max(...valuesAll) * 1.1;
|
||||
|
||||
const x = v => x0 + ((v - minX) / Math.max(1, maxX - minX)) * (x1 - x0);
|
||||
const y = v => y0 - ((v - minY) / Math.max(1e-9, maxY - minY)) * (y0 - y1);
|
||||
|
||||
ctx.strokeStyle = '#dde4e4';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {{
|
||||
const yy = y0 - ((y0 - y1) * i / 5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, yy);
|
||||
ctx.lineTo(x1, yy);
|
||||
ctx.stroke();
|
||||
const val = (maxY * i / 5).toFixed(1);
|
||||
ctx.fillStyle = '#4f5b5b';
|
||||
ctx.fillText(val, 16, yy + 4);
|
||||
}}
|
||||
|
||||
const xTicks = [...new Set(clientsAll)].sort((a, b) => a - b);
|
||||
xTicks.forEach(v => {{
|
||||
const xx = x(v);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xx, y0);
|
||||
ctx.lineTo(xx, y0 + 5);
|
||||
ctx.strokeStyle = '#9da9a9';
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#4f5b5b';
|
||||
ctx.fillText(String(v), xx - 6, y0 + 18);
|
||||
}});
|
||||
|
||||
ctx.strokeStyle = '#182020';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, y0);
|
||||
ctx.lineTo(x1, y0);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, y0);
|
||||
ctx.lineTo(x0, y1);
|
||||
ctx.stroke();
|
||||
|
||||
const legend = document.getElementById('legend');
|
||||
if (metric === 'tps') legend.innerHTML = '';
|
||||
|
||||
Object.entries(series).forEach(([mode, items], idx) => {{
|
||||
const color = palette[idx % palette.length];
|
||||
const sorted = [...items]
|
||||
.filter(p => typeof p[metric] === 'number' && Number.isFinite(p[metric]))
|
||||
.sort((a, b) => a.clients - b.clients);
|
||||
|
||||
if (!sorted.length) return;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
sorted.forEach((p, i) => {{
|
||||
const px = x(p.clients);
|
||||
const py = y(p[metric]);
|
||||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
}});
|
||||
ctx.stroke();
|
||||
|
||||
sorted.forEach(p => {{
|
||||
const px = x(p.clients);
|
||||
const py = y(p[metric]);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}});
|
||||
|
||||
if (metric === 'tps') {{
|
||||
const item = document.createElement('div');
|
||||
item.className = 'legend-item';
|
||||
item.innerHTML = `<span class=\"swatch\" style=\"background:${{color}}\"></span>${{mode}}`;
|
||||
legend.appendChild(item);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
drawChart('tpsChart', 'tps', 'TPS');
|
||||
drawChart('latencyChart', 'latency_ms', 'Latency (ms)');
|
||||
drawChart('p95Chart', 'p95_ms', 'p95 (ms)');
|
||||
drawChart('p99Chart', 'p99_ms', 'p99 (ms)');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
summary_path = Path(args.summary_csv).resolve()
|
||||
if not summary_path.is_file():
|
||||
raise FileNotFoundError(f"summary.csv not found: {summary_path}")
|
||||
|
||||
rows = load_rows(summary_path)
|
||||
if not rows:
|
||||
raise ValueError("summary.csv has no data rows")
|
||||
|
||||
series = build_series(rows)
|
||||
if not series:
|
||||
raise ValueError("No valid numeric rows found in summary.csv")
|
||||
|
||||
output_path = (
|
||||
Path(args.output).resolve()
|
||||
if args.output
|
||||
else summary_path.parent / "pgbench_report.html"
|
||||
)
|
||||
|
||||
title = f"pgbench report: {summary_path.parent.name}"
|
||||
output_path.write_text(html_report(title=title, data=series), encoding="utf-8")
|
||||
print(f"Report written: {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
# pgbench benchmark quickstart
|
||||
|
||||
This workspace now includes repeatable benchmark harnesses:
|
||||
|
||||
- `pgbench_benchmark.ps1` (Windows PowerShell)
|
||||
- `pgbench_benchmark.sh` (Bash)
|
||||
- `pgbench_custom_workload.sql` (sample custom SQL transaction mix)
|
||||
- `pgbench_export_report.py` (exports TPS/latency chart report from `summary.csv`)
|
||||
|
||||
## 1) Prerequisites
|
||||
|
||||
- PostgreSQL client tools installed (`psql`, `pgbench`)
|
||||
- Network access to your PostgreSQL server
|
||||
- Credentials available via environment variables (`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`)
|
||||
|
||||
## 2) Run the benchmark (Windows PowerShell)
|
||||
|
||||
From this folder:
|
||||
|
||||
```powershell
|
||||
$env:PGPASSWORD = "your_password"
|
||||
.\pgbench_benchmark.ps1 -PgHost localhost -PgPort 5432 -PgUser postgres
|
||||
```
|
||||
|
||||
You can override defaults:
|
||||
|
||||
```powershell
|
||||
$env:PGPASSWORD = "your_password"
|
||||
.\pgbench_benchmark.ps1 -BenchDb pgbench -Scale 100 -Duration 120 -Clients "1 8 16 32 64" -Jobs 8 -Reinit $true
|
||||
```
|
||||
|
||||
Run with custom SQL workload mode enabled:
|
||||
|
||||
```powershell
|
||||
$env:PGPASSWORD = "your_password"
|
||||
.\pgbench_benchmark.ps1 -RunCustom $true -CustomSqlFile .\pgbench_custom_workload.sql -CustomModeName app_like -CustomReadPct 85 -CustomHotPct 92 -CustomTxnSize 3 -CustomHotAccounts 12000
|
||||
```
|
||||
|
||||
## 3) Optional Bash usage (WSL/Git Bash)
|
||||
|
||||
```bash
|
||||
chmod +x pgbench_benchmark.sh
|
||||
PGHOST=localhost PGPORT=5432 PGUSER=postgres PGPASSWORD=your_password ./pgbench_benchmark.sh
|
||||
```
|
||||
|
||||
Run with custom SQL workload mode enabled:
|
||||
|
||||
```bash
|
||||
PGHOST=localhost PGPORT=5432 PGUSER=postgres PGPASSWORD=your_password RUN_CUSTOM=1 CUSTOM_SQL_FILE=./pgbench_custom_workload.sql CUSTOM_MODE_NAME=app_like CUSTOM_READ_PCT=85 CUSTOM_HOT_PCT=92 CUSTOM_TXN_SIZE=3 CUSTOM_HOT_ACCOUNTS=12000 ./pgbench_benchmark.sh
|
||||
```
|
||||
|
||||
## 4) Optional tuning inputs (Bash script)
|
||||
|
||||
```bash
|
||||
BENCH_DB=pgbench SCALE=100 DURATION=120 CLIENTS="1 8 16 32 64" JOBS=8 REINIT=1 ./pgbench_benchmark.sh
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `BENCH_DB`: database name for benchmark data (default: `pgbench`)
|
||||
- `SCALE`: data size scale factor (default: `50`)
|
||||
- `DURATION`: measured run time per test case in seconds (default: `60`)
|
||||
- `CLIENTS`: space-separated client counts (default: `"1 4 8 16 32"`)
|
||||
- `JOBS`: worker threads for pgbench (default: `4`)
|
||||
- `WARMUP_SECONDS`: unmeasured warm-up before each test (default: `15`)
|
||||
- `RUN_READ_ONLY`: set to `0` to skip `-S` read-only tests (default: `1`)
|
||||
- `REINIT`: set to `1` to reinitialize data each run (default: `0`)
|
||||
- `RUN_CUSTOM`: set to `1` to run custom SQL workload mode (default: `0`)
|
||||
- `CUSTOM_SQL_FILE`: path to custom SQL file for `pgbench -f` (default: `pgbench_custom_workload.sql`)
|
||||
- `CUSTOM_MODE_NAME`: mode label written to `summary.csv` for custom runs (default: `custom_sql`)
|
||||
- `CUSTOM_READ_PCT` / `-CustomReadPct`: read share percentage for custom mode; writes happen in `100-read_pct` (default: `80`)
|
||||
- `CUSTOM_HOT_PCT` / `-CustomHotPct`: chance to pick from hot account set in custom mode (default: `90`)
|
||||
- `CUSTOM_TXN_SIZE` / `-CustomTxnSize`: custom transaction size tier from `1` to `4` (default: `2`)
|
||||
- `CUSTOM_HOT_ACCOUNTS` / `-CustomHotAccounts`: number of hot accounts near start of keyspace (default: `10000`)
|
||||
|
||||
## 5) Output
|
||||
|
||||
Each run creates:
|
||||
|
||||
- `pgbench_results_YYYYMMDD_HHMMSS/summary.csv`
|
||||
- Per-test raw output files in the same results directory
|
||||
|
||||
Use `summary.csv` to compare TPS and latency across runs.
|
||||
|
||||
`summary.csv` now includes percentile latency columns:
|
||||
|
||||
- `p95_ms`
|
||||
- `p99_ms`
|
||||
|
||||
## 6) Export chart report from summary.csv
|
||||
|
||||
Generate an HTML report with TPS and latency charts:
|
||||
|
||||
```powershell
|
||||
python .\pgbench_export_report.py .\pgbench_results_YYYYMMDD_HHMMSS\summary.csv
|
||||
```
|
||||
|
||||
Optional output path:
|
||||
|
||||
```powershell
|
||||
python .\pgbench_export_report.py .\pgbench_results_YYYYMMDD_HHMMSS\summary.csv -o .\pgbench_results_YYYYMMDD_HHMMSS\my_report.html
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to read and parse Powerball numbers from a CSV file with advanced pattern detection.
|
||||
"""
|
||||
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple, Optional, Set
|
||||
from collections import Counter, defaultdict
|
||||
from itertools import combinations
|
||||
|
||||
|
||||
def read_powerball_csv(filepath: str) -> List[Dict]:
|
||||
"""
|
||||
Read a CSV file containing Powerball numbers.
|
||||
|
||||
Expected CSV format:
|
||||
Draw Date,Winning Numbers,Multiplier
|
||||
09/26/2020,11 21 27 36 62 24,3
|
||||
|
||||
Args:
|
||||
filepath: Path to the CSV file
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing parsed powerball data
|
||||
"""
|
||||
powerball_data = []
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
|
||||
for row_num, row in enumerate(reader, start=2): # start=2 because row 1 is header
|
||||
try:
|
||||
# Skip empty rows
|
||||
if not any(row.values()):
|
||||
continue
|
||||
|
||||
# Parse the row
|
||||
draw_date = datetime.strptime(row['Draw Date'].strip(), '%m/%d/%Y')
|
||||
|
||||
# Parse winning numbers - filter out empty strings
|
||||
numbers_str = row['Winning Numbers'].strip()
|
||||
winning_numbers = [int(n) for n in numbers_str.split() if n.strip()]
|
||||
|
||||
multiplier = int(row['Multiplier'].strip())
|
||||
|
||||
powerball_data.append({
|
||||
'draw_date': draw_date,
|
||||
'date_str': draw_date.strftime('%Y-%m-%d'),
|
||||
'winning_numbers': winning_numbers,
|
||||
'multiplier': multiplier
|
||||
})
|
||||
except ValueError as e:
|
||||
print(f"Warning: Skipping row {row_num} due to error: {e}")
|
||||
print(f" Row data: {row}")
|
||||
continue
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File '{filepath}' not found.")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to read CSV file - {e}")
|
||||
return []
|
||||
|
||||
return powerball_data
|
||||
|
||||
|
||||
def display_powerball_data(data: List[Dict]) -> None:
|
||||
"""
|
||||
Display powerball data in a formatted table.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
"""
|
||||
if not data:
|
||||
print("No data to display.")
|
||||
return
|
||||
|
||||
print(f"{'Draw Date':<12} {'Winning Numbers':<30} {'Multiplier':<10}")
|
||||
print("-" * 52)
|
||||
|
||||
for entry in data:
|
||||
numbers_str = ' '.join(map(str, entry['winning_numbers']))
|
||||
print(f"{entry['date_str']:<12} {numbers_str:<30} {entry['multiplier']:<10}")
|
||||
|
||||
|
||||
def _get_unweighted_top_numbers(draws: List[Dict]) -> Tuple[List[int], Optional[int]]:
|
||||
"""Return top 5 white balls and top Powerball from a draw list."""
|
||||
if not draws:
|
||||
return [], None
|
||||
|
||||
white_counter = Counter()
|
||||
powerball_counter = Counter()
|
||||
|
||||
for entry in draws:
|
||||
numbers = entry['winning_numbers']
|
||||
for number in numbers[:5]:
|
||||
white_counter[number] += 1
|
||||
|
||||
if len(numbers) >= 6:
|
||||
powerball_counter[numbers[5]] += 1
|
||||
|
||||
top_white = [number for number, _ in white_counter.most_common(5)]
|
||||
top_powerball = powerball_counter.most_common(1)[0][0] if powerball_counter else None
|
||||
|
||||
return top_white, top_powerball
|
||||
|
||||
|
||||
def _get_recency_weighted_top_numbers(draws: List[Dict]) -> Tuple[List[int], Optional[int]]:
|
||||
"""Return top numbers weighted so newer draws contribute more."""
|
||||
if not draws:
|
||||
return [], None
|
||||
|
||||
sorted_draws = sorted(draws, key=lambda x: x['draw_date'])
|
||||
total_draws = len(sorted_draws)
|
||||
|
||||
white_scores: Dict[int, float] = defaultdict(float)
|
||||
powerball_scores: Dict[int, float] = defaultdict(float)
|
||||
|
||||
for index, entry in enumerate(sorted_draws, start=1):
|
||||
weight = index / total_draws
|
||||
numbers = entry['winning_numbers']
|
||||
|
||||
for number in numbers[:5]:
|
||||
white_scores[number] += weight
|
||||
|
||||
if len(numbers) >= 6:
|
||||
powerball_scores[numbers[5]] += weight
|
||||
|
||||
top_white = [number for number, _ in sorted(white_scores.items(), key=lambda x: x[1], reverse=True)[:5]]
|
||||
top_powerball = max(powerball_scores.items(), key=lambda x: x[1])[0] if powerball_scores else None
|
||||
|
||||
return top_white, top_powerball
|
||||
|
||||
|
||||
def _get_aggressive_recency_weighted_top_numbers(draws: List[Dict]) -> Tuple[List[int], Optional[int]]:
|
||||
"""Return top numbers with an aggressive recency curve (cubic weighting)."""
|
||||
if not draws:
|
||||
return [], None
|
||||
|
||||
sorted_draws = sorted(draws, key=lambda x: x['draw_date'])
|
||||
total_draws = len(sorted_draws)
|
||||
|
||||
white_scores: Dict[int, float] = defaultdict(float)
|
||||
powerball_scores: Dict[int, float] = defaultdict(float)
|
||||
|
||||
for index, entry in enumerate(sorted_draws, start=1):
|
||||
normalized_position = index / total_draws
|
||||
weight = normalized_position ** 3
|
||||
numbers = entry['winning_numbers']
|
||||
|
||||
for number in numbers[:5]:
|
||||
white_scores[number] += weight
|
||||
|
||||
if len(numbers) >= 6:
|
||||
powerball_scores[numbers[5]] += weight
|
||||
|
||||
top_white = [number for number, _ in sorted(white_scores.items(), key=lambda x: x[1], reverse=True)[:5]]
|
||||
top_powerball = max(powerball_scores.items(), key=lambda x: x[1])[0] if powerball_scores else None
|
||||
|
||||
return top_white, top_powerball
|
||||
|
||||
|
||||
def analyze_most_frequent_numbers(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze and display the most frequent number in each slot position.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
"""
|
||||
if not data:
|
||||
print("No data to analyze.")
|
||||
return {}
|
||||
|
||||
# Initialize lists to hold numbers for each position
|
||||
num_positions = len(data[0]['winning_numbers'])
|
||||
position_numbers = [[] for _ in range(num_positions)]
|
||||
|
||||
# Collect all numbers for each position
|
||||
for entry in data:
|
||||
for position, number in enumerate(entry['winning_numbers']):
|
||||
position_numbers[position].append(number)
|
||||
|
||||
# Find most common number in each position
|
||||
print("\n" + "=" * 60)
|
||||
print("Most Frequent Numbers by Slot Position")
|
||||
print("=" * 60)
|
||||
|
||||
most_frequent = []
|
||||
for position, numbers in enumerate(position_numbers, start=1):
|
||||
counter = Counter(numbers)
|
||||
most_common_num, frequency = counter.most_common(1)[0]
|
||||
most_frequent.append(most_common_num)
|
||||
percentage = (frequency / len(numbers)) * 100
|
||||
print(f"Position {position}: {most_common_num:<3} (appears {frequency} times, {percentage:.1f}%)")
|
||||
|
||||
print("-" * 60)
|
||||
print(f"Most frequent numbers combined: {' '.join(map(str, most_frequent))}")
|
||||
|
||||
# Analyze most frequent number across first 5 positions combined
|
||||
print("\n" + "=" * 60)
|
||||
print("Additional Analysis")
|
||||
print("=" * 60)
|
||||
|
||||
# Combine all numbers from positions 1-5
|
||||
first_five_combined = []
|
||||
for i in range(min(5, num_positions)):
|
||||
first_five_combined.extend(position_numbers[i])
|
||||
|
||||
top_five_numbers = []
|
||||
top_ten_numbers = []
|
||||
if first_five_combined:
|
||||
counter_first_five = Counter(first_five_combined)
|
||||
top_five_numbers = counter_first_five.most_common(5)
|
||||
top_ten_numbers = counter_first_five.most_common(10)
|
||||
print("Top 5 most drawn numbers in first 5 positions combined:")
|
||||
for rank, (number, frequency) in enumerate(top_five_numbers, start=1):
|
||||
percentage = (frequency / len(first_five_combined)) * 100
|
||||
print(f" {rank}. {number:<3} (appears {frequency} times, {percentage:.1f}%)")
|
||||
|
||||
# Analyze the sixth position (Powerball)
|
||||
powerball_ranked = []
|
||||
most_common_sixth = None
|
||||
if num_positions >= 6:
|
||||
sixth_position_numbers = position_numbers[5]
|
||||
counter_sixth = Counter(sixth_position_numbers)
|
||||
powerball_ranked = counter_sixth.most_common(5)
|
||||
most_common_sixth, freq_sixth = counter_sixth.most_common(1)[0]
|
||||
percentage_sixth = (freq_sixth / len(sixth_position_numbers)) * 100
|
||||
print(f"\nMost drawn number in position 6 (Powerball): {most_common_sixth} (appears {freq_sixth} times, {percentage_sixth:.1f}%)")
|
||||
|
||||
# Third strategy: most frequent numbers from the most recent 104 draws
|
||||
sorted_by_date = sorted(data, key=lambda x: x['draw_date'])
|
||||
recent_104_draws = sorted_by_date[-104:]
|
||||
recent_104_top_five, recent_104_powerball = _get_unweighted_top_numbers(recent_104_draws)
|
||||
|
||||
# Fourth strategy: weighted-by-recency across all draws
|
||||
weighted_top_five, weighted_powerball = _get_recency_weighted_top_numbers(data)
|
||||
|
||||
# Fifth strategy: aggressive weighted-by-recency across all draws
|
||||
aggressive_weighted_top_five, aggressive_weighted_powerball = _get_aggressive_recency_weighted_top_numbers(data)
|
||||
|
||||
# Return statistics for prediction
|
||||
return {
|
||||
'top_five_combined': [num for num, freq in top_five_numbers] if first_five_combined else [],
|
||||
'top_ten_combined': [num for num, freq in top_ten_numbers],
|
||||
'most_common_powerball': most_common_sixth,
|
||||
'powerball_ranked': [num for num, freq in powerball_ranked],
|
||||
'recent_104_top_five': recent_104_top_five,
|
||||
'recent_104_powerball': recent_104_powerball,
|
||||
'weighted_top_five': weighted_top_five,
|
||||
'weighted_powerball': weighted_powerball,
|
||||
'aggressive_weighted_top_five': aggressive_weighted_top_five,
|
||||
'aggressive_weighted_powerball': aggressive_weighted_powerball
|
||||
}
|
||||
|
||||
|
||||
def generate_suggested_combination(stats: dict) -> None:
|
||||
"""
|
||||
Generate a suggested combination based on statistical analysis.
|
||||
|
||||
Args:
|
||||
stats: Dictionary containing statistical analysis results
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Suggested Combination Based on Statistical Analysis")
|
||||
print("=" * 60)
|
||||
|
||||
print("\n*** DISCLAIMER: This is purely statistical analysis.")
|
||||
print("Past results do NOT predict future outcomes.")
|
||||
print("Each draw is random and independent.\n")
|
||||
|
||||
top_five = stats['top_five_combined']
|
||||
top_ten = stats.get('top_ten_combined', [])
|
||||
powerball = stats['most_common_powerball']
|
||||
ranked_powerballs = stats.get('powerball_ranked', [])
|
||||
recent_104_top_five = stats.get('recent_104_top_five', [])
|
||||
recent_104_powerball = stats.get('recent_104_powerball')
|
||||
weighted_top_five = stats.get('weighted_top_five', [])
|
||||
weighted_powerball = stats.get('weighted_powerball')
|
||||
aggressive_weighted_top_five = stats.get('aggressive_weighted_top_five', [])
|
||||
aggressive_weighted_powerball = stats.get('aggressive_weighted_powerball')
|
||||
|
||||
if len(top_five) >= 5 and powerball:
|
||||
# Use the top 5 most frequent numbers from positions 1-5
|
||||
primary_numbers = sorted(top_five[:5])
|
||||
primary_powerball = powerball
|
||||
|
||||
# Build a secondary set from the next-most-frequent historical numbers.
|
||||
secondary_pool = top_ten[5:10]
|
||||
if len(secondary_pool) < 5:
|
||||
secondary_pool = top_ten[:5]
|
||||
|
||||
secondary_numbers = sorted(secondary_pool[:5])
|
||||
secondary_powerball = ranked_powerballs[1] if len(ranked_powerballs) > 1 else primary_powerball
|
||||
|
||||
third_numbers = sorted(recent_104_top_five[:5]) if len(recent_104_top_five) >= 5 else []
|
||||
third_powerball = recent_104_powerball
|
||||
|
||||
fourth_numbers = sorted(weighted_top_five[:5]) if len(weighted_top_five) >= 5 else []
|
||||
fourth_powerball = weighted_powerball
|
||||
|
||||
fifth_numbers = sorted(aggressive_weighted_top_five[:5]) if len(aggressive_weighted_top_five) >= 5 else []
|
||||
fifth_powerball = aggressive_weighted_powerball
|
||||
|
||||
print("Primary set (most frequent):")
|
||||
print(f" White balls: {' '.join(map(str, primary_numbers))}")
|
||||
print(f" Powerball: {primary_powerball}")
|
||||
|
||||
print("\nSecondary set (next-most frequent):")
|
||||
print(f" White balls: {' '.join(map(str, secondary_numbers))}")
|
||||
print(f" Powerball: {secondary_powerball}")
|
||||
|
||||
if third_numbers and third_powerball:
|
||||
print("\nThird set (most frequent in recent 104 draws):")
|
||||
print(f" White balls: {' '.join(map(str, third_numbers))}")
|
||||
print(f" Powerball: {third_powerball}")
|
||||
|
||||
if fourth_numbers and fourth_powerball:
|
||||
print("\nFourth set (weighted by recency):")
|
||||
print(f" White balls: {' '.join(map(str, fourth_numbers))}")
|
||||
print(f" Powerball: {fourth_powerball}")
|
||||
|
||||
if fifth_numbers and fifth_powerball:
|
||||
print("\nFifth set (aggressive recency weighting):")
|
||||
print(f" White balls: {' '.join(map(str, fifth_numbers))}")
|
||||
print(f" Powerball: {fifth_powerball}")
|
||||
print()
|
||||
print(f"Primary combination: {' '.join(map(str, primary_numbers))} + {primary_powerball}")
|
||||
print(f"Secondary combination: {' '.join(map(str, secondary_numbers))} + {secondary_powerball}")
|
||||
|
||||
if third_numbers and third_powerball:
|
||||
print(f"Third combination: {' '.join(map(str, third_numbers))} + {third_powerball}")
|
||||
|
||||
if fourth_numbers and fourth_powerball:
|
||||
print(f"Fourth combination: {' '.join(map(str, fourth_numbers))} + {fourth_powerball}")
|
||||
|
||||
if fifth_numbers and fifth_powerball:
|
||||
print(f"Fifth combination: {' '.join(map(str, fifth_numbers))} + {fifth_powerball}")
|
||||
print()
|
||||
print("These combinations are based on:")
|
||||
print(" • The 5 most frequently drawn numbers across all white ball positions")
|
||||
print(f" • The most frequently drawn Powerball number ({primary_powerball})")
|
||||
print(" • A secondary set from the next-most-frequent white ball and Powerball values")
|
||||
print(" • Recent-window frequency (last 104 draws)")
|
||||
print(" • Recency-weighted scoring where newer draws count more")
|
||||
print(" • Aggressive recency weighting using a cubic curve")
|
||||
else:
|
||||
print("Insufficient data to generate a suggestion.")
|
||||
|
||||
|
||||
def detect_consecutive_patterns(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze frequency of consecutive numbers in draws.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with consecutive number statistics
|
||||
"""
|
||||
consecutive_counts = Counter()
|
||||
total_draws = len(data)
|
||||
draws_with_consecutives = 0
|
||||
consecutive_pairs = []
|
||||
|
||||
for entry in data:
|
||||
white_balls = sorted(entry['winning_numbers'][:5])
|
||||
has_consecutive = False
|
||||
|
||||
for i in range(len(white_balls) - 1):
|
||||
if white_balls[i+1] - white_balls[i] == 1:
|
||||
consecutive_counts[f"{white_balls[i]}-{white_balls[i+1]}"] += 1
|
||||
consecutive_pairs.append((white_balls[i], white_balls[i+1]))
|
||||
has_consecutive = True
|
||||
|
||||
if has_consecutive:
|
||||
draws_with_consecutives += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Consecutive Numbers Analysis")
|
||||
print("=" * 60)
|
||||
percentage = (draws_with_consecutives / total_draws) * 100 if total_draws > 0 else 0
|
||||
print(f"Draws with consecutive numbers: {draws_with_consecutives}/{total_draws} ({percentage:.1f}%)")
|
||||
|
||||
if consecutive_counts:
|
||||
print("\nMost common consecutive pairs:")
|
||||
for pair, count in consecutive_counts.most_common(10):
|
||||
pair_pct = (count / total_draws) * 100
|
||||
print(f" {pair}: {count} times ({pair_pct:.1f}%)")
|
||||
|
||||
return {
|
||||
'draws_with_consecutives': draws_with_consecutives,
|
||||
'percentage': percentage,
|
||||
'common_pairs': dict(consecutive_counts.most_common(10))
|
||||
}
|
||||
|
||||
|
||||
def detect_odd_even_patterns(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze odd/even distribution patterns.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with odd/even distribution statistics
|
||||
"""
|
||||
distribution = Counter()
|
||||
|
||||
for entry in data:
|
||||
white_balls = entry['winning_numbers'][:5]
|
||||
odd_count = sum(1 for num in white_balls if num % 2 == 1)
|
||||
even_count = 5 - odd_count
|
||||
distribution[f"{odd_count}odd-{even_count}even"] += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Odd/Even Distribution")
|
||||
print("=" * 60)
|
||||
print("Distribution of odd vs even numbers:")
|
||||
|
||||
total = sum(distribution.values())
|
||||
for pattern, count in sorted(distribution.items(), key=lambda x: x[1], reverse=True):
|
||||
percentage = (count / total) * 100
|
||||
print(f" {pattern}: {count} times ({percentage:.1f}%)")
|
||||
|
||||
return {'distribution': dict(distribution)}
|
||||
|
||||
|
||||
def detect_high_low_patterns(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze high/low number distribution (1-35 = low, 36-69 = high).
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with high/low distribution statistics
|
||||
"""
|
||||
distribution = Counter()
|
||||
SPLIT_POINT = 35
|
||||
|
||||
for entry in data:
|
||||
white_balls = entry['winning_numbers'][:5]
|
||||
low_count = sum(1 for num in white_balls if num <= SPLIT_POINT)
|
||||
high_count = 5 - low_count
|
||||
distribution[f"{low_count}low-{high_count}high"] += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"PATTERN: High/Low Distribution (Low: 1-{SPLIT_POINT}, High: {SPLIT_POINT+1}-69)")
|
||||
print("=" * 60)
|
||||
print("Distribution of low vs high numbers:")
|
||||
|
||||
total = sum(distribution.values())
|
||||
for pattern, count in sorted(distribution.items(), key=lambda x: x[1], reverse=True):
|
||||
percentage = (count / total) * 100
|
||||
print(f" {pattern}: {count} times ({percentage:.1f}%)")
|
||||
|
||||
return {'distribution': dict(distribution)}
|
||||
|
||||
|
||||
def detect_sum_patterns(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze the sum of winning numbers.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with sum statistics
|
||||
"""
|
||||
sums = []
|
||||
|
||||
for entry in data:
|
||||
white_balls = entry['winning_numbers'][:5]
|
||||
total = sum(white_balls)
|
||||
sums.append(total)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Sum of Winning Numbers")
|
||||
print("=" * 60)
|
||||
|
||||
avg_sum = sum(sums) / len(sums) if sums else 0
|
||||
min_sum = min(sums) if sums else 0
|
||||
max_sum = max(sums) if sums else 0
|
||||
|
||||
print(f"Average sum: {avg_sum:.1f}")
|
||||
print(f"Range: {min_sum} - {max_sum}")
|
||||
|
||||
# Create buckets for sum ranges
|
||||
buckets = Counter()
|
||||
for s in sums:
|
||||
bucket = (s // 25) * 25 # Group into 25-number ranges
|
||||
buckets[f"{bucket}-{bucket+24}"] += 1
|
||||
|
||||
print("\nSum distribution by range:")
|
||||
for range_str, count in sorted(buckets.items(), key=lambda x: int(x[0].split('-')[0])):
|
||||
percentage = (count / len(sums)) * 100
|
||||
print(f" {range_str}: {count} times ({percentage:.1f}%)")
|
||||
|
||||
return {
|
||||
'average': avg_sum,
|
||||
'min': min_sum,
|
||||
'max': max_sum,
|
||||
'buckets': dict(buckets)
|
||||
}
|
||||
|
||||
|
||||
def detect_gap_patterns(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze gaps (spacing) between consecutive numbers when sorted.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with gap statistics
|
||||
"""
|
||||
all_gaps = []
|
||||
|
||||
for entry in data:
|
||||
white_balls = sorted(entry['winning_numbers'][:5])
|
||||
gaps = [white_balls[i+1] - white_balls[i] for i in range(len(white_balls) - 1)]
|
||||
all_gaps.extend(gaps)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Gap Analysis (spacing between sorted numbers)")
|
||||
print("=" * 60)
|
||||
|
||||
avg_gap = sum(all_gaps) / len(all_gaps) if all_gaps else 0
|
||||
gap_counter = Counter(all_gaps)
|
||||
|
||||
print(f"Average gap: {avg_gap:.1f}")
|
||||
print("\nMost common gap sizes:")
|
||||
for gap, count in gap_counter.most_common(10):
|
||||
percentage = (count / len(all_gaps)) * 100
|
||||
print(f" Gap of {gap}: {count} times ({percentage:.1f}%)")
|
||||
|
||||
return {
|
||||
'average_gap': avg_gap,
|
||||
'gap_distribution': dict(gap_counter.most_common(15))
|
||||
}
|
||||
|
||||
|
||||
def detect_hot_cold_numbers(data: List[Dict], recent_draws: int = 20) -> Dict:
|
||||
"""
|
||||
Identify hot (frequently appearing) and cold (rarely appearing) numbers in recent draws.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
recent_draws: Number of recent draws to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with hot/cold number analysis
|
||||
"""
|
||||
sorted_data = sorted(data, key=lambda x: x['draw_date'])
|
||||
recent = sorted_data[-recent_draws:] if len(sorted_data) >= recent_draws else sorted_data
|
||||
|
||||
white_counter = Counter()
|
||||
powerball_counter = Counter()
|
||||
|
||||
for entry in recent:
|
||||
for num in entry['winning_numbers'][:5]:
|
||||
white_counter[num] += 1
|
||||
if len(entry['winning_numbers']) >= 6:
|
||||
powerball_counter[entry['winning_numbers'][5]] += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"PATTERN: Hot & Cold Numbers (Last {len(recent)} draws)")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\nHottest white balls (appeared most in last {len(recent)} draws):")
|
||||
for num, count in white_counter.most_common(10):
|
||||
print(f" {num}: {count} times")
|
||||
|
||||
print(f"\nHottest Powerballs:")
|
||||
for num, count in powerball_counter.most_common(5):
|
||||
print(f" {num}: {count} times")
|
||||
|
||||
# Cold numbers - all possible white ball numbers (1-69) that appeared least
|
||||
all_white_numbers = set(range(1, 70))
|
||||
appeared = set(white_counter.keys())
|
||||
not_appeared = all_white_numbers - appeared
|
||||
|
||||
print(f"\nColdest white balls (appeared least or not at all):")
|
||||
cold_numbers = sorted(
|
||||
[(num, white_counter.get(num, 0)) for num in all_white_numbers],
|
||||
key=lambda x: (x[1], x[0])
|
||||
)[:10]
|
||||
|
||||
for num, count in cold_numbers:
|
||||
if count == 0:
|
||||
print(f" {num}: 0 times (not appeared)")
|
||||
else:
|
||||
print(f" {num}: {count} times")
|
||||
|
||||
return {
|
||||
'hot_white': dict(white_counter.most_common(10)),
|
||||
'hot_powerball': dict(powerball_counter.most_common(5)),
|
||||
'cold_white': dict(cold_numbers[:10])
|
||||
}
|
||||
|
||||
|
||||
def detect_overdue_numbers(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Identify numbers that haven't appeared in the longest time.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with overdue number analysis
|
||||
"""
|
||||
sorted_data = sorted(data, key=lambda x: x['draw_date'])
|
||||
|
||||
# Track last appearance of each number
|
||||
last_seen_white = {}
|
||||
last_seen_powerball = {}
|
||||
|
||||
for entry in sorted_data:
|
||||
draw_date = entry['draw_date']
|
||||
for num in entry['winning_numbers'][:5]:
|
||||
last_seen_white[num] = draw_date
|
||||
if len(entry['winning_numbers']) >= 6:
|
||||
last_seen_powerball[entry['winning_numbers'][5]] = draw_date
|
||||
|
||||
most_recent_draw = sorted_data[-1]['draw_date']
|
||||
|
||||
# Calculate days since last appearance
|
||||
overdue_white = {}
|
||||
for num in range(1, 70): # White balls are 1-69
|
||||
if num in last_seen_white:
|
||||
days_overdue = (most_recent_draw - last_seen_white[num]).days
|
||||
overdue_white[num] = days_overdue
|
||||
else:
|
||||
overdue_white[num] = float('inf') # Never appeared
|
||||
|
||||
overdue_powerball = {}
|
||||
for num in range(1, 27): # Powerballs are 1-26
|
||||
if num in last_seen_powerball:
|
||||
days_overdue = (most_recent_draw - last_seen_powerball[num]).days
|
||||
overdue_powerball[num] = days_overdue
|
||||
else:
|
||||
overdue_powerball[num] = float('inf')
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Overdue Numbers (longest time since last appearance)")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nMost overdue white balls:")
|
||||
sorted_overdue_white = sorted(overdue_white.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
for num, days in sorted_overdue_white:
|
||||
if days == float('inf'):
|
||||
print(f" {num}: Never appeared")
|
||||
else:
|
||||
print(f" {num}: {days} days ago")
|
||||
|
||||
print("\nMost overdue Powerballs:")
|
||||
sorted_overdue_powerball = sorted(overdue_powerball.items(), key=lambda x: x[1], reverse=True)[:5]
|
||||
for num, days in sorted_overdue_powerball:
|
||||
if days == float('inf'):
|
||||
print(f" {num}: Never appeared")
|
||||
else:
|
||||
print(f" {num}: {days} days ago")
|
||||
|
||||
return {
|
||||
'overdue_white': dict(sorted_overdue_white),
|
||||
'overdue_powerball': dict(sorted_overdue_powerball)
|
||||
}
|
||||
|
||||
|
||||
def detect_number_pairs(data: List[Dict], top_n: int = 10) -> Dict:
|
||||
"""
|
||||
Identify which number pairs appear together most frequently.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
top_n: Number of top pairs to display
|
||||
|
||||
Returns:
|
||||
Dictionary with pair frequency analysis
|
||||
"""
|
||||
pair_counter = Counter()
|
||||
|
||||
for entry in data:
|
||||
white_balls = entry['winning_numbers'][:5]
|
||||
# Get all pairs from this draw
|
||||
for pair in combinations(sorted(white_balls), 2):
|
||||
pair_counter[pair] += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Number Pair Analysis")
|
||||
print("=" * 60)
|
||||
print(f"\nTop {top_n} most common number pairs:")
|
||||
|
||||
for (num1, num2), count in pair_counter.most_common(top_n):
|
||||
percentage = (count / len(data)) * 100
|
||||
print(f" ({num1}, {num2}): appeared together {count} times ({percentage:.1f}%)")
|
||||
|
||||
return {
|
||||
'top_pairs': dict(pair_counter.most_common(top_n))
|
||||
}
|
||||
|
||||
|
||||
def detect_decade_distribution(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Analyze how numbers are distributed across decades (1-9, 10-19, 20-29, etc.).
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary with decade distribution statistics
|
||||
"""
|
||||
decade_counts = Counter()
|
||||
|
||||
for entry in data:
|
||||
white_balls = entry['winning_numbers'][:5]
|
||||
for num in white_balls:
|
||||
decade = (num // 10) * 10
|
||||
decade_label = f"{decade:02d}-{decade+9:02d}" if decade < 60 else "60-69"
|
||||
decade_counts[decade_label] += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN: Decade Distribution")
|
||||
print("=" * 60)
|
||||
print("How often numbers from each range appear:")
|
||||
|
||||
total = sum(decade_counts.values())
|
||||
for decade in ["00-09", "10-19", "20-29", "30-39", "40-49", "50-59", "60-69"]:
|
||||
count = decade_counts.get(decade, 0)
|
||||
percentage = (count / total) * 100 if total > 0 else 0
|
||||
print(f" {decade}: {count} times ({percentage:.1f}%)")
|
||||
|
||||
return {'decade_distribution': dict(decade_counts)}
|
||||
|
||||
|
||||
def run_all_pattern_analyses(data: List[Dict]) -> Dict:
|
||||
"""
|
||||
Run all pattern detection analyses.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary containing all pattern analysis results
|
||||
"""
|
||||
print("\n" + "#" * 60)
|
||||
print("# ADVANCED PATTERN DETECTION ANALYSIS")
|
||||
print("#" * 60)
|
||||
|
||||
results = {}
|
||||
|
||||
results['consecutive'] = detect_consecutive_patterns(data)
|
||||
results['odd_even'] = detect_odd_even_patterns(data)
|
||||
results['high_low'] = detect_high_low_patterns(data)
|
||||
results['sum'] = detect_sum_patterns(data)
|
||||
results['gaps'] = detect_gap_patterns(data)
|
||||
results['hot_cold'] = detect_hot_cold_numbers(data, recent_draws=20)
|
||||
results['overdue'] = detect_overdue_numbers(data)
|
||||
results['pairs'] = detect_number_pairs(data, top_n=15)
|
||||
results['decades'] = detect_decade_distribution(data)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def generate_pattern_based_combinations(data: List[Dict], pattern_results: Dict, num_combinations: int = 5) -> List[Dict]:
|
||||
"""
|
||||
Generate number combinations based on pattern analysis.
|
||||
|
||||
Args:
|
||||
data: List of powerball data dictionaries
|
||||
pattern_results: Results from run_all_pattern_analyses
|
||||
num_combinations: Number of combinations to generate
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing combination details
|
||||
"""
|
||||
import random
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("PATTERN-BASED NUMBER COMBINATIONS")
|
||||
print("=" * 60)
|
||||
print("\n*** DISCLAIMER: These combinations are based on pattern analysis.")
|
||||
print("Lottery draws are random. Past patterns do NOT predict future results.")
|
||||
print("Play responsibly.\n")
|
||||
|
||||
combinations = []
|
||||
|
||||
# Get pattern insights
|
||||
hot_white = list(pattern_results['hot_cold']['hot_white'].keys())[:15]
|
||||
cold_white = [num for num, _ in pattern_results['hot_cold']['cold_white'].items() if _ < 3][:10]
|
||||
overdue_white = [num for num, days in pattern_results['overdue']['overdue_white'].items() if days != float('inf')][:15]
|
||||
hot_powerball = list(pattern_results['hot_cold']['hot_powerball'].keys())[:3]
|
||||
overdue_powerball = [num for num, days in pattern_results['overdue']['overdue_powerball'].items() if days != float('inf')][:5]
|
||||
|
||||
# Get most common distributions
|
||||
odd_even_dist = pattern_results['odd_even']['distribution']
|
||||
most_common_oe = max(odd_even_dist.items(), key=lambda x: x[1])[0]
|
||||
target_odds = int(most_common_oe.split('odd')[0])
|
||||
|
||||
high_low_dist = pattern_results['high_low']['distribution']
|
||||
most_common_hl = max(high_low_dist.items(), key=lambda x: x[1])[0]
|
||||
target_lows = int(most_common_hl.split('low')[0])
|
||||
|
||||
# Get average sum
|
||||
avg_sum = pattern_results['sum']['average']
|
||||
sum_tolerance = 30
|
||||
|
||||
# Get top pairs
|
||||
top_pairs = list(pattern_results['pairs']['top_pairs'].keys())[:20]
|
||||
|
||||
# Strategy descriptions
|
||||
strategies = [
|
||||
("Hot Numbers Focus", hot_white, "Uses frequently drawn recent numbers"),
|
||||
("Balanced Hot + Overdue", hot_white[:8] + overdue_white[:12], "Mixes hot numbers with overdue picks"),
|
||||
("Overdue Numbers Focus", overdue_white, "Focuses on numbers that haven't appeared recently"),
|
||||
("Cold Numbers Gamble", cold_white + hot_white[:10], "Includes rarely drawn numbers"),
|
||||
("Pair-Based Selection", [n for pair in top_pairs for n in pair], "Uses numbers from common pairs")
|
||||
]
|
||||
|
||||
for strategy_idx in range(min(num_combinations, len(strategies))):
|
||||
strategy_name, number_pool, strategy_desc = strategies[strategy_idx]
|
||||
|
||||
# Ensure we have enough numbers in pool
|
||||
if len(number_pool) < 20:
|
||||
number_pool = list(set(number_pool + hot_white + list(range(1, 70))))
|
||||
|
||||
# Try to generate a combination that fits the patterns
|
||||
attempts = 0
|
||||
max_attempts = 1000
|
||||
selected = [] # Initialize to avoid unbound variable
|
||||
|
||||
while attempts < max_attempts:
|
||||
# Select 5 random numbers from pool
|
||||
selected = random.sample(number_pool[:30], min(5, len(number_pool[:30])))
|
||||
selected = sorted(selected)
|
||||
|
||||
# Check odd/even distribution
|
||||
odds_count = sum(1 for n in selected if n % 2 == 1)
|
||||
if abs(odds_count - target_odds) > 1:
|
||||
attempts += 1
|
||||
continue
|
||||
|
||||
# Check high/low distribution
|
||||
lows_count = sum(1 for n in selected if n <= 35)
|
||||
if abs(lows_count - target_lows) > 1:
|
||||
attempts += 1
|
||||
continue
|
||||
|
||||
# Check sum is reasonable
|
||||
total = sum(selected)
|
||||
if abs(total - avg_sum) > sum_tolerance:
|
||||
attempts += 1
|
||||
continue
|
||||
|
||||
# Check gaps are reasonable (not too clustered or too spread)
|
||||
gaps = [selected[i+1] - selected[i] for i in range(4)]
|
||||
if min(gaps) < 2 or max(gaps) > 25:
|
||||
attempts += 1
|
||||
continue
|
||||
|
||||
# Good combination found
|
||||
break
|
||||
|
||||
# If we didn't find a perfect match, selected still has the last attempt
|
||||
if not selected:
|
||||
selected = sorted(random.sample(range(1, 70), 5))
|
||||
|
||||
# Select powerball
|
||||
if strategy_idx % 2 == 0 and hot_powerball:
|
||||
powerball = random.choice(hot_powerball)
|
||||
elif overdue_powerball:
|
||||
powerball = random.choice(overdue_powerball[:5])
|
||||
else:
|
||||
powerball = random.randint(1, 26)
|
||||
|
||||
# Calculate combination stats
|
||||
odds = sum(1 for n in selected if n % 2 == 1)
|
||||
evens = 5 - odds
|
||||
lows = sum(1 for n in selected if n <= 35)
|
||||
highs = 5 - lows
|
||||
total = sum(selected)
|
||||
|
||||
combinations.append({
|
||||
'strategy': strategy_name,
|
||||
'description': strategy_desc,
|
||||
'numbers': selected,
|
||||
'powerball': powerball,
|
||||
'odds': odds,
|
||||
'evens': evens,
|
||||
'lows': lows,
|
||||
'highs': highs,
|
||||
'sum': total,
|
||||
'attempts': attempts
|
||||
})
|
||||
|
||||
# Display combinations
|
||||
for i, combo in enumerate(combinations, 1):
|
||||
print(f"\nCombination #{i}: {combo['strategy']}")
|
||||
print(f" Strategy: {combo['description']}")
|
||||
print(f" Numbers: {' '.join(map(str, combo['numbers']))} + Powerball: {combo['powerball']}")
|
||||
print(f" Stats: {combo['odds']}odd-{combo['evens']}even, {combo['lows']}low-{combo['highs']}high, Sum: {combo['sum']}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Quick Pick Lines:")
|
||||
print("=" * 60)
|
||||
for i, combo in enumerate(combinations, 1):
|
||||
numbers_str = ' - '.join(f"{n:2d}" for n in combo['numbers'])
|
||||
print(f" Line {i}: {numbers_str} | PB: {combo['powerball']:2d}")
|
||||
|
||||
return combinations
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
# Default to 'powerball_numbers.csv' in the same directory as the script
|
||||
script_dir = Path(__file__).parent
|
||||
csv_file = script_dir / 'powerball_numbers.csv'
|
||||
|
||||
# You can also specify a different file path here
|
||||
# csv_file = Path('path/to/your/file.csv')
|
||||
|
||||
print(f"Reading Powerball numbers from: {csv_file}\n")
|
||||
|
||||
powerball_data = read_powerball_csv(str(csv_file))
|
||||
|
||||
if powerball_data:
|
||||
print(f"Successfully loaded {len(powerball_data)} records.\n")
|
||||
display_powerball_data(powerball_data)
|
||||
stats = analyze_most_frequent_numbers(powerball_data)
|
||||
generate_suggested_combination(stats)
|
||||
|
||||
# Run advanced pattern detection
|
||||
pattern_results = run_all_pattern_analyses(powerball_data)
|
||||
|
||||
# Generate pattern-based combinations
|
||||
pattern_combos = generate_pattern_based_combinations(powerball_data, pattern_results, num_combinations=5)
|
||||
else:
|
||||
print("Failed to load powerball data.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/bin/python3
|
||||
"""
|
||||
Parallel rsync launcher.
|
||||
|
||||
Requires rsync on PATH (e.g. via MSYS/Cygwin on Windows).
|
||||
Runs multiple rsync invocations in parallel using either top-level entries
|
||||
or balanced recursive file chunks.
|
||||
"""
|
||||
|
||||
# Usage examples:
|
||||
#
|
||||
# Basic sync (recursive-files mode, auto worker count):
|
||||
# python pyrsync.py /src/dir user@host:/dest/
|
||||
#
|
||||
# Auto-tune workers based on file size and local vs remote destination:
|
||||
# python pyrsync.py /src/dir user@host:/dest/ --auto-tune
|
||||
#
|
||||
# Limit to a specific number of parallel workers:
|
||||
# python pyrsync.py /src/dir /local/dest/ -w 8
|
||||
#
|
||||
# Use top-level mode (one rsync process per immediate source entry):
|
||||
# python pyrsync.py /src/dir /local/dest/ --mode top-level
|
||||
#
|
||||
# Dry run (show what would be transferred without doing it):
|
||||
# python pyrsync.py /src/dir user@host:/dest/ --dry-run
|
||||
#
|
||||
# Verbose output (shows command templates, task list preview, and elapsed time):
|
||||
# python pyrsync.py /src/dir /local/dest/ -v
|
||||
#
|
||||
# Show rsync output for all tasks:
|
||||
# python pyrsync.py /src/dir /local/dest/ --show-rsync-output
|
||||
#
|
||||
# Show rsync output only when a task fails:
|
||||
# python pyrsync.py /src/dir user@host:/dest/ --show-rsync-output-on-failure
|
||||
#
|
||||
# Full-featured example (auto-tune, verbose, show failures):
|
||||
# python pyrsync.py /src/dir user@host:/dest/ --auto-tune -v --show-rsync-output-on-failure
|
||||
#
|
||||
# Override rsync flags entirely:
|
||||
# python pyrsync.py /src/dir /local/dest/ -r "-az --checksum"
|
||||
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
# DEFAULT_RSYNC_ARGS = ["-av", "--partial", "--compress", "--info=progress2"]
|
||||
DEFAULT_RSYNC_ARGS = ["-a", "--partial", "--info=stats2"]
|
||||
|
||||
|
||||
def build_file_list(src: str) -> List[Path]:
|
||||
p = Path(src)
|
||||
if p.is_dir():
|
||||
# sync each top-level entry separately (files and immediate subdirs)
|
||||
return [x for x in p.iterdir()]
|
||||
# allow glob patterns
|
||||
if any(ch in src for ch in ["*", "?"]):
|
||||
return list(Path(".").glob(src))
|
||||
# single file
|
||||
return [p]
|
||||
|
||||
|
||||
def build_recursive_file_list(src_dir: Path) -> List[Path]:
|
||||
"""Return all files under src_dir as relative paths for --files-from."""
|
||||
return [p.relative_to(src_dir) for p in src_dir.rglob("*") if p.is_file()]
|
||||
|
||||
|
||||
def get_sized_files(src_dir: Path, rel_files: Sequence[Path]) -> List[Tuple[int, Path]]:
|
||||
"""Return (size_bytes, rel_path) tuples with best-effort size lookup."""
|
||||
sized_files: List[Tuple[int, Path]] = []
|
||||
for rel_path in rel_files:
|
||||
abs_path = src_dir / rel_path
|
||||
try:
|
||||
size = abs_path.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
sized_files.append((size, rel_path))
|
||||
return sized_files
|
||||
|
||||
|
||||
def partition_by_size(sized_files: Sequence[Tuple[int, Path]], workers: int) -> List[List[Path]]:
|
||||
"""Greedy size-balanced partitioning to reduce worker skew."""
|
||||
buckets: List[List[Path]] = [[] for _ in range(workers)]
|
||||
bucket_sizes = [0] * workers
|
||||
|
||||
# Largest files first gives better load balancing across workers.
|
||||
sorted_files = sorted(sized_files, key=lambda x: x[0], reverse=True)
|
||||
for size, rel_path in sorted_files:
|
||||
idx = min(range(workers), key=lambda i: bucket_sizes[i])
|
||||
buckets[idx].append(rel_path)
|
||||
bucket_sizes[idx] += size
|
||||
|
||||
return [b for b in buckets if b]
|
||||
|
||||
|
||||
def detect_remote_destination(dest: str) -> bool:
|
||||
"""Heuristic detection of rsync remote destinations."""
|
||||
if dest.startswith("rsync://"):
|
||||
return True
|
||||
if "::" in dest:
|
||||
return True
|
||||
if re.match(r"^[a-zA-Z]:[\\/]", dest):
|
||||
return False
|
||||
if re.match(r"^\\\\", dest):
|
||||
return False
|
||||
return bool(re.match(r"^[^:/\\]+@?[^:/\\]*:[^/].*", dest))
|
||||
|
||||
|
||||
def format_bytes(num_bytes: float) -> str:
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
value = float(num_bytes)
|
||||
for unit in units:
|
||||
if value < 1024.0 or unit == units[-1]:
|
||||
return f"{value:.1f}{unit}"
|
||||
value /= 1024.0
|
||||
return f"{num_bytes:.1f}B"
|
||||
|
||||
|
||||
def auto_tune_workers(
|
||||
total_units: int,
|
||||
avg_size_bytes: float,
|
||||
is_remote: bool,
|
||||
cpu_count: int,
|
||||
configured_workers: int,
|
||||
) -> int:
|
||||
"""Pick worker count from workload size profile and destination type."""
|
||||
cpu = max(1, cpu_count)
|
||||
|
||||
if avg_size_bytes < 1 * 1024 * 1024:
|
||||
base = min(32, cpu * 4)
|
||||
elif avg_size_bytes < 32 * 1024 * 1024:
|
||||
base = min(16, cpu * 2)
|
||||
elif avg_size_bytes < 256 * 1024 * 1024:
|
||||
base = min(8, cpu)
|
||||
else:
|
||||
base = min(4, max(2, cpu // 2))
|
||||
|
||||
if is_remote:
|
||||
base = max(2, min(base, 8))
|
||||
else:
|
||||
base = min(base + 2, 24)
|
||||
|
||||
tuned = max(1, min(base, total_units))
|
||||
return min(tuned, configured_workers)
|
||||
|
||||
|
||||
def maybe_print_task_output(label: str, out: str, err: str, show_output: bool, show_on_fail_only: bool, code: int) -> None:
|
||||
if not show_output:
|
||||
return
|
||||
if show_on_fail_only and code == 0:
|
||||
return
|
||||
|
||||
if out.strip():
|
||||
print(f"--- stdout: {label} ---")
|
||||
print(out.rstrip())
|
||||
if err.strip():
|
||||
print(f"--- stderr: {label} ---", file=sys.stderr)
|
||||
print(err.rstrip(), file=sys.stderr)
|
||||
|
||||
|
||||
def run_rsync(item: Path, dest: str, rsync_args: List[str], dry_run: bool) -> Tuple[Path, int, str, str]:
|
||||
cmd = ["rsync", *rsync_args]
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
# preserve relative path by sending the item name, not its parent
|
||||
cmd += [str(item), dest]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return item, proc.returncode, proc.stdout, proc.stderr
|
||||
except FileNotFoundError:
|
||||
return item, 127, "", "rsync not found on PATH"
|
||||
|
||||
|
||||
def run_rsync_files_from(
|
||||
src_dir: Path,
|
||||
rel_files: Sequence[Path],
|
||||
dest: str,
|
||||
rsync_args: List[str],
|
||||
dry_run: bool,
|
||||
) -> Tuple[str, int, str, str]:
|
||||
cmd = ["rsync", *rsync_args]
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
|
||||
cmd += ["--from0", "--files-from=-", f"{src_dir}{os.sep}", dest]
|
||||
|
||||
payload = "\0".join(str(p) for p in rel_files) + "\0"
|
||||
try:
|
||||
proc = subprocess.run(cmd, input=payload, capture_output=True, text=True)
|
||||
label = f"chunk({len(rel_files)} files)"
|
||||
return label, proc.returncode, proc.stdout, proc.stderr
|
||||
except FileNotFoundError:
|
||||
return "chunk(0 files)", 127, "", "rsync not found on PATH"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Transfer files using rsync in parallel.")
|
||||
ap.add_argument("src", help="Source file, directory or glob pattern")
|
||||
ap.add_argument("dest", help="Rsync destination (local path or user@host:/path)")
|
||||
ap.add_argument(
|
||||
"-w",
|
||||
"--workers",
|
||||
type=int,
|
||||
default=min(8, (os.cpu_count() or 4)),
|
||||
help="Parallel rsync workers (default: min(8, CPU count))",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--mode",
|
||||
choices=["top-level", "recursive-files"],
|
||||
default="recursive-files",
|
||||
help=(
|
||||
"top-level: one rsync per immediate src entry; "
|
||||
"recursive-files: split full file tree into balanced rsync chunks"
|
||||
),
|
||||
)
|
||||
ap.add_argument("-r", "--rsync-args", default=" ".join(DEFAULT_RSYNC_ARGS),
|
||||
help="Additional rsync args (quoted). Default: -a --partial --info=stats2")
|
||||
ap.add_argument(
|
||||
"--auto-tune",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Auto-tune worker count from average file size and destination type. "
|
||||
"Uses --workers as an upper bound."
|
||||
),
|
||||
)
|
||||
ap.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed progress, including task start/completion and rsync command lines.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--show-rsync-output",
|
||||
action="store_true",
|
||||
help="Print rsync stdout/stderr for each task (can be noisy).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--show-rsync-output-on-failure",
|
||||
action="store_true",
|
||||
help="Print rsync stdout/stderr only for failed tasks.",
|
||||
)
|
||||
ap.add_argument("--dry-run", action="store_true", help="Don't transfer, just show what would happen")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.workers < 1:
|
||||
print("--workers must be >= 1", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
rsync_args = shlex.split(args.rsync_args)
|
||||
results = []
|
||||
started_at = time.perf_counter()
|
||||
|
||||
if args.show_rsync_output and args.show_rsync_output_on_failure:
|
||||
print(
|
||||
"Both --show-rsync-output and --show-rsync-output-on-failure set; "
|
||||
"showing output for all tasks.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
show_output = args.show_rsync_output or args.show_rsync_output_on_failure
|
||||
show_on_fail_only = args.show_rsync_output_on_failure and not args.show_rsync_output
|
||||
|
||||
src_path = Path(args.src)
|
||||
is_remote_dest = detect_remote_destination(args.dest)
|
||||
cpu_count = os.cpu_count() or 4
|
||||
if args.mode == "recursive-files" and src_path.is_dir():
|
||||
rel_files = build_recursive_file_list(src_path)
|
||||
if not rel_files:
|
||||
print("No files found for:", args.src, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
sized_files = get_sized_files(src_path, rel_files)
|
||||
avg_size = sum(size for size, _ in sized_files) / max(1, len(sized_files))
|
||||
if args.auto_tune:
|
||||
workers = auto_tune_workers(len(rel_files), avg_size, is_remote_dest, cpu_count, args.workers)
|
||||
dest_kind = "remote" if is_remote_dest else "local"
|
||||
print(
|
||||
f"Auto-tuned workers={workers} ({dest_kind} dest, avg file {format_bytes(avg_size)})"
|
||||
)
|
||||
else:
|
||||
workers = min(args.workers, len(rel_files))
|
||||
|
||||
chunks = partition_by_size(sized_files, workers)
|
||||
print(f"Starting {len(chunks)} rsync chunk task(s) with {workers} worker(s)...")
|
||||
|
||||
if args.verbose:
|
||||
cmd_preview = ["rsync", *rsync_args]
|
||||
if args.dry_run:
|
||||
cmd_preview.append("--dry-run")
|
||||
cmd_preview += ["--from0", "--files-from=-", f"{src_path}{os.sep}", args.dest]
|
||||
print("Command template:", " ".join(shlex.quote(part) for part in cmd_preview))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futures = {
|
||||
ex.submit(run_rsync_files_from, src_path, chunk, args.dest, rsync_args, args.dry_run): chunk
|
||||
for chunk in chunks
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
label, code, out, err = fut.result()
|
||||
results.append((label, code, out, err))
|
||||
status = "OK" if code == 0 else f"ERR({code})"
|
||||
print(f"[{status}] {label}")
|
||||
maybe_print_task_output(label, out, err, show_output, show_on_fail_only, code)
|
||||
else:
|
||||
items = build_file_list(args.src)
|
||||
if not items:
|
||||
print("No files found for:", args.src, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
size_samples = []
|
||||
for item in items:
|
||||
try:
|
||||
if item.is_file():
|
||||
size_samples.append(item.stat().st_size)
|
||||
else:
|
||||
size_samples.append(64 * 1024 * 1024)
|
||||
except OSError:
|
||||
size_samples.append(0)
|
||||
|
||||
avg_size = sum(size_samples) / max(1, len(size_samples))
|
||||
if args.auto_tune:
|
||||
workers = auto_tune_workers(len(items), avg_size, is_remote_dest, cpu_count, args.workers)
|
||||
dest_kind = "remote" if is_remote_dest else "local"
|
||||
print(
|
||||
f"Auto-tuned workers={workers} ({dest_kind} dest, avg unit {format_bytes(avg_size)})"
|
||||
)
|
||||
else:
|
||||
workers = min(args.workers, len(items))
|
||||
|
||||
print(f"Starting {len(items)} rsync task(s) with {workers} worker(s)...")
|
||||
|
||||
if args.verbose:
|
||||
print("Task list preview:")
|
||||
preview_limit = 10
|
||||
for idx, item in enumerate(items[:preview_limit], start=1):
|
||||
print(f" {idx}. {item}")
|
||||
if len(items) > preview_limit:
|
||||
print(f" ... and {len(items) - preview_limit} more")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futures = {}
|
||||
for item in items:
|
||||
if args.verbose:
|
||||
cmd_preview = ["rsync", *rsync_args]
|
||||
if args.dry_run:
|
||||
cmd_preview.append("--dry-run")
|
||||
cmd_preview += [str(item), args.dest]
|
||||
print("Scheduling:", " ".join(shlex.quote(part) for part in cmd_preview))
|
||||
futures[ex.submit(run_rsync, item, args.dest, rsync_args, args.dry_run)] = item
|
||||
|
||||
for fut in as_completed(futures):
|
||||
item, code, out, err = fut.result()
|
||||
results.append((str(item), code, out, err))
|
||||
status = "OK" if code == 0 else f"ERR({code})"
|
||||
print(f"[{status}] {item}")
|
||||
maybe_print_task_output(str(item), out, err, show_output, show_on_fail_only, code)
|
||||
|
||||
# summary
|
||||
failed = [r for r in results if r[1] != 0]
|
||||
elapsed = time.perf_counter() - started_at
|
||||
print(f"\nCompleted: {len(results)} tasks, {len(failed)} failed in {elapsed:.2f}s.")
|
||||
if failed:
|
||||
print("Failures detail:")
|
||||
for label, code, out, err in failed:
|
||||
print(f"--- {label} (exit {code}) ---")
|
||||
if out:
|
||||
print(out.strip())
|
||||
if err:
|
||||
print(err.strip())
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def collect_first_tokens_from_full_table_files(directory: Path) -> list[str]:
|
||||
first_tokens: list[str] = []
|
||||
|
||||
for file_path in directory.iterdir():
|
||||
# Match file names like "Full_Table_List_..." regardless of case.
|
||||
if file_path.is_file() and file_path.name.lower().startswith("full_table_list"):
|
||||
with file_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if parts:
|
||||
first_tokens.append(parts[0])
|
||||
|
||||
return first_tokens
|
||||
|
||||
|
||||
def main() -> None:
|
||||
current_directory = Path.cwd()
|
||||
tokens = collect_first_tokens_from_full_table_files(current_directory)
|
||||
tokens.sort()
|
||||
|
||||
for token in tokens:
|
||||
print(token)
|
||||
|
||||
output_file = current_directory / "full_table_list_tokens.csv"
|
||||
with output_file.open("w", newline="", encoding="utf-8") as csv_file:
|
||||
writer = csv.writer(csv_file)
|
||||
writer.writerow(["value"])
|
||||
for token in tokens:
|
||||
writer.writerow([token])
|
||||
|
||||
print(f"\nTotal extracted values: {len(tokens)}")
|
||||
print(f"Saved CSV file: {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
#Requires -Version 5.1
|
||||
|
||||
|
||||
# Use -ShutdownRegardless to force shutdown even if one or more sync jobs fail.
|
||||
param(
|
||||
[switch]$ShutdownRegardless
|
||||
)
|
||||
|
||||
$FfsExe = 'C:\Program Files\FreeFileSync\FreeFileSync.exe'
|
||||
$ScriptDir = $PSScriptRoot
|
||||
$LogDir = Join-Path $ScriptDir 'logs'
|
||||
$ReportDir = Join-Path $ScriptDir 'reports'
|
||||
|
||||
$SyncJobs = @(
|
||||
'C:\sync_jobs\shuttlebay_SyncSettings.ffs_batch'
|
||||
'C:\sync_jobs\main_bridge_SyncSettings.ffs_batch'
|
||||
'C:\sync_jobs\engineering_SyncSettings.ffs_batch'
|
||||
)
|
||||
|
||||
# ── Setup ──────────────────────────────────────────────────────────────────────
|
||||
foreach ($dir in $LogDir, $ReportDir) {
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
|
||||
}
|
||||
|
||||
$RunTs = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$LogFile = Join-Path $LogDir "sync_history_$RunTs.log"
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$line = "[$(Get-Date -Format 'ddd MM/dd/yyyy HH:mm:ss.ff')] $Message"
|
||||
Write-Host $line
|
||||
Add-Content -Path $LogFile -Value $line
|
||||
}
|
||||
|
||||
Write-Log 'Sync run started'
|
||||
Write-Host "Log file: $LogFile"
|
||||
|
||||
# ── Run sync jobs ──────────────────────────────────────────────────────────────
|
||||
$HadFatalError = $false
|
||||
$FatalExitCodes = @()
|
||||
|
||||
foreach ($job in $SyncJobs) {
|
||||
$jobName = Split-Path $job -Leaf
|
||||
Write-Host ''
|
||||
Write-Log "Running synchronization: $jobName"
|
||||
|
||||
$tmpOut = [IO.Path]::GetTempFileName()
|
||||
$proc = Start-Process -FilePath $FfsExe -ArgumentList $job `
|
||||
-RedirectStandardOutput $tmpOut -NoNewWindow -PassThru -Wait
|
||||
$rc = $proc.ExitCode
|
||||
$output = Get-Content -Path $tmpOut -Raw
|
||||
Remove-Item $tmpOut -Force
|
||||
if ($output) { Add-Content -Path $LogFile -Value $output }
|
||||
|
||||
switch ($rc) {
|
||||
0 { Write-Log 'Synchronization completed successfully.' }
|
||||
1 { Write-Log 'Synchronization completed with warnings.' }
|
||||
default {
|
||||
Write-Log "Synchronization failed or was aborted. Exit code: $rc"
|
||||
$HadFatalError = $true
|
||||
$FatalExitCodes += $rc
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Log 'All synchronization jobs completed.'
|
||||
|
||||
# ── Copy HTML reports ──────────────────────────────────────────────────────────
|
||||
$logPaths = Select-String -Path $LogFile -Pattern '"logFile"\s*:\s*"([^"]+)"' |
|
||||
ForEach-Object { $_.Matches[0].Groups[1].Value -replace '\\\\', '\' } |
|
||||
Sort-Object -Unique
|
||||
|
||||
$copied = 0
|
||||
foreach ($path in $logPaths) {
|
||||
if ([IO.Path]::GetExtension($path) -ieq '.html') {
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
Copy-Item -LiteralPath $path -Destination $ReportDir -Force
|
||||
Write-Log "Copied report: $(Split-Path $path -Leaf)"
|
||||
$copied++
|
||||
} else {
|
||||
Write-Log "Report not found: $path"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Total reports copied: $copied"
|
||||
|
||||
# ── Shutdown ───────────────────────────────────────────────────────────────────
|
||||
if ($HadFatalError) {
|
||||
$codes = ($FatalExitCodes | Sort-Object -Unique) -join ', '
|
||||
Write-Log "One or more synchronization jobs failed. Fatal exit code(s): $codes"
|
||||
|
||||
if (-not $ShutdownRegardless) {
|
||||
Write-Log 'Skipping shutdown because not all synchronization jobs completed successfully.'
|
||||
Write-Log 'Use -ShutdownRegardless to force shutdown even when jobs fail.'
|
||||
exit ($FatalExitCodes | Select-Object -Last 1)
|
||||
}
|
||||
|
||||
Write-Log 'Forced shutdown enabled. Continuing to shutdown despite synchronization failures.'
|
||||
}
|
||||
|
||||
Write-Log 'Shutting down server now.'
|
||||
Invoke-CimMethod -ClassName Win32_OperatingSystem -MethodName Win32Shutdown -Arguments @{ Flags = 5 }
|
||||
Reference in New Issue
Block a user