Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS (57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash commands, memory files, architecture docs, and deploy procedures. Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted 155 .strftime() calls across 71 templates so timestamps display in Polish timezone regardless of server timezone. Also includes: created_by_id tracking, abort import fix, ICS calendar fix for missing end times, Pros Poland data cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
341 lines
12 KiB
Python
341 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OVH VPS Availability Monitor for Warsaw (WAW)
|
|
Sprawdza dostępność VPS-1, VPS-2, VPS-3, VPS-4 w Warszawie.
|
|
Powiadamia przez macOS notification + opcjonalnie Telegram.
|
|
|
|
Użycie:
|
|
python3 scripts/ovh_vps_monitor.py # jednorazowe sprawdzenie
|
|
python3 scripts/ovh_vps_monitor.py --daemon # co 10 minut
|
|
|
|
Konfiguracja OVH API (jednorazowo):
|
|
1. Wejdź na https://eu.api.ovh.com/createToken/
|
|
2. Zaloguj się kontem pm861830-ovh
|
|
3. Ustaw:
|
|
- GET /order/cart/*
|
|
- POST /order/cart/*
|
|
- DELETE /order/cart/*
|
|
4. Wpisz klucze do ~/.ovh.conf (format poniżej)
|
|
|
|
~/.ovh.conf:
|
|
[default]
|
|
endpoint=ovh-eu
|
|
application_key=TWÓJ_APP_KEY
|
|
application_secret=TWÓJ_APP_SECRET
|
|
consumer_key=TWÓJ_CONSUMER_KEY
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# --- Configuration ---
|
|
MODELS = {
|
|
'vps-2025-model1': 'VPS-1 (4 vCores, 8 GB, 75 GB SSD) — 23,50 PLN/m',
|
|
'vps-2025-model2': 'VPS-2 (6 vCores, 12 GB, 100 GB NVMe) — 36,21 PLN/m',
|
|
'vps-2025-model3': 'VPS-3 (8 vCores, 24 GB, 200 GB NVMe) — 72,42 PLN/m',
|
|
'vps-2025-model4': 'VPS-4 (12 vCores, 48 GB, 300 GB NVMe) — 133,96 PLN/m',
|
|
}
|
|
|
|
TARGET_DC = 'WAW'
|
|
CHECK_INTERVAL = 600 # 10 minut
|
|
STATE_FILE = Path(__file__).parent / '.ovh_vps_monitor_state.json'
|
|
TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
|
|
TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
|
|
|
|
|
|
def check_availability_curl():
|
|
"""Sprawdza dostępność VPS w WAW przez publiczne API OVH (cart flow)."""
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
results = {}
|
|
|
|
# 1. Utwórz koszyk
|
|
req = urllib.request.Request(
|
|
'https://eu.api.ovh.com/1.0/order/cart',
|
|
data=json.dumps({'ovhSubsidiary': 'PL'}).encode(),
|
|
headers={'Content-Type': 'application/json'},
|
|
method='POST'
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
cart = json.loads(resp.read())
|
|
cart_id = cart['cartId']
|
|
except Exception as e:
|
|
print(f'[ERROR] Nie mogę utworzyć koszyka: {e}')
|
|
return {}
|
|
|
|
# 2. Dla każdego modelu — dodaj do koszyka i sprawdź DC
|
|
for plan_code, plan_name in MODELS.items():
|
|
try:
|
|
# Dodaj VPS do koszyka
|
|
add_req = urllib.request.Request(
|
|
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/vps',
|
|
data=json.dumps({
|
|
'planCode': plan_code,
|
|
'duration': 'P1M',
|
|
'pricingMode': 'default',
|
|
'quantity': 1
|
|
}).encode(),
|
|
headers={'Content-Type': 'application/json'},
|
|
method='POST'
|
|
)
|
|
with urllib.request.urlopen(add_req, timeout=15) as resp:
|
|
item = json.loads(resp.read())
|
|
item_id = item['itemId']
|
|
|
|
# Skonfiguruj WAW
|
|
dc_req = urllib.request.Request(
|
|
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/item/{item_id}/configuration',
|
|
data=json.dumps({'label': 'vps_datacenter', 'value': TARGET_DC}).encode(),
|
|
headers={'Content-Type': 'application/json'},
|
|
method='POST'
|
|
)
|
|
with urllib.request.urlopen(dc_req, timeout=15) as resp:
|
|
dc_result = json.loads(resp.read())
|
|
|
|
# Skonfiguruj OS i region
|
|
for cfg in [
|
|
{'label': 'vps_os', 'value': 'Ubuntu 24.04'},
|
|
{'label': 'region', 'value': 'europe'},
|
|
]:
|
|
cfg_req = urllib.request.Request(
|
|
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/item/{item_id}/configuration',
|
|
data=json.dumps(cfg).encode(),
|
|
headers={'Content-Type': 'application/json'},
|
|
method='POST'
|
|
)
|
|
with urllib.request.urlopen(cfg_req, timeout=15) as resp:
|
|
pass
|
|
|
|
# Sprawdź podsumowanie koszyka (publiczne, nie wymaga auth)
|
|
summary_req = urllib.request.Request(
|
|
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/summary',
|
|
method='GET'
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(summary_req, timeout=15) as resp:
|
|
summary = json.loads(resp.read())
|
|
results[plan_code] = 'available'
|
|
except urllib.error.HTTPError as e:
|
|
error_body = e.read().decode() if e.fp else ''
|
|
if 'stock' in error_body.lower() or 'unavailable' in error_body.lower():
|
|
results[plan_code] = 'out_of_stock'
|
|
elif e.code == 401:
|
|
# Auth wymagane — nie wiemy na pewno, spróbuj auth flow
|
|
results[plan_code] = 'needs_auth'
|
|
else:
|
|
results[plan_code] = 'unknown'
|
|
|
|
# Usuń item z koszyka
|
|
del_req = urllib.request.Request(
|
|
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}/item/{item_id}',
|
|
method='DELETE'
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(del_req, timeout=15) as resp:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
except urllib.error.HTTPError as e:
|
|
error_body = e.read().decode() if e.fp else ''
|
|
if 'stock' in error_body.lower() or 'not available' in error_body.lower():
|
|
results[plan_code] = 'out_of_stock'
|
|
else:
|
|
results[plan_code] = f'error: {e.code}'
|
|
except Exception as e:
|
|
results[plan_code] = f'error: {e}'
|
|
|
|
# Usuń koszyk
|
|
try:
|
|
del_cart = urllib.request.Request(
|
|
f'https://eu.api.ovh.com/1.0/order/cart/{cart_id}',
|
|
method='DELETE'
|
|
)
|
|
urllib.request.urlopen(del_cart, timeout=15)
|
|
except Exception:
|
|
pass
|
|
|
|
return results
|
|
|
|
|
|
def check_availability_ovh_lib():
|
|
"""Sprawdza dostępność VPS w WAW przez ovh Python library (z autoryzacją)."""
|
|
try:
|
|
import ovh
|
|
except ImportError:
|
|
print('[WARN] Brak biblioteki ovh — pip3 install ovh')
|
|
return {}
|
|
|
|
conf_path = Path.home() / '.ovh.conf'
|
|
if not conf_path.exists():
|
|
print(f'[WARN] Brak {conf_path} — użyj trybu bez autoryzacji')
|
|
return {}
|
|
|
|
try:
|
|
client = ovh.Client()
|
|
except Exception as e:
|
|
print(f'[ERROR] Nie mogę połączyć z OVH API: {e}')
|
|
return {}
|
|
|
|
results = {}
|
|
|
|
for plan_code, plan_name in MODELS.items():
|
|
try:
|
|
# Utwórz koszyk
|
|
cart = client.post('/order/cart', ovhSubsidiary='PL')
|
|
cart_id = cart['cartId']
|
|
client.post(f'/order/cart/{cart_id}/assign')
|
|
|
|
# Dodaj VPS
|
|
item = client.post(f'/order/cart/{cart_id}/vps',
|
|
planCode=plan_code, duration='P1M',
|
|
pricingMode='default', quantity=1)
|
|
item_id = item['itemId']
|
|
|
|
# Skonfiguruj WAW + OS + region
|
|
client.post(f'/order/cart/{cart_id}/item/{item_id}/configuration',
|
|
label='vps_datacenter', value=TARGET_DC)
|
|
client.post(f'/order/cart/{cart_id}/item/{item_id}/configuration',
|
|
label='vps_os', value='Ubuntu 24.04')
|
|
client.post(f'/order/cart/{cart_id}/item/{item_id}/configuration',
|
|
label='region', value='europe')
|
|
|
|
# Validate checkout (GET = validate, POST = place order)
|
|
checkout = client.get(f'/order/cart/{cart_id}/checkout')
|
|
# Jeśli doszliśmy tutaj — VPS jest dostępny w WAW!
|
|
results[plan_code] = 'available'
|
|
|
|
# Cleanup
|
|
client.delete(f'/order/cart/{cart_id}')
|
|
|
|
except Exception as e:
|
|
error_msg = str(e).lower()
|
|
if 'stock' in error_msg or 'not available' in error_msg or 'unavailable' in error_msg:
|
|
results[plan_code] = 'out_of_stock'
|
|
elif 'expired' in error_msg:
|
|
results[plan_code] = 'error_expired'
|
|
else:
|
|
results[plan_code] = f'error: {e}'
|
|
|
|
# Cleanup
|
|
try:
|
|
client.delete(f'/order/cart/{cart_id}')
|
|
except Exception:
|
|
pass
|
|
|
|
return results
|
|
|
|
|
|
def notify_macos(title, message):
|
|
"""Powiadomienie macOS."""
|
|
subprocess.run([
|
|
'osascript', '-e',
|
|
f'display notification "{message}" with title "{title}" sound name "Glass"'
|
|
], check=False)
|
|
|
|
|
|
def notify_telegram(message):
|
|
"""Powiadomienie Telegram."""
|
|
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
|
return
|
|
import urllib.request
|
|
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
|
|
data = json.dumps({
|
|
'chat_id': TELEGRAM_CHAT_ID,
|
|
'text': message,
|
|
'parse_mode': 'Markdown'
|
|
}).encode()
|
|
req = urllib.request.Request(url, data=data,
|
|
headers={'Content-Type': 'application/json'})
|
|
try:
|
|
urllib.request.urlopen(req, timeout=10)
|
|
except Exception as e:
|
|
print(f'[WARN] Telegram notification failed: {e}')
|
|
|
|
|
|
def load_state():
|
|
"""Wczytaj poprzedni stan."""
|
|
if STATE_FILE.exists():
|
|
return json.loads(STATE_FILE.read_text())
|
|
return {}
|
|
|
|
|
|
def save_state(state):
|
|
"""Zapisz stan."""
|
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
|
|
|
|
def run_check():
|
|
"""Główna funkcja sprawdzająca."""
|
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
print(f'\n[{now}] Sprawdzam dostępność VPS w {TARGET_DC}...')
|
|
|
|
# Preferuj auth flow (dokładniejszy), fallback na public API
|
|
conf_path = Path.home() / '.ovh.conf'
|
|
if conf_path.exists():
|
|
print(' Tryb: OVH API (z autoryzacją)')
|
|
results = check_availability_ovh_lib()
|
|
else:
|
|
print(' Tryb: Public API (bez autoryzacji — mniej dokładny)')
|
|
results = check_availability_curl()
|
|
|
|
if not results:
|
|
print(' [WARN] Brak wyników — problem z API?')
|
|
return
|
|
|
|
prev_state = load_state()
|
|
new_available = []
|
|
|
|
for plan_code, status in results.items():
|
|
plan_name = MODELS.get(plan_code, plan_code)
|
|
icon = '✅' if status == 'available' else '❌' if 'stock' in str(status) else '❓'
|
|
print(f' {icon} {plan_name}: {status}')
|
|
|
|
# Czy to nowa dostępność?
|
|
prev = prev_state.get(plan_code, '')
|
|
if status == 'available' and prev != 'available':
|
|
new_available.append(plan_name)
|
|
|
|
# Powiadom o nowych dostępnościach
|
|
if new_available:
|
|
msg_lines = ['🟢 VPS dostępny w Warszawie!'] + [f' • {n}' for n in new_available]
|
|
msg_lines.append(f'\n🔗 https://www.ovhcloud.com/pl/vps/')
|
|
message = '\n'.join(msg_lines)
|
|
|
|
print(f'\n 🔔 POWIADOMIENIE: {message}')
|
|
notify_macos('OVH VPS Warszawa!', '\n'.join(new_available))
|
|
notify_telegram(message)
|
|
else:
|
|
print(' Brak nowych dostępności.')
|
|
|
|
save_state(results)
|
|
|
|
|
|
def main():
|
|
daemon = '--daemon' in sys.argv
|
|
|
|
if daemon:
|
|
print(f'Uruchamiam monitoring VPS w {TARGET_DC} (co {CHECK_INTERVAL}s)...')
|
|
print(f'Stan zapisywany w: {STATE_FILE}')
|
|
print('Ctrl+C aby zatrzymać\n')
|
|
while True:
|
|
try:
|
|
run_check()
|
|
time.sleep(CHECK_INTERVAL)
|
|
except KeyboardInterrupt:
|
|
print('\nZatrzymano.')
|
|
break
|
|
else:
|
|
run_check()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|