├── m ├── __init__.py ├── servers.py ├── config.py ├── orders.py ├── monitor.py ├── email.py ├── availability.py ├── print.py ├── api.py └── catalog.py ├── .gitignore ├── availability_monitor.py ├── README.md ├── conf.example.yaml ├── buy_ovh.py └── LICENSE /m/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /ovh.conf 2 | /conf.py 3 | /conf.yaml 4 | __pycache__/ 5 | *.py[cod] 6 | .vscode/ 7 | -------------------------------------------------------------------------------- /m/servers.py: -------------------------------------------------------------------------------- 1 | import m.api 2 | import m.print 3 | 4 | __all__ = ['servers_specs'] 5 | 6 | servers_specs_list = [] 7 | 8 | def servers_specs(printMessage=False): 9 | m.print.print_servers(m.api.get_servers_list(printMessage)) 10 | input("Press Enter.") 11 | -------------------------------------------------------------------------------- /m/config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import yaml 3 | 4 | __all__ = ['configFile'] 5 | 6 | # config path optionally given as argv 7 | config_path = sys.argv[1] if len(sys.argv) > 1 else 'conf.yaml' 8 | 9 | configFile = {} 10 | try: 11 | print(f"Loading config from {config_path}") 12 | configFile = yaml.safe_load(open(config_path, 'r')) 13 | except Exception as e: 14 | print("Error with config file") 15 | print(e) 16 | sys.exit("Bye now.") 17 | -------------------------------------------------------------------------------- /availability_monitor.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import sys 3 | import time 4 | 5 | import m.api 6 | import m.availability 7 | import m.monitor 8 | 9 | availabilities=[] 10 | previousAvailabilities=[] 11 | 12 | while True: 13 | try: 14 | if availabilities: 15 | previousAvailabilities = availabilities 16 | availabilities = m.availability.build_availability_dict(m.api.api_url("ovh-eu"), sys.argv[1:]) 17 | strChanged = m.monitor.avail_added_removed_Str(previousAvailabilities, availabilities) 18 | if strChanged: 19 | current_time = datetime.datetime.now() 20 | print(datetime.datetime.now(), " :") 21 | print(strChanged) 22 | time.sleep(30) 23 | except KeyboardInterrupt: 24 | break 25 | -------------------------------------------------------------------------------- /m/orders.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import m.api 4 | import m.print 5 | 6 | __all__ = ['unpaid_orders', 'undelivered_orders'] 7 | 8 | # ----------------- SHOW UNPAID ORDERS -------------------------------------------------------- 9 | def unpaid_orders(printMessage=False): 10 | # Get today's date 11 | today = datetime.now() 12 | tomorrow = today + timedelta(days=1) 13 | # Calculate the date 14 days ago 14 | date_14_days_ago = today - timedelta(days=14) 15 | 16 | unpaidOrderList = [] 17 | try: 18 | unpaidOrderList = m.api.get_orders_per_status(date_14_days_ago, tomorrow, ['notPaid'], printMessage) 19 | except KeyboardInterrupt: 20 | pass 21 | m.print.print_orders(unpaidOrderList, True) 22 | 23 | while True: 24 | sChoice = input("Which one? ") 25 | if not sChoice.isdigit() or int (sChoice) >= len(unpaidOrderList): 26 | break 27 | choice = int (sChoice) 28 | print ("URL: " + unpaidOrderList[choice]['url']) 29 | 30 | # ----------------- SHOW UNDELIVERED ORDERS -------------------------------------------------------- 31 | def undelivered_orders(printMessage=False): 32 | # Get today's date 33 | today = datetime.now() 34 | tomorrow = today + timedelta(days=1) 35 | # Calculate the date 14 days ago 36 | date_30_days_ago = today - timedelta(days=30) 37 | 38 | undeliveredOrderList = [] 39 | try: 40 | undeliveredOrderList = m.api.get_orders_per_status(date_30_days_ago, tomorrow, ['delivering'], printMessage) 41 | except KeyboardInterrupt: 42 | pass 43 | m.print.print_orders(undeliveredOrderList, True) 44 | 45 | while True: 46 | sChoice = input("Which one? ") 47 | if not sChoice.isdigit() or int (sChoice) >= len(undeliveredOrderList): 48 | break 49 | choice = int (sChoice) 50 | print ("URL: " + undeliveredOrderList[choice]['url']) 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # buy_ovh.py 2 | Python script that uses the OVH API and their Python helper. See here: https://github.com/ovh/python-ovh 3 | 4 | The 'ovh' and 'PyYAML' modules must be installed. 5 | ``` 6 | pip install ovh 7 | ``` 8 | ``` 9 | pip install PyYAML 10 | ``` 11 | 12 | The main program is buy_ovh.py. There is also a small program availability_monitor which just does the availability monitoring. 13 | 14 | There is a conf file that you need to make, conf.yaml, where you can define stuff. There is an example file provided. The file explains all the parameters. 15 | 16 | In there you at least need the following for the connection to the API: endpoint, api key and secret. You can read about them at the python-ovh repo (above). A consumer key is also needed but the script will help you generate one if you have not got one. 17 | To know what parameter does what, read the code. 18 | 19 | It's recommended to have at least a filter on the server name (or plan code) otherwise the list will be huge. 20 | 21 | The colour coding is in the code. Red is unavailable. Green and yellow are available. Etc. 22 | 23 | Once you have chosen a server that happens to be available, press CTRL-C to stop the infinite loop (if you have defined 'loop' to true). 24 | Then you can chose which server you want, and if you want to generate the invoice or pay with your favourite method. 25 | 26 | You can also toggle the display of some stuff or the auto-loop, and change or empty the filters. 27 | 28 | The script can also show you a list of your unpaid orders and provide an URL if you want to pay for one. 29 | 30 | If you end up buying a 600€ server, it's not the script fault, it's yours, because this is just a random python script you found on the internet. 31 | 32 | I have only tested with OVH France. 33 | 34 | # Donations 35 | If you would like to make a small donation because this script helped you get the server of your dreams, feel free: https://paypal.me/fredo1664 36 | 37 | If it has to be in crypto, here's a Monero address: 8BTX8pJYmtf6STY2s7TdTT3NATocMpMReeV6DP5Vgnfx8MWBMXoTMypDjWv4Wf639habTNTED9bXSV9pYXs8s7BCHERFUxh 38 | -------------------------------------------------------------------------------- /m/monitor.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import m.availability 4 | import m.catalog 5 | 6 | __all__ = ['avail_added_removed_Str', 'avail_changed_Str', 'catalog_added_removed_Str'] 7 | 8 | # ---------------- EMAIL MONITOR AVAILAIBILITIES --------------------------------------- 9 | # - detect new servers appearing in availabilities (or leaving) 10 | # - monitor availability of some servers 11 | def avail_added_removed_Str(previousA, newA, preStr="", postStr=""): 12 | strToSend = "" 13 | # look for new FQN in availabilities (no filters) 14 | addedFqns, removedFqns = m.availability.added_removed(previousA, newA) 15 | if previousA and newA: 16 | for added in addedFqns: 17 | strToSend += preStr + "Added to availabilities: " + added + postStr + "\n" 18 | for removed in removedFqns: 19 | strToSend += preStr + "Removed from availabilities: " + removed + postStr + "\n" 20 | return strToSend 21 | 22 | def avail_changed_Str(previousA, newA, regex, preStr="", postStr=""): 23 | # look for availability change (unavailable <--> available) 24 | # for this there is a filter in order to not spam 25 | # the filter is on the FQN 26 | strToSend = "" 27 | availNow, availNotAnymore = m.availability.changed(previousA, newA, regex) 28 | for fqn in availNow: 29 | strToSend += preStr + "Available now: " + fqn + postStr + "\n" 30 | for fqn in availNotAnymore: 31 | strToSend += preStr + "No longer available: " + fqn + postStr + "\n" 32 | return strToSend 33 | 34 | # ---------------- EMAIL IF SOMETHING APPEARS IN THE CATALOG ----------------------------------- 35 | # The catalog is filtered (name and disk), so the new server must pass these filters 36 | def catalog_added_removed_Str(previousP, newP, preStr="", postStr=""): 37 | strChanged = "" 38 | addedFqns, removedFqns = m.catalog.added_removed(previousP, newP) 39 | for fqn in addedFqns: 40 | strChanged += preStr + "New to the catalog: " + fqn + postStr + "\n" 41 | for fqn in removedFqns: 42 | strChanged += preStr + "No longer in the catalog: " + fqn + postStr + "\n" 43 | return strChanged -------------------------------------------------------------------------------- /m/email.py: -------------------------------------------------------------------------------- 1 | import time 2 | import smtplib 3 | 4 | from email.mime.text import MIMEText 5 | from email.mime.multipart import MIMEMultipart 6 | 7 | import m.config 8 | 9 | __all__ = ['send_email', 'send_startup_email'] 10 | 11 | # from the conf file 12 | email_server_port = m.config.configFile['email_server_port'] if 'email_server_port' in m.config.configFile else 0 13 | email_server_name = m.config.configFile['email_server_name'] if 'email_server_name' in m.config.configFile else "" 14 | email_server_login = m.config.configFile['email_server_login'] if 'email_server_login' in m.config.configFile else "" 15 | email_server_password = m.config.configFile['email_server_password'] if 'email_server_password' in m.config.configFile else "" 16 | email_sender = m.config.configFile['email_sender'] if 'email_sender' in m.config.configFile else "" 17 | email_receiver = m.config.configFile['email_receiver'] if 'email_receiver' in m.config.configFile else "" 18 | 19 | # send an email 20 | def send_email(subject, text, warnUser=False): 21 | 22 | html = """\ 23 | 24 |
25 | """ + text + """\ 26 | 27 | 28 | """ 29 | try: 30 | # Create a multipart message and set headers 31 | message = MIMEMultipart() 32 | message["From"] = email_sender 33 | message["To"] = email_receiver 34 | message["Subject"] = subject 35 | 36 | # Attach the HTML part 37 | message.attach(MIMEText(html, "html")) 38 | 39 | # if not in infinite loop, warn the user 40 | if warnUser: 41 | print("Sending an email --> " + subject) 42 | # Send the email 43 | with smtplib.SMTP(email_server_name, email_server_port) as server: 44 | server.starttls() 45 | server.login(email_server_login, email_server_password) 46 | server.sendmail(email_sender, email_receiver, message.as_string()) 47 | except Exception as e: 48 | print("Failed to send an email.") 49 | print(e) 50 | time.sleep(2) 51 | 52 | def send_startup_email(): 53 | send_email("BUY_OVH: startup", "BUY_OVH has started
") 54 | 55 | def send_auto_buy_email(string): 56 | send_email("BUY_OVH: autobuy", "" + string + "
") 57 | -------------------------------------------------------------------------------- /m/availability.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | 4 | __all__ = ['unavailableList', 'unavailableAndUnknownList', 'added_removed', 'build_availability_dict', 'changed', 'look_up_avail'] 5 | 6 | # below lists are useful for 'if' statements 7 | unavailableList = ['comingSoon', 'unavailable'] 8 | unavailableAndUnknownList = ['comingSoon', 'unavailable', 'unknown'] 9 | 10 | # -------------- BUILD AVAILABILITY DICT ------------------------------------------------------------------------- 11 | def build_availability_dict(url, datacenters=[]): 12 | myAvail = {} 13 | if datacenters: 14 | response = requests.get(url + "dedicated/server/datacenter/availabilities?datacenters=" + ",".join(datacenters)) 15 | else: 16 | response = requests.get(url + "dedicated/server/datacenter/availabilities") 17 | for avail in response.json(): 18 | if 'fqn' in avail: 19 | myFqn = avail['fqn'] 20 | for da in avail['datacenters']: 21 | myLongFqn = myFqn + "." + da['datacenter'] 22 | myAvail[myLongFqn] = da['availability'] 23 | return myAvail 24 | 25 | # -------------- CHECK IF FQNS HAVE BEEN ADDED OR REMOVED ------------------------------------- 26 | def added_removed(previousA, newA): 27 | if previousA: 28 | return ([x for x in newA.keys() if x not in previousA.keys()], 29 | [x for x in previousA.keys() if x not in newA.keys()]) 30 | else: 31 | return ([],[]) 32 | 33 | # -------------- CHECK IF AVAILABILITY OF FQN HAS CHANGED ------------------------------------- 34 | def changed(previousA, newA, regex): 35 | # look for availability change (unavailable <--> available) 36 | # for this there is a filter in order to not spam 37 | # the filter is on the FQN 38 | availNow = [] 39 | availNotAnymore = [] 40 | if previousA: 41 | for fqn in newA: 42 | if bool(re.search(regex, fqn)): 43 | if (newA[fqn] not in unavailableAndUnknownList): 44 | # found an available server that matches the filter 45 | if (fqn not in previousA.keys() 46 | or previousA[fqn] in unavailableAndUnknownList): 47 | # its availability went from unavailable to available 48 | availNow.append(fqn) 49 | else: 50 | # found an unavailable server that matches the filter 51 | if (fqn in previousA.keys() 52 | and previousA[fqn] not in unavailableAndUnknownList): 53 | # its availability went from available to unavailable 54 | availNotAnymore.append(fqn) 55 | return (availNow, availNotAnymore) 56 | 57 | # ----------------- LOOK UP AVAILABILITIES ---------------------------------------------------- 58 | def look_up_avail(avail): 59 | 60 | sChoice = 'a' 61 | while sChoice: 62 | sChoice = input("FQN starts with: ") 63 | if sChoice: 64 | fqnsToShow = [] 65 | # size of column 66 | sizeCol = 0 67 | for eachFqn in avail.keys(): 68 | if eachFqn.startswith(sChoice): 69 | fqnsToShow.append(eachFqn) 70 | sizeCol = max(sizeCol, len(eachFqn)) 71 | for eachFqn in fqnsToShow: 72 | if eachFqn.startswith(sChoice): 73 | print(eachFqn.ljust(sizeCol) + " | " + avail[eachFqn]) -------------------------------------------------------------------------------- /conf.example.yaml: -------------------------------------------------------------------------------- 1 | # example configuration file for buy_ovh 2 | 3 | # API stuff 4 | # They are explained here: https://github.com/ovh/python-ovh 5 | APIEndpoint: ovh-eu 6 | APIKey: 123 7 | APISecret: 456 8 | #APIConsumerKey: 789 9 | 10 | # in which datacenters are we looking for? 11 | # this is a list 12 | datacenters: 13 | - gra 14 | - rbx 15 | - sbg 16 | - lon 17 | - fra 18 | - waw 19 | - bhs 20 | 21 | # the invoice name or plan code of the servers must match the regular expression: 22 | # example for all SYS from 2025, all KS-4, and KS-LE-B 23 | filterName: 25sys|24sk04|KS-LE-B 24 | 25 | # type of disks we want, usually among ssd,nvme,sa. 26 | # This is a regular expression 27 | # don't define it if you want all types 28 | filterDisk: ssd|nvme 29 | 30 | # memory filter, regular expression as well 31 | #filterMemory: 32g 32 | 33 | # max price for a server 34 | maxPrice: 100 35 | 36 | ovhSubsidiary: FR 37 | 38 | # add the VAT to the price? 39 | addVAT: True 40 | 41 | # show a helpful prompt after the list of server 42 | showPrompt: True 43 | # show the CPU type if available (unless showFqn=true) 44 | showCpu: False 45 | # show the servers even if they are unavailable 46 | showUnavailable: False 47 | # show the servers even if they have an unknown availability 48 | # (most probably there is a discrepancy between OVH's catalog and availabilities) 49 | showUnknown: False 50 | # show the bandwidth, including of the vRack if available 51 | # if this is False, only the price for the default bandwidth is displayed 52 | # (in case there are bandwidth options for the server) 53 | showBandwidth: False 54 | # print the FQN instead of spliting it 55 | # less readeable but up to you 56 | showFqn: False 57 | 58 | # define if you have a coupon 59 | # buying will fail with an incorrect coupon so be careful 60 | #coupon: MYCOUPON 61 | 62 | # if True, don't actually buy (for testing) 63 | # set to False if you want to actually buy the servers 64 | # Try the script once with fakeBuy but don't forget to set it to False after your test 65 | fakeBuy: True 66 | 67 | # Loop? 68 | # If True, the script refreshes the list of servers 69 | # every 'sleepsecs' seconds and does the monitoring (email sending). 70 | # You need to press CTRL-C to stop the loop and then you can choose. 71 | # If False, it displays the list of servers once and let you choose. 72 | # This is only the initial state, the user can always start the loop 73 | # by pressing 'L' 74 | loop: False 75 | 76 | # how many seconds before a refresh (if loop = True) 77 | sleepsecs: 20 78 | 79 | # email sending (email_on = False deactivate all emails) 80 | email_on: False 81 | 82 | # Send an email at startup (for test maybe) 83 | email_at_startup: False 84 | 85 | # Send email if something is added or removed on the 86 | # availability endpoint during a run 87 | email_added_removed: False 88 | 89 | # Monitor availability of servers whose FQN match the regular expression 90 | # This is a "super" FQN which has the datacenter at the end 91 | # Example: all KS-4 in GRA 92 | email_availability_monitor: 24sk40.*gra 93 | 94 | # Checks if any server was added to the catalog 95 | # These must pass the filters (name, disk and memory) 96 | email_catalog_monitor: False 97 | 98 | # For debug, send an email when an exception is raised during infinite loop 99 | email_exception: False 100 | 101 | # email server details 102 | email_server_port: 587 103 | email_server_name: "my.server.com" 104 | email_server_login: "login" 105 | email_server_password: "password" 106 | email_sender: "login@hello.com" 107 | email_receiver: "receiver@hello.com" 108 | -------------------------------------------------------------------------------- /m/print.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import m.availability 4 | 5 | __all__ = ['whichColor', 'print_plan_list', 'print_prompt', 'print_and_sleep', 'print_orders', 'print_servers'] 6 | 7 | # --- Coloring stuff ------------------------ 8 | class color: 9 | PURPLE = '\033[0;35;48m' 10 | CYAN = '\033[0;36;48m' 11 | BOLD = '\033[0;37;48m' 12 | BLUE = '\033[0;34;48m' 13 | GREEN = '\033[0;32;48m' 14 | YELLOW = '\033[0;33;48m' 15 | RED = '\033[0;31;48m' 16 | BLACK = '\033[0;30;48m' 17 | UNDERLINE = '\033[0;37;48m' 18 | END = '\033[0;37;0m' 19 | 20 | whichColor = { 'unknown' : color.CYAN, 21 | 'low' : color.YELLOW, 22 | 'high' : color.GREEN, 23 | 'unavailable' : color.RED, 24 | 'comingSoon' : color.BLUE, 25 | 'autobuy' : color.PURPLE 26 | } 27 | 28 | # ----------------- PRINT LIST OF SERVERS ----------------------------------------------------- 29 | def print_plan_list(plans, 30 | showCpu, showFqn, showBandwidth, 31 | showPrice, showFee, showTotalPrice): 32 | if not plans: 33 | print(whichColor['unavailable'] + "No availability." + color.END) 34 | sizeOfCol = { 35 | 'index' : 0, 36 | 'planCode' : 0, 37 | 'datacenter' : 0, 38 | 'model' : 0, 39 | 'cpu' : 0, 40 | 'fqn' : 0, 41 | 'memory' : 0, 42 | 'storage' : 0, 43 | 'bandwidth' : 0, 44 | 'vrack' : 0, 45 | 'price' : 0, 46 | 'fee' : 0, 47 | 'total' : 0 48 | } 49 | plansForDisplay = [] 50 | # determine what to print 51 | for plan in plans: 52 | if plan['vrack'] == 'none': 53 | vrack = 'none' 54 | else: 55 | vrack = plan['vrack'].split("-")[2] 56 | myPlanD = { 57 | 'index': str(plans.index(plan)), 58 | 'planCode': plan['planCode'], 59 | 'datacenter': plan['datacenter'], 60 | 'fqn': plan['fqn'], 61 | 'memory': plan['memory'].split("-")[1], 62 | 'storage': "-".join(plan['storage'].split("-")[1:-1]), 63 | 'bandwidth': plan['bandwidth'].split("-")[1], 64 | 'vrack': vrack, 65 | 'autobuy': plan['autobuy'], 66 | 'availability': plan['availability'], 67 | 'model': plan['model'], 68 | 'cpu': plan['cpu'], 69 | 'price': "{:.2f}".format(plan['price']), 70 | 'fee': "{:.2f}".format(plan['fee']), 71 | 'total': "{:.2f}".format(plan['fee']+plan['price']) 72 | } 73 | plansForDisplay.append(myPlanD) 74 | # update the max width of each column if needed 75 | for col in myPlanD.keys(): 76 | if col not in ['autobuy', 'availability']: 77 | sizeOfCol[col]=max(sizeOfCol[col], len(myPlanD[col])) 78 | 79 | # print the list 80 | for planD in plansForDisplay: 81 | # what colour? 82 | avail = planD['availability'] 83 | if avail in m.availability.unavailableList: 84 | printcolor = whichColor[avail] 85 | elif avail.endswith("low") or avail.endswith('H'): 86 | printcolor = whichColor['low'] 87 | elif avail.endswith("high"): 88 | printcolor = whichColor['high'] 89 | else: 90 | printcolor = whichColor['unknown'] 91 | # special colour for autobuy 92 | if planD['autobuy']: 93 | planColor = whichColor['autobuy'] 94 | else: 95 | planColor = printcolor 96 | # show CPU or not? 97 | if showCpu: 98 | modelStr = planD['model'].ljust(sizeOfCol['model']) + " | " + planD['cpu'].ljust(sizeOfCol['cpu']) 99 | else: 100 | modelStr = planD['model'].ljust(sizeOfCol['model']) 101 | # show FQN or split info into different columns? 102 | if showFqn: 103 | fqnStr = planColor + planD['fqn'].ljust(sizeOfCol['fqn']) + printcolor 104 | else: 105 | codeStr = planColor + planD['planCode'].ljust(sizeOfCol['planCode']) + printcolor 106 | fqnStr = codeStr + " | " + modelStr + " | " + \ 107 | planD['datacenter'].ljust(sizeOfCol['datacenter']) + " | " + \ 108 | planD['memory'].rjust(sizeOfCol['memory']) + " | " + \ 109 | planD['storage'].ljust(sizeOfCol['storage']) 110 | # show bandwidth and vrack? 111 | if showBandwidth: 112 | if planD['vrack'] == 'none': 113 | vRackStr = 'none' 114 | else: 115 | vRackStr = planD['vrack'].rjust(sizeOfCol['vrack']) 116 | bandwidthStr = planD['bandwidth'].rjust(sizeOfCol['bandwidth']) + " | " + vRackStr + " | " 117 | else: 118 | bandwidthStr = "" 119 | 120 | colStr = printcolor + planD['index'].rjust(sizeOfCol['index']) + " | " + \ 121 | fqnStr + " | " + bandwidthStr 122 | if showPrice: 123 | colStr = colStr + planD['price'].rjust(sizeOfCol['price']) + " | " 124 | if showFee: 125 | colStr = colStr + planD['fee'].rjust(sizeOfCol['fee']) + " | " 126 | if showTotalPrice: 127 | colStr = colStr + planD['total'].rjust(sizeOfCol['total']) + " | " 128 | colStr = colStr + color.END 129 | print(colStr) 130 | 131 | # ----------------- PRINT PROMPT -------------------------------------------------------------- 132 | def print_prompt(acceptable_dc, filterMemory, filterName, filterDisk, maxPrice, coupon): 133 | if maxPrice > 0: 134 | strPrice = "[" + str(maxPrice) + "]" 135 | else: 136 | strPrice = "" 137 | print("- DCs : [" + ",".join(acceptable_dc) 138 | + "] - Filters : [" + filterName 139 | + "][" + filterDisk 140 | + "][" + filterMemory 141 | + "]" + strPrice + " - Coupon : [" + coupon + "]") 142 | 143 | # ----------------- SLEEP x SECONDS ----------------------------------------------------------- 144 | def print_and_sleep(showMessage, sleepsecs): 145 | for i in range(sleepsecs,0,-1): 146 | if showMessage: 147 | print(f"- Refresh in {i}s. CTRL-C to stop and buy/quit.", end="\r", flush=True) 148 | time.sleep(1) 149 | 150 | # ----------------- PRINT AUTO BUY STATS ------------------------------------------------------- 151 | def print_auto_buy(autoBuyNum, autoBuyNumInit, autoOK, autoKO, autoFake): 152 | print("Auto buy left: " + str(autoBuyNum) + "/" + str(autoBuyNumInit) 153 | + " - OK: " + str(autoOK) + ", NOK: " + str(autoKO) + ", Fake: " + str(autoFake)) 154 | 155 | # ----------------- PRINT LIST OF ORDERS ------------------------------------------------------- 156 | def print_orders(orderList, printDate=False): 157 | for order in orderList: 158 | strOrder = str(orderList.index(order)).ljust(4) + "| " \ 159 | + order['description'].ljust(10) + "| " \ 160 | + order['location'] 161 | if printDate: 162 | strOrder = strOrder + "| " + order['date'] 163 | print (strOrder) 164 | 165 | # ------------------ PRINT SERVER SPECS ---------------------------------------------------------- 166 | def print_servers(server_list): 167 | for server in server_list: 168 | print ("NAME: " + server['name']) 169 | print ("DC: " + server['datacenter']) 170 | print ("CPU: " + server['cpu']) 171 | print ("RAM: " + server['memory']) 172 | print ("DISKS: " + " + ".join(server['disks'])) 173 | print () 174 | -------------------------------------------------------------------------------- /m/api.py: -------------------------------------------------------------------------------- 1 | import ovh 2 | import time 3 | from datetime import datetime, timezone 4 | 5 | __all__ = ['api_url','build_cart', 'checkout_cart', 'get_orders_per_status', 6 | 'get_consumer_key', 'get_servers_list', 'login', 'is_logged_in'] 7 | 8 | # --- Exceptions ---------------------------- 9 | class NotLoggedIn(Exception): 10 | def __init__(self, message): 11 | self.message = message 12 | super().__init__(self.message) 13 | 14 | # --- Variables ----------------------------- 15 | client = None 16 | 17 | # --- What is the URL of the API? -------------------------------------------------------------- 18 | def api_url(endpoint): 19 | if endpoint == 'ovh-ca': 20 | return "https://ca.api.ovh.com/v1/" 21 | elif endpoint == 'ovh-us': 22 | return "https://api.us.ovhcloud.com/v1/" 23 | else: 24 | return "https://eu.api.ovh.com/v1/" 25 | 26 | # ---------------- ARE WE LOGGED IN? ----------------------------------------------------------- 27 | def is_logged_in(): 28 | return client != None 29 | 30 | # ---------------- LOGIN TO THE API ------------------------------------------------------------ 31 | def login(endpoint, application_key, application_secret, consumer_key): 32 | global client 33 | try: 34 | tmpClient = ovh.Client(endpoint=endpoint, 35 | application_key=application_key, 36 | application_secret=application_secret, 37 | consumer_key=consumer_key) 38 | # if the API credential are incorrect, this will fail 39 | tmpClient.get('/me') 40 | client = tmpClient 41 | return True 42 | except Exception as e: 43 | return False 44 | 45 | # ---------------- GET A CONSUMER KEY ---------------------------------------------------------- 46 | def get_consumer_key(endpoint, application_key, application_secret): 47 | global client 48 | try: 49 | client = ovh.Client(endpoint=endpoint, 50 | application_key=application_key, 51 | application_secret=application_secret) 52 | 53 | ck = client.new_consumer_key_request() 54 | ck.add_recursive_rules(ovh.API_READ_WRITE, '/') 55 | validation = ck.request() 56 | 57 | print("Please visit %s to authenticate" % validation['validationUrl']) 58 | input("and press Enter to continue...") 59 | 60 | # this will fail if they did not authenticate 61 | client.get('/me') 62 | 63 | return validation['consumerKey'] 64 | except Exception as e: 65 | client = None 66 | return "nokey" 67 | 68 | # ---------------- BUILD THE CART -------------------------------------------------------------- 69 | def build_cart(plan, ovhSubsidiary, coupon, fake=False): 70 | if fake: 71 | print("Fake cart!") 72 | time.sleep(1) 73 | return 0 74 | elif client == None: 75 | raise NotLoggedIn("Need to be logged in to build the cart.") 76 | 77 | # make a cart 78 | cart = client.post("/order/cart", ovhSubsidiary=ovhSubsidiary) 79 | cartId = cart.get("cartId") 80 | client.post("/order/cart/{0}/assign".format(cartId)) 81 | # add the server 82 | result = client.post( 83 | f'/order/cart/{cart.get("cartId")}/eco', 84 | duration = "P1M", 85 | planCode = plan['planCode'], 86 | pricingMode = "default", 87 | quantity = 1 88 | ) 89 | itemId = result['itemId'] 90 | 91 | # add options 92 | result = client.post( 93 | f'/order/cart/{cartId}/eco/options', 94 | duration = "P1M", 95 | itemId = itemId, 96 | planCode = plan['memory'], 97 | pricingMode = "default", 98 | quantity = 1 99 | ) 100 | result = client.post( 101 | f'/order/cart/{cartId}/eco/options', 102 | itemId = itemId, 103 | duration = "P1M", 104 | planCode = plan['storage'], 105 | pricingMode = "default", 106 | quantity = 1 107 | ) 108 | result = client.post( 109 | f'/order/cart/{cartId}/eco/options', 110 | itemId = itemId, 111 | duration = "P1M", 112 | planCode = plan['bandwidth'], 113 | pricingMode = "default", 114 | quantity = 1 115 | ) 116 | if plan['vrack'] != 'none': 117 | result = client.post( 118 | f'/order/cart/{cartId}/eco/options', 119 | itemId = itemId, 120 | duration = "P1M", 121 | planCode = plan['vrack'], 122 | pricingMode = "default", 123 | quantity = 1 124 | ) 125 | 126 | # add configuration 127 | result = client.post( 128 | f'/order/cart/{cartId}/item/{itemId}/configuration', 129 | label = "dedicated_datacenter", 130 | value = plan['datacenter'] 131 | ) 132 | result = client.post( 133 | f'/order/cart/{cartId}/item/{itemId}/configuration', 134 | label = "dedicated_os", 135 | value = "none_64.en" 136 | ) 137 | if plan['datacenter'] in ["bhs", "syd", "sgp"]: 138 | myregion = "canada" 139 | else: 140 | myregion = "europe" 141 | result = client.post( 142 | f'/order/cart/{cartId}/item/{itemId}/configuration', 143 | label = "region", 144 | value = myregion 145 | ) 146 | 147 | # add coupon 148 | if coupon: 149 | result = client.post(f'/order/cart/{cartId}/coupon', 150 | label = "coupon", 151 | value = coupon) 152 | 153 | return cartId 154 | 155 | # ---------------- CHECKOUT THE CART --------------------------------------------------------- 156 | def checkout_cart(cartId, buyNow, fake=False): 157 | if fake: 158 | print("Fake buy! Now: " + str(buyNow)) 159 | time.sleep(2) 160 | return 161 | elif client == None: 162 | raise NotLoggedIn("Need to be logged in to check out the cart.") 163 | 164 | # this is it, we checkout the cart! 165 | result = client.post(f'/order/cart/{cartId}/checkout', 166 | autoPayWithPreferredPaymentMethod=buyNow, 167 | waiveRetractationPeriod=buyNow 168 | ) 169 | 170 | def get_orders_per_status(date_from, date_to, status_list, printMessage=False): 171 | if client == None: 172 | raise NotLoggedIn("Need to be logged in to get unpaid orders.") 173 | params = {} 174 | params['date.from'] = date_from.strftime('%Y-%m-%d') 175 | params['date.to'] = date_to.strftime('%Y-%m-%d') 176 | API_orders = client.get("/me/order/", **params) 177 | orderList = [] 178 | if printMessage: 179 | print("Building list of orders. Please wait.") 180 | for orderId in API_orders: 181 | if printMessage: 182 | print("(" + str(API_orders.index(orderId)+1) + "/" + str(len(API_orders)) + ")", end="\r", flush=True) 183 | orderStatus = client.get("/me/order/{0}/status/".format(orderId)) 184 | if orderStatus in status_list: 185 | details = client.get("/me/order/{0}/details/".format(orderId)) 186 | for detailId in details: 187 | orderDetail = client.get("/me/order/{0}/details/{1}".format(orderId, detailId)) 188 | if orderDetail['domain'] == '*001' and orderDetail['detailType'] == "DURATION": 189 | theOrder = client.get("/me/order/{0}/".format(orderId)) 190 | # check if the order has expired 191 | dt = datetime.fromisoformat(theOrder['expirationDate']) 192 | # Current time in UTC 193 | now = datetime.now(timezone.utc) 194 | # order awaiting delivery don't expire 195 | if now < dt or orderStatus == 'delivering': 196 | description = orderDetail['description'].split('|')[0].split(' ')[0] 197 | location = orderDetail['description'].split('-')[-2][-4:] 198 | orderURL = theOrder['url'] 199 | orderDate = theOrder['expirationDate'].split('T')[0] 200 | orderList.append({ 201 | 'orderId' : orderId, 202 | 'description' : description, 203 | 'location' : location, 204 | 'url' : orderURL, 205 | 'date' : orderDate}) 206 | break 207 | return orderList 208 | 209 | # ---------------- SERVERS ----------------------------------------------------------------------- 210 | def get_servers_list(printMessage=False): 211 | if client == None: 212 | raise NotLoggedIn("Need to be logged in to see the servers.") 213 | API_servers = client.get("/dedicated/server") 214 | servers_list = [] 215 | if printMessage: 216 | print("Building list of servers. Please wait.") 217 | for server_name in API_servers: 218 | if printMessage: 219 | print("(" + str(API_servers.index(server_name)+1) + "/" + str(len(API_servers)) + ")", end="\r", flush=True) 220 | # don't take expired or suspended ones 221 | server_info = client.get("/dedicated/server/"+server_name) 222 | if server_info['iam']['state']=="OK": 223 | server_hw = client.get("/dedicated/server/"+server_name+"/specifications/hardware") 224 | disk_groups = [x['description'] for x in server_hw['diskGroups']] 225 | servers_list.append({'id': server_name, 226 | 'name': server_info['iam']['displayName'], 227 | 'datacenter' : server_info['datacenter'], 228 | 'cpu' : server_hw['processorName'], 229 | 'memory': str(server_hw['memorySize']['value'])+" "+server_hw['memorySize']['unit'], 230 | 'disks': disk_groups 231 | }) 232 | return servers_list -------------------------------------------------------------------------------- /m/catalog.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | 4 | __all__ = ['added_removed', 'build_list'] 5 | 6 | # Here we fix errors in the catalog to match the FQN listed in the availabilities 7 | def fixMem(mem): 8 | fixedMem = mem 9 | # For 25rises011 and 021, OVH add "-rise-s" instead of the plancode at the end of the RAM 10 | # and in the availabilities there is an extra "-on-die-ecc-5200" 11 | if mem.endswith("-rise"): 12 | fixedMem = mem.removesuffix("-rise") + "-on-die-ecc-5200" 13 | elif mem.endswith("-16g"): 14 | # For KS-STOR and SYS-STOR they don't have the ECC part at the end of the mem in the catalog 15 | fixedMem = mem + "-ecc-2133" 16 | return fixedMem 17 | 18 | def fixSto(sto): 19 | fixedSto = sto 20 | # For SYS-01 with hybrid disks, the availabilities have 500nvme instead of 512nvme 21 | if sto.endswith("4000sa-2x512nvme") or sto.endswith("4000sa-1x512nvme"): 22 | fixedSto = sto.replace("512", "500") 23 | return fixedSto 24 | 25 | # -------------- EXTRACT THE PRICE INCLUDING PROMOTION ----------------------------------------------------------- 26 | def getPrice(price): 27 | myPrice = float(price['price'])/100000000 28 | allPromo = price['promotions'] 29 | if allPromo: 30 | allPercentPromo = [x for x in allPromo if x['type']=='percentage'] 31 | # take only the first one 32 | if allPercentPromo: 33 | myPromo = float(100-allPercentPromo[0]['value'])/100 34 | else: 35 | myPromo = 1 36 | else: 37 | myPromo = 1 38 | return myPrice * myPromo 39 | 40 | # -------------- BUILD LIST OF SERVERS --------------------------------------------------------------------------- 41 | def build_list(url, 42 | avail, ovhSubsidiary, 43 | filterName, filterDisk, filterMemory, acceptable_dc, maxPrice, 44 | addVAT, 45 | bandwidthAndVRack): 46 | response = requests.get(url + "order/catalog/public/eco?ovhSubsidiary=" + ovhSubsidiary) 47 | API_catalog = response.json() 48 | 49 | allPlans = API_catalog['plans'] 50 | myPlans = [] 51 | 52 | allAddons = API_catalog['addons'] 53 | 54 | try: 55 | vatRate = 1 + (API_catalog['locale']['taxRate']) / 100 56 | except: 57 | if addVAT: 58 | print("Could not read VAT from the catalog") 59 | vatRate = 1 60 | 61 | for plan in allPlans: 62 | planCode = plan['planCode'] 63 | invoiceNameSplit = plan['invoiceName'].split('|') 64 | model = invoiceNameSplit[0] 65 | if len(invoiceNameSplit) > 1: 66 | cpu = invoiceNameSplit[1][1:] 67 | # remove extra space at the end of the model name 68 | model = model[:-1] 69 | else: 70 | cpu = "unknown" 71 | # only consider plans passing the name filter, which is a regular expression 72 | # Either model (from invoice name) of plan code must match 73 | if not (bool(re.search(filterName, model)) 74 | or bool(re.search(filterName, plan['planCode']))): 75 | continue 76 | 77 | # find the price 78 | allPrices = plan['pricings'] 79 | # first pricing is the setup fee, second is the monthly price 80 | # (1 month commitment) 81 | if allPrices: 82 | planFee = getPrice(allPrices[0]) 83 | planPrice = getPrice(allPrices[1]) 84 | else: 85 | planFee = 0.0 86 | planPrice = 0.0 87 | 88 | allStorages = [] 89 | allMemories = [] 90 | allBandwidths = [] 91 | allVRack = [] 92 | 93 | # find mandatory addons 94 | for family in plan['addonFamilies']: 95 | if family['name'] == "storage": 96 | allStorages = family['addons'] 97 | elif family['name'] == "memory": 98 | allMemories = family['addons'] 99 | elif family['name'] == "bandwidth": 100 | allBandwidths = family['addons'] 101 | elif family['name'] == "vrack": 102 | allVRack = family['addons'] 103 | 104 | # vRack is not always present 105 | if not allVRack: 106 | allVRack = ['none'] 107 | 108 | allDatacenters = [] 109 | 110 | # same for datacenters 111 | for config in plan['configurations']: 112 | if config['name'] == "dedicated_datacenter": 113 | allDatacenters = config['values'] 114 | # filter and sort datacenters per acceptable_dc 115 | if acceptable_dc: 116 | filteredDatacenters = [x for x in allDatacenters if x in acceptable_dc] 117 | sortedDatacenters = sorted(filteredDatacenters, key=lambda x: acceptable_dc.index(x)) 118 | else: 119 | sortedDatacenters = allDatacenters 120 | 121 | # build a list of all possible combinations 122 | for da in sortedDatacenters: 123 | for me in allMemories: 124 | # the API adds the name of the plan at the end of the addons, drop it 125 | # (only for building the FQN) 126 | # for KS-LE-* they also add the v1 at the end which needs to go 127 | # Also there are sometimes differences between catalog and availabilities 128 | # fix these errors (only for building the FQN) 129 | if me.split("-")[-1] == "v1": 130 | shortme = fixMem("-".join(me.split("-")[:-2])) 131 | else: 132 | shortme = fixMem("-".join(me.split("-")[:-1])) 133 | # apply the memory filter 134 | if not bool(re.search(filterMemory,shortme)): 135 | continue 136 | for st in allStorages: 137 | if st.split("-")[-1] == "v1": 138 | shortst = fixSto("-".join(st.split("-")[:-2])) 139 | else: 140 | shortst = fixSto("-".join(st.split("-")[:-1])) 141 | # apply the disk filter 142 | if not bool(re.search(filterDisk,shortst)): 143 | continue 144 | for ba in allBandwidths: 145 | for vr in allVRack: 146 | # each config may have a different price within the same plan 147 | thisPrice = planPrice 148 | thisFee = planFee 149 | # try to find out the full price 150 | try: 151 | storagePlan = [x for x in allAddons if (x['planCode'] == st)] 152 | thisFee = thisFee + getPrice(storagePlan[0]['pricings'][0]) 153 | thisPrice = thisPrice + getPrice(storagePlan[0]['pricings'][1]) 154 | except Exception as e: 155 | print(e) 156 | try: 157 | memoryPlan = [x for x in allAddons if (x['planCode'] == me)] 158 | thisFee = thisFee + getPrice(memoryPlan[0]['pricings'][0]) 159 | thisPrice = thisPrice + getPrice(memoryPlan[0]['pricings'][1]) 160 | except Exception as e: 161 | print(e) 162 | try: 163 | bandwidthPlan = [x for x in allAddons if (x['planCode'] == ba)] 164 | bandwidthPrice = getPrice(bandwidthPlan[0]['pricings'][1]) 165 | # if showBandwidth is false, drop the plans with a bandwidth that costs money 166 | if not bandwidthAndVRack and bandwidthPrice > 0.0: 167 | continue 168 | # not sure if there is setup fee for the bandwidth? 169 | thisPrice = thisPrice + bandwidthPrice 170 | except Exception as e: 171 | print(e) 172 | if vr != 'none': 173 | try: 174 | vRackPlan = [x for x in allAddons if (x['planCode'] == vr)] 175 | vRackPrice = getPrice(vRackPlan[0]['pricings'][2]) 176 | # if showBandwidth is false, drop the plans with a vRack that costs money 177 | if not bandwidthAndVRack and vRackPrice > 0.0: 178 | continue 179 | # not sure if there is setup fee for the vRack? 180 | thisPrice = thisPrice + vRackPrice 181 | except Exception as e: 182 | print(e) 183 | if addVAT: 184 | # apply the VAT to the price 185 | thisFee = round(thisFee * vatRate, 2) 186 | thisPrice = round(thisPrice * vatRate, 2) 187 | # apply the max price filter if different from 0 188 | if maxPrice > 0 and thisPrice > maxPrice: 189 | continue 190 | myFqn = planCode + "." + shortme + "." + shortst + "." + da 191 | if myFqn in avail: 192 | myavailability = avail[myFqn] 193 | else: 194 | myavailability = 'unknown' 195 | # Add the plan to the list 196 | myPlans.append( 197 | { 'planCode' : planCode, 198 | 'model' : model, 199 | 'cpu' : cpu, 200 | 'datacenter' : da, 201 | 'storage' : st, 202 | 'memory' : me, 203 | 'bandwidth' : ba, 204 | 'vrack' : vr, 205 | 'fqn' : myFqn, # for auto buy 206 | 'price' : thisPrice, 207 | 'fee' : thisFee, 208 | 'availability' : myavailability 209 | }) 210 | return sorted(myPlans, key=lambda x: x['planCode']) 211 | 212 | # -------------- ADD AUTO BUY INFO TO PLAN LIST --------------------------------------------- 213 | def add_auto_buy(plans, autoBuyRE, autoBuyMaxPrice): 214 | for plan in plans: 215 | plan['autobuy'] = (autoBuyRE and 216 | (bool(re.search(autoBuyRE, plan['fqn'])) or bool(re.search(autoBuyRE, plan['model']))) 217 | and (autoBuyMaxPrice == 0 or plan['price'] <= autoBuyMaxPrice)) 218 | 219 | # -------------- CHECK IF A SERVER WAS ADDED OR REMOVED ------------------------------------- 220 | def added_removed(previousP, newP): 221 | addedFqns = [] 222 | removedFqns = [] 223 | if previousP: 224 | previousFqns = [x['fqn'] for x in previousP] 225 | newFqns = [x['fqn'] for x in newP] 226 | addedFqns = [ x for x in newFqns if x not in previousFqns] 227 | removedFqns = [ x for x in previousFqns if x not in newFqns] 228 | return (addedFqns, removedFqns) 229 | -------------------------------------------------------------------------------- /buy_ovh.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import time 5 | 6 | # modules 7 | import m.api 8 | import m.availability 9 | import m.catalog 10 | import m.email 11 | import m.monitor 12 | import m.orders 13 | import m.print 14 | import m.servers 15 | 16 | from m.config import configFile 17 | 18 | # ----------------- GLOBAL VARIABLES ---------------------------------------------------------- 19 | 20 | def loadConfigMain(cf): 21 | global acceptable_dc, filterName, filterDisk, filterMemory, maxPrice, addVAT, APIEndpoint, ovhSubsidiary, \ 22 | loop, sleepsecs, showPrompt, showCpu, showFqn, showUnavailable, showUnknown, \ 23 | showBandwidth, fakeBuy, coupon, \ 24 | showPrice, showFee, showTotalPrice 25 | acceptable_dc = cf['datacenters'] if 'datacenters' in cf else acceptable_dc 26 | filterName = cf['filterName'] if 'filterName' in cf else filterName 27 | filterDisk = cf['filterDisk'] if 'filterDisk' in cf else filterDisk 28 | filterMemory = cf['filterMemory'] if 'filterMemory' in cf else filterMemory 29 | maxPrice = cf['maxPrice'] if 'maxPrice' in cf else maxPrice 30 | addVAT = cf['addVAT'] if 'addVAT' in cf else addVAT 31 | ovhSubsidiary = cf['ovhSubsidiary'] if 'ovhSubsidiary' in cf else ovhSubsidiary 32 | APIEndpoint = cf['APIEndpoint'] if 'APIEndpoint' in cf else APIEndpoint 33 | loop = cf['loop'] if 'loop' in cf else loop 34 | sleepsecs = cf['sleepsecs'] if 'sleepsecs' in cf else sleepsecs 35 | showPrompt = cf['showPrompt'] if 'showPrompt' in cf else showPrompt 36 | showCpu = cf['showCpu'] if 'showCpu' in cf else showCpu 37 | showFqn = cf['showFqn'] if 'showFqn' in cf else showFqn 38 | showUnavailable = cf['showUnavailable'] if 'showUnavailable' in cf else showUnavailable 39 | showUnknown = cf['showUnknown'] if 'showUnknown' in cf else showUnknown 40 | showBandwidth = cf['showBandwidth'] if 'showBandwidth' in cf else showBandwidth 41 | showPrice = cf['showPrice'] if 'showPrice' in cf else showPrice 42 | showFee = cf['showFee'] if 'showFee' in cf else showFee 43 | showTotalPrice = cf['showTotalPrice'] if 'showTotalPrice' in cf else showTotalPrice 44 | fakeBuy = cf['fakeBuy'] if 'fakeBuy' in cf else fakeBuy 45 | coupon = cf['coupon'] if 'coupon' in cf else coupon 46 | 47 | def loadConfigEmail(cf): 48 | global email_on, email_at_startup, email_auto_buy, email_added_removed, \ 49 | email_availability_monitor, email_catalog_monitor, email_exception 50 | email_on = cf['email_on'] if 'email_on' in cf else email_on 51 | email_at_startup = cf['email_at_startup'] if 'email_at_startup' in cf and email_on else email_at_startup 52 | email_auto_buy = cf['email_auto_buy'] if 'email_auto_buy' in cf and email_on else email_auto_buy 53 | email_added_removed = cf['email_added_removed'] if 'email_added_removed' in cf and email_on else email_added_removed 54 | email_availability_monitor = cf['email_availability_monitor'] if 'email_availability_monitor' in cf and email_on else email_availability_monitor 55 | email_catalog_monitor = cf['email_catalog_monitor'] if 'email_catalog_monitor' in cf and email_on else email_catalog_monitor 56 | email_exception = cf['email_exception'] if 'email_exception' in cf and email_on else email_exception 57 | 58 | def loadConfigAutoBuy(cf): 59 | global autoBuyRE, autoBuyNum, autoBuyMaxPrice, autoBuyInvoicesNum, autoBuyUnknown, \ 60 | autoBuyNumInit, autoOK, autoKO, autoFake 61 | autoBuyRE = cf['auto_buy'] if 'auto_buy' in cf else autoBuyRE 62 | autoBuyNum = cf['auto_buy_num'] if 'auto_buy_num' in cf else autoBuyNum 63 | autoBuyMaxPrice = cf['auto_buy_max_price'] if 'auto_buy_max_price' in cf else autoBuyMaxPrice 64 | autoBuyInvoicesNum = cf['auto_buy_num_invoices'] if 'auto_buy_num_invoices' in cf else autoBuyInvoicesNum 65 | autoBuyUnknown = cf['auto_buy_unknown'] if 'auto_buy_unknown' in cf else autoBuyUnknown 66 | if autoBuyNum == 0: 67 | autoBuyRE = "" 68 | autoBuyNumInit = autoBuyNum 69 | autoOK = 0 70 | autoKO = 0 71 | autoFake = 0 72 | 73 | acceptable_dc = [] 74 | filterName = "" 75 | filterDisk = "" 76 | filterMemory = "" 77 | maxPrice = 0 78 | addVAT = False 79 | ovhSubsidiary = "FR" 80 | APIEndpoint = "ovh-eu" 81 | loop = False 82 | sleepsecs = 60 83 | showPrompt = True 84 | showCpu = True 85 | showFqn = False 86 | showUnavailable = True 87 | showUnknown = True 88 | showBandwidth = True 89 | showPrice = True 90 | showFee = False 91 | showTotalPrice = False 92 | fakeBuy = True 93 | coupon = '' 94 | 95 | loadConfigMain(configFile) 96 | 97 | email_on = False 98 | email_at_startup = False 99 | email_auto_buy = False 100 | email_added_removed = False 101 | email_availability_monitor = "" 102 | email_catalog_monitor = False 103 | email_exception = False 104 | loadConfigEmail(configFile) 105 | 106 | # Auto Buy 107 | autoBuyRE = "" 108 | autoBuyNum = 1 109 | autoBuyMaxPrice = 0 110 | autoBuyInvoicesNum = 0 111 | autoBuyNumInit = 0 112 | autoBuyUnknown = False 113 | # counters to display how auto buy are doing 114 | autoOK = 0 115 | autoKO = 0 116 | autoFake = 0 117 | loadConfigAutoBuy(configFile) 118 | 119 | # ----------------- CONNECT IF INFO IN CONF FILE ---------------------------------------------- 120 | if ('APIKey' in m.config.configFile and 121 | 'APISecret' in m.config.configFile): 122 | # if the customer key is there too, we can connect 123 | if 'APIConsumerKey' in m.config.configFile: 124 | m.api.login(APIEndpoint, 125 | m.config.configFile['APIKey'], 126 | m.config.configFile['APISecret'], 127 | m.config.configFile['APIConsumerKey']) 128 | else: 129 | ck = m.api.get_consumer_key(APIEndpoint, 130 | m.config.configFile['APIKey'], 131 | m.config.configFile['APISecret']) 132 | if ck != "nokey": 133 | print("To add the generated consumer key to your conf.yaml file:") 134 | print("APIConsumerKey: " + ck) 135 | else: 136 | print("Failed to get a consumer key, did you authenticate?") 137 | input("Press Enter to continue...") 138 | 139 | # ----------------- DISPLAY HELP -------------------------------------------------------------- 140 | def showHelp(): 141 | print("") 142 | print("Colour coding") 143 | print("-------------") 144 | print(m.print.whichColor['high'] + "Available HIGH") 145 | print(m.print.whichColor['low'] + "Available LOW") 146 | print(m.print.whichColor['unavailable'] + "Unavailable") 147 | print(m.print.whichColor['comingSoon'] + "Coming Soon") 148 | print(m.print.whichColor['unknown'] + "Availability unknown" + m.print.color.END) 149 | print("") 150 | print("Infinite Loop") 151 | print("-------------") 152 | print("When the loop is ON, the script updates the catalog and availabilities every " + str(sleepsecs) + "s.") 153 | print("You need to press CTRL-C to stop the loop and interact with the script.") 154 | print("") 155 | print("Toggles") 156 | print("-------") 157 | print(" B - show Bandwidth and vRack options ON/OFF") 158 | print(" C - show CPU type ON/OFF") 159 | print(" F - show FQN instead of server details ON/OFF") 160 | print(" P - show helpful prompt ON/OFF") 161 | print(" PP - show the monthly price ON/OFF") 162 | print(" PF - show the installation fee ON/OFF") 163 | print(" PT - show the total price ON/OFF") 164 | print(" U - show Unavailable servers ON/OFF") 165 | print(" UK - show servers with Unknown availability ON/OFF") 166 | print(" T - add Tax (VAT) to the price ON/OFF") 167 | print(" $ - fake buy ON/OFF") 168 | print("") 169 | print("Filters") 170 | print("-------") 171 | print(" FD - re-enter the Disk filter (sa, nvme, ssd)") 172 | print(" FM - re-enter the Memory filter (ex: 32g)") 173 | print(" FN - re-enter the Name filter (invoice name or plan code)") 174 | print(" FP - set maximum price") 175 | print("") 176 | print("Commands") 177 | print("--------") 178 | print(" D - show your undelivered orders and a link to see your bill for one") 179 | print(" K - enter a coupon (buying will fail if coupon is invalid)") 180 | print(" L - (re)start the infinite loop, activating monitoring if configured") 181 | print(" O - show your unpaid orders and a link to pay for one") 182 | print(" R - reload the configuration file") 183 | print(" S - print a list of your servers with some specs") 184 | print(" V - look up availabilities for a specific FQN") 185 | print("") 186 | print("Buying") 187 | print("------") 188 | print("Enter the server number in the list to either get an invoice or buy it straight away.") 189 | print(" Example :> 0") 190 | print("Start with ! to buy it now, ? for invoice.") 191 | print(" Example :> ?1") 192 | print("Add * followed by a number to buy multiple time") 193 | print("(this creates as many orders, each of them for one server)") 194 | print(" Example :> !3*4") 195 | print("") 196 | print("It is possible to enter more than one command at a time.") 197 | print("For example, to deactivate fake buy, buy 2 servers number 6 and get one invoice, re-activate fake buy and then restart the loop:") 198 | print(" > $ !6*2 ?6 $ l") 199 | print("") 200 | dummy=input("Press ENTER.") 201 | 202 | # ----------------- BUY SERVER ---------------------------------------------------------------- 203 | def buyServer(plan, buyNow, autoMode): 204 | global autoFake, autoOK, autoKO 205 | if autoMode: 206 | strAuto = " -Auto Mode-" 207 | else: 208 | strAuto = "" 209 | if buyNow: 210 | strBuyNow = "buy now a " 211 | else: 212 | strBuyNow = "get an invoice for a " 213 | strBuy = strBuyNow + plan['model'] + " in " + plan['datacenter'] + "." 214 | print("Let's " + strBuy + strAuto) 215 | try: 216 | m.api.checkout_cart(m.api.build_cart(plan, ovhSubsidiary, coupon, fakeBuy), buyNow, fakeBuy) 217 | if autoMode: 218 | if fakeBuy: 219 | autoFake += 1 220 | else: 221 | autoOK += 1 222 | if email_auto_buy and loop: 223 | m.email.send_auto_buy_email("SUCCESS: " + strBuy) 224 | except Exception as e: 225 | print("Not today.") 226 | print(e) 227 | if autoMode: 228 | autoKO += 1 229 | if email_auto_buy and loop: 230 | m.email.send_auto_buy_email("FAILED: " + strBuy) 231 | time.sleep(3) 232 | 233 | # ------------------ TOOL --------------------------------------------------------------------- 234 | # when ordering servers, the user can type something like "!0*3" 235 | # "*3" means repeat 3 times 236 | # this function expand these, so "!2*3" becomes "!2 !2 !2" 237 | # if no multiplier is specified, it means 1 238 | def expandMulti(line): 239 | pattern = r'(^|\s)([?!]?\d+)\*(\d+)' 240 | 241 | def replacer(match): 242 | first, word, count = match.groups() 243 | return first + ' '.join([word] * int(count)) 244 | 245 | return re.sub(pattern, replacer, line) 246 | # ----------------- MAIN PROGRAM -------------------------------------------------------------- 247 | 248 | # send email at startup 249 | if email_at_startup: 250 | m.email.send_startup_email() 251 | 252 | availabilities = {} 253 | # previous list of availabilities so we can send email if something pops up 254 | previousAvailabilities = {} 255 | 256 | # Plans which pass the filters (name + disk) 257 | plans = [] 258 | # previous plans 259 | previousPlans = [] 260 | # Unavailable servers can be hidden (see conf file), 261 | # so we need a list of non hidden plans for display and order 262 | displayedPlans = [] 263 | 264 | # do the catalog monitoring only if filters have not changed 265 | filtersChanged = False 266 | 267 | # loop until the user wants out 268 | while True: 269 | 270 | try: 271 | while True: 272 | try: 273 | os.system('cls' if os.name == 'nt' else 'clear') 274 | if availabilities: 275 | previousAvailabilities = availabilities 276 | previousPlans = plans 277 | availabilities = m.availability.build_availability_dict(m.api.api_url(APIEndpoint),acceptable_dc) 278 | plans = m.catalog.build_list(m.api.api_url(APIEndpoint), 279 | availabilities, 280 | ovhSubsidiary, 281 | filterName, filterDisk, filterMemory, acceptable_dc, maxPrice, 282 | addVAT, 283 | showBandwidth) 284 | m.catalog.add_auto_buy(plans, autoBuyRE, autoBuyMaxPrice) 285 | displayedPlans = [ x for x in plans \ 286 | if (x['availability'] not in m.availability.unavailableAndUnknownList or 287 | (x['availability'] in m.availability.unavailableList and showUnavailable) or 288 | (x['availability'] == 'unknown' and showUnknown) or 289 | x['autobuy'])] 290 | m.print.print_plan_list(displayedPlans, showCpu, showFqn, showBandwidth, 291 | showPrice, showFee, showTotalPrice) 292 | if fakeBuy: 293 | print("- Fake Buy ON") 294 | if not m.api.is_logged_in(): 295 | print("- Not logged in") 296 | foundAutoBuyServer = False 297 | if autoBuyRE: 298 | for plan in plans: 299 | if (plan['autobuy'] and 300 | autoBuyNum > 0 and 301 | (plan['availability'] not in m.availability.unavailableAndUnknownList or 302 | (plan['availability'] == 'unknown' and autoBuyUnknown)) 303 | ): 304 | # auto buy 305 | foundAutoBuyServer = True 306 | # The last x are invoices (rather than direct buy) if a number 307 | # of invoices is defined in the config file 308 | autoBuyInvoice = autoBuyNum <= autoBuyInvoicesNum 309 | buyServer(plan, not autoBuyInvoice, True) 310 | autoBuyNum -= 1 311 | if autoBuyNum < 1: 312 | autoBuyRE = "" 313 | break 314 | # availability and catalog monitor if configured 315 | strAvailMonitor = "" 316 | if email_added_removed: 317 | strAvailMonitor = m.monitor.avail_added_removed_Str(previousAvailabilities, availabilities, "", "
") 318 | if email_availability_monitor: 319 | strAvailMonitor = strAvailMonitor + \ 320 | m.monitor.avail_changed_Str(previousAvailabilities, 321 | availabilities, 322 | email_availability_monitor, 323 | "", "
") 324 | if strAvailMonitor: 325 | m.email.send_email("BUY_OVH: availabilities", strAvailMonitor, not loop) 326 | # Don't do the catalog monitoring if the user has just changed the filters 327 | if not filtersChanged: 328 | strCatalogMonitor = m.monitor.catalog_added_removed_Str(previousPlans, plans, "", "
") 329 | if strCatalogMonitor: 330 | m.email.send_email("BUY_OVH: catalog", strCatalogMonitor, not loop) 331 | else: 332 | filtersChanged = False 333 | # if the conf says no loop, jump to the menu 334 | if not loop: 335 | if showPrompt: 336 | m.print.print_prompt(acceptable_dc, filterMemory, filterName, filterDisk, maxPrice, coupon) 337 | # if there has been at least one auto buy, show counters 338 | if autoBuyNumInit > 0 and autoBuyNum < autoBuyNumInit: 339 | m.print.print_auto_buy(autoBuyNum, autoBuyNumInit, 340 | autoOK, autoKO, autoFake) 341 | break 342 | if not foundAutoBuyServer: 343 | if showPrompt: 344 | m.print.print_prompt(acceptable_dc, filterMemory, filterName, filterDisk, maxPrice, coupon) 345 | if autoBuyNumInit > 0 and autoBuyNum < autoBuyNumInit: 346 | m.print.print_auto_buy(autoBuyNum, autoBuyNumInit, 347 | autoOK, autoKO, autoFake) 348 | m.print.print_and_sleep(showPrompt, sleepsecs) 349 | except KeyboardInterrupt: 350 | raise 351 | except Exception as e: 352 | print("Exception!") 353 | print(e) 354 | if loop and email_exception: 355 | m.email.send_email("BUY_OVH: Exception",str(e)) 356 | print("Wait " + str(sleepsecs) + "s before retry.") 357 | time.sleep(sleepsecs) 358 | except KeyboardInterrupt: 359 | pass 360 | 361 | print("") 362 | # stop the infinite loop, the user must press L to restart it 363 | loop = False 364 | allChoices = input("(H for Help)> ") 365 | # The user can specify to buy a server multiple times 366 | # "2*5" means buy server 2, 5 times 367 | # "2" and "2*1" mean the same thing 368 | # "!2*3 ?2*10" works too (see below for ! and ?) 369 | allChoicesExpanded = expandMulti(allChoices) 370 | listChoices = allChoicesExpanded.split(' ') 371 | for sChoice in listChoices: 372 | # when buying, the user can specify if they want an invoice or buy now, by starting with ? or ! 373 | # example: ?2 means an invoice for server two 374 | # !4 means buy server 4 now 375 | # 3 means I want server 3 but ask me if I want an invoice or to buy now 376 | if sChoice.startswith('?'): 377 | # invoice, no need to ask 378 | whattodo = 'i' 379 | sChoice = sChoice[1:] 380 | elif sChoice.startswith('!'): 381 | # buy now, no need to ask 382 | whattodo = 'n' 383 | sChoice = sChoice[1:] 384 | else: 385 | # if it's a server number, we'll ask if use wants an invoice or buy now 386 | whattodo = 'a' 387 | # if the user entered a number, it's a server number so let's buy it or get an invoice 388 | if sChoice.isdigit(): 389 | choice = int (sChoice) 390 | if choice >= len(displayedPlans): 391 | sys.exit("You had one job.") 392 | if whattodo == 'a': 393 | print(displayedPlans[choice]['model']) 394 | whattodo = input("Last chance : Make an invoice = I , Buy now = N , other = out : ").lower() 395 | if whattodo == 'i': 396 | mybool = False 397 | elif whattodo == 'n': 398 | mybool = True 399 | else: 400 | continue 401 | buyServer(displayedPlans[choice], mybool, False) 402 | # not a number means command 403 | # the '?', '!', and '*' have no effect here 404 | elif sChoice.lower() == 'fd': 405 | print("Current: " + filterDisk) 406 | filterDisk = input("New filter: ") 407 | filtersChanged = True 408 | elif sChoice.lower() == 'fm': 409 | print("Current: " + filterMemory) 410 | filterMemory = input("New filter: ") 411 | filtersChanged = True 412 | elif sChoice.lower() == 'fn': 413 | print("Current: " + filterName) 414 | filterName = input("New filter: ") 415 | filtersChanged = True 416 | elif sChoice.lower() == 'fp': 417 | if maxPrice > 0: 418 | print("Current:" + str(maxPrice)) 419 | else: 420 | print("Current: None") 421 | tmpMaxPrice = input("New Max Price: ") 422 | if tmpMaxPrice == "": 423 | maxPrice = 0 424 | else: 425 | maxPrice = float(tmpMaxPrice) 426 | filtersChanged = True 427 | elif sChoice.lower() == 'k': 428 | print("Current: " + coupon) 429 | coupon = input("Enter Coupon: ") 430 | elif sChoice.lower() == 'uk': 431 | showUnknown = not showUnknown 432 | elif sChoice.lower() == 'u': 433 | showUnavailable = not showUnavailable 434 | elif sChoice.lower() == 'p': 435 | showPrompt = not showPrompt 436 | elif sChoice.lower() == 'pp': 437 | showPrice = not showPrice 438 | elif sChoice.lower() == 'pf': 439 | showFee = not showFee 440 | elif sChoice.lower() == 'pt': 441 | showTotalPrice = not showTotalPrice 442 | elif sChoice.lower() == 'c': 443 | showCpu = not showCpu 444 | elif sChoice.lower() == 'f': 445 | showFqn = not showFqn 446 | elif sChoice.lower() == 'b': 447 | showBandwidth = not showBandwidth 448 | filtersChanged = True 449 | elif sChoice == '$': 450 | fakeBuy = not fakeBuy 451 | elif sChoice.lower() == 'l': 452 | loop = True 453 | elif sChoice.lower() == 'o': 454 | m.orders.unpaid_orders(True) 455 | elif sChoice.lower() == 'd': 456 | m.orders.undelivered_orders(True) 457 | elif sChoice.lower() == 'r': 458 | # reload conf 459 | loadConfigMain(configFile) 460 | filtersChanged = True 461 | elif sChoice.lower() == 'rr': 462 | # reload conf including autobuy 463 | loadConfigMain(configFile) 464 | loadConfigAutoBuy(configFile) 465 | filtersChanged = True 466 | elif sChoice.lower() == 's': 467 | m.servers.servers_specs(True) 468 | elif sChoice.lower() == 't': 469 | addVAT = not addVAT 470 | # VAT increases the price which could no longer pass the max price filter 471 | # so a server could "disappear" or "appear" in the catalog 472 | # triggering the catalog monitor 473 | filtersChanged = True 474 | elif sChoice.lower() == 'v': 475 | m.availability.look_up_avail(availabilities) 476 | elif sChoice.lower() == 'h': 477 | showHelp() 478 | elif sChoice.lower() == 'q': 479 | sys.exit("Bye now.") 480 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc.