From 349e5d9b93568f30408dab910751e2647d57522a Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Fri, 1 Aug 2025 16:36:12 +0200 Subject: [PATCH] Initial checkin of pip wheel --- .gitignore | 2 + LICENSE | 194 ++++++++++++ MANIFEST.in | 5 + Makefile | 46 +++ README.md | 79 +++++ kuma_client.py | 564 --------------------------------- setup.py | 47 +++ src/kumacli/__init__.py | 11 + src/kumacli/__main__.py | 73 +++++ src/kumacli/client.py | 170 ++++++++++ src/kumacli/cmd/__init__.py | 0 src/kumacli/cmd/maintenance.py | 289 +++++++++++++++++ src/kumacli/cmd/monitor.py | 108 +++++++ 13 files changed, 1024 insertions(+), 564 deletions(-) create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.md delete mode 100755 kuma_client.py create mode 100644 setup.py create mode 100644 src/kumacli/__init__.py create mode 100755 src/kumacli/__main__.py create mode 100644 src/kumacli/client.py create mode 100644 src/kumacli/cmd/__init__.py create mode 100644 src/kumacli/cmd/maintenance.py create mode 100644 src/kumacli/cmd/monitor.py diff --git a/.gitignore b/.gitignore index c4727b4..c005eea 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ lib lib64 pyvenv.cfg __pycache__ +dist/ +kumacli.egg-info diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f90f2c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,194 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of control, an entity +is "controlled by" another entity if it is controlled, directly or +indirectly, through the ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(which shall not be construed as modifying the License). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based upon (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and derivative works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control +systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work, +but excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to use, reproduce, modify, distribute, prepare +Derivative Works of, publicly display, publicly perform, sublicense, +and distribute the Work and such Derivative Works in Source or Object +form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, trademark, patent, + attribution and other notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + +You may add Your own copyright notice(s) and license terms and +conditions for use, reproduction, or distribution of Your modifications, +or for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +7. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +8. Accepting Warranty or Additional Liability. When redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same page as the copyright notice for easier identification within +third-party archives. + +Copyright 2025 KumaCLI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..12d8646 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include LICENSE +recursive-include src *.py +global-exclude __pycache__ +global-exclude *.py[co] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..149b621 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: clean build install test help + +# Default target +help: + @echo "Available targets:" + @echo " clean - Remove build artifacts and cache files" + @echo " build - Build the wheel package" + @echo " install - Install the package in development mode" + @echo " test - Run tests (if available)" + @echo " help - Show this help message" + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -rf build/ + rm -rf dist/ + rm -rf src/kumacli.egg-info/ + rm -rf src/kumacli/__pycache__/ + rm -rf src/kumacli/cmd/__pycache__/ + find . -name "*.pyc" -delete + find . -name "*.pyo" -delete + find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + @echo "Clean complete." + +# Build the wheel package +build: clean + @echo "Building wheel package..." + python -m build + @echo "Build complete. Artifacts in dist/" + +# Install package in development mode +install: + @echo "Installing package in development mode..." + pip install -e . + +# Test the package (placeholder for when tests are added) +test: + @echo "Running tests..." + @echo "No tests configured yet." + +# Rebuild and reinstall (useful during development) +dev: clean build + @echo "Installing newly built package..." + pip uninstall kumacli -y 2>/dev/null || true + pip install dist/kumacli-*.whl + @echo "Development installation complete." \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..611c2c6 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# KumaCLI + +A command-line interface for Uptime Kuma, allowing you to manage monitors and maintenance windows from the terminal. + +## Installation + +```bash +pip install kumacli +``` + +## Configuration + +Set environment variables for authentication: + +```bash +export KUMA_URL="http://your-uptime-kuma-instance:3001" +export KUMA_USERNAME="your-username" +export KUMA_PASSWORD="your-password" +``` + +Or pass them as command-line arguments: + +```bash +kumacli --url http://localhost:3001 --username admin --password password monitor list +``` + +## Usage + +### Monitor Commands + +```bash +# List all monitors +kumacli monitor list + +# List monitors matching patterns +kumacli monitor list --monitor "*web*" + +# List monitors in specific groups +kumacli monitor list --group "production*" + +# Combine filters +kumacli monitor list --monitor "*api*" --group "web*" +``` + +### Maintenance Commands + +```bash +# Create maintenance for specific monitors (90 minutes, starting now) +kumacli maintenance add --monitor "*nextcloud*" + +# Create maintenance for groups +kumacli maintenance add --group "web*" + +# Custom timing and description +kumacli maintenance add --monitor "*db*" --duration "2h" --start "2025-08-01 22:00:00" --title "Database Update" + +# List all maintenances +kumacli maintenance list + +# Delete specific maintenance +kumacli maintenance delete --id 5 + +# Delete all maintenances +kumacli maintenance delete --all +``` + +## Features + +- **Wildcard Pattern Matching**: Use `*` wildcards for monitor and group names +- **Time Scheduling**: Flexible time parsing and duration formats (90m, 2h, 3600s) +- **Group Support**: Target entire groups of monitors +- **Environment Variables**: Store credentials securely +- **Auto-generated Descriptions**: Default descriptions list affected monitors + +## Requirements + +- Python 3.8+ +- Access to an Uptime Kuma instance +- uptime-kuma-api library \ No newline at end of file diff --git a/kuma_client.py b/kuma_client.py deleted file mode 100755 index 2be76ec..0000000 --- a/kuma_client.py +++ /dev/null @@ -1,564 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import fnmatch -import os -import re -import sys -from datetime import datetime, timedelta -from uptime_kuma_api import UptimeKumaApi - - -class KumaClient: - def __init__(self, url, username=None, password=None): - self.url = url - self.username = username - self.password = password - self.api = None - - def parse_duration(self, duration_str): - """Parse duration string like '90m', '1h', '3600s' into seconds""" - if not duration_str: - return 90 * 60 # Default 90 minutes in seconds - - match = re.match(r"^(\d+)([smh])$", duration_str.lower()) - if not match: - raise ValueError( - f"Invalid duration format: {duration_str}. Use format like '90m', '1h', '3600s'" - ) - - value, unit = match.groups() - value = int(value) - - if unit == "s": - return value - elif unit == "m": - return value * 60 - elif unit == "h": - return value * 3600 - - raise ValueError(f"Invalid duration unit: {unit}") - - def parse_start_time(self, start_str): - """Parse start time string or use current time if None""" - if not start_str: - return datetime.utcnow() - - # Try to parse ISO format first - try: - return datetime.fromisoformat(start_str.replace("Z", "+00:00")).replace( - tzinfo=None - ) - except ValueError: - pass - - # Try common formats - formats = [ - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%d %H:%M", - "%Y-%m-%d", - ] - - for fmt in formats: - try: - return datetime.strptime(start_str, fmt) - except ValueError: - continue - - raise ValueError( - f"Invalid start time format: {start_str}. Use ISO format or YYYY-MM-DD HH:MM:SS" - ) - - def connect(self): - """Connect and login to Uptime Kuma""" - try: - self.api = UptimeKumaApi(self.url) - if self.username and self.password: - result = self.api.login(self.username, self.password) - print(f"Connected to {self.url}") - return True - except Exception as e: - print(f"Failed to connect: {e}") - return False - - def disconnect(self): - """Disconnect from Uptime Kuma""" - if self.api: - self.api.disconnect() - - def list_monitors(self, monitor_patterns=None, group_patterns=None): - """List all monitors or filter by patterns and/or groups""" - try: - all_monitors = self.api.get_monitors() - if not all_monitors: - print("No monitors found") - return - - monitors = all_monitors[:] # Start with all monitors - - # Filter by group if specified - if group_patterns: - group_monitors = self.get_monitors_in_groups(group_patterns) - if not group_monitors: - print("No monitors found in the specified groups") - return - monitor_ids = {m["id"] for m in group_monitors} - monitors = [m for m in monitors if m.get("id") in monitor_ids] - - # Filter monitors by name patterns if specified - if monitor_patterns: - matched_monitors = self.find_monitors_by_pattern(monitor_patterns) - if not matched_monitors: - print("No monitors found matching the specified patterns") - return - pattern_monitor_ids = {m["id"] for m in matched_monitors} - monitors = [m for m in monitors if m.get("id") in pattern_monitor_ids] - - # Display results with group information - print( - f"{'ID':<5} {'Name':<25} {'Type':<12} {'Group':<20} {'URL':<35} {'Status':<10}" - ) - print("-" * 112) - - for monitor in monitors: - monitor_id = monitor.get("id", "N/A") - name = monitor.get("name", "N/A") - monitor_type = monitor.get("type", "N/A") - url = monitor.get("url", "N/A") - active = "Active" if monitor.get("active") else "Inactive" - - # Find parent group name - parent_id = monitor.get("parent") - parent_name = "None" - if parent_id: - parent_monitor = next( - (m for m in all_monitors if m.get("id") == parent_id), None - ) - if parent_monitor: - parent_name = parent_monitor.get("name", f"Group {parent_id}") - - print( - f"{monitor_id:<5} {name:<25} {monitor_type:<12} {parent_name:<20} {url:<35} {active:<10}" - ) - - except Exception as e: - print(f"Error listing monitors: {e}") - - def list_maintenances(self): - """List all maintenances""" - try: - maintenances = self.api.get_maintenances() - if not maintenances: - print("No maintenances found") - return - - print( - f"{'ID':<5} {'Title':<30} {'Strategy':<15} {'Active':<10} {'Description':<50}" - ) - print("-" * 115) - - for maintenance in maintenances: - maintenance_id = maintenance.get("id", "N/A") - title = maintenance.get("title", "N/A") - strategy = maintenance.get("strategy", "N/A") - active = "Active" if maintenance.get("active") else "Inactive" - description = maintenance.get("description", "N/A") - - print( - f"{maintenance_id:<5} {title:<30} {strategy:<15} {active:<10} {description:<50}" - ) - - except Exception as e: - print(f"Error listing maintenances: {e}") - - def find_monitors_by_pattern(self, patterns): - """Find monitor IDs by name patterns (case insensitive, supports wildcards)""" - try: - monitors = self.api.get_monitors() - matched_monitors = [] - - for pattern in patterns: - pattern_lower = pattern.lower() - for monitor in monitors: - monitor_name = monitor.get("name", "").lower() - if fnmatch.fnmatch(monitor_name, pattern_lower): - matched_monitors.append( - {"id": monitor.get("id"), "name": monitor.get("name")} - ) - - # Remove duplicates while preserving order - seen = set() - unique_monitors = [] - for monitor in matched_monitors: - if monitor["id"] not in seen: - seen.add(monitor["id"]) - unique_monitors.append(monitor) - - return unique_monitors - - except Exception as e: - print(f"Error finding monitors: {e}") - return [] - - def find_groups_by_pattern(self, patterns): - """Find group IDs by name patterns (case insensitive, supports wildcards)""" - try: - monitors = self.api.get_monitors() - groups = [m for m in monitors if m.get("type") == "group"] - matched_groups = [] - - for pattern in patterns: - pattern_lower = pattern.lower() - for group in groups: - group_name = group.get("name", "").lower() - if fnmatch.fnmatch(group_name, pattern_lower): - matched_groups.append( - {"id": group.get("id"), "name": group.get("name")} - ) - - # Remove duplicates while preserving order - seen = set() - unique_groups = [] - for group in matched_groups: - if group["id"] not in seen: - seen.add(group["id"]) - unique_groups.append(group) - - return unique_groups - - except Exception as e: - print(f"Error finding groups: {e}") - return [] - - def get_monitors_in_groups(self, group_patterns): - """Get all monitors that belong to specified groups""" - try: - monitors = self.api.get_monitors() - matched_groups = self.find_groups_by_pattern(group_patterns) - - if not matched_groups: - return [] - - print(f"Found {len(matched_groups)} matching groups:") - for group in matched_groups: - print(f" - {group['name']} (ID: {group['id']})") - - group_ids = {g["id"] for g in matched_groups} - group_members = [] - - for monitor in monitors: - # Check if monitor's parent is in our group list - if monitor.get("parent") in group_ids: - group_members.append(monitor) - - return group_members - - except Exception as e: - print(f"Error getting group members: {e}") - return [] - - def add_maintenance( - self, - title, - description, - start_time=None, - duration="90m", - monitor_patterns=None, - group_patterns=None, - ): - """Add a new maintenance""" - try: - # Check if we have either monitor patterns or group patterns - if not monitor_patterns and not group_patterns: - print( - "Error: Either --monitor or --group flag is required. Specify at least one pattern." - ) - return - - matched_monitors = [] - - # Find monitors by patterns if specified - if monitor_patterns: - pattern_monitors = self.find_monitors_by_pattern(monitor_patterns) - matched_monitors.extend(pattern_monitors) - - # Find monitors by groups if specified - if group_patterns: - group_monitors = self.get_monitors_in_groups(group_patterns) - # Convert to the same format as find_monitors_by_pattern - group_monitor_objs = [ - {"id": m.get("id"), "name": m.get("name")} for m in group_monitors - ] - matched_monitors.extend(group_monitor_objs) - - # Remove duplicates while preserving order - seen = set() - unique_monitors = [] - for monitor in matched_monitors: - if monitor["id"] not in seen: - seen.add(monitor["id"]) - unique_monitors.append(monitor) - - matched_monitors = unique_monitors - - if not matched_monitors: - print( - "Error: No monitors found matching the specified patterns or groups" - ) - return - - print(f"Found {len(matched_monitors)} matching monitors:") - for monitor in matched_monitors: - print(f" - {monitor['name']} (ID: {monitor['id']})") - - # Set default description if not provided - if not description: - monitor_names = [monitor["name"] for monitor in matched_monitors] - description = "Maintenance on:\n" + "\n".join(monitor_names) - - # Parse start time and duration - start_dt = self.parse_start_time(start_time) - duration_seconds = self.parse_duration(duration) - end_dt = start_dt + timedelta(seconds=duration_seconds) - - print( - f"Maintenance window: {start_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} - {end_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({duration})" - ) - - # Create the maintenance with single strategy and date range - maintenance_data = { - "title": title, - "description": description, - "strategy": "single", - "active": True, - "dateRange": [ - start_dt.strftime("%Y-%m-%d %H:%M:%S"), - end_dt.strftime("%Y-%m-%d %H:%M:%S"), - ], - } - - result = self.api.add_maintenance(**maintenance_data) - maintenance_id = result.get("maintenanceID") - - # Add monitors to maintenance - if maintenance_id: - print(f"Maintenance added successfully: {result}") - - # Prepare monitors list in the correct format for the API - monitors_list = [{"id": monitor["id"]} for monitor in matched_monitors] - - try: - result = self.api.add_monitor_maintenance( - maintenance_id, monitors_list - ) - print( - f"Successfully added {len(matched_monitors)} monitors to maintenance" - ) - print(f"API response: {result}") - except Exception as e: - print(f"Error: Failed to add monitors to maintenance: {e}") - print( - "This might be due to API compatibility issues or server configuration" - ) - - except Exception as e: - print(f"Error adding maintenance: {e}") - - def delete_maintenance(self, maintenance_id=None, delete_all=False): - """Delete a specific maintenance or all maintenances""" - try: - if delete_all: - # Get all maintenances first - maintenances = self.api.get_maintenances() - if not maintenances: - print("No maintenances found to delete") - return - - print(f"Found {len(maintenances)} maintenances to delete:") - for maintenance in maintenances: - print( - f" - {maintenance.get('title', 'N/A')} (ID: {maintenance.get('id', 'N/A')})" - ) - - # Delete all maintenances - deleted_count = 0 - for maintenance in maintenances: - try: - result = self.api.delete_maintenance(maintenance.get("id")) - print( - f"Deleted maintenance '{maintenance.get('title', 'N/A')}' (ID: {maintenance.get('id')})" - ) - deleted_count += 1 - except Exception as e: - print( - f"Failed to delete maintenance '{maintenance.get('title', 'N/A')}': {e}" - ) - - print( - f"Successfully deleted {deleted_count} out of {len(maintenances)} maintenances" - ) - - elif maintenance_id: - # Delete specific maintenance - try: - # First get the maintenance info for confirmation - maintenance = self.api.get_maintenance(maintenance_id) - maintenance_title = maintenance.get("title", "N/A") - - result = self.api.delete_maintenance(maintenance_id) - print( - f"Successfully deleted maintenance '{maintenance_title}' (ID: {maintenance_id})" - ) - print(f"API response: {result}") - - except Exception as e: - print(f"Failed to delete maintenance ID {maintenance_id}: {e}") - else: - print( - "Error: Either --id or --all flag is required for delete operation" - ) - - except Exception as e: - print(f"Error during maintenance deletion: {e}") - - -def main(): - parser = argparse.ArgumentParser(description="Uptime Kuma CLI Client") - parser.add_argument( - "--url", help="Uptime Kuma server URL (can also use KUMA_URL env var)" - ) - parser.add_argument( - "--username", - help="Username for authentication (can also use KUMA_USERNAME env var)", - ) - parser.add_argument( - "--password", - help="Password for authentication (can also use KUMA_PASSWORD env var)", - ) - - subparsers = parser.add_subparsers(dest="resource", help="Resource to operate on") - - # Monitor commands - monitor_parser = subparsers.add_parser("monitor", help="Monitor operations") - monitor_subparsers = monitor_parser.add_subparsers( - dest="monitor_action", help="Monitor actions" - ) - list_monitors_parser = monitor_subparsers.add_parser( - "list", help="List all monitors" - ) - list_monitors_parser.add_argument( - "--monitor", - action="append", - help="Monitor name pattern to filter by (supports wildcards, can be repeated)", - ) - list_monitors_parser.add_argument( - "--group", - action="append", - help="Group name pattern to filter by (supports wildcards, can be repeated)", - ) - - # Maintenance commands - maintenance_parser = subparsers.add_parser( - "maintenance", help="Maintenance operations" - ) - maintenance_subparsers = maintenance_parser.add_subparsers( - dest="maintenance_action", help="Maintenance actions" - ) - maintenance_subparsers.add_parser("list", help="List all maintenances") - - # Delete maintenance command - delete_maintenance_parser = maintenance_subparsers.add_parser( - "delete", help="Delete maintenance(s)" - ) - delete_maintenance_parser.add_argument( - "--id", type=int, help="Maintenance ID to delete" - ) - delete_maintenance_parser.add_argument( - "--all", action="store_true", help="Delete all maintenances" - ) - - # Add maintenance command - add_maintenance_parser = maintenance_subparsers.add_parser( - "add", help="Add a new maintenance" - ) - add_maintenance_parser.add_argument( - "--title", - help='Maintenance title (defaults to "Update started on ")', - ) - add_maintenance_parser.add_argument("--description", help="Maintenance description") - add_maintenance_parser.add_argument( - "--start", - help="Start time (defaults to now, format: YYYY-MM-DD HH:MM:SS or ISO format)", - ) - add_maintenance_parser.add_argument( - "--duration", - default="90m", - help="Duration (default: 90m, format: 3600s, 1h, 60m)", - ) - add_maintenance_parser.add_argument( - "--monitor", - action="append", - help="Monitor name pattern to add to maintenance (supports wildcards like *NextCloud*, can be repeated)", - ) - add_maintenance_parser.add_argument( - "--group", - action="append", - help="Group name pattern to add all group members to maintenance (supports wildcards, can be repeated)", - ) - - args = parser.parse_args() - - if not args.resource: - parser.print_help() - sys.exit(1) - - # Get configuration from environment variables or command line args - url = args.url or os.getenv("KUMA_URL") - username = args.username or os.getenv("KUMA_USERNAME") - password = args.password or os.getenv("KUMA_PASSWORD") - - if not url: - print( - "Error: URL is required. Provide --url or set KUMA_URL environment variable." - ) - sys.exit(1) - - client = KumaClient(url, username, password) - - if not client.connect(): - sys.exit(1) - - try: - if args.resource == "monitor": - if args.monitor_action == "list": - client.list_monitors( - monitor_patterns=args.monitor, group_patterns=args.group - ) - else: - monitor_parser.print_help() - elif args.resource == "maintenance": - if args.maintenance_action == "list": - client.list_maintenances() - elif args.maintenance_action == "add": - title = ( - args.title - or f"Update started on {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}" - ) - client.add_maintenance( - title, - args.description, - args.start, - args.duration, - monitor_patterns=args.monitor, - group_patterns=args.group, - ) - elif args.maintenance_action == "delete": - client.delete_maintenance(maintenance_id=args.id, delete_all=args.all) - else: - maintenance_parser.print_help() - finally: - client.disconnect() - - -if __name__ == "__main__": - main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d58921a --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="kumacli", + version="1.0.0", + author="Uptime Kuma CLI", + description="A command-line interface for Uptime Kuma", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yourusername/kumacli", + packages=find_packages(where="src"), + package_dir={"": "src"}, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: System :: Monitoring", + "Topic :: System :: Systems Administration", + ], + python_requires=">=3.8", + install_requires=[ + "uptime-kuma-api>=1.0.0", + ], + entry_points={ + "console_scripts": [ + "kumacli=kumacli.__main__:main", + ], + }, + keywords="uptime kuma monitoring cli", + project_urls={ + "Bug Reports": "https://github.com/yourusername/kumacli/issues", + "Source": "https://github.com/yourusername/kumacli", + }, +) \ No newline at end of file diff --git a/src/kumacli/__init__.py b/src/kumacli/__init__.py new file mode 100644 index 0000000..5156f9a --- /dev/null +++ b/src/kumacli/__init__.py @@ -0,0 +1,11 @@ +""" +KumaCLI - A command-line interface for Uptime Kuma +""" + +__version__ = "1.0.0" +__author__ = "KumaCLI Team" +__email__ = "info@kumacli.com" + +from kumacli.__main__ import main + +__all__ = ["main"] \ No newline at end of file diff --git a/src/kumacli/__main__.py b/src/kumacli/__main__.py new file mode 100755 index 0000000..956cbe6 --- /dev/null +++ b/src/kumacli/__main__.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +import argparse +import os +import sys +from datetime import datetime + +from kumacli.client import KumaClient +from kumacli.cmd.monitor import setup_monitor_parser, handle_monitor_command +from kumacli.cmd.maintenance import setup_maintenance_parser, handle_maintenance_command + + +def main(): + parser = argparse.ArgumentParser(description="Uptime Kuma CLI Client") + parser.add_argument( + "--url", help="Uptime Kuma server URL (can also use KUMA_URL env var)" + ) + parser.add_argument( + "--username", + help="Username for authentication (can also use KUMA_USERNAME env var)", + ) + parser.add_argument( + "--password", + help="Password for authentication (can also use KUMA_PASSWORD env var)", + ) + + subparsers = parser.add_subparsers(dest="resource", help="Resource to operate on") + + # Setup command parsers + monitor_parser = setup_monitor_parser(subparsers) + maintenance_parser = setup_maintenance_parser(subparsers) + + args = parser.parse_args() + + if not args.resource: + parser.print_help() + sys.exit(1) + + # Get configuration from environment variables or command line args + url = args.url or os.getenv("KUMA_URL") + username = args.username or os.getenv("KUMA_USERNAME") + password = args.password or os.getenv("KUMA_PASSWORD") + + if not url: + print( + "Error: URL is required. Provide --url or set KUMA_URL environment variable." + ) + sys.exit(1) + + client = KumaClient(url, username, password) + + if not client.connect(): + sys.exit(1) + + try: + success = False + if args.resource == "monitor": + success = handle_monitor_command(args, client) + elif args.resource == "maintenance": + success = handle_maintenance_command(args, client) + else: + parser.print_help() + sys.exit(1) + + if not success: + sys.exit(1) + + finally: + client.disconnect() + + +if __name__ == "__main__": + main() diff --git a/src/kumacli/client.py b/src/kumacli/client.py new file mode 100644 index 0000000..21f075f --- /dev/null +++ b/src/kumacli/client.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +import fnmatch +import re +from datetime import datetime, timedelta +from uptime_kuma_api import UptimeKumaApi + + +class KumaClient: + def __init__(self, url, username=None, password=None): + self.url = url + self.username = username + self.password = password + self.api = None + + def parse_duration(self, duration_str): + """Parse duration string like '90m', '1h', '3600s' into seconds""" + if not duration_str: + return 90 * 60 # Default 90 minutes in seconds + + match = re.match(r"^(\d+)([smh])$", duration_str.lower()) + if not match: + raise ValueError( + f"Invalid duration format: {duration_str}. Use format like '90m', '1h', '3600s'" + ) + + value, unit = match.groups() + value = int(value) + + if unit == "s": + return value + elif unit == "m": + return value * 60 + elif unit == "h": + return value * 3600 + + raise ValueError(f"Invalid duration unit: {unit}") + + def parse_start_time(self, start_str): + """Parse start time string or use current time if None""" + if not start_str: + return datetime.utcnow() + + # Try to parse ISO format first + try: + return datetime.fromisoformat(start_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) + except ValueError: + pass + + # Try common formats + formats = [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%d", + ] + + for fmt in formats: + try: + return datetime.strptime(start_str, fmt) + except ValueError: + continue + + raise ValueError( + f"Invalid start time format: {start_str}. Use ISO format or YYYY-MM-DD HH:MM:SS" + ) + + def connect(self): + """Connect and login to Uptime Kuma""" + try: + self.api = UptimeKumaApi(self.url) + if self.username and self.password: + result = self.api.login(self.username, self.password) + print(f"Connected to {self.url}") + return True + except Exception as e: + print(f"Failed to connect: {e}") + return False + + def disconnect(self): + """Disconnect from Uptime Kuma""" + if self.api: + self.api.disconnect() + + def find_monitors_by_pattern(self, patterns): + """Find monitor IDs by name patterns (case insensitive, supports wildcards)""" + try: + monitors = self.api.get_monitors() + matched_monitors = [] + + for pattern in patterns: + pattern_lower = pattern.lower() + for monitor in monitors: + monitor_name = monitor.get("name", "").lower() + if fnmatch.fnmatch(monitor_name, pattern_lower): + matched_monitors.append( + {"id": monitor.get("id"), "name": monitor.get("name")} + ) + + # Remove duplicates while preserving order + seen = set() + unique_monitors = [] + for monitor in matched_monitors: + if monitor["id"] not in seen: + seen.add(monitor["id"]) + unique_monitors.append(monitor) + + return unique_monitors + + except Exception as e: + print(f"Error finding monitors: {e}") + return [] + + def find_groups_by_pattern(self, patterns): + """Find group IDs by name patterns (case insensitive, supports wildcards)""" + try: + monitors = self.api.get_monitors() + groups = [m for m in monitors if m.get("type") == "group"] + matched_groups = [] + + for pattern in patterns: + pattern_lower = pattern.lower() + for group in groups: + group_name = group.get("name", "").lower() + if fnmatch.fnmatch(group_name, pattern_lower): + matched_groups.append( + {"id": group.get("id"), "name": group.get("name")} + ) + + # Remove duplicates while preserving order + seen = set() + unique_groups = [] + for group in matched_groups: + if group["id"] not in seen: + seen.add(group["id"]) + unique_groups.append(group) + + return unique_groups + + except Exception as e: + print(f"Error finding groups: {e}") + return [] + + def get_monitors_in_groups(self, group_patterns): + """Get all monitors that belong to specified groups""" + try: + monitors = self.api.get_monitors() + matched_groups = self.find_groups_by_pattern(group_patterns) + + if not matched_groups: + return [] + + print(f"Found {len(matched_groups)} matching groups:") + for group in matched_groups: + print(f" - {group['name']} (ID: {group['id']})") + + group_ids = {g["id"] for g in matched_groups} + group_members = [] + + for monitor in monitors: + # Check if monitor's parent is in our group list + if monitor.get("parent") in group_ids: + group_members.append(monitor) + + return group_members + + except Exception as e: + print(f"Error getting group members: {e}") + return [] diff --git a/src/kumacli/cmd/__init__.py b/src/kumacli/cmd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kumacli/cmd/maintenance.py b/src/kumacli/cmd/maintenance.py new file mode 100644 index 0000000..924da76 --- /dev/null +++ b/src/kumacli/cmd/maintenance.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 + +from datetime import datetime, timedelta +from kumacli.client import KumaClient + + +class MaintenanceCommands: + def __init__(self, client: KumaClient): + self.client = client + + def list_maintenances(self): + """List all maintenances""" + try: + maintenances = self.client.api.get_maintenances() + if not maintenances: + print("No maintenances found") + return + + print( + f"{'ID':<5} {'Title':<30} {'Strategy':<15} {'Active':<10} {'Description':<50}" + ) + print("-" * 115) + + for maintenance in maintenances: + maintenance_id = maintenance.get("id", "N/A") + title = maintenance.get("title", "N/A") + strategy = maintenance.get("strategy", "N/A") + active = "Active" if maintenance.get("active") else "Inactive" + description = maintenance.get("description", "N/A") + + print( + f"{maintenance_id:<5} {title:<30} {strategy:<15} {active:<10} {description:<50}" + ) + + except Exception as e: + print(f"Error listing maintenances: {e}") + + def add_maintenance( + self, + title, + description, + start_time=None, + duration="90m", + monitor_patterns=None, + group_patterns=None, + ): + """Add a new maintenance""" + try: + # Check if we have either monitor patterns or group patterns + if not monitor_patterns and not group_patterns: + print( + "Error: Either --monitor or --group flag is required. Specify at least one pattern." + ) + return + + matched_monitors = [] + + # Find monitors by patterns if specified + if monitor_patterns: + pattern_monitors = self.client.find_monitors_by_pattern( + monitor_patterns + ) + matched_monitors.extend(pattern_monitors) + + # Find monitors by groups if specified + if group_patterns: + group_monitors = self.client.get_monitors_in_groups(group_patterns) + # Convert to the same format as find_monitors_by_pattern + group_monitor_objs = [ + {"id": m.get("id"), "name": m.get("name")} for m in group_monitors + ] + matched_monitors.extend(group_monitor_objs) + + # Remove duplicates while preserving order + seen = set() + unique_monitors = [] + for monitor in matched_monitors: + if monitor["id"] not in seen: + seen.add(monitor["id"]) + unique_monitors.append(monitor) + + matched_monitors = unique_monitors + + if not matched_monitors: + print( + "Error: No monitors found matching the specified patterns or groups" + ) + return + + print(f"Found {len(matched_monitors)} matching monitors:") + for monitor in matched_monitors: + print(f" - {monitor['name']} (ID: {monitor['id']})") + + # Set default description if not provided + if not description: + monitor_names = [monitor["name"] for monitor in matched_monitors] + description = "Maintenance on:\n" + "\n".join(monitor_names) + + # Parse start time and duration + start_dt = self.client.parse_start_time(start_time) + duration_seconds = self.client.parse_duration(duration) + end_dt = start_dt + timedelta(seconds=duration_seconds) + + print( + f"Maintenance window: {start_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} - {end_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({duration})" + ) + + # Create the maintenance with single strategy and date range + maintenance_data = { + "title": title, + "description": description, + "strategy": "single", + "active": True, + "dateRange": [ + start_dt.strftime("%Y-%m-%d %H:%M:%S"), + end_dt.strftime("%Y-%m-%d %H:%M:%S"), + ], + } + + result = self.client.api.add_maintenance(**maintenance_data) + maintenance_id = result.get("maintenanceID") + + # Add monitors to maintenance + if maintenance_id: + print(f"Maintenance added successfully: {result}") + + # Prepare monitors list in the correct format for the API + monitors_list = [{"id": monitor["id"]} for monitor in matched_monitors] + + try: + result = self.client.api.add_monitor_maintenance( + maintenance_id, monitors_list + ) + print( + f"Successfully added {len(matched_monitors)} monitors to maintenance" + ) + print(f"API response: {result}") + except Exception as e: + print(f"Error: Failed to add monitors to maintenance: {e}") + print( + "This might be due to API compatibility issues or server configuration" + ) + + except Exception as e: + print(f"Error adding maintenance: {e}") + + def delete_maintenance(self, maintenance_id=None, delete_all=False): + """Delete a specific maintenance or all maintenances""" + try: + if delete_all: + # Get all maintenances first + maintenances = self.client.api.get_maintenances() + if not maintenances: + print("No maintenances found to delete") + return + + print(f"Found {len(maintenances)} maintenances to delete:") + for maintenance in maintenances: + print( + f" - {maintenance.get('title', 'N/A')} (ID: {maintenance.get('id', 'N/A')})" + ) + + # Delete all maintenances + deleted_count = 0 + for maintenance in maintenances: + try: + result = self.client.api.delete_maintenance( + maintenance.get("id") + ) + print( + f"Deleted maintenance '{maintenance.get('title', 'N/A')}' (ID: {maintenance.get('id')})" + ) + deleted_count += 1 + except Exception as e: + print( + f"Failed to delete maintenance '{maintenance.get('title', 'N/A')}': {e}" + ) + + print( + f"Successfully deleted {deleted_count} out of {len(maintenances)} maintenances" + ) + + elif maintenance_id: + # Delete specific maintenance + try: + # First get the maintenance info for confirmation + maintenance = self.client.api.get_maintenance(maintenance_id) + maintenance_title = maintenance.get("title", "N/A") + + result = self.client.api.delete_maintenance(maintenance_id) + print( + f"Successfully deleted maintenance '{maintenance_title}' (ID: {maintenance_id})" + ) + print(f"API response: {result}") + + except Exception as e: + print(f"Failed to delete maintenance ID {maintenance_id}: {e}") + else: + print( + "Error: Either --id or --all flag is required for delete operation" + ) + + except Exception as e: + print(f"Error during maintenance deletion: {e}") + + +def setup_maintenance_parser(subparsers): + """Setup maintenance command parser""" + maintenance_parser = subparsers.add_parser( + "maintenance", help="Maintenance operations" + ) + maintenance_subparsers = maintenance_parser.add_subparsers( + dest="maintenance_action", help="Maintenance actions" + ) + + # List maintenances command + maintenance_subparsers.add_parser("list", help="List all maintenances") + + # Delete maintenance command + delete_maintenance_parser = maintenance_subparsers.add_parser( + "delete", help="Delete maintenance(s)" + ) + delete_maintenance_parser.add_argument( + "--id", type=int, help="Maintenance ID to delete" + ) + delete_maintenance_parser.add_argument( + "--all", action="store_true", help="Delete all maintenances" + ) + + # Add maintenance command + add_maintenance_parser = maintenance_subparsers.add_parser( + "add", help="Add a new maintenance" + ) + add_maintenance_parser.add_argument( + "--title", + help='Maintenance title (defaults to "Update started on ")', + ) + add_maintenance_parser.add_argument("--description", help="Maintenance description") + add_maintenance_parser.add_argument( + "--start", + help="Start time (defaults to now, format: YYYY-MM-DD HH:MM:SS or ISO format)", + ) + add_maintenance_parser.add_argument( + "--duration", + default="90m", + help="Duration (default: 90m, format: 3600s, 1h, 60m)", + ) + add_maintenance_parser.add_argument( + "--monitor", + action="append", + help="Monitor name pattern to add to maintenance (supports wildcards like *NextCloud*, can be repeated)", + ) + add_maintenance_parser.add_argument( + "--group", + action="append", + help="Group name pattern to add all group members to maintenance (supports wildcards, can be repeated)", + ) + + return maintenance_parser + + +def handle_maintenance_command(args, client): + """Handle maintenance command execution""" + maintenance_commands = MaintenanceCommands(client) + + if args.maintenance_action == "list": + maintenance_commands.list_maintenances() + elif args.maintenance_action == "add": + title = ( + args.title + or f"Update started on {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}" + ) + maintenance_commands.add_maintenance( + title, + args.description, + args.start, + args.duration, + monitor_patterns=args.monitor, + group_patterns=args.group, + ) + elif args.maintenance_action == "delete": + maintenance_commands.delete_maintenance( + maintenance_id=args.id, delete_all=args.all + ) + else: + print("Unknown maintenance action. Use --help for usage information.") + return False + + return True diff --git a/src/kumacli/cmd/monitor.py b/src/kumacli/cmd/monitor.py new file mode 100644 index 0000000..a88d2ee --- /dev/null +++ b/src/kumacli/cmd/monitor.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 + +from kumacli.client import KumaClient + + +class MonitorCommands: + def __init__(self, client: KumaClient): + self.client = client + + def list_monitors(self, monitor_patterns=None, group_patterns=None): + """List all monitors or filter by patterns and/or groups""" + try: + all_monitors = self.client.api.get_monitors() + if not all_monitors: + print("No monitors found") + return + + monitors = all_monitors[:] # Start with all monitors + + # Filter by group if specified + if group_patterns: + group_monitors = self.client.get_monitors_in_groups(group_patterns) + if not group_monitors: + print("No monitors found in the specified groups") + return + monitor_ids = {m["id"] for m in group_monitors} + monitors = [m for m in monitors if m.get("id") in monitor_ids] + + # Filter monitors by name patterns if specified + if monitor_patterns: + matched_monitors = self.client.find_monitors_by_pattern( + monitor_patterns + ) + if not matched_monitors: + print("No monitors found matching the specified patterns") + return + pattern_monitor_ids = {m["id"] for m in matched_monitors} + monitors = [m for m in monitors if m.get("id") in pattern_monitor_ids] + + # Display results with group information + print( + f"{'ID':<5} {'Name':<25} {'Type':<12} {'Group':<20} {'URL':<35} {'Status':<10}" + ) + print("-" * 112) + + for monitor in monitors: + monitor_id = monitor.get("id", "N/A") + name = monitor.get("name", "N/A") + monitor_type = monitor.get("type", "N/A") + url = monitor.get("url", "N/A") + active = "Active" if monitor.get("active") else "Inactive" + + # Find parent group name + parent_id = monitor.get("parent") + parent_name = "None" + if parent_id: + parent_monitor = next( + (m for m in all_monitors if m.get("id") == parent_id), None + ) + if parent_monitor: + parent_name = parent_monitor.get("name", f"Group {parent_id}") + + print( + f"{monitor_id:<5} {name:<25} {monitor_type:<12} {parent_name:<20} {url:<35} {active:<10}" + ) + + except Exception as e: + print(f"Error listing monitors: {e}") + + +def setup_monitor_parser(subparsers): + """Setup monitor command parser""" + monitor_parser = subparsers.add_parser("monitor", help="Monitor operations") + monitor_subparsers = monitor_parser.add_subparsers( + dest="monitor_action", help="Monitor actions" + ) + + # List monitors command + list_monitors_parser = monitor_subparsers.add_parser( + "list", help="List all monitors" + ) + list_monitors_parser.add_argument( + "--monitor", + action="append", + help="Monitor name pattern to filter by (supports wildcards, can be repeated)", + ) + list_monitors_parser.add_argument( + "--group", + action="append", + help="Group name pattern to filter by (supports wildcards, can be repeated)", + ) + + return monitor_parser + + +def handle_monitor_command(args, client): + """Handle monitor command execution""" + monitor_commands = MonitorCommands(client) + + if args.monitor_action == "list": + monitor_commands.list_monitors( + monitor_patterns=args.monitor, group_patterns=args.group + ) + else: + print("Unknown monitor action. Use --help for usage information.") + return False + + return True