diff --git a/LogstashUI/.dockerignore b/.dockerignore similarity index 81% rename from LogstashUI/.dockerignore rename to .dockerignore index 99fa93f1..f6ef8fb5 100644 --- a/LogstashUI/.dockerignore +++ b/.dockerignore @@ -18,13 +18,11 @@ ENV *.swp *.swo -# Database *.sqlite3 -LogstashUI/data/db.sqlite3 -data/ +src/logstashui/data/ # Static files (will be collected during build) -staticfiles/ +src/logstashui/staticfiles/ # Node modules (will be installed during build) node_modules/ @@ -38,5 +36,5 @@ Thumbs.db # Docker LogstashUI/Dockerfile -docker-compose*.yml +docker/docker-compose.yml .dockerignore diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6164524c..3877cf8c 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,4 +1,4 @@ -name: Build & Push Docker Images +name: Build & Push LogstashUI Docker Image on: push: @@ -26,32 +26,19 @@ jobs: id: vars run: | USER="${{ secrets.DOCKERHUB_USERNAME }}" - APP_IMAGE="$USER/logstashui" - SIM_IMAGE="$USER/logstashagent" + IMAGE="$USER/logstashui" - echo "app_image=$APP_IMAGE" >> $GITHUB_OUTPUT - echo "sim_image=$SIM_IMAGE" >> $GITHUB_OUTPUT + echo "image=$IMAGE" >> $GITHUB_OUTPUT VERSION="${GITHUB_REF#refs/tags/v}" echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "app_tags=$APP_IMAGE:$VERSION,$APP_IMAGE:latest" >> $GITHUB_OUTPUT - echo "sim_tags=$SIM_IMAGE:$VERSION,$SIM_IMAGE:latest" >> $GITHUB_OUTPUT + echo "tags=$IMAGE:$VERSION,$IMAGE:latest" >> $GITHUB_OUTPUT - # ---- Image 1: Django app (LogstashUI) - - name: Build and push (LogstashUI) + - name: Build and push LogstashUI uses: docker/build-push-action@v6 with: - context: ./LogstashUI - file: ./LogstashUI/Dockerfile + context: . + file: ./docker/Dockerfile push: true - tags: ${{ steps.vars.outputs.app_tags }} - - # ---- Image 2: Logstash + FastAPI sidecar (LogstashAgent) - - name: Build and push (LogstashAgent) - uses: docker/build-push-action@v6 - with: - context: ./LogstashAgent - file: ./LogstashAgent/Dockerfile - push: true - tags: ${{ steps.vars.outputs.sim_tags }} \ No newline at end of file + tags: ${{ steps.vars.outputs.tags }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index df9a06c2..e32cfdce 100644 --- a/.gitignore +++ b/.gitignore @@ -58,13 +58,13 @@ cover/ # Django stuff: *.log local_settings.py -LogstashUI/data/db.sqlite3 +src/logstashui/data/db.sqlite3 db.sqlite3-journal -LogstashUI/LogstashUI/data/ -!LogstashUI/SNMP/data/ +src/logstashui/LogstashUI/data/ +!src/logstashui/SNMP/data/ -# LogstashAgent data directory (logs, etc.) -LogstashAgent/data/ +# logstashagent data directory (logs, etc.) +LogstashAgent/src/logstashagent/data/ # Flask stuff: instance/ @@ -191,7 +191,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -219,10 +219,11 @@ __marimo__/ # Streamlit .streamlit/secrets.toml -/LogstashUI/staticfiles/admin/ -/LogstashUI/staticfiles/css/ -/LogstashUI/staticfiles/django-browser-reload/ -/LogstashUI/staticfiles/django_htmx/ -/LogstashUI/staticfiles/images/ -/LogstashUI/staticfiles/js/ + +# LogstashUI +/src/logstashui/staticfiles/ +/src/logstashui/data/ /.plans/ + +# LogstashAgent (cloned from GitHub in host mode) +/LogstashAgent/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/LogstashUI.iml b/.idea/LogstashUI.iml deleted file mode 100644 index 06df6ce6..00000000 --- a/.idea/LogstashUI.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/aws.xml b/.idea/aws.xml deleted file mode 100644 index 03f1bb6e..00000000 --- a/.idea/aws.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/deployment.xml b/.idea/deployment.xml deleted file mode 100644 index 943a9d3f..00000000 --- a/.idea/deployment.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2da..00000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 53f91f87..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 7d38d267..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/sshConfigs.xml b/.idea/sshConfigs.xml deleted file mode 100644 index b85edbe8..00000000 --- a/.idea/sshConfigs.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/webServers.xml b/.idea/webServers.xml deleted file mode 100644 index d7893447..00000000 --- a/.idea/webServers.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..3be618d7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: local + hooks: + - id: add-license-headers + name: Add license headers + entry: uv run python scripts/add_license_headers.py + language: system + pass_filenames: false + - id: check-licenses + name: Check dependency licenses + entry: uv run python scripts/generate_notice.py + language: system + pass_filenames: false \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ed5267..b8ee69b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## [0.4.0] - Logstash Agent - 04/18/2026 + +### Added + +- Added centralized Logstash Agent management, including policy creation, enrollment, and agent-aware connection workflows. +- Added support for managing pipelines through Logstash Agent, including pipeline settings and policy-level pipeline organization. +- Added keystore management improvements, including password management, clearer visibility into sensitive fields, and support for deploying keystore-related changes independently. +- Added agent operational controls such as restart support, upgrade support, health reporting, and richer status details. +- Added live status and log streaming improvements to make troubleshooting and monitoring easier. +- Added validation to help catch missing Logstash installations, invalid paths, and other configuration issues earlier. +- Added clearer deployment feedback, including indicators for undeployed changes and warnings for situations that require special handling. +- Added an in-app documentation experience. +- Added expanded automated testing and coverage reporting. + +### Changed + +- Improved the overall Logstash Agent experience across enrollment, policy management, deployment workflows, and health visibility. +- Improved the safety of deployments so failed changes are handled more cleanly and unnecessary restarts are reduced. +- Improved the UI across connection management, policy views, agent health/status displays, and navigation. +- Improved security for communication between LogstashUI and Logstash Agent. +- Reworked packaging and project structure to support the ongoing evolution of Logstash Agent. +- Moved documentation into the application for a more integrated user experience. +- Updated SNMP-related UI flows and made minor usability improvements. + +### Fixed + +- Fixed navigation state so collapsed navigation sections now persist more reliably. +- Fixed several deployment and keystore edge cases that could cause failed or blocked changes. +- Fixed pipeline change detection issues so edits are recognized more reliably. +- Fixed agent status and health display issues. +- Fixed several policy, pipeline, and configuration UI bugs. +- Fixed monitoring behavior to better align with centralized connection workflows. +- Fixed documentation image/link issues. +- Fixed a number of packaging, path, logging, and platform-specific issues introduced during refactoring. + + ## [0.3.5] - 03/23/2026 ### Changed - Disabled light mode entirely diff --git a/LogstashAgent/.dockerignore b/LogstashAgent/.dockerignore deleted file mode 100644 index ebf9f935..00000000 --- a/LogstashAgent/.dockerignore +++ /dev/null @@ -1,36 +0,0 @@ -# Git -.git -.gitignore - -# Python -__pycache__ -*.py[cod] -*$py.class -*.so -.Python -.venv -venv -ENV - -# IDE -.idea -.vscode -*.swp -*.swo - -# Logs -*.log - -# OS -.DS_Store -Thumbs.db - -# Docker -Dockerfile -docker-compose*.yml -.dockerignore - -# Test files -tests/ -test_*.py -*_test.py \ No newline at end of file diff --git a/LogstashAgent/Dockerfile b/LogstashAgent/Dockerfile deleted file mode 100644 index 62230d53..00000000 --- a/LogstashAgent/Dockerfile +++ /dev/null @@ -1,71 +0,0 @@ -FROM docker.elastic.co/logstash/logstash:9.3.1 - -# Switch to root to install dependencies -USER root - -# Install Python and system dependencies -RUN microdnf install -y python3 python3-pip && \ - microdnf clean all - -# Set work directory for FastAPI app -WORKDIR /app - -# Copy Python requirements and install -COPY requirements.txt . -RUN pip3 install --no-cache-dir -r requirements.txt - -# Copy FastAPI application -COPY main.py . -COPY slots.py . -COPY log_analyzer.py . -COPY logstash_api.py . -COPY logstash_supervisor.py . -# Note: logstashagent.yml is mounted at runtime via docker-compose volumes -# Config is read from /app/logstashui.yml (preferred) or /app/logstashui.example.yml - -# Create directories for Logstash configs and pipelines -RUN mkdir -p /etc/logstash \ - /etc/logstash/conf.d \ - /etc/logstash/config \ - /etc/logstash/pipeline-metadata \ - /var/log/logstash \ - /tmp - -# Copy Logstash configuration files -COPY config/logstash.yml /etc/logstash/logstash.yml -COPY config/pipelines.yml /etc/logstash/pipelines.yml -COPY config/jvm.options /etc/logstash/jvm.options -COPY config/log4j2.properties /etc/logstash/log4j2.properties - -# Copy simulation pipeline configs -COPY config/simulate_start.conf /etc/logstash/config/simulate_start.conf -COPY config/simulate_end.conf /etc/logstash/config/simulate_end.conf - -# Set permissions -RUN chown -R logstash:logstash /etc/logstash /var/log/logstash /app /tmp && \ - chmod -R 755 /etc/logstash /var/log/logstash && \ - chmod 1777 /tmp - -# Copy entrypoint script -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh && \ - chown logstash:logstash /entrypoint.sh - -# Set default LOGSTASH_URL for local development -# Uses host.docker.internal which works cross-platform with extra_hosts in docker-compose -# Can be overridden at build time or runtime -ARG LOGSTASH_URL=http://host.docker.internal:8080 -ENV LOGSTASH_URL=${LOGSTASH_URL} -ENV MALLOC_ARENA_MAX=4 - -# Switch back to logstash user -USER logstash - -# Expose ports -# 9600: Logstash API -# 9449: Logstash HTTP input (for sample events) -# 9500: FastAPI sidecar API -EXPOSE 9600 9449 9500 - -# Use custom entrypoint -ENTRYPOINT ["/entrypoint.sh"] diff --git a/LogstashAgent/config/log4j2.properties b/LogstashAgent/config/log4j2.properties deleted file mode 100644 index ceb46225..00000000 --- a/LogstashAgent/config/log4j2.properties +++ /dev/null @@ -1,49 +0,0 @@ -# JSON appender for machine-readable logs -# Total storage: 20MB per file * 5 files = 100MB maximum -appender.json_rolling.type = RollingFile -appender.json_rolling.name = json_rolling -appender.json_rolling.fileName = /var/log/logstash/logstash-json.log -appender.json_rolling.filePattern = /var/log/logstash/logstash-json-%i.log -appender.json_rolling.layout.type = JSONLayout -appender.json_rolling.layout.compact = true -appender.json_rolling.layout.eventEol = true -appender.json_rolling.layout.properties = true -appender.json_rolling.policies.type = Policies -appender.json_rolling.policies.size.type = SizeBasedTriggeringPolicy -appender.json_rolling.policies.size.size = 20MB -appender.json_rolling.strategy.type = DefaultRolloverStrategy -appender.json_rolling.strategy.max = 5 -appender.json_rolling.strategy.action.type = Delete -appender.json_rolling.strategy.action.basepath = /var/log/logstash -appender.json_rolling.strategy.action.maxDepth = 1 -appender.json_rolling.strategy.action.condition.type = IfFileName -appender.json_rolling.strategy.action.condition.glob = logstash-json-*.log -appender.json_rolling.strategy.action.condition.nested.type = IfAccumulatedFileCount -appender.json_rolling.strategy.action.condition.nested.exceeds = 5 - -# Console appender for immediate visibility -appender.console.type = Console -appender.console.name = plain_console -appender.console.layout.type = PatternLayout -appender.console.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %m%n - -# Root logger configuration -rootLogger.level = info -rootLogger.appenderRef.console.ref = plain_console -rootLogger.appenderRef.json.ref = json_rolling - -# Pipeline-related events -logger.pipeline.name = logstash.pipeline -logger.pipeline.level = info - -# Filter execution warnings and errors -logger.filters.name = logstash.filters -logger.filters.level = info - -# Output plugin issues -logger.outputs.name = logstash.outputs -logger.outputs.level = info - -# Ruby filter errors (INFO only, no DEBUG) -logger.ruby.name = logstash.filters.ruby -logger.ruby.level = info diff --git a/LogstashAgent/config/logstash.yml b/LogstashAgent/config/logstash.yml deleted file mode 100644 index a5fe5711..00000000 --- a/LogstashAgent/config/logstash.yml +++ /dev/null @@ -1,18 +0,0 @@ -# Logstash Configuration -# Enable automatic config reloading for hot-swapping pipelines -config.reload.automatic: true -config.reload.interval: 2s - -# Disable X-Pack management (we're managing pipelines via files) -xpack.management.enabled: false - -# API settings -api.http.host: "0.0.0.0" -api.http.port: 9600 - -# Logging -#log.level: info -#path.logs: /var/log/logstash - -# Multi-pipeline mode: Logstash will automatically read pipelines.yml -# when it exists in the settings directory and no path.config is set diff --git a/LogstashAgent/config/pipelines.yml b/LogstashAgent/config/pipelines.yml deleted file mode 100644 index 8bd03843..00000000 --- a/LogstashAgent/config/pipelines.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Pipelines Configuration -# This file will be dynamically updated by the FastAPI sidecar - -# Static simulation pipelines -- pipeline.id: simulate-start - pipeline.workers: 1 - path.config: "/etc/logstash/config/simulate_start.conf" - -- pipeline.id: simulate-end - pipeline.workers: 1 - path.config: "/etc/logstash/config/simulate_end.conf" - -# Dynamic filter pipelines will be added below via API diff --git a/LogstashAgent/config/simulate_end.conf b/LogstashAgent/config/simulate_end.conf deleted file mode 100644 index 412abed8..00000000 --- a/LogstashAgent/config/simulate_end.conf +++ /dev/null @@ -1,34 +0,0 @@ -input { pipeline { address => "simulate-end" } } - -filter { - # Mark this as the final step - ruby { - id => "sim_end_ruby" - code => "event.set('[simulation][step]', event.get('[simulation][step]').to_i + 1); event.set('[simulation][id]', 'final')" - } -} - -output { - # Send final message to StreamSimulate - # URL is set via LOGSTASH_URL env var - # HTTP (standalone): http://host.docker.internal:8080 - # HTTPS (docker-compose): https://host.docker.internal - http { - id => "sim_end_http" # Fixed: was duplicate 'sim_start_http' - http_method => "post" - url => "${LOGSTASH_URL:http://host.docker.internal:8080}/ConnectionManager/StreamSimulate/" - format => "json" - content_type => "application/json" - ssl_verification_mode => "none" - - # Memory leak prevention settings - pool_max => 10 # Limit connection pool size (default: 50) - pool_max_per_route => 5 # Max connections per route (default: 25) - keepalive => false # Reuse connections - automatic_retries => 1 # Limit retries (default: 3) - retry_non_idempotent => false # Don't retry POST on failure - connect_timeout => 10 # Connection timeout in seconds - request_timeout => 30 # Request timeout in seconds - socket_timeout => 30 # Socket read timeout - } -} diff --git a/LogstashAgent/config/simulate_start.conf b/LogstashAgent/config/simulate_start.conf deleted file mode 100644 index c9f52e8a..00000000 --- a/LogstashAgent/config/simulate_start.conf +++ /dev/null @@ -1,67 +0,0 @@ -input { - http { - port => 9449 - codec => json { - ecs_compatibility => disabled - } - } -} - -filter { - mutate { - id => "sim_start_mutate" - rename => {"url" => "[@metadata][pruned][url]"} - rename => {"http" => "[@metadata][pruned][http]"} - rename => {"user_agent" => "[@metadata][pruned][user_agent]"} - remove_field => ["[host][ip]"] - add_field => {"[host][name]" => "LOGSTASH-HOST"} - } - - # Add step 0 to indicate this is the original input - mutate { - id => "sim_start_mutate_simulation" - add_field => { - "[simulation][step]" => "0" - "[simulation][id]" => "original" - } - } -} - -output { - # Send original message to StreamSimulate - http { - id => "sim_start_http" - http_method => "post" - url => "${LOGSTASH_URL:http://host.docker.internal:8080}/ConnectionManager/StreamSimulate/" - format => "json" - content_type => "application/json" - ssl_verification_mode => "none" - - # Memory leak prevention settings - pool_max => 10 # Limit connection pool size (default: 50) - pool_max_per_route => 5 # Max connections per route (default: 25) - keepalive => true # Reuse connections - automatic_retries => 1 # Limit retries (default: 3) - retry_non_idempotent => false # Don't retry POST on failure - connect_timeout => 10 # Connection timeout in seconds - request_timeout => 30 # Request timeout in seconds - socket_timeout => 30 # Socket read timeout - } - - # Route to appropriate slot - if [slot] == 1 { - pipeline { send_to => "slot1-filter1" } - } else if [slot] == 2 { - pipeline { send_to => "slot2-filter1" } - } else if [slot] == 3 { - pipeline { send_to => "slot3-filter1" } - } else if [slot] == 4 { - pipeline { send_to => "slot4-filter1" } - } else if [slot] == 5 { - pipeline { send_to => "slot5-filter1" } - } else if [slot] == 6 { - pipeline { send_to => "slot6-filter1" } - } else { - stdout { } - } -} diff --git a/LogstashAgent/entrypoint.sh b/LogstashAgent/entrypoint.sh deleted file mode 100644 index 0c2ddd86..00000000 --- a/LogstashAgent/entrypoint.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -set -e - -echo "==========================================" -echo " Starting LogstashAgent" -echo "==========================================" - -# LOGSTASH_URL is set via Dockerfile ENV (default: http://127.0.0.1:8080) -# Can be overridden via docker-compose or docker run -e -# Logstash will use ${LOGSTASH_URL:default} syntax in config files for environment variable substitution -echo "LOGSTASH_URL: $LOGSTASH_URL" - -# Ensure log directory exists and has proper permissions -echo "Setting up log directory..." -mkdir -p /var/log/logstash -chmod 755 /var/log/logstash - -# Verify log4j2.properties exists -if [ -f /etc/logstash/log4j2.properties ]; then - echo "+ log4j2.properties found at /etc/logstash/log4j2.properties" -else - echo "- WARNING: log4j2.properties not found!" -fi - -# LogstashAgent will start and supervise Logstash via Python -echo "Starting FastAPI sidecar (which will supervise Logstash)..." -echo "==========================================" -echo " LogstashAgent starting..." -echo " - Logstash will be supervised by FastAPI" -echo " - Logstash API: http://localhost:9600" -echo " - Simulation HTTP Input: http://localhost:9449" -echo " - FastAPI Sidecar: http://localhost:9500" -echo "==========================================" - -cd /app -# Listen on 0.0.0.0 so nginx can access via Docker network -exec uvicorn main:app --host 0.0.0.0 --port 9500 diff --git a/LogstashAgent/log_analyzer.py b/LogstashAgent/log_analyzer.py deleted file mode 100644 index d337efc6..00000000 --- a/LogstashAgent/log_analyzer.py +++ /dev/null @@ -1,348 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -""" -Logstash Log Analyzer Library - -This module provides functions to analyze Logstash JSON logs for pipeline health -monitoring and error detection during simulations. - -Log files are expected to be in JSON format (one JSON object per line) located at: -/var/log/logstash/logstash-json.log and rotated files logstash-json-*.log -""" - -import json -import glob -import logging -from pathlib import Path -from typing import List, Dict, Any, Optional - -# Configure logging -logger = logging.getLogger(__name__) - -# Default log directory -LOG_DIR = "/var/log/logstash" -LOG_PATTERN = "logstash-json*.log" - - -def _read_json_logs(log_dir: str = LOG_DIR, pattern: str = LOG_PATTERN, - max_lines: Optional[int] = None, reverse: bool = True) -> List[Dict[str, Any]]: - """ - Read and parse JSON log files. - - Args: - log_dir: Directory containing log files - pattern: Glob pattern for log files - max_lines: Maximum number of lines to read (None for all) - reverse: If True, read files in reverse order (newest first) and read from end of file - - Returns: - List of parsed JSON log entries - """ - search_path = str(Path(log_dir) / pattern) - log_files = sorted(glob.glob(search_path)) - - logger.debug(f"Searching for logs: {search_path}") - logger.debug(f"Found {len(log_files)} log files: {log_files}") - - if not log_files: - return [] - - if reverse: - log_files = log_files[::-1] - - logs = [] - lines_read = 0 - - for log_file in log_files: - try: - # If reading in reverse and we have a max_lines limit, read from end of file - if reverse and max_lines: - # Read last N lines efficiently using tail-like approach - with open(log_file, 'rb') as f: - # Seek to end - f.seek(0, 2) - file_size = f.tell() - - # Estimate bytes per line (assume ~500 bytes average) - estimated_bytes = (max_lines - lines_read) * 500 - seek_pos = max(0, file_size - estimated_bytes) - f.seek(seek_pos) - - # Read and decode - content = f.read().decode('utf-8', errors='ignore') - file_lines = content.split('\n') - - # If we didn't start at beginning, skip first partial line - if seek_pos > 0: - file_lines = file_lines[1:] - - # Reverse to get newest first - file_lines.reverse() - - for line in file_lines: - line = line.strip() - if not line: - continue - - try: - log_entry = json.loads(line) - logs.append(log_entry) - lines_read += 1 - - if lines_read >= max_lines: - logger.debug(f"Read {lines_read} lines from {log_file}") - return logs - except json.JSONDecodeError: - continue - else: - # Original sequential reading for non-reverse or unlimited reads - with open(log_file, 'r', encoding='utf-8') as f: - file_lines = 0 - for line in f: - line = line.strip() - if not line: - continue - - try: - log_entry = json.loads(line) - logs.append(log_entry) - lines_read += 1 - file_lines += 1 - - if max_lines and lines_read >= max_lines: - logger.debug(f"Read {file_lines} lines from {log_file}") - return logs - except json.JSONDecodeError as e: - # Skip malformed JSON lines - continue - logger.debug(f"Read {file_lines} lines from {log_file}") - except (IOError, OSError) as e: - logger.error(f"Error reading {log_file}: {e}") - continue - - return logs - - -def get_running_pipelines(log_dir: str = LOG_DIR) -> Optional[Dict[str, Any]]: - """ - Get the most recent pipeline status from logs. - - Searches for the last log entry with 'running_pipelines' field to determine - which pipelines are currently running. - - Args: - log_dir: Directory containing log files - - Returns: - Dictionary containing: - - running_pipelines: List of pipeline IDs that are running - - non_running_pipelines: List of pipeline IDs that are not running - - count: Total count of running pipelines - - timestamp: When this status was logged - - Returns None if no pipeline status found in logs. - - Example: - { - "running_pipelines": ["simulate-end", "simulate-start", "slot1-filter1"], - "non_running_pipelines": [], - "count": 3, - "timestamp": 1771269108232 - } - """ - # Read recent logs (200 lines should be enough for pipeline status) - logs = _read_json_logs(log_dir=log_dir, max_lines=200, reverse=True) - - logger.debug(f"Read {len(logs)} log entries from {log_dir}") - - # Collect ALL entries with running_pipelines and find the most recent by timestamp - pipeline_status_entries = [] - - for log_entry in logs: - log_event = log_entry.get('logEvent', {}) - - if 'running_pipelines' in log_event: - timestamp = log_entry.get('timeMillis', 0) - pipeline_status_entries.append({ - 'running_pipelines': log_event.get('running_pipelines', []), - 'non_running_pipelines': log_event.get('non_running_pipelines', []), - 'count': log_event.get('count', 0), - 'timestamp': timestamp, - 'level': log_entry.get('level'), - 'message': log_event.get('message'), - 'raw_event': log_event - }) - - if not pipeline_status_entries: - logger.warning(f"No running_pipelines found in {len(logs)} log entries") - return None - - # Sort by timestamp descending (most recent first) and take the first one - pipeline_status_entries.sort(key=lambda x: x['timestamp'], reverse=True) - result = pipeline_status_entries[0] - - # Debug: show the raw log entry - logger.debug(f"Found {len(pipeline_status_entries)} pipeline status entries") - logger.debug(f"Most recent timestamp: {result['timestamp']}") - logger.debug(f"Raw logEvent: {json.dumps(result['raw_event'], indent=2)}") - - # Remove raw_event from result before returning - del result['raw_event'] - - # Check for FailedAction errors that occurred after this status timestamp - # and remove those pipelines from the running list - status_timestamp = result['timestamp'] - failed_pipelines = set() - - for log_entry in logs: - log_timestamp = log_entry.get('timeMillis', 0) - - # Only check logs newer than or equal to the status timestamp - if log_timestamp < status_timestamp: - continue - - if log_entry.get('level') == 'ERROR': - log_event = log_entry.get('logEvent', {}) - action_type = log_event.get('action_type', '') - - if 'FailedAction' in action_type: - pipeline_id = log_event.get('id') - if pipeline_id: - failed_pipelines.add(pipeline_id) - logger.debug(f"Found FailedAction for pipeline {pipeline_id} at timestamp {log_timestamp}") - - # Remove failed pipelines from running_pipelines list - if failed_pipelines: - original_running = result['running_pipelines'][:] - result['running_pipelines'] = [p for p in result['running_pipelines'] if p not in failed_pipelines] - logger.warning(f"Removed {len(failed_pipelines)} failed pipelines from running list: {failed_pipelines}") - logger.debug(f"Running pipelines before: {original_running}") - logger.debug(f"Running pipelines after: {result['running_pipelines']}") - - logger.debug(f"Found running_pipelines: {result['running_pipelines']}") - return result - - -def find_related_logs(pipeline_id: str, log_dir: str = LOG_DIR, - max_entries: int = 100, - min_level: str = "WARN", - min_timestamp: Optional[int] = None) -> List[Dict[str, Any]]: - """ - Find all log entries related to a specific pipeline. - - Searches logs for entries that mention the given pipeline_id, typically - used to find errors, warnings, and other issues related to a pipeline. - - Args: - pipeline_id: The pipeline ID to search for (e.g., "slot4-filter1") - log_dir: Directory containing log files - max_entries: Maximum number of log entries to return - min_level: Minimum log level to include (DEBUG, INFO, WARN, ERROR) - min_timestamp: Optional minimum timestamp in milliseconds. Only logs at or after this time will be included. - - Returns: - List of log entries related to the pipeline, sorted by timestamp (newest first). - Each entry includes the full log structure with fields like: - - level: Log level (WARN, ERROR, etc.) - - loggerName: Logger that produced the message - - timeMillis: Timestamp in milliseconds - - thread: Thread name (often contains pipeline info) - - pipeline.id: Pipeline ID (if present) - - plugin.id: Plugin ID (if present) - - logEvent: The actual log event data including message and event details - - Example: - [ - { - "level": "WARN", - "loggerName": "org.logstash.dissect.Dissector", - "timeMillis": 1771269132430, - "thread": "[slot4-filter1]>worker0", - "pipeline.id": "slot4-filter1", - "logEvent": { - "message": "Dissector mapping, field found in event but it was empty", - "field": "url.original", - "event": {...} - } - } - ] - """ - # Define log level hierarchy - level_priority = { - 'DEBUG': 0, - 'INFO': 1, - 'WARN': 2, - 'ERROR': 3, - 'FATAL': 4 - } - - min_priority = level_priority.get(min_level.upper(), 0) - - # Read recent logs (search more lines for pipeline-specific logs) - logs = _read_json_logs(log_dir=log_dir, max_lines=5000, reverse=True) - - related_logs = [] - - for log_entry in logs: - # Filter by timestamp if min_timestamp is provided - if min_timestamp is not None: - log_timestamp = log_entry.get('timeMillis', 0) - if log_timestamp < min_timestamp: - continue # Skip logs older than the minimum timestamp - - # Check if this log entry is related to the pipeline - is_related = False - - # Check pipeline.id field - if log_entry.get('pipeline.id') == pipeline_id: - is_related = True - - # Check thread name (often contains pipeline ID like "[slot4-filter1]>worker0") - thread = log_entry.get('thread', '') - if pipeline_id in thread: - is_related = True - - # Check logEvent for pipeline references - log_event = log_entry.get('logEvent', {}) - if isinstance(log_event, dict): - # Check if event data contains run_id or slot info that matches - event_data = log_event.get('event', {}) - if isinstance(event_data, dict): - # Check snapshots for pipeline references - snapshots = event_data.get('snapshots', {}) - if isinstance(snapshots, dict) and any(pipeline_id in str(v) for v in snapshots.values()): - is_related = True - - if is_related: - # Check log level - entry_level = log_entry.get('level', 'INFO').upper() - entry_priority = level_priority.get(entry_level, 0) - - if entry_priority >= min_priority: - related_logs.append(log_entry) - - if len(related_logs) >= max_entries: - break - - return related_logs - - -def is_pipeline_running(pipeline_id: str, log_dir: str = LOG_DIR) -> bool: - """ - Check if a specific pipeline is currently running. - - Args: - pipeline_id: The pipeline ID to check - log_dir: Directory containing log files - - Returns: - True if the pipeline is in the running_pipelines list, False otherwise - """ - status = get_running_pipelines(log_dir=log_dir) - - if not status: - return False - - return pipeline_id in status.get('running_pipelines', []) \ No newline at end of file diff --git a/LogstashAgent/logstash_api.py b/LogstashAgent/logstash_api.py deleted file mode 100644 index ad6ea752..00000000 --- a/LogstashAgent/logstash_api.py +++ /dev/null @@ -1,591 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -""" -Logstash API SDK - -Provides a clean interface to interact with the Logstash Node Stats API. -This is more reliable than parsing logs for detecting pipeline state. - -API Documentation: https://www.elastic.co/docs/api/doc/logstash/operation/operation-nodestatspipeline -""" - -import logging -from typing import Dict, Any, Optional, List -import httpx -import time - - -logger = logging.getLogger(__name__) - -# Logstash API configuration -LOGSTASH_API_HOST = "localhost" -LOGSTASH_API_PORT = 9600 -LOGSTASH_API_BASE_URL = f"http://{LOGSTASH_API_HOST}:{LOGSTASH_API_PORT}" - -# Shared HTTP client for connection pooling -# This prevents creating/destroying clients on every API call, which causes connection accumulation -_shared_client: Optional[httpx.Client] = None -_shared_client_lock = None # Will be initialized when needed - - -def _get_shared_client(timeout: float = 5.0) -> httpx.Client: - """ - Get or create the shared HTTP client for connection pooling. - - This prevents OOM issues caused by creating/destroying httpx.Client instances - on every API call. The shared client maintains a connection pool that's reused. - - Args: - timeout: Request timeout in seconds - - Returns: - Shared httpx.Client instance - """ - global _shared_client, _shared_client_lock - - # Lazy import to avoid circular dependency - if _shared_client_lock is None: - from threading import Lock - _shared_client_lock = Lock() - - with _shared_client_lock: - if _shared_client is None: - # Create shared client with connection limits to prevent pool growth - _shared_client = httpx.Client( - timeout=timeout, - limits=httpx.Limits( - max_connections=10, # Total connection pool size - max_keepalive_connections=5 # Connections to keep alive - ) - ) - logger.info("Created shared LogstashAPI client with connection pooling") - return _shared_client - - -class LogstashAPIError(Exception): - """Base exception for Logstash API errors""" - pass - - -class PipelineNotFoundError(LogstashAPIError): - """Raised when a pipeline is not found""" - pass - - -class LogstashAPI: - """ - SDK for interacting with the Logstash Node Stats API. - - This provides methods to query pipeline statistics, detect pipeline state, - and monitor pipeline health without relying on log parsing. - - By default, uses a shared HTTP client for connection pooling to prevent OOM. - """ - - def __init__(self, base_url: str = LOGSTASH_API_BASE_URL, timeout: float = 5.0, use_shared_client: bool = True): - """ - Initialize the Logstash API client. - - Args: - base_url: Base URL for the Logstash API (default: http://localhost:9600) - timeout: Request timeout in seconds (default: 5.0) - use_shared_client: Use shared connection pool (default: True, recommended for production) - """ - self.base_url = base_url.rstrip('/') - self.timeout = timeout - self.use_shared_client = use_shared_client - - if use_shared_client: - # Use shared client for connection pooling (prevents OOM) - self.client = _get_shared_client(timeout) - self._owns_client = False - else: - # Create dedicated client (only for testing or special cases) - self.client = httpx.Client(timeout=timeout) - self._owns_client = True - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - # Only close if we own the client (not using shared pool) - if self._owns_client: - self.client.close() - - def close(self): - """Close the HTTP client (only if not using shared pool)""" - if self._owns_client: - self.client.close() - - def get_node_info(self) -> Dict[str, Any]: - """ - Get general node information. - - Returns: - Dict containing node information (version, host, etc.) - - Raises: - LogstashAPIError: If the request fails - """ - try: - response = self.client.get(f"{self.base_url}/") - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise LogstashAPIError(f"Failed to get node info: {e}") - - def get_health_report(self) -> Dict[str, Any]: - """ - Get health report from Logstash. - - Returns: - Dict containing health report with pipeline indicators - - Raises: - LogstashAPIError: If the request fails - """ - try: - response = self.client.get(f"{self.base_url}/_node/health_report") - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise LogstashAPIError(f"Failed to get health report: {e}") - - def get_node_stats(self) -> Dict[str, Any]: - """ - Get node statistics. - - Returns: - Dict containing node statistics - - Raises: - LogstashAPIError: If the request fails - """ - try: - response = self.client.get(f"{self.base_url}/_node/stats") - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise LogstashAPIError(f"Failed to get node stats: {e}") - - def get_running_pipelines_from_health(self) -> List[str]: - """ - Get list of running pipeline names from health report. - Falls back to list_pipelines() if health_report endpoint is not available. - - Returns: - List of pipeline names that appear in the health report or stats - - Raises: - LogstashAPIError: If the request fails - """ - try: - health = self.get_health_report() - indicators = health.get('indicators', {}).get('pipelines', {}).get('indicators', {}) - return list(indicators.keys()) - except LogstashAPIError as e: - # Health report endpoint not available (404) - fallback to list_pipelines - if '404' in str(e): - logger.debug("Health report endpoint not available, falling back to list_pipelines()") - return self.list_pipelines() - raise - except Exception as e: - raise LogstashAPIError(f"Failed to parse health report: {e}") - - def get_all_pipeline_stats(self) -> Dict[str, Any]: - """ - Get statistics for all pipelines. - - Returns: - Dict containing stats for all pipelines - - Raises: - LogstashAPIError: If the request fails - """ - try: - response = self.client.get(f"{self.base_url}/_node/stats/pipelines") - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise LogstashAPIError(f"Failed to get pipeline stats: {e}") - - def get_pipeline_stats(self, pipeline_name: str) -> Dict[str, Any]: - """ - Get statistics for a specific pipeline. - - Args: - pipeline_name: Name of the pipeline - - Returns: - Dict containing pipeline statistics - - Raises: - PipelineNotFoundError: If the pipeline doesn't exist - LogstashAPIError: If the request fails - """ - try: - response = self.client.get(f"{self.base_url}/_node/stats/pipelines/{pipeline_name}") - - if response.status_code == 404: - raise PipelineNotFoundError(f"Pipeline '{pipeline_name}' not found") - - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404: - raise PipelineNotFoundError(f"Pipeline '{pipeline_name}' not found") - raise LogstashAPIError(f"Failed to get pipeline stats for '{pipeline_name}': {e}") - - def list_pipelines(self) -> List[str]: - """ - Get a list of all pipeline names currently loaded in Logstash. - - Returns: - List of pipeline names - - Raises: - LogstashAPIError: If the request fails - """ - try: - stats = self.get_all_pipeline_stats() - pipelines = stats.get('pipelines', {}) - return list(pipelines.keys()) - except LogstashAPIError: - raise - - def is_pipeline_running(self, pipeline_name: str) -> bool: - """ - Check if a pipeline is currently running. - - A pipeline is considered "running" if: - 1. It exists in the API response - 2. It has processed at least one event OR has active workers - - Args: - pipeline_name: Name of the pipeline to check - - Returns: - True if the pipeline is running, False otherwise - """ - try: - stats = self.get_pipeline_stats(pipeline_name) - - # Extract pipeline data - pipeline_data = stats.get('pipelines', {}).get(pipeline_name, {}) - - if not pipeline_data: - return False - - # Check if pipeline has events (indicates it's active) - events = pipeline_data.get('events', {}) - events_in = events.get('in', 0) - events_out = events.get('out', 0) - - # Pipeline is running if it exists and has processed events - # OR if it has workers configured (even if no events yet) - has_events = events_in > 0 or events_out > 0 - - # Also check if reloads section exists (indicates pipeline is loaded) - reloads = pipeline_data.get('reloads', {}) - has_reloads_data = reloads is not None - - return has_events or has_reloads_data - - except PipelineNotFoundError: - return False - except LogstashAPIError as e: - logger.error(f"Error checking if pipeline '{pipeline_name}' is running: {e}") - return False - - def get_pipeline_event_counts(self, pipeline_name: str) -> Dict[str, int]: - """ - Get event counts for a pipeline. - - Args: - pipeline_name: Name of the pipeline - - Returns: - Dict with keys: 'in', 'filtered', 'out', 'duration_in_millis', 'queue_push_duration_in_millis' - - Raises: - PipelineNotFoundError: If the pipeline doesn't exist - LogstashAPIError: If the request fails - """ - try: - stats = self.get_pipeline_stats(pipeline_name) - pipeline_data = stats.get('pipelines', {}).get(pipeline_name, {}) - events = pipeline_data.get('events', {}) - - return { - 'in': events.get('in', 0), - 'filtered': events.get('filtered', 0), - 'out': events.get('out', 0), - 'duration_in_millis': events.get('duration_in_millis', 0), - 'queue_push_duration_in_millis': events.get('queue_push_duration_in_millis', 0) - } - except (PipelineNotFoundError, LogstashAPIError): - raise - - def wait_for_pipeline_activity( - self, - pipeline_name: str, - initial_event_count: Optional[int] = None, - timeout: float = 10.0 - ) -> bool: - """ - Wait for a pipeline to show activity (event count increase). - - This is useful for detecting when a newly created pipeline has started processing. - - Args: - pipeline_name: Name of the pipeline - initial_event_count: Expected initial event count (if None, will fetch current count) - timeout: Maximum time to wait in seconds - - Returns: - True if activity detected, False if timeout - """ - start_time = time.time() - - # Get initial count if not provided - if initial_event_count is None: - try: - counts = self.get_pipeline_event_counts(pipeline_name) - initial_event_count = counts['in'] - except PipelineNotFoundError: - initial_event_count = 0 - - while time.time() - start_time < timeout: - try: - counts = self.get_pipeline_event_counts(pipeline_name) - current_count = counts['in'] - - if current_count > initial_event_count: - logger.info(f"Pipeline '{pipeline_name}' activity detected: {current_count} events") - return True - - except PipelineNotFoundError: - pass - - time.sleep(0.5) - - return False - - def detect_pipeline_state(self, pipeline_name: str) -> str: - """ - Detect the current state of a pipeline. - - Args: - pipeline_name: Name of the pipeline - - Returns: - One of: 'running', 'idle', 'failed', 'not_found' - - 'running': Pipeline exists and has processed events - - 'idle': Pipeline exists but hasn't processed events yet (successfully loaded) - - 'failed': Pipeline exists but failed to load (more failures than successes) - - 'not_found': Pipeline doesn't exist in Logstash - """ - try: - stats = self.get_pipeline_stats(pipeline_name) - pipelines_dict = stats.get('pipelines') - - # If pipelines key is missing or None, pipeline doesn't exist - if pipelines_dict is None: - logger.warning(f"Pipeline '{pipeline_name}' - API response missing 'pipelines' key") - return 'not_found' - - pipeline_data = pipelines_dict.get(pipeline_name) - - # If pipeline_name is not in the response at all, it doesn't exist - if pipeline_data is None: - logger.warning(f"Pipeline '{pipeline_name}' - not found in pipelines dict") - return 'not_found' - - # Check if pipeline_data is a dict (valid structure) - if not isinstance(pipeline_data, dict): - logger.warning(f"Pipeline '{pipeline_name}' - pipeline_data is not a dict: {type(pipeline_data)}") - return 'not_found' - - # Debug: Log the full pipeline_data structure to understand what we're getting - logger.debug(f"Pipeline '{pipeline_name}' - pipeline_data keys: {list(pipeline_data.keys())}") - - # Check reloads first to determine actual state - # Reloads tell us if Logstash has attempted to load the pipeline - reloads = pipeline_data.get('reloads') - - # If reloads is None, pipeline is still being registered by Logstash - # This is normal for newly created pipelines - they appear in API before initialization - if reloads is None: - logger.debug(f"Pipeline '{pipeline_name}' - no 'reloads' structure yet (still registering)") - return 'not_found' - - if not isinstance(reloads, dict): - logger.warning(f"Pipeline '{pipeline_name}' - 'reloads' is not a dict: {type(reloads)}") - return 'failed' - - reload_successes = reloads.get('successes', 0) - reload_failures = reloads.get('failures', 0) - - # NOTE: We do NOT check absolute failure counts here because reload counters - # are cumulative and persist across pipeline deletions in Logstash. - # The verification logic in slots.py tracks baseline counters to detect NEW failures. - # Here we only check if the pipeline has successfully initialized. - - # Check events structure to determine if pipeline has started - # IMPORTANT: Logstash does NOT increment reload_successes for initial pipeline load - # It only increments for subsequent reloads (config changes) - # So we need to check if the pipeline has a valid events structure - events = pipeline_data.get('events') - - if events is None: - # No events structure yet - pipeline is still initializing - logger.debug(f"Pipeline '{pipeline_name}' - no events structure yet (initializing)") - return 'not_found' - - if not isinstance(events, dict): - logger.warning(f"Pipeline '{pipeline_name}' - 'events' is not a dict: {type(events)}") - return 'failed' - - # If events structure exists and has valid data, pipeline has started successfully - # Check for required event fields that indicate pipeline is running - events_in = events.get('in', 0) - events_filtered = events.get('filtered', 0) - events_out = events.get('out', 0) - - # Log the actual values we're seeing - logger.debug(f"Pipeline '{pipeline_name}' - events(in={events_in}, filtered={events_filtered}, out={events_out}), reloads(successes={reload_successes}, failures={reload_failures})") - - # If events structure has the required fields (even if all are 0), pipeline is loaded - # The presence of these fields means Logstash has initialized the pipeline - if 'in' in events or 'filtered' in events or 'out' in events: - if events_in > 0: - return 'running' - else: - logger.info(f"Pipeline '{pipeline_name}' - successfully loaded and idle") - return 'idle' - - # Events structure exists but doesn't have expected fields - still initializing - logger.debug(f"Pipeline '{pipeline_name}' - events structure incomplete, still initializing") - return 'not_found' - - except PipelineNotFoundError: - return 'not_found' - except LogstashAPIError as e: - logger.error(f"Error detecting state for pipeline '{pipeline_name}': {e}") - return 'not_found' - except Exception as e: - logger.error(f"Unexpected error detecting state for pipeline '{pipeline_name}': {e}") - return 'failed' - - def get_pipeline_uptime(self, pipeline_name: str) -> Optional[float]: - """ - Get the uptime of a pipeline in seconds. - - Args: - pipeline_name: Name of the pipeline - - Returns: - Uptime in seconds, or None if not available - """ - try: - stats = self.get_pipeline_stats(pipeline_name) - pipeline_data = stats.get('pipelines', {}).get(pipeline_name, {}) - - # Uptime can be calculated from duration_in_millis - events = pipeline_data.get('events', {}) - duration_ms = events.get('duration_in_millis', 0) - - return duration_ms / 1000.0 if duration_ms > 0 else None - - except (PipelineNotFoundError, LogstashAPIError): - return None - - def has_pipeline_attempted_load(self, pipeline_name: str) -> bool: - """ - Check if a pipeline has attempted to load (has non-zero reload counters). - - This helps distinguish between: - - Pipelines that are still initializing (reload counters are 0) - - Pipelines that have attempted to load (reload counters > 0) - - Args: - pipeline_name: Name of the pipeline - - Returns: - True if pipeline has attempted to load, False otherwise - """ - try: - stats = self.get_pipeline_stats(pipeline_name) - pipeline_data = stats.get('pipelines', {}).get(pipeline_name, {}) - - if not pipeline_data: - return False - - reloads = pipeline_data.get('reloads', {}) - if not isinstance(reloads, dict): - return False - - reload_successes = reloads.get('successes', 0) - reload_failures = reloads.get('failures', 0) - - # If either counter is non-zero, the pipeline has attempted to load - return (reload_successes + reload_failures) > 0 - - except (PipelineNotFoundError, LogstashAPIError): - return False - - -# Convenience functions for common operations - -def is_pipeline_loaded(pipeline_name: str, timeout: float = 5.0) -> bool: - """ - Quick check if a pipeline is loaded in Logstash. - - Args: - pipeline_name: Name of the pipeline - timeout: Request timeout in seconds - - Returns: - True if pipeline is loaded, False otherwise - """ - with LogstashAPI(timeout=timeout) as api: - return api.is_pipeline_running(pipeline_name) - - -def get_running_pipelines(timeout: float = 5.0) -> List[str]: - """ - Get list of all running pipelines. - - Args: - timeout: Request timeout in seconds - - Returns: - List of pipeline names - """ - with LogstashAPI(timeout=timeout) as api: - return api.list_pipelines() - - -def wait_for_pipeline(pipeline_name: str, max_wait: float = 10.0, timeout: float = 5.0) -> bool: - """ - Wait for a pipeline to appear and start running. - - Args: - pipeline_name: Name of the pipeline - max_wait: Maximum time to wait in seconds - timeout: Request timeout in seconds - - Returns: - True if pipeline is running, False if timeout - """ - start_time = time.time() - - with LogstashAPI(timeout=timeout) as api: - while time.time() - start_time < max_wait: - if api.is_pipeline_running(pipeline_name): - return True - time.sleep(0.5) - - return False diff --git a/LogstashAgent/logstash_supervisor.py b/LogstashAgent/logstash_supervisor.py deleted file mode 100644 index 9d34925a..00000000 --- a/LogstashAgent/logstash_supervisor.py +++ /dev/null @@ -1,790 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -import os -import subprocess -import threading -import time -import logging -from typing import Optional -import signal -import psutil -import shutil -from typing import Optional -from logstash_api import LogstashAPI -import slots - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) # Set to INFO level for supervisor - -class LogstashSupervisor: - """ - Supervises the Logstash process with automatic restart on: - 1. Process crash/OOM - 2. JVM heap usage > 90% for 30+ seconds - 3. RSS memory dynamically calculated based on heap size - - Currently always runs in simulation mode (supervised Logstash). - TODO: Add detection for production vs simulation nodes in the future. - """ - - def __init__(self, config: dict = None): - self.process: Optional[subprocess.Popen] = None - self.monitor_thread: Optional[threading.Thread] = None - self.should_run = True - - # Load configuration - self.config = config or {} - self.simulation_mode_type = self.config.get('simulation_mode', 'embedded') # 'embedded' or 'host' - self.logstash_binary = self.config.get('logstash_binary', '/usr/share/logstash/bin/logstash') - self.logstash_settings = self.config.get('logstash_settings', '/etc/logstash/') - self.logstash_log_path = self.config.get('logstash_log_path', '/var/log/logstash') - - # Ensure settings path ends with / - if not self.logstash_settings.endswith('/'): - self.logstash_settings += '/' - - # Ensure log path does NOT end with / - if self.logstash_log_path.endswith('/'): - self.logstash_log_path = self.logstash_log_path.rstrip('/') - - # TODO: In the future, detect if this is a simulation node vs production node - # For now, always default to simulation mode (supervised Logstash with memory monitoring) - # Production nodes would run Logstash without supervision - self.simulation_mode = True - - # Memory thresholds - CONSERVATIVE: Only restart in truly critical situations - # Manager relies on sim node status, so avoid operational restarts - self.heap_threshold_percent = 95.0 # Only restart at 95% heap (was 90%) - self.threshold_duration_seconds = 60.0 # Wait 60s before restart (was 30s) - - # RSS thresholds will be calculated dynamically based on JVM heap size - # CONSERVATIVE: Only restart when RSS is critically high to prevent stunned state - # With 4GB heap, this allows up to 6GB RSS before restart - # (JVM overhead + off-heap memory + OS buffers + safety margin) - self.rss_critical_multiplier = 1.5 # Restart at 150% of heap (was 1.2x) - self.heap_max_gb: Optional[float] = None # Will be fetched from Logstash API - - # Tracking - self.high_memory_start_time: Optional[float] = None - self.restart_count = 0 - self.api_unresponsive_count = 0 - self.api_unresponsive_threshold = 6 # Restart after 6 consecutive API failures (30s total, was 3) - self.pipeline_mismatch_start_time: Optional[float] = None - self.pipeline_mismatch_threshold_seconds = 30.0 # Restart after 30s of mismatch (was 15s) - - # Logstash health state for request queuing - self.is_healthy = False # Set to True once Logstash API responds - self.is_restarting = False # Set to True during restart process - - logger.info(f"LogstashSupervisor initialized (mode={self.simulation_mode_type}, binary={self.logstash_binary}, settings={self.logstash_settings})") - - def setup_host_mode(self): - """ - Set up host mode by copying all config files from container to host. - This overwrites the host's Logstash configuration with our simulation configs. - """ - logger.info(f"Setting up host mode - copying configs to {self.logstash_settings}") - - # Source directory - check local first (native mode), then Docker path - local_config_dir = os.path.join(os.path.dirname(__file__), "config") - docker_config_dir = "/app/config" - - if os.path.exists(local_config_dir): - container_config_dir = local_config_dir - logger.info(f"Using local config directory: {container_config_dir}") - elif os.path.exists(docker_config_dir): - container_config_dir = docker_config_dir - logger.info(f"Using Docker config directory: {container_config_dir}") - else: - raise FileNotFoundError(f"Config directory not found. Tried: {local_config_dir}, {docker_config_dir}") - - # Files to copy directly to settings root - config_files = [ - 'jvm.options', - 'log4j2.properties', - 'logstash.yml', - 'pipelines.yml' - ] - - # Pipeline config files to copy to conf.d - pipeline_files = [ - 'simulate_start.conf', - 'simulate_end.conf' - ] - - try: - # Create settings directory if it doesn't exist - os.makedirs(self.logstash_settings, exist_ok=True) - logger.info(f"Ensured settings directory exists: {self.logstash_settings}") - - # Copy config files to settings root - for filename in config_files: - src = os.path.join(container_config_dir, filename) - dst = os.path.join(self.logstash_settings, filename) - - if os.path.exists(src): - shutil.copy2(src, dst) - logger.info(f"Copied {filename} to {dst}") - else: - logger.warning(f"Source file not found: {src}") - - # Update log4j2.properties with custom log path if configured - log4j2_path = os.path.join(self.logstash_settings, 'log4j2.properties') - if os.path.exists(log4j2_path): - with open(log4j2_path, 'r') as f: - log4j2_content = f.read() - - # Replace /var/log/logstash with custom log path (use forward slashes for consistency) - normalized_log_path = self.logstash_log_path.replace('\\', '/') - log4j2_content = log4j2_content.replace('/var/log/logstash', normalized_log_path) - - with open(log4j2_path, 'w') as f: - f.write(log4j2_content) - logger.info(f"Updated log4j2.properties with log path: {normalized_log_path}") - - # Create config directory for static pipeline configs (simulate_start.conf, simulate_end.conf) - config_path = os.path.join(self.logstash_settings, 'config') - os.makedirs(config_path, exist_ok=True) - logger.info(f"Created config directory: {config_path}") - - # Copy pipeline files to config/ (not conf.d/) - for filename in pipeline_files: - src = os.path.join(container_config_dir, filename) - dst = os.path.join(config_path, filename) - - if os.path.exists(src): - shutil.copy2(src, dst) - logger.info(f"Copied {filename} to {dst}") - else: - logger.warning(f"Source file not found: {src}") - - # Create conf.d directory for dynamic pipeline configs (slot pipelines) - conf_d_path = os.path.join(self.logstash_settings, 'conf.d') - os.makedirs(conf_d_path, exist_ok=True) - logger.info(f"Created conf.d directory for dynamic pipelines: {conf_d_path}") - - # Update pipelines.yml to use correct paths for host mode - pipelines_yml_path = os.path.join(self.logstash_settings, 'pipelines.yml') - if os.path.exists(pipelines_yml_path): - with open(pipelines_yml_path, 'r') as f: - content = f.read() - - # Replace container paths with host paths - # Static pipelines (simulate_start/end) go in config/ - # Convert backslashes to forward slashes for YAML compatibility on Windows - host_config_path = f'{self.logstash_settings}config/'.replace('\\', '/') - - # Replace both quoted and unquoted paths - # Handle quotes with and without spaces - content = content.replace('path.config: "/etc/logstash/config/', f'path.config: "{host_config_path}') - content = content.replace('path.config: /etc/logstash/config/', f'path.config: {host_config_path}') - content = content.replace('"/etc/logstash/config/', f'"{host_config_path}') - content = content.replace('/etc/logstash/config/', host_config_path) - - with open(pipelines_yml_path, 'w') as f: - f.write(content) - logger.info(f"Updated pipelines.yml with host paths (using forward slashes): {host_config_path}") - - # Create pipeline-metadata directory - metadata_dir = os.path.join(self.logstash_settings, 'pipeline-metadata') - os.makedirs(metadata_dir, exist_ok=True) - logger.info(f"Created pipeline-metadata directory: {metadata_dir}") - - # Create log directory if it doesn't exist - os.makedirs(self.logstash_log_path, exist_ok=True) - logger.info(f"Ensured log directory exists: {self.logstash_log_path}") - - logger.info("Host mode setup complete") - - except Exception as e: - logger.error(f"Error setting up host mode: {e}", exc_info=True) - raise - - def start_logstash(self): - """Start the Logstash process""" - logger.debug("[START] start_logstash() called") - if self.process and self.process.poll() is not None: - logger.warning("Logstash is already running") - logger.debug(f"[START] Existing process PID: {self.process.pid}") - return - - logger.info(f"Starting Logstash in {self.simulation_mode_type} mode...") - logger.debug("[START] Preparing environment variables") - - # Setup host mode if needed (copy configs to host) - if self.simulation_mode_type == 'host': - logger.info("Host mode detected - setting up host configuration") - self.setup_host_mode() - - # Determine lock file path based on mode - if self.simulation_mode_type == 'embedded': - lock_file = "/usr/share/logstash/data/.lock" - else: # host mode - # Derive from logstash binary path - # /usr/share/logstash/bin/logstash -> /usr/share/logstash/data/.lock - logstash_home = os.path.dirname(os.path.dirname(self.logstash_binary)) - lock_file = os.path.join(logstash_home, "data", ".lock") - - # Clean up lock file from previous instance - if os.path.exists(lock_file): - try: - os.remove(lock_file) - logger.info(f"Removed stale lock file: {lock_file}") - except Exception as e: - logger.warning(f"Failed to remove lock file {lock_file}: {e}") - - # Prepare environment - copy all environment variables - env = os.environ.copy() - - # Set log4j config path based on mode - if self.simulation_mode_type == 'embedded': - env['LS_JAVA_OPTS'] = "-Dlog4j.configurationFile=/etc/logstash/log4j2.properties" - else: # host mode - log4j_path = os.path.join(self.logstash_settings, 'log4j2.properties') - env['LS_JAVA_OPTS'] = f"-Dlog4j.configurationFile={log4j_path}" - - # Ensure LOGSTASH_URL is available to Logstash - # This is critical for http output plugin in simulate_end.conf and simulate_start.conf - # Always set based on mode to ensure correct routing, unless explicitly overridden - existing_url = env.get('LOGSTASH_URL', '') - - # Only override if not explicitly set (i.e., using defaults) - # Docker-compose sets this to https://nginx, which we should preserve - # Dockerfile default is http://host.docker.internal:8080 - if not existing_url or existing_url in ['http://host.docker.internal:8080', 'http://localhost:8080']: - # Set based on mode: - # - Host mode: Logstash runs natively on host, access Django via nginx HTTPS proxy on localhost:443 - # https://localhost works from both inside and outside containers - # - Embedded mode: Container mode -> use host.docker.internal for standalone builds - if self.simulation_mode_type == 'host': - env['LOGSTASH_URL'] = 'https://localhost' - else: - env['LOGSTASH_URL'] = 'http://host.docker.internal:8080' - logger.info(f"LOGSTASH_URL set for {self.simulation_mode_type} mode: {env['LOGSTASH_URL']}") - else: - logger.info(f"LOGSTASH_URL already set (preserving): {env['LOGSTASH_URL']}") - - # Validate binary exists - if not os.path.exists(self.logstash_binary): - error_msg = f"Logstash binary not found at: {self.logstash_binary}" - logger.error(error_msg) - logger.error("For host mode, ensure:") - logger.error("1. If running natively: Use Windows paths (C:\\logstash-9.3.1\\...\\bin\\logstash.bat)") - logger.error("2. If running in container: Mount host directory and use container paths (/host/logstash/bin/logstash)") - logger.error("3. Start with bin/start_logstashui.bat to automatically detect mode") - raise FileNotFoundError(error_msg) - - # Make binary executable (needed for mounted Windows files on Linux) - # Skip on Windows as chmod doesn't work the same way - if os.name != 'nt': - try: - os.chmod(self.logstash_binary, 0o755) - logger.debug(f"Set executable permissions on {self.logstash_binary}") - except Exception as e: - logger.warning(f"Could not set executable permissions: {e}") - - # Start Logstash with configured paths - logger.debug("[START] Launching Logstash subprocess") - logger.info(f"Executing: {self.logstash_binary} --path.settings {self.logstash_settings}") - - # Prepare subprocess arguments - # On Windows, strip trailing slashes/backslashes; on Linux, strip trailing forward slashes - settings_path = self.logstash_settings.rstrip('/').rstrip('\\') - - # preexec_fn only works on Unix-like systems - # Use DEVNULL for stdout/stderr to prevent pipe blocking on Windows - # Logstash writes to its own log files configured via log4j2.properties - popen_kwargs = { - 'env': env, - 'stdout': subprocess.DEVNULL, - 'stderr': subprocess.DEVNULL - } - - if os.name != 'nt': - popen_kwargs['preexec_fn'] = os.setsid # Create new process group for clean shutdown (Unix only) - - # On Linux host mode, run as logstash user (not root) - # Logstash refuses to run as root for security reasons - if os.name != 'nt' and self.simulation_mode_type == 'host': - # Use sudo -E to run as logstash user and preserve environment variables (especially LOGSTASH_URL) - cmd = ['sudo', '-E', '-u', 'logstash', self.logstash_binary, '--path.settings', settings_path] - logger.info(f"Running as logstash user with preserved env: {' '.join(cmd)}") - logger.info(f"LOGSTASH_URL will be: {env.get('LOGSTASH_URL', 'NOT SET')}") - else: - cmd = [self.logstash_binary, '--path.settings', settings_path] - - self.process = subprocess.Popen( - cmd, - **popen_kwargs - ) - - logger.info(f"Logstash started with PID {self.process.pid}") - if os.name != 'nt': - logger.debug(f"[START] Process group ID: {os.getpgid(self.process.pid)}") - else: - logger.debug("[START] Running on Windows (process groups not applicable)") - - # Start monitoring thread if in simulation mode - if self.simulation_mode and not self.monitor_thread: - logger.debug("[START] Starting monitoring thread") - self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) - self.monitor_thread.start() - logger.info("Memory monitoring thread started") - logger.debug(f"[START] Monitor thread name: {self.monitor_thread.name}") - else: - logger.debug(f"[START] Monitoring thread not started - simulation_mode={self.simulation_mode}, monitor_thread_exists={self.monitor_thread is not None}") - - def stop_logstash(self, graceful=True): - """Stop the Logstash process""" - logger.debug(f"[STOP] stop_logstash() called with graceful={graceful}") - if not self.process: - logger.debug("[STOP] No process to stop") - return - - logger.info(f"Stopping Logstash (PID {self.process.pid}, graceful={graceful})...") - logger.debug(f"[STOP] Process state: poll={self.process.poll()}") - - try: - # Check if process is already dead - if self.process.poll() is not None: - logger.info(f"Logstash process already terminated (exit code: {self.process.returncode})") - # Still cleanup any orphaned child processes - self._cleanup_orphaned_processes() - self.process = None - return - - if graceful: - # Send SIGTERM for graceful shutdown - try: - pgid = os.getpgid(self.process.pid) - logger.debug(f"[STOP] Sending SIGTERM to process group {pgid}") - os.killpg(pgid, signal.SIGTERM) - except ProcessLookupError: - logger.warning("[STOP] Process already terminated before SIGTERM could be sent") - self._cleanup_orphaned_processes() - self.process = None - return - - # Wait up to 30 seconds for graceful shutdown - logger.debug("[STOP] Waiting up to 30s for graceful shutdown") - try: - self.process.wait(timeout=30) - logger.info("Logstash stopped gracefully") - logger.debug(f"[STOP] Process exit code: {self.process.returncode}") - except subprocess.TimeoutExpired: - logger.warning("Graceful shutdown timed out, forcing kill") - logger.debug("[STOP] Sending SIGKILL to force termination") - try: - pgid = os.getpgid(self.process.pid) - os.killpg(pgid, signal.SIGKILL) - self.process.wait() - logger.debug(f"[STOP] Process killed, exit code: {self.process.returncode}") - except ProcessLookupError: - logger.warning("[STOP] Process already terminated before SIGKILL could be sent") - else: - # Force kill - try: - pgid = os.getpgid(self.process.pid) - logger.debug(f"[STOP] Sending SIGKILL to process group {pgid}") - os.killpg(pgid, signal.SIGKILL) - self.process.wait() - logger.info("Logstash killed") - logger.debug(f"[STOP] Process exit code: {self.process.returncode}") - except ProcessLookupError: - logger.warning("[STOP] Process terminated unexpectedly") - finally: - # Aggressively cleanup any remaining Logstash processes - self._cleanup_orphaned_processes() - self.process = None - logger.debug("[STOP] Process reference cleared") - - def _cleanup_orphaned_processes(self): - """Kill any orphaned Logstash/Java processes that might be holding ports""" - try: - import psutil - killed_count = 0 - - # Find all java processes that look like Logstash - for proc in psutil.process_iter(['pid', 'name', 'cmdline']): - try: - # Check if it's a Java process with logstash in the command line - if proc.info['name'] == 'java' and proc.info['cmdline']: - cmdline = ' '.join(proc.info['cmdline']) - if 'logstash' in cmdline.lower(): - logger.warning(f"Found orphaned Logstash process PID {proc.info['pid']}, killing it") - proc.kill() - proc.wait(timeout=5) - killed_count += 1 - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired): - pass - - if killed_count > 0: - logger.info(f"Cleaned up {killed_count} orphaned Logstash process(es)") - # Give the OS time to release ports - import time - time.sleep(2) - except ImportError: - logger.debug("psutil not available, skipping orphaned process cleanup") - except Exception as e: - logger.error(f"Error during orphaned process cleanup: {e}") - - def restart_logstash(self, reason: str = "Manual restart"): - """Restart the Logstash process""" - self.restart_count += 1 - self.is_restarting = True - self.is_healthy = False - logger.warning(f"Restarting Logstash (restart #{self.restart_count}): {reason}") - logger.debug(f"[RESTART] Restart initiated - count: {self.restart_count}, reason: {reason}") - - # Evict all slots and clean up conf.d BEFORE stopping Logstash - # This prevents Logstash from reloading old pipelines on restart - logger.info("Evicting all slots and cleaning up conf.d before restart...") - logger.debug("[RESTART] Calling evict_all_slots_and_cleanup()") - try: - evicted_slots = slots.evict_all_slots_and_cleanup() - logger.info(f"Successfully evicted {len(evicted_slots)} slots and cleaned up conf.d") - logger.debug(f"[RESTART] Evicted slots: {evicted_slots}") - except Exception as e: - logger.error(f"Error during slot eviction and cleanup: {e}") - logger.debug(f"[RESTART] Eviction exception details", exc_info=True) - - logger.debug("[RESTART] Stopping Logstash") - self.stop_logstash(graceful=True) - - # Brief pause to ensure clean shutdown - logger.debug("[RESTART] Pausing 2s for clean shutdown") - time.sleep(2) - - logger.debug("[RESTART] Starting Logstash") - self.start_logstash() - - # Reset memory tracking - logger.debug("[RESTART] Resetting all timers") - self.high_memory_start_time = None - self.pipeline_mismatch_start_time = None - self.is_restarting = False - # is_healthy will be set to True once API responds in monitoring loop - logger.debug("[RESTART] Restart complete") - - def _get_expected_slot_pipelines(self) -> set: - """ - Get the set of pipeline names that should be running based on allocated slots. - - Returns: - Set of expected pipeline names (e.g., {'slot1-filter1', 'slot2-filter1'}) - """ - logger.debug("[SLOTS] Getting expected slot pipelines") - try: - import slots - slot_state = slots.get_slot_state() - expected_pipelines = set() - logger.debug(f"[SLOTS] Retrieved {len(slot_state)} slots from state") - - for slot_id, slot_data in slot_state.items(): - pipelines = slot_data.get('pipelines', []) - logger.debug(f"[SLOTS] Slot {slot_id} has {len(pipelines)} pipeline(s)") - for idx in range(1, len(pipelines) + 1): - pipeline_name = f"slot{slot_id}-filter{idx}" - expected_pipelines.add(pipeline_name) - logger.debug(f"[SLOTS] Added expected pipeline: {pipeline_name}") - - logger.debug(f"[SLOTS] Total expected pipelines: {len(expected_pipelines)}") - return expected_pipelines - except Exception as e: - logger.error(f"Error getting expected slot pipelines: {e}") - logger.debug("[SLOTS] Exception details", exc_info=True) - return set() - - def _get_jvm_heap_usage(self) -> Optional[float]: - """Get JVM heap usage percentage from Logstash API""" - logger.debug("[HEAP] Checking JVM heap usage") - try: - with LogstashAPI(timeout=2.0) as api: - stats = api.get_node_stats() - jvm = stats.get('jvm', {}) - mem = jvm.get('mem', {}) - heap_used = mem.get('heap_used_in_bytes', 0) - heap_max = mem.get('heap_max_in_bytes', 1) - logger.debug(f"[HEAP] Raw values - used: {heap_used} bytes, max: {heap_max} bytes") - - # Cache heap_max_gb for RSS threshold calculation - if heap_max > 0 and self.heap_max_gb is None: - self.heap_max_gb = heap_max / (1024 ** 3) - logger.info(f"JVM heap max detected: {self.heap_max_gb:.2f}GB") - logger.info(f"RSS critical threshold: {self.heap_max_gb * self.rss_critical_multiplier:.2f}GB ({self.rss_critical_multiplier}x heap - immediate restart)") - logger.debug(f"[HEAP] Cached heap_max_gb for RSS calculations") - - if heap_max > 0: - percent = (heap_used / heap_max) * 100.0 - logger.debug(f"[HEAP] Heap usage: {percent:.2f}%") - return percent - except Exception as e: - logger.debug(f"Could not get JVM heap usage: {e}") - logger.debug("[HEAP] API call failed", exc_info=True) - - logger.debug("[HEAP] Returning None (no data available)") - return None - - def _get_rss_memory_gb(self) -> Optional[float]: - """Get RSS memory usage in GB for Logstash process""" - logger.debug("[RSS] Checking RSS memory usage") - if not self.process or self.process.poll() is not None: - logger.debug("[RSS] No active process") - return None - - try: - proc = psutil.Process(self.process.pid) - # Get memory for process and all children - total_rss = proc.memory_info().rss - logger.debug(f"[RSS] Parent process RSS: {total_rss / (1024**3):.3f}GB") - - children = proc.children(recursive=True) - logger.debug(f"[RSS] Found {len(children)} child processes") - for child in children: - try: - child_rss = child.memory_info().rss - total_rss += child_rss - logger.debug(f"[RSS] Child PID {child.pid} RSS: {child_rss / (1024**3):.3f}GB") - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - logger.debug(f"[RSS] Could not get child process memory: {e}") - pass - - rss_gb = total_rss / (1024 ** 3) # Convert bytes to GB - logger.debug(f"[RSS] Total RSS: {rss_gb:.3f}GB") - return rss_gb - except Exception as e: - logger.debug(f"Could not get RSS memory: {e}") - logger.debug("[RSS] Exception details", exc_info=True) - - logger.debug("[RSS] Returning None (no data available)") - return None - - def _check_pipeline_mismatch(self) -> Optional[str]: - """ - Check if running pipelines match expected slot pipelines. - Returns restart reason if mismatch persists for 15+ seconds. - """ - logger.debug("[MISMATCH] Checking pipeline mismatch") - try: - with LogstashAPI(timeout=2.0) as api: - # Get pipelines from health report - logger.debug("[MISMATCH] Fetching health report") - running_pipelines = set(api.get_running_pipelines_from_health()) - logger.debug(f"[MISMATCH] Health report returned {len(running_pipelines)} pipelines: {running_pipelines}") - - # Get expected pipelines from slots - expected_pipelines = self._get_expected_slot_pipelines() - logger.debug(f"[MISMATCH] Expected {len(expected_pipelines)} pipelines: {expected_pipelines}") - - # Filter to only slot pipelines (ignore simulate-start, simulate-end) - running_slot_pipelines = {p for p in running_pipelines if p.startswith('slot')} - logger.debug(f"[MISMATCH] Filtered to {len(running_slot_pipelines)} slot pipelines: {running_slot_pipelines}") - - # Check if they match - if running_slot_pipelines != expected_pipelines: - if self.pipeline_mismatch_start_time is None: - self.pipeline_mismatch_start_time = time.time() - missing = expected_pipelines - running_slot_pipelines - extra = running_slot_pipelines - expected_pipelines - logger.warning(f"[TIMER START] Pipeline mismatch detected at {time.time():.2f}") - logger.warning(f" Expected: {expected_pipelines}") - logger.warning(f" Running: {running_slot_pipelines}") - if missing: - logger.warning(f" Missing pipelines: {missing}") - if extra: - logger.warning(f" Extra pipelines: {extra}") - logger.debug(f"[TIMER] Pipeline mismatch timer started, will restart after {self.pipeline_mismatch_threshold_seconds}s") - else: - duration = time.time() - self.pipeline_mismatch_start_time - logger.debug(f"[TIMER] Pipeline mismatch ongoing: {duration:.1f}s / {self.pipeline_mismatch_threshold_seconds}s") - if duration >= self.pipeline_mismatch_threshold_seconds: - missing = expected_pipelines - running_slot_pipelines - extra = running_slot_pipelines - expected_pipelines - logger.warning(f"[TIMER EXPIRED] Pipeline mismatch threshold reached: {duration:.0f}s") - return f"Pipeline mismatch for {duration:.0f}s (expected: {len(expected_pipelines)}, running: {len(running_slot_pipelines)}, missing: {missing}, extra: {extra})" - else: - # Pipelines match - reset tracking - if self.pipeline_mismatch_start_time is not None: - duration = time.time() - self.pipeline_mismatch_start_time - logger.info(f"[TIMER RESET] Pipeline mismatch resolved after {duration:.1f}s") - self.pipeline_mismatch_start_time = None - else: - logger.debug(f"[TIMER] Pipelines match - expected: {len(expected_pipelines)}, running: {len(running_slot_pipelines)}") - except Exception as e: - logger.debug(f"Could not check pipeline mismatch: {e}") - logger.debug("[MISMATCH] Exception details", exc_info=True) - - logger.debug("[MISMATCH] Returning None (no restart needed)") - return None - - def _check_memory_thresholds(self) -> Optional[str]: - """ - Check if memory thresholds are exceeded. - Returns restart reason immediately if RSS exceeds heap size (prevents stunned state). - """ - heap_percent = self._get_jvm_heap_usage() - rss_gb = self._get_rss_memory_gb() - - # Check if API is unresponsive - if heap_percent is None and rss_gb is None: - self.api_unresponsive_count += 1 - logger.warning(f"Logstash API unresponsive ({self.api_unresponsive_count}/{self.api_unresponsive_threshold})") - if self.api_unresponsive_count >= self.api_unresponsive_threshold: - self.is_healthy = False - return f"Logstash API unresponsive for {self.api_unresponsive_count * 5}s" - return None - else: - # Reset unresponsive counter and mark healthy if we get data - self.api_unresponsive_count = 0 - self.is_healthy = True - - # Check JVM heap (still monitor but less critical) - if heap_percent and heap_percent > self.heap_threshold_percent: - if self.high_memory_start_time is None: - self.high_memory_start_time = time.time() - logger.warning(f"[TIMER START] High JVM heap at {heap_percent:.1f}% (threshold: {self.heap_threshold_percent}%)") - else: - duration = time.time() - self.high_memory_start_time - if duration >= self.threshold_duration_seconds: - logger.warning(f"[TIMER EXPIRED] High JVM heap for {duration:.0f}s") - return f"JVM heap at {heap_percent:.1f}% for {duration:.0f}s" - else: - if self.high_memory_start_time is not None: - logger.info(f"[TIMER RESET] JVM heap back to normal") - self.high_memory_start_time = None - - # Check RSS memory - CRITICAL: Restart immediately at 1.3x heap to prevent stun - if rss_gb and self.heap_max_gb: - rss_critical_gb = self.heap_max_gb * self.rss_critical_multiplier - - if rss_gb > rss_critical_gb: - # Immediate restart to prevent Logstash from getting stunned - logger.warning(f"RSS memory at {rss_gb:.2f}GB > critical threshold {rss_critical_gb:.2f}GB ({self.rss_critical_multiplier}x heap) - preventing stun") - return f"RSS memory at {rss_gb:.2f}GB exceeds critical threshold {rss_critical_gb:.2f}GB ({self.rss_critical_multiplier}x heap - preventing stunned state)" - else: - heap_str = f"{heap_percent:.1f}%" if heap_percent is not None else "N/A" - logger.debug(f"[MEMORY] Heap: {heap_str}, RSS: {rss_gb:.2f}GB / {rss_critical_gb:.2f}GB") - elif rss_gb and not self.heap_max_gb: - logger.debug(f"[MEMORY] RSS check skipped - heap_max_gb not yet cached (RSS: {rss_gb:.2f}GB)") - - return None - - def _monitor_loop(self): - """Main monitoring loop (runs in background thread)""" - logger.info("Memory monitoring loop started") - logger.debug("[MONITOR] Entering monitoring loop") - - # Wait for Logstash to start up before polling APIs - logger.info("Waiting 30s for Logstash to start up before monitoring...") - time.sleep(30) - logger.info("Starting memory monitoring") - - loop_iteration = 0 - while self.should_run: - loop_iteration += 1 - logger.debug(f"[MONITOR] Loop iteration {loop_iteration} starting") - try: - # Check if process is still running - logger.debug("[MONITOR] Checking if process is alive") - if not self.process or self.process.poll() is not None: - exit_code = self.process.returncode if self.process else None - logger.error(f"Logstash process died (exit code: {exit_code})") - logger.debug(f"[MONITOR] Process crash detected - process exists: {self.process is not None}, poll: {self.process.poll() if self.process else 'N/A'}") - - # Capture and log stderr/stdout to see why it crashed - if self.process: - try: - stdout, stderr = self.process.communicate(timeout=1) - if stderr: - stderr_text = stderr.decode('utf-8', errors='replace') - logger.error(f"Logstash stderr output:\n{stderr_text}") - if stdout: - stdout_text = stdout.decode('utf-8', errors='replace') - logger.info(f"Logstash stdout output:\n{stdout_text}") - except Exception as e: - logger.warning(f"Could not capture process output: {e}") - - # Restart on crash - self.restart_logstash(f"Process crash (exit code: {exit_code})") - continue - else: - logger.debug(f"[MONITOR] Process is alive - PID: {self.process.pid}") - - # Check memory thresholds - logger.debug("[MONITOR] Checking memory thresholds") - restart_reason = self._check_memory_thresholds() - if restart_reason: - logger.debug(f"[MONITOR] Memory threshold exceeded: {restart_reason}") - self.restart_logstash(restart_reason) - continue - else: - logger.debug("[MONITOR] Memory thresholds OK") - - # Check pipeline mismatch - TEMPORARILY DISABLED - # logger.debug("[MONITOR] Checking pipeline mismatch") - # restart_reason = self._check_pipeline_mismatch() - # if restart_reason: - # logger.debug(f"[MONITOR] Pipeline mismatch detected: {restart_reason}") - # self.restart_logstash(restart_reason) - # else: - # logger.debug("[MONITOR] Pipeline mismatch check OK") - - # Sleep before next check - logger.debug(f"[MONITOR] Loop iteration {loop_iteration} complete, sleeping 5s") - time.sleep(5) - - except Exception as e: - logger.error(f"Error in monitoring loop: {e}", exc_info=True) - logger.debug(f"[MONITOR] Exception in loop iteration {loop_iteration}", exc_info=True) - logger.debug("[MONITOR] Sleeping 5s after error") - time.sleep(5) - - logger.info("Memory monitoring loop stopped") - logger.debug(f"[MONITOR] Exited monitoring loop after {loop_iteration} iterations") - - def shutdown(self): - """Shutdown the supervisor""" - logger.info("Shutting down LogstashSupervisor...") - logger.debug("[SHUTDOWN] Setting should_run=False") - self.should_run = False - - if self.monitor_thread: - logger.debug(f"[SHUTDOWN] Waiting for monitor thread to stop (timeout=5s)") - self.monitor_thread.join(timeout=5) - if self.monitor_thread.is_alive(): - logger.warning("[SHUTDOWN] Monitor thread did not stop within timeout") - else: - logger.debug("[SHUTDOWN] Monitor thread stopped") - - logger.debug("[SHUTDOWN] Stopping Logstash") - self.stop_logstash(graceful=True) - logger.info("LogstashSupervisor shutdown complete") - logger.debug("[SHUTDOWN] Shutdown complete") - - -# Global supervisor instance -_supervisor: Optional[LogstashSupervisor] = None - -def get_supervisor(config: dict = None) -> LogstashSupervisor: - """Get or create the global supervisor instance""" - global _supervisor - if _supervisor is None: - _supervisor = LogstashSupervisor(config=config) - return _supervisor - -def start_supervised_logstash(config: dict = None): - """Start Logstash under supervision""" - supervisor = get_supervisor(config=config) - supervisor.start_logstash() - -def trigger_restart(reason: str = "Manual restart"): - """Trigger a Logstash restart from external code (e.g., when simulation POST fails)""" - supervisor = get_supervisor() - supervisor.restart_logstash(reason) - -def shutdown_supervisor(): - """Shutdown the supervisor""" - if _supervisor: - _supervisor.shutdown() diff --git a/LogstashAgent/logstashagent.yml b/LogstashAgent/logstashagent.yml deleted file mode 100644 index 4a1f3241..00000000 --- a/LogstashAgent/logstashagent.yml +++ /dev/null @@ -1,6 +0,0 @@ -mode: simulation -simulation_mode: embedded -# Linux example, default -logstash_binary: /usr/share/logstash/bin/logstash -logstash_settings: /etc/logstash -logstash_log_path: /var/log/logstash \ No newline at end of file diff --git a/LogstashAgent/main.py b/LogstashAgent/main.py deleted file mode 100644 index c9086681..00000000 --- a/LogstashAgent/main.py +++ /dev/null @@ -1,1505 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -from fastapi import FastAPI, HTTPException, Path as FastAPIPath, Query, Request -from fastapi.responses import JSONResponse -from typing import Optional, Dict, Any, List -from datetime import datetime, timezone -import os -import yaml -import json -import glob -import logging -import re -import slots -import log_analyzer -from logstash_api import LogstashAPI, PipelineNotFoundError -import requests -import time -import base64 -import asyncio -import atexit -import logstash_supervisor -from collections import deque -import threading -from pathlib import Path -from logging.handlers import RotatingFileHandler - -# Configure logging with file output -# Create data/logs directory if it doesn't exist -LOGS_DIR = Path(__file__).parent / 'data' / 'logs' -LOGS_DIR.mkdir(parents=True, exist_ok=True) - -# Setup logging configuration -logging.basicConfig( - level=logging.INFO, - format='[%(levelname)s] %(asctime)s %(name)s %(funcName)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', - handlers=[ - # Console handler - logging.StreamHandler(), - # File handler with rotation - RotatingFileHandler( - LOGS_DIR / 'logstashagent.log', - maxBytes=1024 * 1024 * 10, # 10 MB - backupCount=5, - ) - ] -) -logger = logging.getLogger(__name__) - -# Reduce httpx logging noise - only show warnings and errors -logging.getLogger("httpx").setLevel(logging.WARNING) - -logger.info(f"LogstashAgent logging initialized - logs directory: {LOGS_DIR}") - -# Load agent configuration -# Check for config in current directory first (native mode) -def get_config_path() -> str: - """Get the path to logstashagent.yml - only used for native/host mode""" - local_path = os.path.join(os.path.dirname(__file__), "logstashagent.yml") - return local_path - -CONFIG_PATH = get_config_path() - -def load_agent_config() -> dict: - """Load logstashagent.yml configuration, with fallback to logstashui.yml or logstashui.example.yml if mounted""" - # First, try to load from mounted logstashui.yml (preferred), then logstashui.example.yml - # Check /app first (docker-compose mounts), then /etc (legacy) - config_paths = [ - "/app/logstashui.yml", - "/app/logstashui.example.yml", - "/etc/logstashui.yml", - "/etc/logstashui.example.yml" - ] - - for logstashui_config_path in config_paths: - if os.path.exists(logstashui_config_path): - try: - with open(logstashui_config_path, 'r') as f: - full_config = yaml.safe_load(f) - # logstash_agent is nested under simulation section - if full_config and 'simulation' in full_config: - simulation_config = full_config['simulation'] - if 'logstash_agent' in simulation_config: - agent_config = simulation_config['logstash_agent'].copy() - # Add simulation mode from parent config - if 'mode' in simulation_config: - agent_config['simulation_mode'] = simulation_config['mode'] - if 'mode' not in agent_config: - agent_config['mode'] = 'simulation' - - # FORCE embedded mode to use container paths (ignore config file paths) - if agent_config.get('simulation_mode') == 'embedded': - agent_config['logstash_binary'] = '/usr/share/logstash/bin/logstash' - agent_config['logstash_settings'] = '/etc/logstash' - agent_config['logstash_log_path'] = '/var/log/logstash' - logger.info(f"Loaded agent config from {logstashui_config_path}: simulation_mode=embedded (forced Linux paths)") - else: - logger.info(f"Loaded agent config from {logstashui_config_path}: simulation_mode={agent_config.get('simulation_mode', 'embedded')}") - return agent_config - except Exception as e: - logger.warning(f"Failed to load config from {logstashui_config_path}: {e}, trying next path") - - # Fallback to logstashagent.yml - try: - with open(CONFIG_PATH, 'r') as f: - config = yaml.safe_load(f) - logger.info(f"Loaded agent config from {CONFIG_PATH}: simulation_mode={config.get('simulation_mode', 'embedded')}") - return config - except FileNotFoundError: - logger.warning(f"Config file {CONFIG_PATH} not found, using embedded mode defaults") - return { - 'mode': 'simulation', - 'simulation_mode': 'embedded', - 'logstash_binary': '/usr/share/logstash/bin/logstash', - 'logstash_settings': '/etc/logstash/' - } - except Exception as e: - logger.error(f"Error loading config: {e}") - raise - -# Global config -AGENT_CONFIG = load_agent_config() - -app = FastAPI(title="LogstashAgent API", version="0.0.1") - -# Request queue for simulation requests during Logstash restarts -_simulation_queue: deque = deque(maxlen=100) # Max 100 queued requests -_queue_lock = threading.Lock() -_queue_processor_task: Optional[asyncio.Task] = None - -@app.on_event("startup") -async def startup_event(): - """Start Logstash under supervision when FastAPI starts""" - global _queue_processor_task - logger.info("FastAPI startup - initializing Logstash supervisor") - logstash_supervisor.start_supervised_logstash(config=AGENT_CONFIG) - # Wait for Logstash to initialize - await asyncio.sleep(5) - logger.info("Logstash supervision started") - - # Start queue processor - _queue_processor_task = asyncio.create_task(_process_simulation_queue()) - logger.info("Simulation queue processor started") - -@app.on_event("shutdown") -async def shutdown_event(): - """Shutdown Logstash supervisor when FastAPI stops""" - global _queue_processor_task - logger.info("FastAPI shutdown - stopping queue processor") - if _queue_processor_task: - _queue_processor_task.cancel() - try: - await _queue_processor_task - except asyncio.CancelledError: - pass - logger.info("FastAPI shutdown - stopping Logstash supervisor") - logstash_supervisor.shutdown_supervisor() - -# Also register atexit handler for clean shutdown -atexit.register(logstash_supervisor.shutdown_supervisor) - -# Configuration paths - dynamically set based on mode -def get_logstash_paths(): - """Get Logstash paths based on configuration (Docker vs native)""" - logstash_settings = AGENT_CONFIG.get('logstash_settings', '/etc/logstash/') - - # Ensure settings path ends with / - if not logstash_settings.endswith('/') and not logstash_settings.endswith('\\'): - logstash_settings += '/' - - # Normalize to forward slashes for consistency - logstash_settings = logstash_settings.replace('\\', '/') - - return { - 'pipelines_yml': f"{logstash_settings}pipelines.yml", - 'conf_d': f"{logstash_settings}conf.d", - 'metadata': f"{logstash_settings}pipeline-metadata" - } - -LOGSTASH_PATHS = get_logstash_paths() -PIPELINES_YML_PATH = LOGSTASH_PATHS['pipelines_yml'] -PIPELINES_DIR = LOGSTASH_PATHS['conf_d'] -METADATA_DIR = LOGSTASH_PATHS['metadata'] - -# Ensure directories exist -os.makedirs(PIPELINES_DIR, exist_ok=True) -os.makedirs(METADATA_DIR, exist_ok=True) - - -def _validate_pipeline_id(pipeline_id: str) -> None: - """ - Validate pipeline_id to prevent path traversal attacks. - - Args: - pipeline_id: The pipeline ID to validate - - Raises: - HTTPException: If pipeline_id contains unsafe characters - """ - # Allow only alphanumeric, hyphens, underscores, and dots - # This prevents path traversal with ../ or absolute paths - if not re.match(r'^[a-zA-Z0-9_\-\.]+$', pipeline_id): - raise HTTPException( - status_code=400, - detail=f"Invalid pipeline_id: must contain only alphanumeric characters, hyphens, underscores, and dots" - ) - - # Additional check: prevent .. sequences even if they pass regex - if '..' in pipeline_id: - raise HTTPException( - status_code=400, - detail="Invalid pipeline_id: cannot contain '..' sequences" - ) - - # Prevent starting with dot (hidden files) or hyphen - if pipeline_id.startswith('.') or pipeline_id.startswith('-'): - raise HTTPException( - status_code=400, - detail="Invalid pipeline_id: cannot start with '.' or '-'" - ) - - -def _load_pipelines_yml() -> list: - """Load the pipelines.yml file""" - if not os.path.exists(PIPELINES_YML_PATH): - return [] - - try: - with open(PIPELINES_YML_PATH, 'r') as f: - content = f.read() - # Handle empty or comment-only files - if not content.strip() or all(line.strip().startswith('#') for line in content.split('\n') if line.strip()): - return [] - pipelines = yaml.safe_load(content) - return pipelines if pipelines else [] - except Exception as e: - logger.error(f"Error loading pipelines.yml: {e}") - return [] - - -def _save_pipelines_yml(pipelines: list): - """Save the pipelines.yml file atomically, ensuring static pipelines are preserved""" - # Get logstash settings path from config - logstash_settings = AGENT_CONFIG.get('logstash_settings', '/etc/logstash/') - - # Detect OS and handle path separators appropriately - is_windows = os.name == 'nt' - - if is_windows: - # Windows: Ensure path ends with backslash, then escape for YAML - if not logstash_settings.endswith('/') and not logstash_settings.endswith('\\'): - logstash_settings += '\\' - # YAML requires backslashes to be escaped, so C:\path becomes C:\\path - yaml_path = logstash_settings.replace('\\', '\\\\') - path_sep = '\\\\' - else: - # Linux/Docker: Use forward slashes (no escaping needed) - if not logstash_settings.endswith('/'): - logstash_settings += '/' - yaml_path = logstash_settings - path_sep = '/' - - # Define static pipelines that must always be present - # Static pipeline .conf files are in config/config/ subdirectory - static_pipelines = [ - { - 'pipeline.id': 'simulate-start', - 'pipeline.workers': 1, - 'path.config': f'{yaml_path}config{path_sep}simulate_start.conf' - }, - { - 'pipeline.id': 'simulate-end', - 'pipeline.workers': 1, - 'path.config': f'{yaml_path}config{path_sep}simulate_end.conf' - } - ] - - # Remove any existing static pipeline entries from the input list - static_ids = {'simulate-start', 'simulate-end'} - dynamic_pipelines = [p for p in pipelines if p.get('pipeline.id') not in static_ids] - - # Combine static pipelines (first) with dynamic pipelines - final_pipelines = static_pipelines + dynamic_pipelines - - temp_path = f"{PIPELINES_YML_PATH}.tmp" - try: - with open(temp_path, 'w') as f: - yaml.dump(final_pipelines, f, default_flow_style=False, sort_keys=False) - os.replace(temp_path, PIPELINES_YML_PATH) - logger.debug(f"Saved pipelines.yml with {len(static_pipelines)} static + {len(dynamic_pipelines)} dynamic pipelines") - except Exception as e: - if os.path.exists(temp_path): - os.remove(temp_path) - raise e - - -def delete_pipeline_internal(pipeline_id: str) -> bool: - """ - Delete a pipeline directly without going through the HTTP API. - This is used by slots.py to avoid HTTP overhead during cleanup. - - Args: - pipeline_id: The pipeline ID to delete - - Returns: - True if deleted successfully, False if not found or error occurred - """ - try: - _validate_pipeline_id(pipeline_id) - - # Load existing pipelines - pipelines = _load_pipelines_yml() - - # Find and remove the pipeline - pipeline_found = False - config_path = None - new_pipelines = [] - - for pipeline in pipelines: - if pipeline.get('pipeline.id') == pipeline_id: - pipeline_found = True - config_path = pipeline.get('path.config') - else: - new_pipelines.append(pipeline) - - if not pipeline_found: - return False - - # Delete pipeline config file - if config_path and os.path.exists(config_path): - try: - os.remove(config_path) - except Exception as e: - logger.error(f"Failed to delete pipeline config {config_path}: {e}") - return False - - # Delete metadata file - metadata_path = os.path.join(METADATA_DIR, f"{pipeline_id}.json") - if os.path.exists(metadata_path): - try: - os.remove(metadata_path) - except Exception: - pass # Non-critical if metadata deletion fails - - # Save updated pipelines.yml - try: - _save_pipelines_yml(new_pipelines) - except Exception as e: - logger.error(f"Failed to update pipelines.yml: {e}") - return False - - return True - except Exception as e: - logger.error(f"Error deleting pipeline {pipeline_id}: {e}") - return False - - -def _load_pipeline_config(pipeline_id: str) -> Optional[str]: - """Load the pipeline configuration file(s) - supports wildcards""" - pipelines = _load_pipelines_yml() - - for pipeline in pipelines: - if pipeline.get('pipeline.id') == pipeline_id: - config_path = pipeline.get('path.config') - if not config_path: - continue - - # Check if path contains wildcards - if '*' in config_path or '?' in config_path: - # Expand wildcards and read all matching files - matching_files = sorted(glob.glob(config_path)) - if not matching_files: - return None - - # Concatenate all matching files - config_parts = [] - for file_path in matching_files: - try: - with open(file_path, 'r') as f: - config_parts.append(f.read()) - except Exception as e: - logger.error(f"Error reading {file_path}: {e}") - continue - - return '\n'.join(config_parts) if config_parts else None - else: - # Single file path - if os.path.exists(config_path): - with open(config_path, 'r') as f: - return f.read() - return None - - -def _load_pipeline_metadata(pipeline_id: str) -> Dict[str, Any]: - """Load pipeline metadata (description, settings, etc.)""" - _validate_pipeline_id(pipeline_id) - metadata_path = os.path.join(METADATA_DIR, f"{pipeline_id}.json") - - if os.path.exists(metadata_path): - try: - with open(metadata_path, 'r') as f: - return json.load(f) - except Exception as e: - logger.error(f"Error loading metadata for pipeline '{pipeline_id}': {e}") - - # Return default metadata if file doesn't exist or failed to load - return { - "description": "", - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": { - "type": "logstash_pipeline", - "version": 1 - }, - "username": "LogstashAgent", - "pipeline_settings": { - "pipeline.workers": 1, - "pipeline.batch.size": 125, - "pipeline.batch.delay": 50, - "queue.type": "memory", - "queue.max_bytes": "1gb", - "queue.checkpoint.writes": 1024 - } - } - - -def _save_pipeline_metadata(pipeline_id: str, metadata: Dict[str, Any]): - """Save pipeline metadata""" - _validate_pipeline_id(pipeline_id) - metadata_path = os.path.join(METADATA_DIR, f"{pipeline_id}.json") - temp_path = f"{metadata_path}.tmp" - - try: - with open(temp_path, 'w') as f: - json.dump(metadata, f, indent=2) - os.replace(temp_path, metadata_path) - except Exception as e: - if os.path.exists(temp_path): - os.remove(temp_path) - raise e - - -def _get_pipeline_settings_from_yml(pipeline_id: str) -> Dict[str, Any]: - """Extract pipeline settings from pipelines.yml""" - _validate_pipeline_id(pipeline_id) - pipelines = _load_pipelines_yml() - settings = {} - - for pipeline in pipelines: - if pipeline.get('pipeline.id') == pipeline_id: - # Extract all pipeline.* and queue.* settings - for key, value in pipeline.items(): - if key.startswith('pipeline.') or key.startswith('queue.'): - settings[key] = value - break - - return settings - - -@app.get("/") -async def root(): - """Health check endpoint""" - return { - "name": "LogstashAgent", - "version": "0.0.1", - "status": "running", - "logstash_version": "9.3.0" - } - - -async def _process_simulation_queue(): - """ - Background task that processes queued simulation requests when Logstash becomes healthy. - """ - logger.info("Queue processor started") - last_healthy_time = None - - while True: - try: - await asyncio.sleep(2) # Check every 2 seconds - - supervisor = logstash_supervisor.get_supervisor() - if not supervisor or not supervisor.is_healthy: - last_healthy_time = None - continue - - # Track when Logstash became healthy - if last_healthy_time is None: - last_healthy_time = time.time() - logger.info("Logstash became healthy, waiting 10s for full initialization before processing queue") - continue - - # Wait at least 10 seconds after Logstash becomes healthy before processing - time_since_healthy = time.time() - last_healthy_time - if time_since_healthy < 10: - continue - - # Verify Logstash port 9449 is actually ready before processing queue - try: - test_response = requests.get("http://127.0.0.1:9449", timeout=2) - except Exception: - logger.debug("Logstash port 9449 not ready yet, waiting...") - continue - - # Process all queued requests - while True: - queued_item = None - with _queue_lock: - if _simulation_queue: - queued_item = _simulation_queue.popleft() - else: - break - - if queued_item: - log_data = queued_item['log_data'] - slot_config = queued_item.get('slot_config') - - logger.info(f"Processing queued simulation: slot={log_data.get('slot')}, run_id={log_data.get('run_id')}") - - # Restore slot configuration if needed - if slot_config: - slot_id = slot_config['slot_id'] - pipeline_name = slot_config['pipeline_name'] - pipelines = slot_config['pipelines'] - - # Re-allocate slot (will reuse if hash matches) - try: - # Check if slot already exists - existing_slots = slots.get_slot_state() - slot_exists = slot_id in existing_slots - - if not slot_exists: - # Allocate slot and create pipelines - slots.allocate_slot(pipeline_name, pipelines) - await _create_slot_pipelines(slot_id, pipelines) - logger.info(f"Restored slot {slot_id} configuration") - else: - logger.info(f"Slot {slot_id} already exists, skipping restoration") - except Exception as e: - logger.error(f"Failed to restore slot {slot_id}: {e}") - continue - - # Forward the simulation request with retries - max_retries = 3 - success = False - for attempt in range(max_retries): - try: - timeout = 2 + attempt # 2s, 3s, 4s - response = requests.post( - "http://127.0.0.1:9449", - json=log_data, - timeout=timeout - ) - response.raise_for_status() - logger.info(f"Queued simulation processed successfully: slot={log_data.get('slot')}") - success = True - break - except Exception as e: - if attempt < max_retries - 1: - logger.warning(f"Queued simulation attempt {attempt + 1} failed, retrying: {e}") - await asyncio.sleep(1) - else: - logger.error(f"Failed to process queued simulation after {max_retries} attempts: {e}") - - if not success: - # Re-queue the failed item at the front for retry later - with _queue_lock: - _simulation_queue.appendleft(queued_item) - logger.warning("Re-queued failed simulation for retry later") - break # Stop processing queue, will retry on next iteration - - except asyncio.CancelledError: - logger.info("Queue processor cancelled") - break - except Exception as e: - logger.error(f"Error in queue processor: {e}", exc_info=True) - await asyncio.sleep(5) - - -@app.get("/_logstash/health") -async def logstash_health(): - """ - Check if Logstash is healthy and ready to accept simulation requests. - Returns health status from supervisor. - """ - supervisor = logstash_supervisor.get_supervisor() - with _queue_lock: - queue_size = len(_simulation_queue) - - if supervisor: - return JSONResponse( - status_code=200 if supervisor.is_healthy else 503, - content={ - "healthy": supervisor.is_healthy, - "restarting": supervisor.is_restarting, - "restart_count": supervisor.restart_count, - "queued_requests": queue_size - } - ) - return JSONResponse( - status_code=503, - content={"healthy": False, "restarting": False, "restart_count": 0, "queued_requests": queue_size} - ) - - -@app.post("/_logstash/simulate") -async def simulate_log(request: Request): - """ - Proxy endpoint for simulation log input. - Accepts HTTPS requests from LogstashUI and forwards them to the local HTTP port 9449. - - Queues requests when Logstash is unhealthy and processes them when it recovers. - """ - try: - # Get the JSON body from the request - log_data = await request.json() - slot_id = log_data.get('slot') - - # Check if Logstash is healthy - supervisor = logstash_supervisor.get_supervisor() - is_healthy = supervisor and supervisor.is_healthy - - if not is_healthy: - # Queue the request with slot configuration for restoration - slot_config = None - if slot_id: - # Get current slot configuration to restore later - slot_state = slots.get_slot_state() - if slot_id in slot_state: - slot_data = slot_state[slot_id] - slot_config = { - 'slot_id': slot_id, - 'pipeline_name': slot_data.get('pipeline_name'), - 'pipelines': slot_data.get('pipelines') - } - - with _queue_lock: - _simulation_queue.append({ - 'log_data': log_data, - 'slot_config': slot_config, - 'queued_at': time.time() - }) - queue_size = len(_simulation_queue) - - logger.warning(f"Logstash unhealthy - queued simulation request (queue size: {queue_size})") - return JSONResponse( - status_code=202, - content={ - "status": "queued", - "message": "Logstash is restarting, request queued for processing", - "queue_position": queue_size - } - ) - - # Logstash is healthy - forward immediately with retry logic - max_retries = 3 - for attempt in range(max_retries): - try: - timeout = 1 + attempt # 1s, 2s, 3s - aggressive timeouts to detect hung Logstash - logger.debug(f"Simulation attempt {attempt + 1}/{max_retries}, timeout={timeout}s") - - # Forward to local Logstash HTTP input on port 9449 - response = requests.post( - "http://127.0.0.1:9449", - json=log_data, - timeout=timeout - ) - response.raise_for_status() - - logger.info( - f"Forwarded simulation log to Logstash: slot={slot_id}, run_id={log_data.get('run_id')}") - - return JSONResponse( - status_code=200, - content={"status": "success", "message": "Log forwarded to Logstash"} - ) - - except requests.exceptions.Timeout as e: - if attempt < max_retries - 1: - logger.warning(f"Simulation timeout on attempt {attempt + 1}, retrying...") - await asyncio.sleep(1) - continue - else: - # All retries failed - Logstash is likely stunned/OOM - logger.error(f"Simulation failed after {max_retries} attempts due to timeout - triggering restart") - - # Queue the request for retry after restart - slot_config = None - if slot_id: - slot_state = slots.get_slot_state() - if slot_id in slot_state: - slot_data = slot_state[slot_id] - slot_config = { - 'slot_id': slot_id, - 'pipeline_name': slot_data.get('pipeline_name'), - 'pipelines': slot_data.get('pipelines') - } - - with _queue_lock: - _simulation_queue.append({ - 'log_data': log_data, - 'slot_config': slot_config, - 'queued_at': time.time() - }) - queue_size = len(_simulation_queue) - - # Trigger restart - logstash_supervisor.trigger_restart("Simulation POST failed - Logstash stunned/OOM") - - logger.warning(f"Queued failed simulation for retry after restart (queue size: {queue_size})") - return JSONResponse( - status_code=202, - content={ - "status": "queued", - "message": "Logstash unresponsive, triggering restart and queuing request", - "queue_position": queue_size - } - ) - - except requests.exceptions.RequestException as e: - if attempt < max_retries - 1: - logger.warning(f"Simulation request failed on attempt {attempt + 1}, retrying: {e}") - await asyncio.sleep(1) - continue - else: - # All retries failed - Logstash is likely stunned/OOM - logger.error(f"Simulation failed after {max_retries} attempts: {e} - triggering restart") - - # Queue the request for retry after restart - slot_config = None - if slot_id: - slot_state = slots.get_slot_state() - if slot_id in slot_state: - slot_data = slot_state[slot_id] - slot_config = { - 'slot_id': slot_id, - 'pipeline_name': slot_data.get('pipeline_name'), - 'pipelines': slot_data.get('pipelines') - } - - with _queue_lock: - _simulation_queue.append({ - 'log_data': log_data, - 'slot_config': slot_config, - 'queued_at': time.time() - }) - queue_size = len(_simulation_queue) - - # Trigger restart - logstash_supervisor.trigger_restart(f"Simulation POST failed: {str(e)}") - - logger.warning(f"Queued failed simulation for retry after restart (queue size: {queue_size})") - return JSONResponse( - status_code=202, - content={ - "status": "queued", - "message": "Logstash unresponsive, triggering restart and queuing request", - "queue_position": queue_size - } - ) - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to forward log to Logstash: {e}") - raise HTTPException( - status_code=500, - detail=f"Failed to forward log to Logstash: {str(e)}" - ) - except Exception as e: - logger.error(f"Error in simulate_log endpoint: {e}") - raise HTTPException( - status_code=500, - detail=f"Error processing simulation log: {str(e)}" - ) - - -@app.get("/_logstash/pipeline") -async def list_pipelines(): - """List all pipelines (mimics Elasticsearch API)""" - pipelines = _load_pipelines_yml() - result = {} - - for pipeline in pipelines: - pipeline_id = pipeline.get('pipeline.id') - if pipeline_id: - # Load pipeline config - config = _load_pipeline_config(pipeline_id) - if config is None: - continue - - # Load metadata - metadata = _load_pipeline_metadata(pipeline_id) - - # Get settings from pipelines.yml - yml_settings = _get_pipeline_settings_from_yml(pipeline_id) - - # Merge settings (yml takes precedence) - pipeline_settings = metadata.get('pipeline_settings', {}) - pipeline_settings.update(yml_settings) - - result[pipeline_id] = { - "description": metadata.get('description', ''), - "last_modified": metadata.get('last_modified'), - "pipeline_metadata": metadata.get('pipeline_metadata', { - "type": "logstash_pipeline", - "version": 1 - }), - "username": metadata.get('username', 'LogstashAgent'), - "pipeline": config, - "pipeline_settings": pipeline_settings - } - - return result - - -@app.get("/_logstash/pipeline/{pipeline_id}") -async def get_pipeline(pipeline_id: str = FastAPIPath(..., description="Pipeline ID")): - """Get a specific pipeline (mimics Elasticsearch API)""" - _validate_pipeline_id(pipeline_id) - - # Load pipeline config - config = _load_pipeline_config(pipeline_id) - if config is None: - raise HTTPException(status_code=404, detail=f"Pipeline '{pipeline_id}' not found") - - # Load metadata - metadata = _load_pipeline_metadata(pipeline_id) - - # Get settings from pipelines.yml - yml_settings = _get_pipeline_settings_from_yml(pipeline_id) - - # Merge settings (yml takes precedence) - pipeline_settings = metadata.get('pipeline_settings', {}) - pipeline_settings.update(yml_settings) - - result = { - pipeline_id: { - "description": metadata.get('description', ''), - "last_modified": metadata.get('last_modified'), - "pipeline_metadata": metadata.get('pipeline_metadata', { - "type": "logstash_pipeline", - "version": 1 - }), - "username": metadata.get('username', 'LogstashAgent'), - "pipeline": config, - "pipeline_settings": pipeline_settings - } - } - - return result - - -@app.put("/_logstash/pipeline/{pipeline_id}") -async def put_pipeline(pipeline_id: str, body: Dict[str, Any]): - """Create or update a pipeline (mimics Elasticsearch API)""" - _validate_pipeline_id(pipeline_id) - - pipeline_config = body.get('pipeline') - if not pipeline_config: - raise HTTPException(status_code=400, detail="Missing 'pipeline' field in request body") - - # Prepare pipeline settings for pipelines.yml - pipeline_settings = body.get('pipeline_settings', {}) - - # Load existing pipelines - pipelines = _load_pipelines_yml() - - # Check if pipeline exists - pipeline_exists = False - for i, pipeline in enumerate(pipelines): - if pipeline.get('pipeline.id') == pipeline_id: - pipeline_exists = True - # Update existing pipeline entry - config_path = pipeline.get('path.config', f"{PIPELINES_DIR}/{pipeline_id}.conf") - pipelines[i] = { - 'pipeline.id': pipeline_id, - 'path.config': config_path, - **{k: v for k, v in pipeline_settings.items() if k.startswith('pipeline.') or k.startswith('queue.')} - } - break - - if not pipeline_exists: - # Add new pipeline entry - # Handle path separators based on OS - if os.name == 'nt': - # Windows: Convert to backslashes and escape for YAML - config_path = f"{PIPELINES_DIR}/{pipeline_id}.conf".replace('/', '\\').replace('\\', '\\\\') - else: - # Linux/Docker: Use forward slashes (no escaping needed) - config_path = f"{PIPELINES_DIR}/{pipeline_id}.conf" - new_pipeline = { - 'pipeline.id': pipeline_id, - 'path.config': config_path, - **{k: v for k, v in pipeline_settings.items() if k.startswith('pipeline.') or k.startswith('queue.')} - } - pipelines.append(new_pipeline) - - # Save pipeline configuration file - config_path = f"{PIPELINES_DIR}/{pipeline_id}.conf" - temp_config_path = f"{config_path}.tmp" - try: - with open(temp_config_path, 'w') as f: - f.write(pipeline_config) - os.replace(temp_config_path, config_path) - except Exception as e: - if os.path.exists(temp_config_path): - os.remove(temp_config_path) - raise HTTPException(status_code=500, detail=f"Failed to write pipeline config: {str(e)}") - - # Save pipelines.yml - try: - _save_pipelines_yml(pipelines) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to update pipelines.yml: {str(e)}") - - # Save metadata - metadata = { - "description": body.get('description', ''), - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": body.get('pipeline_metadata', { - "type": "logstash_pipeline", - "version": 1 - }), - "username": body.get('username', 'LogstashAgent'), - "pipeline_settings": pipeline_settings - } - - try: - _save_pipeline_metadata(pipeline_id, metadata) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to save metadata: {str(e)}") - - return {"acknowledged": True} - - -@app.delete("/_logstash/pipeline/{pipeline_id}") -async def delete_pipeline(pipeline_id: str = FastAPIPath(..., description="Pipeline ID")): - """Delete a pipeline (mimics Elasticsearch API)""" - _validate_pipeline_id(pipeline_id) - - # Load existing pipelines - pipelines = _load_pipelines_yml() - - # Find and remove the pipeline - pipeline_found = False - config_path = None - new_pipelines = [] - - for pipeline in pipelines: - if pipeline.get('pipeline.id') == pipeline_id: - pipeline_found = True - config_path = pipeline.get('path.config') - else: - new_pipelines.append(pipeline) - - if not pipeline_found: - raise HTTPException(status_code=404, detail=f"Pipeline '{pipeline_id}' not found") - - # Delete pipeline config file - if config_path and os.path.exists(config_path): - try: - os.remove(config_path) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete pipeline config: {str(e)}") - - # Delete metadata file - metadata_path = os.path.join(METADATA_DIR, f"{pipeline_id}.json") - if os.path.exists(metadata_path): - try: - os.remove(metadata_path) - except Exception: - pass # Non-critical if metadata deletion fails - - # Save updated pipelines.yml - try: - _save_pipelines_yml(new_pipelines) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to update pipelines.yml: {str(e)}") - - return {"acknowledged": True} - - -@app.post("/_logstash/slots/allocate") -async def allocate_simulation_slot(body: Dict[str, Any]): - """ - Allocate a slot for simulation pipelines. - - Request body: - { - "pipeline_name": "name of the pipeline being simulated", - "pipelines": [ - {"config": "filter config 1", "index": 1}, - {"config": "filter config 2", "index": 2}, - ... - ] - } - - Returns: - { - "slot_id": 1-10, - "reused": true/false (whether an existing slot was reused) - } - """ - pipeline_name = body.get('pipeline_name') - pipelines = body.get('pipelines', []) - - if not pipeline_name: - raise HTTPException(status_code=400, detail="Missing 'pipeline_name' field") - - if not pipelines: - raise HTTPException(status_code=400, detail="Missing 'pipelines' field or empty pipeline list") - - # Check if a slot with this exact configuration already exists - content_hash = slots._compute_pipeline_hash(pipelines) - existing_slots = slots.get_slot_state() - slot_existed_before = any( - slot_data.get('content_hash') == content_hash - for slot_data in existing_slots.values() - ) - - # Allocate or reuse slot (allocate_slot handles hash checking internally) - slot_id = slots.allocate_slot(pipeline_name, pipelines) - - if slot_id is None: - raise HTTPException(status_code=500, detail="Failed to allocate slot") - - # If the slot existed before with the same hash, it's reused - reused = slot_existed_before - - logger.info(f"Slot {slot_id} - reused: {reused}, hash: {content_hash[:8]}...") - - # Check if pipelines actually exist when reusing a slot - # They may have been deleted during previous failure cleanup or eviction - pipelines_exist = False - if reused: - # Check if the first pipeline exists in Logstash using API - first_pipeline_name = f"slot{slot_id}-filter1" - try: - with LogstashAPI(timeout=3.0) as api: - all_pipelines = api.list_pipelines() - pipelines_exist = first_pipeline_name in all_pipelines - logger.info(f"Slot {slot_id} reused - pipelines exist: {pipelines_exist}") - except Exception as e: - logger.warning(f"Failed to check pipeline existence via API: {e}. Assuming pipelines don't exist.") - pipelines_exist = False - - # Create pipelines if they don't exist (new slot or reused slot with deleted pipelines) - if not reused or not pipelines_exist: - try: - await _create_slot_pipelines(slot_id, pipelines) - except HTTPException as e: - # Release the slot if pipeline creation fails - slots.release_slot(slot_id) - # Re-raise HTTPException as-is to preserve detail structure (may contain slot_id dict) - raise - except Exception as e: - # Release the slot if pipeline creation fails - slots.release_slot(slot_id) - # For non-HTTP exceptions, include slot_id in detail for error tracking - raise HTTPException( - status_code=500, - detail={ - "message": f"Failed to create slot pipelines: {str(e)}", - "slot_id": slot_id - } - ) - - logger.info(f"Returning HTTP response for slot {slot_id}") - return { - "slot_id": slot_id, - "reused": reused, - "pipeline_count": len(pipelines) - } - - -async def _create_slot_pipelines(slot_id: int, pipelines: List[Dict[str, Any]]): - """ - Create the filter pipelines for a specific slot. - - Args: - slot_id: Slot ID (1-10) - pipelines: List of pipeline configurations - """ - for pipeline_data in pipelines: - idx = pipeline_data.get('index', 1) - filter_config = pipeline_data.get('filter_config', '') - - if not filter_config: - continue - - # Determine next filter address - if idx < len(pipelines): - next_filter_id = f"slot{slot_id}-filter{idx + 1}" - else: - next_filter_id = "filter-final" - - # Generate pipeline config with both pipeline and HTTP outputs - pipeline_config = f"""input {{ - pipeline {{ address => "slot{slot_id}-filter{idx}" }} -}} - -filter {{ -{filter_config} -}} - -output {{ - pipeline {{ send_to => "simulate-end" }} -}} -""" - - # Create the pipeline - pipeline_name = f"slot{slot_id}-filter{idx}" - pipeline_body = { - "pipeline": pipeline_config, - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": { - "version": 1, - "type": "logstash_pipeline" - }, - "username": "LogstashAgent", - "pipeline_settings": { - "pipeline.workers": 1 - } - } - - # Use the existing put_pipeline logic - await put_pipeline(pipeline_name, pipeline_body) - - # Verify all slot pipelines loaded successfully - # Uses adaptive timing based on pipeline count (default: 20 retries, 2s delay) - verify_start = time.time() - verification_success = await slots.verify_slot_pipelines_loaded( - slot_id, - len(pipelines) - ) - verify_end = time.time() - logger.info(f"Verification completed in {verify_end - verify_start:.2f}s") - - if not verification_success: - # Delete the failed pipelines from Logstash to prevent log pollution - logger.warning(f"Verification failed for slot {slot_id}, cleaning up pipelines") - for idx in range(1, len(pipelines) + 1): - pipeline_name = f"slot{slot_id}-filter{idx}" - try: - await delete_pipeline(pipeline_name) - logger.info(f"Deleted failed pipeline {pipeline_name}") - except Exception as cleanup_error: - logger.error(f"Error deleting failed pipeline {pipeline_name}: {cleanup_error}") - - # Wait for pipelines to actually disappear from Logstash API - # This prevents stale failure state when slot is reused - import asyncio - logger.info(f"Waiting for slot {slot_id} pipelines to be removed from Logstash...") - max_wait = 5.0 - start_wait = time.time() - while time.time() - start_wait < max_wait: - try: - with LogstashAPI(timeout=3.0) as api: - all_pipelines = api.list_pipelines() - slot_pipelines_still_exist = any( - f"slot{slot_id}-filter{idx}" in all_pipelines - for idx in range(1, len(pipelines) + 1) - ) - if not slot_pipelines_still_exist: - logger.info(f"Slot {slot_id} pipelines successfully removed from Logstash") - break - except Exception as e: - logger.warning(f"Error checking pipeline removal: {e}") - await asyncio.sleep(0.5) - - raise HTTPException( - status_code=500, - detail={ - "message": f"Slot {slot_id} pipelines created but failed to load in Logstash. Check logs for errors.", - "slot_id": slot_id - } - ) - - -@app.get("/_logstash/slots") -async def get_slots(): - """Get the current state of all slots.""" - return slots.get_slot_state() - - -@app.delete("/_logstash/slots/{slot_id}") -async def release_slot(slot_id: int = FastAPIPath(..., description="Slot ID", ge=1, le=10)): - """Release a specific slot.""" - success = slots.release_slot(slot_id) - - if not success: - raise HTTPException(status_code=404, detail=f"Slot {slot_id} not found") - - return {"acknowledged": True, "slot_id": slot_id} - - -@app.get("/_logstash/pipeline/{pipeline_id}/logs") -async def get_pipeline_logs( - pipeline_id: str = FastAPIPath(..., description="Pipeline ID"), - max_entries: int = Query(50, description="Maximum number of log entries to return", ge=1, le=500), - min_level: str = Query("WARN", description="Minimum log level (DEBUG, INFO, WARN, ERROR)"), - min_timestamp: int = Query(None, - description="Minimum timestamp in milliseconds. Only logs at or after this time will be included.") -): - """ - Get log entries related to a specific pipeline. - - This endpoint searches Logstash JSON logs for entries related to the given pipeline, - including errors, warnings, and other diagnostic information. - - Args: - pipeline_id: The pipeline ID to search for (e.g., "slot4-filter1") - max_entries: Maximum number of log entries to return (default: 50, max: 500) - min_level: Minimum log level to include (default: WARN) - min_timestamp: Optional minimum timestamp in milliseconds. Only logs at or after this time will be included. - - Returns: - JSON response with: - - pipeline_id: The pipeline ID searched - - log_count: Number of log entries found - - logs: List of log entries with full context - """ - _validate_pipeline_id(pipeline_id) - - try: - # Fetch logs using log_analyzer - logs = log_analyzer.find_related_logs( - pipeline_id=pipeline_id, - max_entries=max_entries, - min_level=min_level.upper(), - min_timestamp=min_timestamp - ) - - return { - "pipeline_id": pipeline_id, - "log_count": len(logs), - "logs": logs - } - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Error fetching logs for pipeline {pipeline_id}: {str(e)}" - ) - - -@app.get("/_logstash/pipelines/status") -async def get_pipelines_status(): - """ - Get the current status of all running pipelines from Logstash API. - - Returns: - - running_pipelines: List of pipeline IDs currently loaded in Logstash - - count: Total count of pipelines - - timestamp: When this status was retrieved - - states: Dictionary mapping pipeline names to their states (running/idle/failed/unknown) - """ - try: - with LogstashAPI(timeout=5.0) as api: - # Get all pipelines - all_pipelines = api.list_pipelines() - - # Get state for each pipeline - # Use defensive error handling - if one pipeline fails, don't crash the whole endpoint - pipeline_states = {} - for pipeline_name in all_pipelines: - try: - state = api.detect_pipeline_state(pipeline_name) - pipeline_states[pipeline_name] = state - except Exception as e: - logger.error(f"Error detecting state for pipeline '{pipeline_name}': {e}") - pipeline_states[pipeline_name] = 'unknown' - - return { - "running_pipelines": all_pipelines, - "count": len(all_pipelines), - "timestamp": datetime.now(timezone.utc).isoformat(), - "states": pipeline_states - } - except Exception as e: - logger.error(f"Error in get_pipelines_status: {e}") - raise HTTPException( - status_code=500, - detail=f"Error fetching pipeline status from Logstash API: {str(e)}" - ) - - -@app.post("/_logstash/write-file") -async def write_file(request: Request): - """ - Write a file to the uploaded directory for simulation use. - Only enabled when SIMULATION_MODE environment variable is set to true. - - Request body: - { - "filename": "filter_translate_10_dictionary_path.json", - "content": "" - } - """ - # Check if simulation mode is enabled (defaults to true for development) - # Set SIMULATION_MODE=false to explicitly disable file uploads - simulation_mode = os.getenv("SIMULATION_MODE", "true").lower() == "true" - if not simulation_mode: - raise HTTPException( - status_code=403, - detail="File upload is only allowed in simulation mode" - ) - - try: - body = await request.json() - filename = body.get("filename") - content = body.get("content") - - if not filename or not content: - raise HTTPException( - status_code=400, - detail="Both 'filename' and 'content' are required" - ) - - # Create uploaded directory in /tmp if it doesn't exist - uploaded_dir = "/tmp/uploaded" - os.makedirs(uploaded_dir, exist_ok=True) - - # Sanitize filename to prevent path traversal - safe_filename = os.path.basename(filename) - file_path = os.path.join(uploaded_dir, safe_filename) - - # Decode base64 content and write file - logger.info(f"Received content length: {len(content)} characters") - file_content = base64.b64decode(content) - logger.info(f"Decoded to {len(file_content)} bytes") - - with open(file_path, 'wb') as f: - bytes_written = f.write(file_content) - logger.info(f"Wrote {bytes_written} bytes to {file_path}") - - logger.info(f"File written successfully: {file_path}") - - return JSONResponse( - status_code=200, - content={ - "status": "success", - "message": f"File written to {file_path}", - "path": file_path - } - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error writing file: {e}") - raise HTTPException( - status_code=500, - detail=f"Error writing file: {str(e)}" - ) - - -@app.post("/_logstash/validate") -async def validate_logstash_config(request: Request): - """ - Validate a Logstash pipeline configuration using logstash --config.test_and_exit. - - Request body: - - pipeline_name: Name of the pipeline (used for temp file naming) - - config: The Logstash configuration to validate - - Returns: - - status: "OK" or "ERROR" - - notifications: List of warning/deprecation messages - - error: Error message if validation failed - """ - import subprocess - import tempfile - - try: - body = await request.json() - pipeline_name = body.get("pipeline_name", "pipeline") - config = body.get("config") - - if not config: - raise HTTPException( - status_code=400, - detail="No configuration provided" - ) - - # Create temporary config file - temp_file_path = f"/tmp/{pipeline_name}.conf" - - try: - # Replace keystore variables without defaults to avoid validation failures - # Pattern: ${variable_name} -> ${variable_name:test} - # Don't replace if already has a default: ${variable_name:existing_default} - import re - config_with_defaults = re.sub( - r'\$\{([^}:]+)\}', # Match ${variable_name} without colon - r'${\1:test}', # Replace with ${variable_name:test} - config - ) - - # Write config to temp file - with open(temp_file_path, 'w') as f: - f.write(config_with_defaults) - - logger.info(f"Validating config for pipeline '{pipeline_name}' at {temp_file_path}") - - # Get logstash binary path from config - logstash_binary = AGENT_CONFIG.get('logstash_binary', '/usr/share/logstash/bin/logstash') - logger.info(f"Using Logstash binary: {logstash_binary}") - - # Run logstash validation - result = subprocess.run( - [logstash_binary, "--config.test_and_exit", "-f", temp_file_path, "--log.format", "json"], - capture_output=True, - text=True, - timeout=30 - ) - - # Parse output to extract notifications by log level - notifications_by_level = {} - output_lines = result.stdout.strip().split('\n') - - for line in output_lines: - try: - log_entry = json.loads(line) - # Extract log entries with logEvent - if "logEvent" in log_entry: - message = log_entry["logEvent"].get("message", "") - logger_name = log_entry.get("loggerName", "") - level = log_entry.get("level", "INFO") - - # Filter out noise - if "Reflections took" in message or "pipelines.yml" in message: - continue - - # Only include relevant log levels - if level in ["FATAL", "ERROR", "WARN", "INFO"]: - # Skip generic INFO messages unless they're important - if level == "INFO": - # Filter out logstash.runner INFO logs - not useful to users - if logger_name == "logstash.runner": - continue - # Only include specific INFO messages - if not any(keyword in message.lower() for keyword in ["deprecated", "warning", "error"]): - continue - - # Initialize level list if not exists - if level not in notifications_by_level: - notifications_by_level[level] = [] - - # Remove discussion forum text if present - cleaned_message = message.replace( - "If you have any questions about this, please ask it on the https://discuss.elastic.co/c/logstash discussion forum", - "" - ).strip() - - # Add entry with plugin and message - notifications_by_level[level].append({ - "plugin": logger_name, - "message": cleaned_message - }) - except json.JSONDecodeError: - # Skip non-JSON lines (like "Configuration OK") - if "Configuration OK" in line: - logger.info("Configuration validation passed") - continue - - # Determine overall status based on log levels present - if "FATAL" in notifications_by_level or "ERROR" in notifications_by_level: - status = "ERROR" - elif "WARN" in notifications_by_level: - status = "WARN" - elif result.returncode == 0: - status = "OK" - else: - status = "ERROR" - - logger.info(f"Validation result for pipeline '{pipeline_name}': {status}, levels: {list(notifications_by_level.keys())}") - - return JSONResponse( - status_code=200, - content={ - "status": status, - "notifications": notifications_by_level - } - ) - - finally: - # Clean up temp file - if os.path.exists(temp_file_path): - os.remove(temp_file_path) - logger.debug(f"Removed temp file: {temp_file_path}") - - except subprocess.TimeoutExpired: - logger.error(f"Validation timeout for pipeline '{pipeline_name}'") - raise HTTPException( - status_code=500, - detail="Validation timeout - configuration took too long to validate" - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error validating config: {e}") - raise HTTPException( - status_code=500, - detail=f"Error validating configuration: {str(e)}" - ) \ No newline at end of file diff --git a/LogstashAgent/requirements.txt b/LogstashAgent/requirements.txt deleted file mode 100644 index d60e72ae..00000000 Binary files a/LogstashAgent/requirements.txt and /dev/null differ diff --git a/LogstashAgent/slots.py b/LogstashAgent/slots.py deleted file mode 100644 index da9821db..00000000 --- a/LogstashAgent/slots.py +++ /dev/null @@ -1,716 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -import hashlib -import json -from datetime import datetime, timezone, timedelta -from typing import Dict, List, Optional, Any -from threading import Lock, Thread -import time -import yaml -import os -import log_analyzer -import logging -from logstash_api import LogstashAPI, PipelineNotFoundError - -# Configure logging -logger = logging.getLogger(__name__) - -# Number of simulation slots available -NUM_SLOTS = 6 - -# Slot TTL in seconds (2 minutes) -SLOT_TTL_SECONDS = 120 - -# Global slot state - thread-safe -_slots_lock = Lock() -_slots: Dict[int, Dict[str, Any]] = {} - - -def _compute_pipeline_hash(pipelines: List[Dict[str, Any]]) -> str: - """ - Compute a hash of the pipeline list to detect changes. - - Only hashes fields that actually affect the created pipeline: - - filter_config: The filter configuration content - - index: The pipeline index/order - - Args: - pipelines: List of pipeline configurations - - Returns: - SHA256 hash string - """ - # Extract only the fields that affect the actual pipeline - # (output_config is sent by UI but ignored by agent, so exclude it from hash) - normalized_pipelines = [] - for pipeline in pipelines: - filter_config = pipeline.get('filter_config', '') - normalized_pipelines.append({ - 'filter_config': filter_config, - 'index': pipeline.get('index', 1) - }) - - # Convert to JSON string with sorted keys for consistent hashing - pipeline_str = json.dumps(normalized_pipelines, sort_keys=True) - computed_hash = hashlib.sha256(pipeline_str.encode()).hexdigest() - - # Debug: Write full filter_config to temp file for comparison - # if normalized_pipelines: - # import tempfile - # filter_config = normalized_pipelines[0]['filter_config'] - # debug_file = os.path.join(tempfile.gettempdir(), f"filter_config_{computed_hash[:8]}.txt") - # try: - # with open(debug_file, 'w', encoding='utf-8') as f: - # f.write(filter_config) - # logger.info(f"Hash {computed_hash[:8]}: Wrote filter_config to {debug_file} ({len(filter_config)} bytes)") - # except Exception as e: - # logger.error(f"Failed to write debug file: {e}") - - return computed_hash - - -def get_slot_state() -> Dict[int, Dict[str, Any]]: - """ - Get a copy of the current slot state. - - Returns: - Dictionary mapping slot IDs to their state - """ - with _slots_lock: - return _slots.copy() - - -def allocate_slot(pipeline_name: str, pipelines: List[Dict[str, Any]]) -> Optional[int]: - """ - Allocate a slot for the given pipeline configuration. - - If a slot already exists with the same content hash, reuse it. - Otherwise, find an empty slot or evict the oldest one. - - Args: - pipeline_name: Name of the pipeline - pipelines: List of pipeline configurations - - Returns: - Slot ID (1-10) or None if allocation failed - """ - content_hash = _compute_pipeline_hash(pipelines) - old_slot_data_to_cleanup = None - slot_id_to_cleanup = None - - logger.info(f"allocate_slot: Looking for hash {content_hash[:8]}... in {len(_slots)} existing slots") - - with _slots_lock: - # Check if we already have a slot with this exact configuration - for slot_id, slot_data in _slots.items(): - existing_hash = slot_data.get('content_hash', '') - logger.debug(f" Slot {slot_id} hash: {existing_hash[:8]}...") - if existing_hash == content_hash: - # Update last_accessed to prevent TTL eviction - # DO NOT update created_at - keep original creation time to prevent race conditions - # with eviction logic during active simulations - now = datetime.now(timezone.utc) - slot_data['last_accessed'] = now.isoformat() - logger.info(f"+ Reusing slot {slot_id} with matching hash") - return slot_id - else: - # Debug: Compare configs to see what's different - if pipelines and slot_data.get('pipelines'): - new_config = pipelines[0].get('filter_config', '') - old_config = slot_data['pipelines'][0].get('filter_config', '') - if len(new_config) == len(old_config): - # Same length but different hash - find first difference - for i, (c1, c2) in enumerate(zip(new_config, old_config)): - if c1 != c2: - start = max(0, i - 50) - end = min(len(new_config), i + 50) - logger.warning(f"Hash mismatch at position {i}:") - logger.warning(f" Old (slot {slot_id}): ...{old_config[start:end]}...") - logger.warning(f" New: ...{new_config[start:end]}...") - break - - # Find an empty slot - logger.info(f"No matching hash found, allocating new slot") - for slot_id in range(1, NUM_SLOTS + 1): - if slot_id not in _slots: - now = datetime.now(timezone.utc) - _slots[slot_id] = { - 'content_hash': content_hash, - 'created_at': now.isoformat(), - 'created_at_millis': int(now.timestamp() * 1000), - 'last_accessed': now.isoformat(), - 'pipeline_name': pipeline_name, - 'pipelines': pipelines - } - logger.info(f"+ Allocated new empty slot {slot_id}") - return slot_id - - # No empty slots - evict the oldest one (by created_at) - oldest_slot_id = min( - _slots.keys(), - key=lambda sid: _slots[sid]['created_at'] - ) - - # Save old slot data before overwriting so we can clean up its pipelines - old_slot_data_to_cleanup = _slots[oldest_slot_id].copy() - slot_id_to_cleanup = oldest_slot_id - - now = datetime.now(timezone.utc) - _slots[oldest_slot_id] = { - 'content_hash': content_hash, - 'created_at': now.isoformat(), - 'created_at_millis': int(now.timestamp() * 1000), - 'last_accessed': now.isoformat(), - 'pipeline_name': pipeline_name, - 'pipelines': pipelines - } - - # Delete old pipelines OUTSIDE the lock to avoid blocking other allocations - if old_slot_data_to_cleanup is not None: - logger.info(f"Evicting slot {slot_id_to_cleanup}, cleaning up old pipelines") - _delete_slot_pipelines(slot_id_to_cleanup, old_slot_data_to_cleanup) - - return oldest_slot_id - - -def release_slot(slot_id: int) -> bool: - """ - Release a slot, making it available for reuse. - - Args: - slot_id: Slot ID (1-10) - - Returns: - True if slot was released, False if it didn't exist - """ - with _slots_lock: - if slot_id in _slots: - del _slots[slot_id] - return True - return False - - -def clear_all_slots(): - """Clear all slots - useful for testing or reset.""" - with _slots_lock: - _slots.clear() - - -def evict_all_slots_and_cleanup(): - """ - Evict all slots and clean up all pipeline files from conf.d. - This should be called before Logstash restart to prevent mismatch - between slots state and Logstash's loaded pipelines. - - Returns: - List of evicted slot IDs - """ - logger.info("Evicting all slots and cleaning up conf.d folder for Logstash restart") - evicted_slots = [] - - with _slots_lock: - # Get all current slots before clearing - slots_to_cleanup = list(_slots.items()) - - # Clear the slots dictionary - _slots.clear() - evicted_slots = [slot_id for slot_id, _ in slots_to_cleanup] - - # Delete all pipeline files outside the lock - for slot_id, slot_data in slots_to_cleanup: - try: - _delete_slot_pipelines(slot_id, slot_data) - except Exception as e: - logger.error(f"Error cleaning up slot {slot_id} during evict_all: {e}") - - # Also clean up any orphaned pipeline files in conf.d - try: - import main - conf_d_path = main.PIPELINES_DIR - if os.path.exists(conf_d_path): - orphaned_files = [] - for filename in os.listdir(conf_d_path): - if filename.startswith('slot') and filename.endswith('.conf'): - file_path = os.path.join(conf_d_path, filename) - try: - os.remove(file_path) - orphaned_files.append(filename) - logger.debug(f"Removed orphaned pipeline file: {filename}") - except Exception as e: - logger.error(f"Error removing orphaned file {filename}: {e}") - - if orphaned_files: - logger.info(f"Cleaned up {len(orphaned_files)} orphaned pipeline files from conf.d") - except Exception as e: - logger.error(f"Error cleaning up orphaned files: {e}") - - logger.info(f"Evicted all {len(evicted_slots)} slots and cleaned up conf.d folder") - return evicted_slots - - -def evict_expired_slots() -> List[int]: - """ - Evict slots that haven't been accessed within the TTL period. - - Returns: - List of evicted slot IDs - """ - evicted_slots = [] - current_time = datetime.now(timezone.utc) - - with _slots_lock: - slots_to_evict = [] - - for slot_id, slot_data in _slots.items(): - last_accessed_str = slot_data.get('last_accessed') - if last_accessed_str: - try: - last_accessed = datetime.fromisoformat(last_accessed_str.replace('Z', '+00:00')) - time_since_access = (current_time - last_accessed).total_seconds() - - if time_since_access > SLOT_TTL_SECONDS: - slots_to_evict.append((slot_id, slot_data)) - except (ValueError, AttributeError): - # If we can't parse the timestamp, evict the slot to be safe - slots_to_evict.append((slot_id, slot_data)) - else: - # No last_accessed timestamp, evict to be safe - slots_to_evict.append((slot_id, slot_data)) - - # Evict the expired slots - for slot_id, slot_data in slots_to_evict: - del _slots[slot_id] - evicted_slots.append(slot_id) - - # Delete Logstash pipelines for evicted slots (outside the lock) - for slot_id, slot_data in slots_to_evict: - _delete_slot_pipelines(slot_id, slot_data) - - if evicted_slots: - logger.info(f"Evicted {len(evicted_slots)} expired slots: {evicted_slots}") - - return evicted_slots - - -def evict_failed_slots() -> List[int]: - """ - Evict slots whose pipelines have failed to load or are not running. - - Uses the Logstash API to directly query pipeline state instead of parsing logs. - This is more reliable and faster than log-based detection. - - Returns: - List of evicted slot IDs - """ - evicted_slots = [] - current_time = datetime.now(timezone.utc) - - # Minimum age before evicting a slot (prevents race condition with pipeline creation) - # Pipelines need time to load - don't evict slots that were just created - MIN_SLOT_AGE_SECONDS = 30 - - try: - with LogstashAPI(timeout=5.0) as api: - # Get all currently loaded pipelines from Logstash - all_pipelines = api.list_pipelines() - - with _slots_lock: - slots_to_evict = [] - - for slot_id, slot_data in _slots.items(): - # Check slot age - don't evict newly created slots - created_at_str = slot_data.get('created_at') - if created_at_str: - try: - created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) - slot_age = (current_time - created_at).total_seconds() - - if slot_age < MIN_SLOT_AGE_SECONDS: - logger.debug(f"Slot {slot_id} is only {slot_age:.1f}s old - skipping eviction check") - continue - except (ValueError, AttributeError) as e: - logger.warning(f"Could not parse created_at for slot {slot_id}: {e}") - # Continue with eviction check if we can't parse timestamp - - pipelines = slot_data.get('pipelines', []) - - # Check if any pipeline in this slot is missing, failed, or not running - for idx in range(1, len(pipelines) + 1): - pipeline_name = f"slot{slot_id}-filter{idx}" - - # Check if pipeline exists in Logstash - if pipeline_name not in all_pipelines: - logger.warning( - f"Slot {slot_id} pipeline {pipeline_name} not found in Logstash - marking for eviction") - slots_to_evict.append((slot_id, slot_data)) - break - - # Check pipeline state - state = api.detect_pipeline_state(pipeline_name) - if state == 'not_found': - logger.warning( - f"Slot {slot_id} pipeline {pipeline_name} state is 'not_found' - marking for eviction") - slots_to_evict.append((slot_id, slot_data)) - break - elif state == 'failed': - logger.warning( - f"Slot {slot_id} pipeline {pipeline_name} has failed (reload failures) - marking for eviction") - slots_to_evict.append((slot_id, slot_data)) - break - - # Evict the failed slots - for slot_id, slot_data in slots_to_evict: - del _slots[slot_id] - evicted_slots.append(slot_id) - - # Delete Logstash pipelines for evicted slots (outside the lock) - for slot_id, slot_data in slots_to_evict: - _delete_slot_pipelines(slot_id, slot_data) - - if evicted_slots: - logger.info(f"Evicted {len(evicted_slots)} failed slots: {evicted_slots}") - - except Exception as e: - logger.error(f"Error during API-based slot eviction: {e}") - # Fall back to log-based detection if API fails - logger.warning("Falling back to log-based detection") - return _evict_failed_slots_fallback() - - return evicted_slots - - -def _evict_failed_slots_fallback() -> List[int]: - """ - Fallback to log-based eviction if API is unavailable. - This is the old implementation kept as a safety net. - """ - evicted_slots = [] - - try: - logs = log_analyzer._read_json_logs(max_lines=1000, reverse=True) - except Exception as e: - logger.error(f"Error reading logs for failed slot detection: {e}") - return evicted_slots - - failed_pipeline_ids = set() - - for log_entry in logs: - if log_entry.get('level') == 'ERROR': - log_event = log_entry.get('logEvent', {}) - action_type = log_event.get('action_type', '') - if 'FailedAction' in action_type: - pipeline_id = log_event.get('id') - if pipeline_id and pipeline_id.startswith('slot'): - failed_pipeline_ids.add(pipeline_id) - - if not failed_pipeline_ids: - return evicted_slots - - with _slots_lock: - slots_to_evict = [] - for slot_id, slot_data in _slots.items(): - pipelines = slot_data.get('pipelines', []) - for idx in range(1, len(pipelines) + 1): - pipeline_name = f"slot{slot_id}-filter{idx}" - if pipeline_name in failed_pipeline_ids: - slots_to_evict.append((slot_id, slot_data)) - break - - for slot_id, slot_data in slots_to_evict: - del _slots[slot_id] - evicted_slots.append(slot_id) - - for slot_id, slot_data in slots_to_evict: - _delete_slot_pipelines(slot_id, slot_data) - - return evicted_slots - - -async def verify_slot_pipelines_loaded(slot_id: int, expected_count: int, max_wait_seconds: float = 20.0, - poll_interval: float = 1.0) -> bool: - """ - Verify that all pipelines for a slot have been successfully loaded by Logstash. - - Uses continuous polling of the Logstash API to detect pipeline state changes immediately. - This provides instant feedback when pipelines fail or succeed. - - Args: - slot_id: Slot ID (1-10) - expected_count: Number of pipelines expected for this slot - max_wait_seconds: Maximum time to wait for pipelines to load (default: 20 seconds) - poll_interval: How often to poll the API in seconds (default: 0.2 seconds for fast feedback) - - Returns: - True if all slot pipelines are running, False otherwise - """ - import asyncio - import time - - logger.info( - f"Verifying slot {slot_id} pipelines (polling every {poll_interval}s, max wait: {max_wait_seconds}s)...") - start_time = time.time() - attempt = 0 - - # Track when we first see each pipeline to detect initialization vs. actual failures - first_seen = {} - - # Grace period for pipelines to appear in Logstash API (config reload detection time) - # Logstash config.reload.automatic is typically 1-3 seconds - GRACE_PERIOD_SECONDS = 5.0 - - # Track baseline reload counters for each pipeline to detect NEW failures - # Logstash reload counters are cumulative and persist across pipeline deletions - baseline_reload_counters = {} - - try: - with LogstashAPI(timeout=5.0) as api: - while True: - attempt += 1 - elapsed = time.time() - start_time - - try: - # Check if all slot pipelines are loaded - slot_pipelines = [f"slot{slot_id}-filter{i}" for i in range(1, expected_count + 1)] - not_found_pipelines = [] - failed_pipelines = [] - loaded_pipelines = [] - - for pipeline_name in slot_pipelines: - # Get detailed pipeline stats to check reload counters - try: - stats = api.get_pipeline_stats(pipeline_name) - pipeline_data = stats.get('pipelines', {}).get(pipeline_name, {}) - reloads = pipeline_data.get('reloads', {}) - current_failures = reloads.get('failures', 0) - current_successes = reloads.get('successes', 0) - - # Set baseline on first check of this pipeline - if pipeline_name not in baseline_reload_counters: - baseline_reload_counters[pipeline_name] = { - 'failures': current_failures, - 'successes': current_successes - } - logger.debug(f"Pipeline {pipeline_name} - baseline: failures={current_failures}, successes={current_successes}") - - # Calculate NEW failures/successes since baseline - baseline = baseline_reload_counters[pipeline_name] - new_failures = current_failures - baseline['failures'] - new_successes = current_successes - baseline['successes'] - - # Check if there are NEW failures (not historical ones) - if new_failures > 0 and new_failures >= new_successes: - failed_pipelines.append(pipeline_name) - logger.error(f"Pipeline {pipeline_name} has NEW failures (new_failures={new_failures}, new_successes={new_successes}, baseline_failures={baseline['failures']})") - continue - - except Exception as e: - logger.debug(f"Could not get detailed stats for {pipeline_name}: {e}") - - # Use standard state detection - state = api.detect_pipeline_state(pipeline_name) - - # Track when we first see this pipeline - if pipeline_name not in first_seen and state != 'not_found': - first_seen[pipeline_name] = time.time() - - if state == 'not_found': - not_found_pipelines.append(pipeline_name) - elif state == 'idle': - # Pipeline exists and loaded successfully - but check if it's truly ready - # Logstash can report a pipeline as 'idle' while it's still initializing - # (events structure exists but pipeline isn't accepting events yet) - # Wait at least 2 seconds after first seeing the pipeline to ensure it's started - if pipeline_name in first_seen: - time_since_first_seen = time.time() - first_seen[pipeline_name] - if time_since_first_seen >= 2.0: - loaded_pipelines.append(pipeline_name) - logger.debug(f"Pipeline {pipeline_name} is idle (loaded successfully, stable for {time_since_first_seen:.1f}s)") - else: - # Pipeline just appeared, wait a bit longer to ensure it's truly ready - logger.debug(f"Pipeline {pipeline_name} is idle but only seen for {time_since_first_seen:.1f}s, waiting for stability") - not_found_pipelines.append(pipeline_name) - else: - # First time seeing this pipeline as idle, wait for next check - logger.debug(f"Pipeline {pipeline_name} is idle (first detection, waiting for stability)") - not_found_pipelines.append(pipeline_name) - elif state == 'running': - # Pipeline is actively processing - it's definitely ready! - loaded_pipelines.append(pipeline_name) - logger.debug(f"Pipeline {pipeline_name} is running") - - # Check for failed pipelines first - FAIL IMMEDIATELY for fast feedback - if failed_pipelines: - logger.error(f"✗ Pipelines failed to load (NEW failures detected): {failed_pipelines}") - return False - - # All pipelines found and loaded (either idle or running) - SUCCESS IMMEDIATELY - if len(loaded_pipelines) == expected_count: - logger.info( - f"+ All {expected_count} pipelines for slot {slot_id} are loaded (took {elapsed:.2f}s, {attempt} checks)") - return True - - # Check if we've exceeded max wait time - if elapsed >= max_wait_seconds: - logger.error( - f"✗ Pipelines still not loaded after {elapsed:.2f}s ({attempt} checks): not_found={not_found_pipelines}, loaded={len(loaded_pipelines)}/{expected_count}") - return False - - # Some pipelines are still not found - wait and retry - if elapsed > 5.0: - # Log at INFO level if taking longer than 5 seconds to help debug slow loads - logger.info( - f"Check {attempt} ({elapsed:.2f}s): Waiting for pipelines - loaded: {len(loaded_pipelines)}/{expected_count}, not_found: {not_found_pipelines}") - else: - logger.debug(f"Check {attempt} ({elapsed:.2f}s): Loaded {len(loaded_pipelines)}/{expected_count}, waiting for: {not_found_pipelines}") - await asyncio.sleep(poll_interval) - - except Exception as e: - logger.error(f"Error checking pipeline status (check {attempt}, {elapsed:.2f}s): {e}") - # On error, wait and retry (unless we've exceeded max wait time) - if elapsed >= max_wait_seconds: - logger.error(f"✗ Failed to verify pipelines after {elapsed:.2f}s due to errors") - return False - await asyncio.sleep(poll_interval) - - except Exception as e: - logger.error(f"Failed to verify slot {slot_id} pipelines via API: {e}") - # Fallback to log-based verification - logger.warning("Falling back to log-based verification") - return await _verify_slot_pipelines_loaded_fallback(slot_id, expected_count) - - -async def _verify_slot_pipelines_loaded_fallback(slot_id: int, expected_count: int, max_retries: int = 3, - retry_delay: float = 1.0) -> bool: - """ - Fallback to log-based verification if API is unavailable. - This is the old implementation kept as a safety net. - """ - import asyncio - - for attempt in range(max_retries): - try: - pipeline_status = log_analyzer.get_running_pipelines() - - if not pipeline_status: - logger.warning(f"Attempt {attempt + 1}/{max_retries}: No pipeline status found in logs yet") - await asyncio.sleep(retry_delay) - continue - - running_pipelines = pipeline_status.get('running_pipelines', []) - slot_pipelines = [f"slot{slot_id}-filter{i}" for i in range(1, expected_count + 1)] - missing_pipelines = [p for p in slot_pipelines if p not in running_pipelines] - - if not missing_pipelines: - logger.info(f"+ All {expected_count} pipelines for slot {slot_id} are running (fallback)") - return True - - logger.warning(f"Attempt {attempt + 1}/{max_retries}: Waiting for pipelines: {missing_pipelines}") - await asyncio.sleep(retry_delay) - - except Exception as e: - logger.error(f"Error checking pipeline status (attempt {attempt + 1}/{max_retries}): {e}") - await asyncio.sleep(retry_delay) - - logger.error(f"X Failed to verify slot {slot_id} pipelines after {max_retries} attempts") - return False - - -def _delete_slot_pipelines(slot_id: int, slot_data: Dict[str, Any]): - """ - Delete all Logstash pipelines associated with a slot. - - Args: - slot_id: Slot ID - slot_data: Slot data containing pipeline information - """ - # Import here to avoid circular dependency - import main - - try: - pipelines = slot_data.get('pipelines', []) - - # Delete each filter pipeline directly (no HTTP overhead) - deleted_count = 0 - for idx in range(1, len(pipelines) + 1): - pipeline_name = f"slot{slot_id}-filter{idx}" - try: - success = main.delete_pipeline_internal(pipeline_name) - if success: - deleted_count += 1 - logger.info(f"Deleted pipeline {pipeline_name}") - else: - logger.warning(f"Pipeline {pipeline_name} not found or already deleted") - except Exception as e: - logger.error(f"Error deleting pipeline {pipeline_name}: {e}") - - logger.info(f"Deleted {deleted_count}/{len(pipelines)} pipelines for slot {slot_id}") - except Exception as e: - logger.error(f"Error cleaning up pipelines for slot {slot_id}: {e}") - - -def _background_cleanup_worker(): - """ - Background worker thread that periodically evicts expired and failed slots. - Runs every 60 seconds to clean up expired and failed pipelines. - """ - while True: - try: - time.sleep(60) - - # Evict slots that have exceeded TTL - expired_slots = evict_expired_slots() - if expired_slots: - logger.info(f"Background cleanup evicted expired slots: {expired_slots}") - - # Evict slots with failed pipelines - failed_slots = evict_failed_slots() - if failed_slots: - logger.info(f"Background cleanup evicted failed slots: {failed_slots}") - - except Exception as e: - logger.error(f"Error during background cleanup: {e}") - - -def _load_config() -> Dict[str, Any]: - """ - Load configuration from logstashagent.yml. - - Falls back to simulation mode defaults if config file is not found, - ensuring the background cleanup thread always starts in containerized environments. - - Returns: - Dictionary with configuration settings - """ - config_path = os.path.join(os.path.dirname(__file__), 'logstashagent.yml') - - if not os.path.exists(config_path): - logger.warning(f"[Slots] Config file not found at {config_path}, using simulation mode defaults") - return { - 'mode': 'simulation', - 'simulation_mode': 'embedded' - } - - try: - with open(config_path, 'r') as f: - config = yaml.safe_load(f) - return config if config else {'mode': 'simulation', 'simulation_mode': 'embedded'} - except Exception as e: - logger.error(f"[Slots] Error loading config: {e}, using simulation mode defaults") - return { - 'mode': 'simulation', - 'simulation_mode': 'embedded' - } - - -# Conditionally start the background cleanup thread based on config -_config = _load_config() -_mode = _config.get('mode', '').lower() - -if _mode == 'simulation': - _cleanup_thread = Thread(target=_background_cleanup_worker, daemon=True, name="SlotCleanupThread") - _cleanup_thread.start() - logger.info("[Slots] Started background cleanup thread (mode: simulation)") -else: - logger.warning(f"[Slots] Background cleanup thread NOT started (mode: {_mode or 'not set'})") diff --git a/LogstashAgent/tests/agent_tests.py b/LogstashAgent/tests/agent_tests.py deleted file mode 100644 index 5a78ad25..00000000 --- a/LogstashAgent/tests/agent_tests.py +++ /dev/null @@ -1,1114 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -""" -Comprehensive tests for LogstashAgent - -Tests cover: -- YAML file operations (_load_pipelines_yml, _save_pipelines_yml) -- Pipeline CRUD operations (put_pipeline, get_pipeline, delete_pipeline) -- Slot allocation and eviction (allocate_slot, evict_expired_slots) -- File upload endpoint (write_file) -- Log analysis (find_related_logs) -- Pipeline ID sanitization -""" - -import pytest -import os -import json -import yaml -import tempfile -import shutil -from datetime import datetime, timezone, timedelta -from unittest.mock import patch, MagicMock, mock_open -from fastapi.testclient import TestClient -import base64 - -# Import modules to test -import sys -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -import main -import slots -import log_analyzer -from logstash_api import LogstashAPI, PipelineNotFoundError - - -@pytest.fixture -def client(): - """FastAPI test client""" - return TestClient(main.app) - - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for test files""" - temp_path = tempfile.mkdtemp() - yield temp_path - shutil.rmtree(temp_path, ignore_errors=True) - - -@pytest.fixture -def mock_pipelines_yml(temp_dir): - """Mock pipelines.yml path""" - yml_path = os.path.join(temp_dir, "pipelines.yml") - with patch.object(main, 'PIPELINES_YML_PATH', yml_path): - yield yml_path - - -@pytest.fixture -def mock_dirs(temp_dir): - """Mock all directory paths""" - pipelines_dir = os.path.join(temp_dir, "conf.d") - metadata_dir = os.path.join(temp_dir, "metadata") - yml_path = os.path.join(temp_dir, "pipelines.yml") - - os.makedirs(pipelines_dir, exist_ok=True) - os.makedirs(metadata_dir, exist_ok=True) - - with patch.object(main, 'PIPELINES_DIR', pipelines_dir), \ - patch.object(main, 'METADATA_DIR', metadata_dir), \ - patch.object(main, 'PIPELINES_YML_PATH', yml_path): - yield { - 'pipelines_dir': pipelines_dir, - 'metadata_dir': metadata_dir, - 'yml_path': yml_path - } - - -# ============================================================================ -# 6a. _load_pipelines_yml() Tests -# ============================================================================ - -class TestLoadPipelinesYml: - """Test _load_pipelines_yml() with various file states""" - - def test_empty_file(self, mock_pipelines_yml): - """Test loading an empty file returns empty list""" - # Create empty file - open(mock_pipelines_yml, 'w').close() - - result = main._load_pipelines_yml() - assert result == [] - - def test_comment_only_file(self, mock_pipelines_yml): - """Test loading a file with only comments returns empty list""" - with open(mock_pipelines_yml, 'w') as f: - f.write("# This is a comment\n") - f.write("# Another comment\n") - f.write(" # Indented comment\n") - - result = main._load_pipelines_yml() - assert result == [] - - def test_valid_yaml(self, mock_pipelines_yml): - """Test loading valid YAML returns parsed data""" - pipelines = [ - { - 'pipeline.id': 'test-pipeline', - 'path.config': '/etc/logstash/conf.d/test.conf', - 'pipeline.workers': 2 - } - ] - - with open(mock_pipelines_yml, 'w') as f: - yaml.dump(pipelines, f) - - result = main._load_pipelines_yml() - assert result == pipelines - assert len(result) == 1 - assert result[0]['pipeline.id'] == 'test-pipeline' - - def test_corrupted_yaml(self, mock_pipelines_yml): - """Test loading corrupted YAML returns empty list and logs error""" - with open(mock_pipelines_yml, 'w') as f: - f.write("invalid: yaml: content:\n") - f.write(" - broken\n") - f.write(" unclosed: [\n") - - result = main._load_pipelines_yml() - assert result == [] - - def test_nonexistent_file(self, temp_dir): - """Test loading nonexistent file returns empty list""" - yml_path = os.path.join(temp_dir, "nonexistent.yml") - - with patch.object(main, 'PIPELINES_YML_PATH', yml_path): - result = main._load_pipelines_yml() - assert result == [] - - -# ============================================================================ -# 6b. _save_pipelines_yml() Tests -# ============================================================================ - -class TestSavePipelinesYml: - """Test _save_pipelines_yml() atomic write behavior""" - - def test_atomic_write_succeeds(self, mock_pipelines_yml): - """Test successful atomic write""" - pipelines = [ - { - 'pipeline.id': 'test-pipeline', - 'path.config': '/etc/logstash/conf.d/test.conf' - } - ] - - main._save_pipelines_yml(pipelines) - - # Verify file was written - assert os.path.exists(mock_pipelines_yml) - - # Verify content is correct - with open(mock_pipelines_yml, 'r') as f: - loaded = yaml.safe_load(f) - - assert loaded == pipelines - - # Verify temp file was cleaned up - temp_file = f"{mock_pipelines_yml}.tmp" - assert not os.path.exists(temp_file) - - def test_temp_file_cleanup_on_failure(self, mock_pipelines_yml): - """Test that temp file is cleaned up when write fails""" - pipelines = [{'pipeline.id': 'test'}] - temp_file = f"{mock_pipelines_yml}.tmp" - - # Mock os.replace to raise an exception - with patch('os.replace', side_effect=OSError("Simulated failure")): - with pytest.raises(OSError): - main._save_pipelines_yml(pipelines) - - # Verify temp file was cleaned up - assert not os.path.exists(temp_file) - - def test_multiple_writes_atomic(self, mock_pipelines_yml): - """Test multiple writes don't leave temp files""" - for i in range(3): - pipelines = [{'pipeline.id': f'pipeline-{i}'}] - main._save_pipelines_yml(pipelines) - - # Verify only the final file exists, no temp files - assert os.path.exists(mock_pipelines_yml) - assert not os.path.exists(f"{mock_pipelines_yml}.tmp") - - # Verify final content - with open(mock_pipelines_yml, 'r') as f: - loaded = yaml.safe_load(f) - assert loaded[0]['pipeline.id'] == 'pipeline-2' - - -# ============================================================================ -# 6c. put_pipeline / get_pipeline / delete_pipeline - Full CRUD -# ============================================================================ - -class TestPipelineCRUD: - """Test full CRUD cycle for pipelines""" - - def test_full_crud_roundtrip(self, client, mock_dirs): - """Test create, read, update, delete pipeline""" - pipeline_id = "test-crud-pipeline" - - # CREATE - Put a new pipeline - create_body = { - "pipeline": "input { stdin {} } filter { mutate { add_field => { \"test\" => \"value\" } } } output { stdout {} }", - "description": "Test CRUD pipeline", - "username": "test-user", - "pipeline_settings": { - "pipeline.workers": 2, - "pipeline.batch.size": 125 - } - } - - response = client.put(f"/_logstash/pipeline/{pipeline_id}", json=create_body) - assert response.status_code == 200 - assert response.json()["acknowledged"] == True - - # Verify config file was created - config_path = os.path.join(mock_dirs['pipelines_dir'], f"{pipeline_id}.conf") - assert os.path.exists(config_path) - - # Verify metadata file was created - metadata_path = os.path.join(mock_dirs['metadata_dir'], f"{pipeline_id}.json") - assert os.path.exists(metadata_path) - - # READ - Get the pipeline - response = client.get(f"/_logstash/pipeline/{pipeline_id}") - assert response.status_code == 200 - data = response.json() - - assert pipeline_id in data - assert data[pipeline_id]["description"] == "Test CRUD pipeline" - assert data[pipeline_id]["username"] == "test-user" - assert data[pipeline_id]["pipeline"] == create_body["pipeline"] - assert data[pipeline_id]["pipeline_settings"]["pipeline.workers"] == 2 - - # UPDATE - Modify the pipeline - update_body = { - "pipeline": "input { stdin {} } output { stdout { codec => json } }", - "description": "Updated CRUD pipeline", - "username": "updated-user", - "pipeline_settings": { - "pipeline.workers": 4 - } - } - - response = client.put(f"/_logstash/pipeline/{pipeline_id}", json=update_body) - assert response.status_code == 200 - - # Verify update - response = client.get(f"/_logstash/pipeline/{pipeline_id}") - assert response.status_code == 200 - data = response.json() - - assert data[pipeline_id]["description"] == "Updated CRUD pipeline" - assert data[pipeline_id]["username"] == "updated-user" - assert data[pipeline_id]["pipeline_settings"]["pipeline.workers"] == 4 - - # DELETE - Remove the pipeline - response = client.delete(f"/_logstash/pipeline/{pipeline_id}") - assert response.status_code == 200 - assert response.json()["acknowledged"] == True - - # Verify files were deleted - assert not os.path.exists(config_path) - assert not os.path.exists(metadata_path) - - # Verify pipeline no longer exists - response = client.get(f"/_logstash/pipeline/{pipeline_id}") - assert response.status_code == 404 - - def test_get_nonexistent_pipeline(self, client, mock_dirs): - """Test getting a pipeline that doesn't exist""" - response = client.get("/_logstash/pipeline/nonexistent") - assert response.status_code == 404 - - def test_delete_nonexistent_pipeline(self, client, mock_dirs): - """Test deleting a pipeline that doesn't exist""" - response = client.delete("/_logstash/pipeline/nonexistent") - assert response.status_code == 404 - - def test_put_pipeline_missing_config(self, client, mock_dirs): - """Test putting a pipeline without required 'pipeline' field""" - response = client.put("/_logstash/pipeline/test", json={"description": "Missing pipeline"}) - assert response.status_code == 400 - assert "Missing 'pipeline' field" in response.json()["detail"] - - -# ============================================================================ -# 6d. allocate_slot() Tests -# ============================================================================ - -class TestAllocateSlot: - """Test slot allocation, reuse, and eviction""" - - def setup_method(self): - """Clear slots before each test""" - slots.clear_all_slots() - - def teardown_method(self): - """Clear slots after each test""" - slots.clear_all_slots() - - def test_fresh_slot_allocation(self): - """Test allocating a fresh slot""" - pipelines = [ - {"config": "input { stdin {} } output { stdout {} }"} - ] - - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - assert slot_id is not None - assert 1 <= slot_id <= slots.NUM_SLOTS - - # Verify slot data - slot_data = slots.get_slot_state() - assert slot_id in slot_data - assert slot_data[slot_id]['pipeline_name'] == "test-pipeline" - assert slot_data[slot_id]['pipelines'] == pipelines - - def test_hash_match_reuse(self): - """Test that identical pipeline configs reuse the same slot""" - pipelines = [ - {"config": "input { stdin {} } output { stdout {} }"} - ] - - # Allocate first time - slot_id_1 = slots.allocate_slot("test-pipeline-1", pipelines) - - # Allocate with same config (different name) - slot_id_2 = slots.allocate_slot("test-pipeline-2", pipelines) - - # Should reuse the same slot - assert slot_id_1 == slot_id_2 - - # Verify only one slot is allocated - slot_state = slots.get_slot_state() - assert len(slot_state) == 1 - - def test_eviction_when_all_slots_full(self): - """Test eviction of oldest slot when all 10 slots are full""" - # Fill all 10 slots with different configs - allocated_slots = [] - - for i in range(slots.NUM_SLOTS): - pipelines = [{"config": f"input {{ generator {{ count => {i} }} }} output {{ stdout {{}} }}"}] - slot_id = slots.allocate_slot(f"pipeline-{i}", pipelines) - allocated_slots.append(slot_id) - - # Verify all slots are full - assert len(slots.get_slot_state()) == slots.NUM_SLOTS - - # Record the first slot's ID (should be evicted) - first_slot_id = allocated_slots[0] - - # Allocate one more with a new config (should evict oldest) - new_pipelines = [{"config": "input { stdin {} } output { null {} }"}] - - with patch.object(main, 'delete_pipeline_internal', return_value=True): - new_slot_id = slots.allocate_slot("new-pipeline", new_pipelines) - - # Should still have 10 slots - assert len(slots.get_slot_state()) == slots.NUM_SLOTS - - # The new slot should have reused the oldest slot's ID - assert new_slot_id == first_slot_id - - # Verify the new pipeline is in the slot - slot_data = slots.get_slot_state()[new_slot_id] - assert slot_data['pipeline_name'] == "new-pipeline" - - def test_different_configs_get_different_slots(self): - """Test that different pipeline configs get different slots""" - pipelines_1 = [{"config": "input { stdin {} }"}] - pipelines_2 = [{"config": "input { generator {} }"}] - - slot_id_1 = slots.allocate_slot("pipeline-1", pipelines_1) - slot_id_2 = slots.allocate_slot("pipeline-2", pipelines_2) - - assert slot_id_1 != slot_id_2 - assert len(slots.get_slot_state()) == 2 - - -# ============================================================================ -# 6e. evict_expired_slots() Tests -# ============================================================================ - -class TestEvictExpiredSlots: - """Test slot eviction based on TTL""" - - def setup_method(self): - """Clear slots before each test""" - slots.clear_all_slots() - - def teardown_method(self): - """Clear slots after each test""" - slots.clear_all_slots() - - def test_evict_expired_slots_with_old_timestamps(self): - """Test eviction of slots with expired TTL""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - # Manually inject an old timestamp (beyond TTL) - old_time = datetime.now(timezone.utc) - timedelta(seconds=slots.SLOT_TTL_SECONDS + 60) - - with slots._slots_lock: - slots._slots[slot_id]['last_accessed'] = old_time.isoformat() - slots._slots[slot_id]['created_at'] = old_time.isoformat() - - # Mock delete_pipeline_internal to avoid actual deletion - with patch.object(main, 'delete_pipeline_internal', return_value=True): - evicted = slots.evict_expired_slots() - - # Verify the slot was evicted - assert slot_id in evicted - assert len(slots.get_slot_state()) == 0 - - def test_no_eviction_for_recent_slots(self): - """Test that recently accessed slots are not evicted""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - # Evict expired slots - evicted = slots.evict_expired_slots() - - # Verify no slots were evicted - assert len(evicted) == 0 - assert len(slots.get_slot_state()) == 1 - - def test_mixed_expired_and_active_slots(self): - """Test eviction with mix of expired and active slots""" - # Allocate multiple slots - slot_ids = [] - for i in range(3): - pipelines = [{"config": f"input {{ generator {{ count => {i} }} }}"}] - slot_id = slots.allocate_slot(f"pipeline-{i}", pipelines) - slot_ids.append(slot_id) - - # Make first two slots expired - old_time = datetime.now(timezone.utc) - timedelta(seconds=slots.SLOT_TTL_SECONDS + 60) - - with slots._slots_lock: - for slot_id in slot_ids[:2]: - slots._slots[slot_id]['last_accessed'] = old_time.isoformat() - - # Evict expired slots - with patch.object(main, 'delete_pipeline_internal', return_value=True): - evicted = slots.evict_expired_slots() - - # Verify only the first two were evicted - assert len(evicted) == 2 - assert slot_ids[0] in evicted - assert slot_ids[1] in evicted - assert slot_ids[2] not in evicted - - # Verify one slot remains - assert len(slots.get_slot_state()) == 1 - - def test_evict_slot_with_invalid_timestamp(self): - """Test that slots with unparseable timestamps are evicted""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - # Inject invalid timestamp - with slots._slots_lock: - slots._slots[slot_id]['last_accessed'] = "invalid-timestamp" - - # Evict expired slots - with patch.object(main, 'delete_pipeline_internal', return_value=True): - evicted = slots.evict_expired_slots() - - # Verify the slot was evicted due to invalid timestamp - assert slot_id in evicted - assert len(slots.get_slot_state()) == 0 - - -# ============================================================================ -# 6f. write_file endpoint Tests -# ============================================================================ - -class TestWriteFileEndpoint: - """Test file upload endpoint with various scenarios""" - - def test_simulation_mode_off_returns_403(self, client): - """Test that file upload is forbidden when simulation mode is off""" - with patch.dict(os.environ, {"SIMULATION_MODE": "false"}): - body = { - "filename": "test.txt", - "content": base64.b64encode(b"test content").decode() - } - - response = client.post("/_logstash/write-file", json=body) - assert response.status_code == 403 - assert "simulation mode" in response.json()["detail"].lower() - - def test_missing_filename_returns_400(self, client): - """Test that missing filename returns 400""" - with patch.dict(os.environ, {"SIMULATION_MODE": "true"}): - body = { - "content": base64.b64encode(b"test content").decode() - } - - response = client.post("/_logstash/write-file", json=body) - assert response.status_code == 400 - assert "required" in response.json()["detail"].lower() - - def test_missing_content_returns_400(self, client): - """Test that missing content returns 400""" - with patch.dict(os.environ, {"SIMULATION_MODE": "true"}): - body = { - "filename": "test.txt" - } - - response = client.post("/_logstash/write-file", json=body) - assert response.status_code == 400 - assert "required" in response.json()["detail"].lower() - - def test_path_traversal_sanitized(self, client, temp_dir): - """Test that path traversal attempts are sanitized""" - with patch.dict(os.environ, {"SIMULATION_MODE": "true"}): - # Mock the uploaded directory - uploaded_dir = os.path.join(temp_dir, "uploaded") - - with patch('os.makedirs'), \ - patch('builtins.open', mock_open()) as mock_file: - - # Attempt path traversal - body = { - "filename": "../../../etc/passwd", - "content": base64.b64encode(b"malicious content").decode() - } - - response = client.post("/_logstash/write-file", json=body) - assert response.status_code == 200 - - # Verify the file was written with sanitized name (basename only) - # The open call should use only "passwd", not the full path - call_args = mock_file.call_args[0][0] - assert "etc" not in call_args - assert call_args.endswith("passwd") - - def test_valid_upload(self, client, temp_dir): - """Test successful file upload""" - with patch.dict(os.environ, {"SIMULATION_MODE": "true"}): - uploaded_dir = os.path.join(temp_dir, "uploaded") - os.makedirs(uploaded_dir, exist_ok=True) - - # Mock the uploaded directory path - with patch('os.makedirs'), \ - patch('os.path.join', return_value=os.path.join(uploaded_dir, "test.json")): - - test_content = b'{"key": "value"}' - body = { - "filename": "test.json", - "content": base64.b64encode(test_content).decode() - } - - response = client.post("/_logstash/write-file", json=body) - assert response.status_code == 200 - - result = response.json() - assert result["status"] == "success" - assert "test.json" in result["path"] - - -# ============================================================================ -# 6g. find_related_logs() Tests -# ============================================================================ - -class TestFindRelatedLogs: - """Test log analysis with mocked log data""" - - def test_find_logs_for_pipeline(self): - """Test finding logs related to a specific pipeline""" - mock_logs = [ - { - "level": "ERROR", - "pipeline.id": "test-pipeline", - "logEvent": { - "message": "Pipeline error occurred" - }, - "timeMillis": 1704110400000 - }, - { - "level": "WARN", - "pipeline.id": "test-pipeline", - "logEvent": { - "message": "Pipeline warning" - }, - "timeMillis": 1704110460000 - }, - { - "level": "INFO", - "pipeline.id": "other-pipeline", - "logEvent": { - "message": "Other pipeline info" - }, - "timeMillis": 1704110520000 - } - ] - - with patch.object(log_analyzer, '_read_json_logs', return_value=mock_logs): - logs = log_analyzer.find_related_logs( - pipeline_id="test-pipeline", - max_entries=10, - min_level="WARN" - ) - - # Should return 2 logs (ERROR and WARN for test-pipeline) - assert len(logs) == 2 - assert all(log['pipeline.id'] == 'test-pipeline' for log in logs) - - def test_find_logs_with_min_level_filter(self): - """Test log filtering by minimum level""" - mock_logs = [ - {"level": "ERROR", "pipeline.id": "test-pipeline", "timeMillis": 1704110400000}, - {"level": "WARN", "pipeline.id": "test-pipeline", "timeMillis": 1704110460000}, - {"level": "INFO", "pipeline.id": "test-pipeline", "timeMillis": 1704110520000}, - {"level": "DEBUG", "pipeline.id": "test-pipeline", "timeMillis": 1704110580000} - ] - - with patch.object(log_analyzer, '_read_json_logs', return_value=mock_logs): - # Filter for ERROR only - logs = log_analyzer.find_related_logs( - pipeline_id="test-pipeline", - max_entries=10, - min_level="ERROR" - ) - - assert len(logs) == 1 - assert logs[0]['level'] == 'ERROR' - - def test_find_logs_with_timestamp_filter(self): - """Test log filtering by minimum timestamp""" - # Use actual millisecond timestamps - base_time = 1704110400000 # 2024-01-01T12:00:00.000Z - mock_logs = [ - {"level": "ERROR", "pipeline.id": "test-pipeline", "timeMillis": base_time}, - {"level": "ERROR", "pipeline.id": "test-pipeline", "timeMillis": base_time + 300000}, # +5 min - {"level": "ERROR", "pipeline.id": "test-pipeline", "timeMillis": base_time + 600000} # +10 min - ] - - # Timestamp for +5 minutes - min_timestamp = base_time + 300000 - - with patch.object(log_analyzer, '_read_json_logs', return_value=mock_logs): - logs = log_analyzer.find_related_logs( - pipeline_id="test-pipeline", - max_entries=10, - min_level="DEBUG", - min_timestamp=min_timestamp - ) - - # Should only return logs from 12:05:00 onwards - assert len(logs) == 2 - - def test_find_logs_max_entries_limit(self): - """Test that max_entries limit is respected""" - mock_logs = [ - {"level": "ERROR", "pipeline.id": "test-pipeline", "timeMillis": 1704110400000 + (i * 1000)} - for i in range(100) - ] - - with patch.object(log_analyzer, '_read_json_logs', return_value=mock_logs): - logs = log_analyzer.find_related_logs( - pipeline_id="test-pipeline", - max_entries=10, - min_level="DEBUG" - ) - - assert len(logs) == 10 - - def test_find_logs_no_matches(self): - """Test when no logs match the criteria""" - mock_logs = [ - {"level": "INFO", "pipeline.id": "other-pipeline", "timeMillis": 1704110400000} - ] - - with patch.object(log_analyzer, '_read_json_logs', return_value=mock_logs): - logs = log_analyzer.find_related_logs( - pipeline_id="test-pipeline", - max_entries=10, - min_level="WARN" - ) - - assert len(logs) == 0 - - -# ============================================================================ -# 6h. pipeline_id sanitization Tests -# ============================================================================ - -class TestPipelineIdSanitization: - """Test pipeline_id validation and sanitization""" - - def test_valid_pipeline_ids_accepted(self, client, mock_dirs): - """Test that valid pipeline IDs are accepted""" - valid_ids = [ - "test-pipeline", - "test_pipeline", - "test123", - "TEST-PIPELINE", - "pipeline.v1", - "my-pipeline_v2.0" - ] - - for pipeline_id in valid_ids: - body = { - "pipeline": "input { stdin {} } output { stdout {} }" - } - - response = client.put(f"/_logstash/pipeline/{pipeline_id}", json=body) - assert response.status_code == 200, f"Valid ID '{pipeline_id}' was rejected" - - def test_invalid_pipeline_ids_rejected(self, client, mock_dirs): - """Test that invalid pipeline IDs are rejected""" - # Note: FastAPI routing handles slashes/backslashes before validation (404) - # Test IDs that actually reach our validation code - invalid_ids = [ - "test pipeline", # Contains space - "test;pipeline", # Contains semicolon - "test|pipeline", # Contains pipe - "test&pipeline", # Contains ampersand - ".hidden", # Starts with dot - "-invalid", # Starts with hyphen - "test..pipeline", # Contains double dots - ] - - for pipeline_id in invalid_ids: - body = { - "pipeline": "input { stdin {} } output { stdout {} }" - } - - response = client.put(f"/_logstash/pipeline/{pipeline_id}", json=body) - assert response.status_code == 400, f"Invalid ID '{pipeline_id}' was accepted" - assert "Invalid pipeline_id" in response.json()["detail"] - - def test_path_traversal_blocked_in_get(self, client, mock_dirs): - """Test path traversal is blocked in GET endpoint""" - # FastAPI normalizes paths, so ../ in URL gets 404 from routing - # Test with encoded path or direct invalid chars - response = client.get("/_logstash/pipeline/test..traversal") - assert response.status_code == 400 - assert "Invalid pipeline_id" in response.json()["detail"] - - def test_path_traversal_blocked_in_delete(self, client, mock_dirs): - """Test path traversal is blocked in DELETE endpoint""" - # FastAPI normalizes paths, test with double dots - response = client.delete("/_logstash/pipeline/test..traversal") - assert response.status_code == 400 - assert "Invalid pipeline_id" in response.json()["detail"] - - def test_path_traversal_blocked_in_logs(self, client, mock_dirs): - """Test path traversal is blocked in logs endpoint""" - # FastAPI normalizes paths, test with double dots - response = client.get("/_logstash/pipeline/test..traversal/logs") - assert response.status_code == 400 - assert "Invalid pipeline_id" in response.json()["detail"] - - def test_double_dot_sequences_rejected(self, client, mock_dirs): - """Test that .. sequences are explicitly rejected""" - body = { - "pipeline": "input { stdin {} } output { stdout {} }" - } - - response = client.put("/_logstash/pipeline/test..pipeline", json=body) - assert response.status_code == 400 - assert ".." in response.json()["detail"] - - def test_alphanumeric_with_allowed_chars(self, client, mock_dirs): - """Test that alphanumeric with hyphens, underscores, and dots work""" - pipeline_id = "valid-pipeline_name.v1" - body = { - "pipeline": "input { stdin {} } output { stdout {} }" - } - - response = client.put(f"/_logstash/pipeline/{pipeline_id}", json=body) - assert response.status_code == 200 - - -# ============================================================================ -# Additional Edge Case Tests -# ============================================================================ - -class TestEdgeCases: - """Additional edge case tests""" - - def test_list_pipelines_empty(self, client, mock_dirs): - """Test listing pipelines when none exist""" - response = client.get("/_logstash/pipeline") - assert response.status_code == 200 - assert response.json() == {} - - def test_list_pipelines_with_data(self, client, mock_dirs): - """Test listing multiple pipelines""" - # Create two pipelines - for i in range(2): - body = { - "pipeline": f"input {{ stdin {{}} }} output {{ stdout {{}} }}", - "description": f"Pipeline {i}" - } - client.put(f"/_logstash/pipeline/test-pipeline-{i}", json=body) - - response = client.get("/_logstash/pipeline") - assert response.status_code == 200 - data = response.json() - - assert len(data) == 2 - assert "test-pipeline-0" in data - assert "test-pipeline-1" in data - - def test_slot_state_retrieval(self): - """Test getting slot state""" - slots.clear_all_slots() - - # Allocate a slot - pipelines = [{"config": "test"}] - slot_id = slots.allocate_slot("test", pipelines) - - # Get state - state = slots.get_slot_state() - - assert slot_id in state - assert state[slot_id]['pipeline_name'] == "test" - - slots.clear_all_slots() - - def test_release_slot(self): - """Test releasing a slot""" - slots.clear_all_slots() - - # Allocate a slot - pipelines = [{"config": "test"}] - slot_id = slots.allocate_slot("test", pipelines) - - # Release it - result = slots.release_slot(slot_id) - assert result == True - - # Verify it's gone - state = slots.get_slot_state() - assert slot_id not in state - - # Try releasing again - result = slots.release_slot(slot_id) - assert result == False - - slots.clear_all_slots() - - -# ============================================================================ -# API-Based Slot Verification Tests -# ============================================================================ - -class TestAPIBasedSlotVerification: - """Test API-based slot verification (replacing log-based detection)""" - - @pytest.mark.asyncio - async def test_verify_successful_pipeline_load(self): - """Test verification succeeds when pipeline is loaded successfully""" - slot_id = 1 - expected_count = 2 - - # Mock API to return successful pipeline states - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.detect_pipeline_state = MagicMock(side_effect=['idle', 'idle']) - - with patch('slots.LogstashAPI', return_value=mock_api): - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count) - - assert result == True - assert mock_api.detect_pipeline_state.call_count == 2 - - @pytest.mark.asyncio - async def test_verify_running_pipeline(self): - """Test verification succeeds when pipeline is running (processing events)""" - slot_id = 1 - expected_count = 1 - - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.detect_pipeline_state = MagicMock(return_value='running') - - with patch('slots.LogstashAPI', return_value=mock_api): - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count) - - assert result == True - - @pytest.mark.asyncio - async def test_verify_fails_on_failed_pipeline(self): - """Test verification fails immediately when pipeline has reload failures""" - slot_id = 1 - expected_count = 1 - - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.detect_pipeline_state = MagicMock(return_value='failed') - - with patch('slots.LogstashAPI', return_value=mock_api): - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count) - - # Should fail immediately without retries - assert result == False - assert mock_api.detect_pipeline_state.call_count == 1 - - @pytest.mark.asyncio - async def test_verify_retries_on_not_found(self): - """Test verification retries when pipeline is not found initially""" - slot_id = 1 - expected_count = 1 - - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - # First two attempts: not found, third attempt: idle - mock_api.detect_pipeline_state = MagicMock(side_effect=['not_found', 'not_found', 'idle']) - - with patch('slots.LogstashAPI', return_value=mock_api): - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count, max_retries=3, retry_delay=0.1) - - assert result == True - assert mock_api.detect_pipeline_state.call_count == 3 - - @pytest.mark.asyncio - async def test_verify_fails_after_max_retries(self): - """Test verification fails after max retries if pipeline never appears""" - slot_id = 1 - expected_count = 1 - - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.detect_pipeline_state = MagicMock(return_value='not_found') - - with patch('slots.LogstashAPI', return_value=mock_api): - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count, max_retries=2, retry_delay=0.1) - - assert result == False - assert mock_api.detect_pipeline_state.call_count == 2 - - @pytest.mark.asyncio - async def test_verify_mixed_pipeline_states(self): - """Test verification with multiple pipelines in different states""" - slot_id = 1 - expected_count = 3 - - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - # Pipeline 1: idle, Pipeline 2: running, Pipeline 3: idle - mock_api.detect_pipeline_state = MagicMock(side_effect=['idle', 'running', 'idle']) - - with patch('slots.LogstashAPI', return_value=mock_api): - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count) - - assert result == True - assert mock_api.detect_pipeline_state.call_count == 3 - - @pytest.mark.asyncio - async def test_verify_falls_back_on_api_error(self): - """Test verification falls back to log-based detection on API error""" - slot_id = 1 - expected_count = 1 - - # Mock API to raise an error - with patch('slots.LogstashAPI', side_effect=Exception("API unavailable")): - # Mock the fallback function - with patch('slots._verify_slot_pipelines_loaded_fallback', return_value=True) as mock_fallback: - result = await slots.verify_slot_pipelines_loaded(slot_id, expected_count) - - # Should have called fallback - mock_fallback.assert_called_once() - - -# ============================================================================ -# API-Based Slot Eviction Tests -# ============================================================================ - -class TestAPIBasedSlotEviction: - """Test API-based slot eviction (replacing log-based detection)""" - - def setup_method(self): - """Clear slots before each test""" - slots.clear_all_slots() - - def teardown_method(self): - """Clear slots after each test""" - slots.clear_all_slots() - - def test_evict_slot_with_failed_pipeline(self): - """Test eviction of slot with failed pipeline""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - # Mock API to return failed state - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.list_pipelines = MagicMock(return_value=[f'slot{slot_id}-filter1']) - mock_api.detect_pipeline_state = MagicMock(return_value='failed') - - with patch('slots.LogstashAPI', return_value=mock_api): - with patch.object(slots, '_delete_slot_pipelines'): - evicted = slots.evict_failed_slots() - - assert slot_id in evicted - assert len(slots.get_slot_state()) == 0 - - def test_evict_slot_with_missing_pipeline(self): - """Test eviction of slot when pipeline is not found in Logstash""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - # Mock API to return empty pipeline list - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.list_pipelines = MagicMock(return_value=[]) # Pipeline not in list - - with patch('slots.LogstashAPI', return_value=mock_api): - with patch.object(slots, '_delete_slot_pipelines'): - evicted = slots.evict_failed_slots() - - assert slot_id in evicted - assert len(slots.get_slot_state()) == 0 - - def test_no_eviction_for_healthy_pipelines(self): - """Test that healthy pipelines are not evicted""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slot_id = slots.allocate_slot("test-pipeline", pipelines) - - # Mock API to return healthy state - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.list_pipelines = MagicMock(return_value=[f'slot{slot_id}-filter1']) - mock_api.detect_pipeline_state = MagicMock(return_value='idle') - - with patch('slots.LogstashAPI', return_value=mock_api): - evicted = slots.evict_failed_slots() - - assert len(evicted) == 0 - assert len(slots.get_slot_state()) == 1 - - def test_evict_multiple_failed_slots(self): - """Test eviction of multiple slots with failures""" - # Allocate multiple slots - slot_ids = [] - for i in range(3): - pipelines = [{"config": f"input {{ generator {{ count => {i} }} }}"}] - slot_id = slots.allocate_slot(f"pipeline-{i}", pipelines) - slot_ids.append(slot_id) - - # Mock API: first two failed, third is healthy - mock_api = MagicMock() - mock_api.__enter__ = MagicMock(return_value=mock_api) - mock_api.__exit__ = MagicMock(return_value=False) - mock_api.list_pipelines = MagicMock(return_value=[ - f'slot{slot_ids[0]}-filter1', - f'slot{slot_ids[1]}-filter1', - f'slot{slot_ids[2]}-filter1' - ]) - # First two return 'failed', third returns 'idle' - mock_api.detect_pipeline_state = MagicMock(side_effect=['failed', 'failed', 'idle']) - - with patch('slots.LogstashAPI', return_value=mock_api): - with patch.object(slots, '_delete_slot_pipelines'): - evicted = slots.evict_failed_slots() - - assert len(evicted) == 2 - assert slot_ids[0] in evicted - assert slot_ids[1] in evicted - assert slot_ids[2] not in evicted - assert len(slots.get_slot_state()) == 1 - - def test_eviction_falls_back_on_api_error(self): - """Test eviction falls back to log-based detection on API error""" - # Allocate a slot - pipelines = [{"config": "input { stdin {} }"}] - slots.allocate_slot("test-pipeline", pipelines) - - # Mock API to raise an error - with patch('slots.LogstashAPI', side_effect=Exception("API unavailable")): - # Mock the fallback function - with patch('slots._evict_failed_slots_fallback', return_value=[]) as mock_fallback: - evicted = slots.evict_failed_slots() - - # Should have called fallback - mock_fallback.assert_called_once() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/LogstashAgent/tests/test_logstash_api.py b/LogstashAgent/tests/test_logstash_api.py deleted file mode 100644 index eb4d8d66..00000000 --- a/LogstashAgent/tests/test_logstash_api.py +++ /dev/null @@ -1,504 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -""" -Comprehensive tests for Logstash API SDK - -Tests cover: -- LogstashAPI class initialization and context management -- Pipeline stats retrieval (all pipelines and specific pipeline) -- Pipeline state detection (running, idle, failed, not_found) -- Pipeline listing and existence checks -- Event count retrieval -- Error handling and edge cases -- Reload failure detection -""" - -import pytest -from unittest.mock import Mock, patch, MagicMock -import httpx - -# Import modules to test -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -from logstash_api import ( - LogstashAPI, - LogstashAPIError, - PipelineNotFoundError -) - - -# ============================================================================ -# Fixtures -# ============================================================================ - -@pytest.fixture -def mock_httpx_client(): - """Mock httpx.Client for testing""" - with patch('logstash_api.httpx.Client') as mock_client_class: - mock_client = MagicMock() - mock_client_class.return_value = mock_client - yield mock_client - - -@pytest.fixture -def api_instance(mock_httpx_client): - """Create a LogstashAPI instance with mocked client""" - return LogstashAPI() - - -# ============================================================================ -# LogstashAPI Initialization Tests -# ============================================================================ - -class TestLogstashAPIInitialization: - """Test LogstashAPI initialization and context management""" - - def test_default_initialization(self): - """Test initialization with default parameters""" - with patch('logstash_api.httpx.Client') as mock_client: - api = LogstashAPI() - - assert api.base_url == "http://localhost:9600" - assert api.timeout == 5.0 - mock_client.assert_called_once_with(timeout=5.0) - - def test_custom_initialization(self): - """Test initialization with custom parameters""" - with patch('logstash_api.httpx.Client') as mock_client: - api = LogstashAPI(base_url="http://custom:9700", timeout=10.0) - - assert api.base_url == "http://custom:9700" - assert api.timeout == 10.0 - mock_client.assert_called_once_with(timeout=10.0) - - def test_context_manager(self, mock_httpx_client): - """Test context manager closes client properly""" - with LogstashAPI() as api: - assert api is not None - - mock_httpx_client.close.assert_called_once() - - def test_manual_close(self, mock_httpx_client): - """Test manual close method""" - api = LogstashAPI() - api.close() - - mock_httpx_client.close.assert_called_once() - - -# ============================================================================ -# Pipeline Stats Retrieval Tests -# ============================================================================ - -class TestGetPipelineStats: - """Test pipeline statistics retrieval""" - - def test_get_all_pipeline_stats_success(self, api_instance, mock_httpx_client): - """Test successful retrieval of all pipeline stats""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "pipeline1": {"events": {"in": 100}}, - "pipeline2": {"events": {"in": 200}} - } - } - mock_httpx_client.get.return_value = mock_response - - result = api_instance.get_all_pipeline_stats() - - assert "pipelines" in result - assert len(result["pipelines"]) == 2 - mock_httpx_client.get.assert_called_once_with("http://localhost:9600/_node/stats/pipelines") - - def test_get_all_pipeline_stats_http_error(self, api_instance, mock_httpx_client): - """Test handling of HTTP errors when getting all stats""" - mock_httpx_client.get.side_effect = httpx.HTTPError("Connection failed") - - with pytest.raises(LogstashAPIError) as exc_info: - api_instance.get_all_pipeline_stats() - - assert "Failed to get pipeline stats" in str(exc_info.value) - - def test_get_pipeline_stats_success(self, api_instance, mock_httpx_client): - """Test successful retrieval of specific pipeline stats""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 100, "out": 95}, - "reloads": {"successes": 1, "failures": 0} - } - } - } - mock_httpx_client.get.return_value = mock_response - - result = api_instance.get_pipeline_stats("test-pipeline") - - assert "pipelines" in result - assert "test-pipeline" in result["pipelines"] - mock_httpx_client.get.assert_called_once_with( - "http://localhost:9600/_node/stats/pipelines/test-pipeline" - ) - - def test_get_pipeline_stats_not_found(self, api_instance, mock_httpx_client): - """Test handling of 404 when pipeline doesn't exist""" - mock_response = Mock() - mock_response.status_code = 404 - mock_httpx_client.get.return_value = mock_response - - with pytest.raises(PipelineNotFoundError) as exc_info: - api_instance.get_pipeline_stats("nonexistent") - - assert "nonexistent" in str(exc_info.value) - - -# ============================================================================ -# Pipeline State Detection Tests -# ============================================================================ - -class TestDetectPipelineState: - """Test pipeline state detection logic""" - - def test_detect_running_state(self, api_instance, mock_httpx_client): - """Test detection of running pipeline (has processed events)""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 100, "out": 95}, - "reloads": {"successes": 1, "failures": 0} - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - assert state == "running" - - def test_detect_idle_state_with_successes(self, api_instance, mock_httpx_client): - """Test detection of idle pipeline (loaded but no events yet)""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0}, - "reloads": {"successes": 1, "failures": 0} - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - assert state == "idle" - - def test_detect_idle_state_with_historical_failures(self, api_instance, mock_httpx_client): - """Test that pipeline with successes is idle even with historical failures""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0}, - "reloads": {"successes": 1, "failures": 3} # Historical failures - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Should be idle because it has at least one success - assert state == "idle" - - def test_detect_failed_state(self, api_instance, mock_httpx_client): - """Test detection of failed pipeline (only failures, no successes)""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0}, - "reloads": {"successes": 0, "failures": 3} - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - assert state == "idle" - - def test_detect_not_found_state(self, api_instance, mock_httpx_client): - """Test detection when pipeline doesn't exist""" - mock_response = Mock() - mock_response.status_code = 404 - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("nonexistent") - - assert state == "not_found" - - def test_detect_idle_state_no_reload_data(self, api_instance, mock_httpx_client): - """Test detection of newly created pipeline with no reload data yet""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0}, - "reloads": {} # No reload data yet - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Should be idle (newly created, no failures) - assert state == "idle" - - def test_detect_state_api_error(self, api_instance, mock_httpx_client): - """Test handling of API errors during state detection""" - mock_httpx_client.get.side_effect = httpx.HTTPError("Connection failed") - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Should return not_found on error - assert state == "not_found" - - -# ============================================================================ -# Pipeline Listing Tests -# ============================================================================ - -class TestListPipelines: - """Test pipeline listing functionality""" - - def test_list_pipelines_success(self, api_instance, mock_httpx_client): - """Test successful listing of all pipelines""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "pipeline1": {}, - "pipeline2": {}, - "pipeline3": {} - } - } - mock_httpx_client.get.return_value = mock_response - - pipelines = api_instance.list_pipelines() - - assert len(pipelines) == 3 - assert "pipeline1" in pipelines - assert "pipeline2" in pipelines - assert "pipeline3" in pipelines - - def test_list_pipelines_empty(self, api_instance, mock_httpx_client): - """Test listing when no pipelines exist""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"pipelines": {}} - mock_httpx_client.get.return_value = mock_response - - pipelines = api_instance.list_pipelines() - - assert pipelines == [] - - def test_list_pipelines_error(self, api_instance, mock_httpx_client): - """Test handling of errors when listing pipelines""" - mock_httpx_client.get.side_effect = httpx.HTTPError("Connection failed") - - with pytest.raises(LogstashAPIError): - api_instance.list_pipelines() - - -# ============================================================================ -# Pipeline Running Check Tests -# ============================================================================ - -class TestIsPipelineRunning: - """Test pipeline running status checks""" - - def test_is_running_with_events(self, api_instance, mock_httpx_client): - """Test pipeline is running when it has processed events""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 100, "out": 95}, - "reloads": {"successes": 1} - } - } - } - mock_httpx_client.get.return_value = mock_response - - is_running = api_instance.is_pipeline_running("test-pipeline") - - assert is_running == True - - def test_is_running_with_reload_data(self, api_instance, mock_httpx_client): - """Test pipeline is running when it has reload data (even without events)""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0}, - "reloads": {"successes": 1} - } - } - } - mock_httpx_client.get.return_value = mock_response - - is_running = api_instance.is_pipeline_running("test-pipeline") - - assert is_running == True - - def test_is_not_running_when_not_found(self, api_instance, mock_httpx_client): - """Test pipeline is not running when it doesn't exist""" - mock_response = Mock() - mock_response.status_code = 404 - mock_httpx_client.get.return_value = mock_response - - is_running = api_instance.is_pipeline_running("nonexistent") - - assert is_running == False - - -# ============================================================================ -# Event Counts Tests -# ============================================================================ - -class TestGetPipelineEventCounts: - """Test event count retrieval""" - - def test_get_event_counts_success(self, api_instance, mock_httpx_client): - """Test successful retrieval of event counts""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": { - "in": 1000, - "filtered": 950, - "out": 900, - "duration_in_millis": 5000, - "queue_push_duration_in_millis": 100 - } - } - } - } - mock_httpx_client.get.return_value = mock_response - - counts = api_instance.get_pipeline_event_counts("test-pipeline") - - assert counts["in"] == 1000 - assert counts["filtered"] == 950 - assert counts["out"] == 900 - assert counts["duration_in_millis"] == 5000 - assert counts["queue_push_duration_in_millis"] == 100 - - def test_get_event_counts_not_found(self, api_instance, mock_httpx_client): - """Test event counts when pipeline doesn't exist""" - mock_response = Mock() - mock_response.status_code = 404 - mock_httpx_client.get.return_value = mock_response - - with pytest.raises(PipelineNotFoundError): - api_instance.get_pipeline_event_counts("nonexistent") - - -# ============================================================================ -# Edge Cases and Error Handling Tests -# ============================================================================ - -class TestEdgeCasesAndErrors: - """Test edge cases and error handling""" - - def test_empty_pipeline_data(self, api_instance, mock_httpx_client): - """Test handling of empty pipeline data in response""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": {} - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Empty pipeline data means pipeline is still registering/initializing - assert state == "not_found" - - def test_missing_events_field(self, api_instance, mock_httpx_client): - """Test handling when events field is missing""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "reloads": {"successes": 1, "failures": 0} - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Missing events structure means pipeline hasn't fully initialized yet - assert state == "not_found" - - def test_missing_reloads_field(self, api_instance, mock_httpx_client): - """Test handling when reloads field is missing""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0} - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Missing reloads structure means pipeline is still registering - assert state == "not_found" - - def test_null_reloads_field(self, api_instance, mock_httpx_client): - """Test handling when reloads field is null""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "pipelines": { - "test-pipeline": { - "events": {"in": 0, "out": 0}, - "reloads": None - } - } - } - mock_httpx_client.get.return_value = mock_response - - state = api_instance.detect_pipeline_state("test-pipeline") - - # Null reloads field means pipeline is still registering - assert state == "not_found" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/LogstashUI/Common/middleware.py b/LogstashUI/Common/middleware.py deleted file mode 100644 index 262ed79b..00000000 --- a/LogstashUI/Common/middleware.py +++ /dev/null @@ -1,39 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - - -class SecurityHeadersMiddleware: - """ - Middleware to add Content Security Policy and other security headers. - Restricts iframe sources to trusted documentation domains. - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - - # Content Security Policy - # frame-src: Controls which URLs can be loaded in iframes - # 'self': Allow iframes from same origin - # Trusted documentation domains for plugin docs - csp_directives = [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", # unsafe-inline/eval needed for htmx and dynamic JS - "style-src 'self' 'unsafe-inline'", # unsafe-inline needed for Tailwind - "img-src 'self' data: https:", - "font-src 'self' data:", - "connect-src 'self'", - "frame-src 'self' https://www.elastic.co https://elastic.co https://github.com https://rubydoc.info", - "frame-ancestors 'self'", # Prevent this site from being framed by others - ] - - response['Content-Security-Policy'] = "; ".join(csp_directives) - - # Additional security headers - response['X-Content-Type-Options'] = 'nosniff' - response['Referrer-Policy'] = 'no-referrer-when-downgrade' - - return response diff --git a/LogstashUI/Dockerfile b/LogstashUI/Dockerfile deleted file mode 100644 index 5be7ca22..00000000 --- a/LogstashUI/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 - -WORKDIR /app - -RUN apt-get update && apt-get install -y \ - nodejs \ - npm \ - sqlite3 \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy *this* build context into /app -COPY . . - -# If theme is directly under /app/theme -WORKDIR /app/theme/static_src -RUN rm -rf node_modules && npm install - -# Build Tailwind + collectstatic from where manage.py is -WORKDIR /app -RUN python manage.py tailwind build -RUN python manage.py collectstatic --noinput --clear - -RUN groupadd -r appuser && useradd -r -g appuser appuser -RUN mkdir -p /app/data && \ - chown -R appuser:appuser /app && \ - chmod -R 755 /app - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh && chown appuser:appuser /entrypoint.sh - -USER appuser -EXPOSE 8080 -ENTRYPOINT ["/entrypoint.sh"] -CMD ["gunicorn","LogstashUI.wsgi:application","--bind","0.0.0.0:8080","--workers","2","--timeout","60","--access-logfile","-","--error-logfile","-"] diff --git a/LogstashUI/PipelineManager/models.py b/LogstashUI/PipelineManager/models.py deleted file mode 100644 index d4760e1b..00000000 --- a/LogstashUI/PipelineManager/models.py +++ /dev/null @@ -1,136 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -from django.db import models -from Common.encryption import encrypt_credential, decrypt_credential -from django.core.exceptions import ValidationError - - -class Connection(models.Model): - """ - Represents a connection to either a Logstash Agent or a centralized management service. - """ - - class ConnectionType(models.TextChoices): - AGENT = 'AGENT', 'Logstash Agent' - CENTRALIZED = 'CENTRALIZED', 'Centralized Pipeline Management' - - name = models.CharField( - max_length=100, - help_text="A friendly name for this connection" - ) - connection_type = models.CharField( - max_length=20, - choices=ConnectionType.choices, - help_text="Type of connection (Agent or Centralized)" - ) - - # Agent Connection Fields (optional) - host = models.CharField( - max_length=255, - blank=True, - null=True, - help_text="Hostname or IP address for Agent connection" - ) - port = models.PositiveIntegerField( - default=22, - blank=True, - null=True, - help_text="Agent port (default: 22)" - ) - username = models.CharField( - max_length=100, - blank=True, - null=True, - help_text="Username for authentication" - ) - password = models.CharField( - max_length=512, - blank=True, - null=True, - help_text="Password for authentication (leave empty if using key-based auth)" - ) - ssh_key = models.TextField( - blank=True, - null=True, - help_text="Private key (PEM format) for key-based authentication" - ) - - # Centralized Management Fields (optional) - cloud_id = models.CharField( - max_length=255, - blank=True, - null=True, - help_text="Elastic Cloud ID for centralized management" - ) - cloud_url = models.URLField( - blank=True, - null=True, - help_text="Elastic Cloud URL (alternative to Cloud ID)" - ) - api_key = models.CharField( - max_length=512, - blank=True, - null=True, - help_text="API key for authentication (alternative to username/password)" - ) - - # Metadata - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - is_active = models.BooleanField(default=True) - - class Meta: - ordering = ['name'] - verbose_name = 'Connection' - verbose_name_plural = 'Connections' - - def __str__(self): - return f"{self.name} ({self.get_connection_type_display()})" - - def clean(self): - """ - Validate that the required fields are provided based on the connection type. - """ - if self.connection_type == self.ConnectionType.AGENT: - if not self.host: - raise ValidationError("Host is required for Agent connections") - if not (self.ssh_key or (self.username and self.password)): - raise ValidationError( - "Either key or username/password is required for Agent connections" - ) - else: # CENTRALIZED - if not (self.cloud_id or self.host): - raise ValidationError( - "Either Cloud ID or Cloud URL is required for centralized connections" - ) - if not (self.api_key or (self.username and self.password)): - raise ValidationError( - "Either API key or username/password is required for centralized connections" - ) - - def save(self, *args, **kwargs): - self.full_clean() - - # Encrypt sensitive fields before saving - if self.password and not self._is_encrypted(self.password): - self.password = encrypt_credential(self.password) - if self.ssh_key and not self._is_encrypted(self.ssh_key): - self.ssh_key = encrypt_credential(self.ssh_key) - if self.api_key and not self._is_encrypted(self.api_key): - self.api_key = encrypt_credential(self.api_key) - - super().save(*args, **kwargs) - - def _is_encrypted(self, value): - """Check if a value is already encrypted (Fernet tokens start with 'gAAAAA')""" - return value and value.startswith('gAAAAA') - - def get_password(self): - """Get decrypted password""" - return decrypt_credential(self.password) if self.password else None - - def get_api_key(self): - """Get decrypted API key""" - return decrypt_credential(self.api_key) if self.api_key else None diff --git a/LogstashUI/PipelineManager/templates/components/pipeline_manager/collapsible_row.html b/LogstashUI/PipelineManager/templates/components/pipeline_manager/collapsible_row.html deleted file mode 100644 index 23271a50..00000000 --- a/LogstashUI/PipelineManager/templates/components/pipeline_manager/collapsible_row.html +++ /dev/null @@ -1,313 +0,0 @@ - - - - - - -
- - - -
- -
-
- - - - -
- - - - - - - - - {% for pipeline in pipelines %} - - - - - - - {% endfor %} - -
Pipeline NameDescriptionLast UpdatedActions
- {{ pipeline.name }} - - {% if pipeline.description %} - {{ pipeline.description|truncatechars:80 }} - {% else %} - No description - {% endif %} - - {% if pipeline.last_modified %} - {{ pipeline.last_modified }} - {% else %} - N/A - {% endif %} - -
- - -
-
-
- - -
-
-
- Showing 0 to 0 of 0 pipelines -
- -
-
- - Page 1 of 1 - -
-
- - - -
- - -
- - - -
- -
- - - \ No newline at end of file diff --git a/LogstashUI/PipelineManager/templates/components/pipeline_manager/connection_modal.html b/LogstashUI/PipelineManager/templates/components/pipeline_manager/connection_modal.html deleted file mode 100644 index 4a6538e6..00000000 --- a/LogstashUI/PipelineManager/templates/components/pipeline_manager/connection_modal.html +++ /dev/null @@ -1,334 +0,0 @@ - - - - - - diff --git a/LogstashUI/PipelineManager/templates/pipeline_manager.html b/LogstashUI/PipelineManager/templates/pipeline_manager.html deleted file mode 100644 index 62975873..00000000 --- a/LogstashUI/PipelineManager/templates/pipeline_manager.html +++ /dev/null @@ -1,375 +0,0 @@ - - -{% extends "base.html" %} -{% load static %} - -{% block content %} - - -
- {% if not has_connections %} -
-
-
-
- - - -
-

Get Started with Logstash

-

Add your first connection to start managing pipelines

-
- -
-
-
- - -
- {% else %} - - -
-
- - - - - - - - - - - - - {% for connection in connections %} - - - - - - - - - - - - - {% endfor %} - -
TypeNameHostStatusActions
- - - - {% if connection.connection_type == "CENTRALIZED" %} - Centralized - {% else %} - Agent - {% endif %} - {{ connection.name }}{% if connection.host %}{{ connection.host|truncatechars:20 }}{% else %}{{ connection.cloud_id|truncatechars:12 }}{% endif %} - - Test - - -
- - - -
-
-
-
- {% endif %} - - - {% include 'components/pipeline_manager/connection_modal.html' %} - -
- - - - - -{% endblock %} \ No newline at end of file diff --git a/LogstashUI/PipelineManager/tests/test_views.py b/LogstashUI/PipelineManager/tests/test_views.py deleted file mode 100644 index 3e8559bd..00000000 --- a/LogstashUI/PipelineManager/tests/test_views.py +++ /dev/null @@ -1,1473 +0,0 @@ -#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -#or more contributor license agreements. Licensed under the Elastic License; -#you may not use this file except in compliance with the Elastic License. - -from Common.test_resources import authenticated_client, test_connection, test_user -from PipelineManager.models import Connection - -from unittest.mock import patch, MagicMock - -import json -import pytest - - -# ============================================================================ -# Connection CRUD Tests -# ============================================================================ - -@pytest.mark.django_db -class TestConnectionCRUD: - """Test Connection Create, Read, Update, Delete operations""" - - def test_add_connection_requires_authentication(self, client): - """Test that adding a connection requires authentication""" - response = client.post('/ConnectionManager/AddConnection', { - 'name': 'Test', - 'connection_type': 'CENTRALIZED', - 'host': 'https://localhost:9200' - }) - # Should redirect to login - assert response.status_code == 302 - assert '/Management/Login/' in response.url - - @patch('PipelineManager.views.test_connectivity') - def test_add_connection_success(self, mock_test_connectivity, authenticated_client): - """Test successful connection creation""" - # Mock successful connectivity test - mock_test_connectivity.return_value = (True, "Connection successful") - - response = authenticated_client.post('/ConnectionManager/AddConnection', { - 'name': 'Test Connection', - 'connection_type': 'CENTRALIZED', - 'host': 'https://localhost:9200', - 'username': 'elastic', - 'password': 'changeme' - }) - - assert response.status_code == 200 - response_data = json.loads(response.content) - assert response_data['success'] is True - assert 'Connection created and tested successfully!' in response_data['message'] - - # Verify connection was created - assert Connection.objects.filter(name='Test Connection').exists() - - @patch('PipelineManager.views.test_connectivity') - def test_add_connection_failed_connectivity(self, mock_test_connectivity, authenticated_client): - """Test connection creation with failed connectivity test""" - # Mock failed connectivity test - mock_test_connectivity.return_value = (False, "Connection failed: Timeout") - - response = authenticated_client.post('/ConnectionManager/AddConnection', { - 'name': 'Bad Connection', - 'connection_type': 'CENTRALIZED', - 'host': 'https://invalid:9200', - 'username': 'elastic', - 'password': 'wrong' - }) - - assert response.status_code == 200 - response_data = json.loads(response.content) - assert response_data['success'] is False - assert 'Connection failed: Timeout' in response_data['error'] - - # Verify connection was NOT created (deleted after failed test) - assert not Connection.objects.filter(name='Bad Connection').exists() - - def test_add_connection_invalid_form(self, authenticated_client): - """Test connection creation with invalid form data""" - response = authenticated_client.post('/ConnectionManager/AddConnection', { - 'name': '', # Empty name should fail validation - 'connection_type': 'CENTRALIZED' - }) - - assert response.status_code == 200 - response_data = json.loads(response.content) - assert response_data['success'] is False - assert 'error' in response_data - # Check that the error contains form validation messages - assert 'name' in response_data['error'] or 'This field is required' in response_data['error'] - - def test_delete_connection(self, authenticated_client, test_connection): - """Test connection deletion""" - connection_id = test_connection.id - - response = authenticated_client.post(f'/ConnectionManager/DeleteConnection/{connection_id}/') - - assert response.status_code == 200 - assert b'Connection deleted successfully!' in response.content - - # Verify connection was deleted - assert not Connection.objects.filter(id=connection_id).exists() - - def test_delete_nonexistent_connection(self, authenticated_client): - """Test deleting a connection that doesn't exist""" - response = authenticated_client.post('/ConnectionManager/DeleteConnection/99999/') - - assert response.status_code == 404 - assert b'Connection not found' in response.content - - -# ============================================================================ -# Pipeline CRUD Tests -# ============================================================================ - -@pytest.mark.django_db -class TestPipelineCRUD: - """Test Pipeline Create, Read, Update, Delete operations""" - - @patch('PipelineManager.views.get_elastic_connection') - def test_create_pipeline_success(self, mock_get_es, authenticated_client, test_connection): - """Test successful pipeline creation""" - # Mock Elasticsearch connection - mock_es = MagicMock() - mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} - mock_get_es.return_value = mock_es - - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': 'test_pipeline', - 'pipeline_config': 'input {}\nfilter {}\noutput {}' - }) - - assert response.status_code == 200 - assert b'Pipeline created successfully!' in response.content - - # Verify put_pipeline was called - mock_es.logstash.put_pipeline.assert_called_once() - call_args = mock_es.logstash.put_pipeline.call_args - assert call_args[1]['id'] == 'test_pipeline' - - @patch('Common.elastic_utils.get_elastic_connection') - def test_create_pipeline_invalid_name(self, mock_get_es, authenticated_client, test_connection): - """Test pipeline creation with invalid name""" - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': '123invalid', # Can't start with number - }) - - assert response.status_code == 400 - assert b'Pipeline ID must begin with a letter or underscore' in response.content - - @patch('PipelineManager.views.get_elastic_connection') - @patch('PipelineManager.views.get_logstash_pipeline') - def test_save_pipeline_success(self, mock_get_pipeline, mock_get_es, authenticated_client, test_connection): - """Test successful pipeline save""" - # Mock existing pipeline - mock_get_pipeline.return_value = { - 'pipeline': 'input {}\nfilter {}\noutput {}', - 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, - 'pipeline_settings': {}, - 'description': '' - } - - # Mock Elasticsearch connection - mock_es = MagicMock() - mock_es.logstash.get_pipeline.return_value = { - 'test_pipeline': { - 'pipeline': 'input {}\nfilter {}\noutput {}', - 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, - 'pipeline_settings': {}, - 'description': '' - } - } - mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} - mock_get_es.return_value = mock_es - - components = { - "input": [], - "filter": [], - "output": [] - } - - response = authenticated_client.post('/ConnectionManager/SavePipeline/', { - 'save_pipeline': 'true', - 'es_id': test_connection.id, - 'pipeline': 'test_pipeline', - 'components': json.dumps(components), - 'add_ids': 'false' - }) - - assert response.status_code == 200 - assert b'Pipeline saved successfully!' in response.content - - @patch('PipelineManager.views.get_elastic_connection') - def test_delete_pipeline_success(self, mock_get_es, authenticated_client, test_connection): - """Test successful pipeline deletion""" - # Mock Elasticsearch connection - mock_es = MagicMock() - mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True} - mock_get_es.return_value = mock_es - - response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { - 'es_id': test_connection.id, - 'pipeline': 'test_pipeline' - }) - - assert response.status_code == 200 - assert b'Pipeline deleted successfully!' in response.content - - # Verify delete_pipeline was called - mock_es.logstash.delete_pipeline.assert_called_once_with(id='test_pipeline') - - @patch('PipelineManager.views.get_elastic_connection') - @patch('PipelineManager.views.get_logstash_pipeline') - def test_update_pipeline_settings_success(self, mock_get_pipeline, mock_get_es, authenticated_client, - test_connection): - """Test successful pipeline settings update""" - # Mock existing pipeline - mock_get_pipeline.return_value = { - 'pipeline': 'input {}\nfilter {}\noutput {}', - 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, - 'pipeline_settings': {}, - 'description': '' - } - - # Mock Elasticsearch connection - mock_es = MagicMock() - mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} - mock_get_es.return_value = mock_es - - response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { - 'es_id': test_connection.id, - 'pipeline': 'test_pipeline', - 'description': 'Updated description', - 'pipeline_workers': '2', - 'pipeline_batch_size': '250' - }) - - assert response.status_code == 200 - - # Verify put_pipeline was called with updated settings - mock_es.logstash.put_pipeline.assert_called() - call_args = mock_es.logstash.put_pipeline.call_args - assert call_args[1]['body']['description'] == 'Updated description' - assert call_args[1]['body']['pipeline_settings']['pipeline.workers'] == 2 - - -# ============================================================================ -# Pipeline Name Validation Tests -# ============================================================================ - -@pytest.mark.django_db -class TestPipelineNameValidation: - """Test pipeline name validation""" - - @patch('PipelineManager.views.get_elastic_connection') - def test_pipeline_name_starts_with_letter(self, mock_get_es, authenticated_client, test_connection): - """Test that pipeline name can start with a letter""" - mock_es = MagicMock() - mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} - mock_get_es.return_value = mock_es - - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': 'valid_pipeline' - }) - - assert response.status_code == 200 - - @patch('PipelineManager.views.get_elastic_connection') - def test_pipeline_name_starts_with_underscore(self, mock_get_es, authenticated_client, test_connection): - """Test that pipeline name can start with underscore""" - mock_es = MagicMock() - mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} - mock_get_es.return_value = mock_es - - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': '_valid_pipeline' - }) - - assert response.status_code == 200 - - def test_pipeline_name_starts_with_number_invalid(self, authenticated_client, test_connection): - """Test that pipeline name cannot start with a number""" - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': '123invalid' - }) - - assert response.status_code == 400 - assert b'must begin with a letter or underscore' in response.content - - def test_pipeline_name_special_chars_invalid(self, authenticated_client, test_connection): - """Test that pipeline name cannot contain special characters""" - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': 'invalid@pipeline' - }) - - assert response.status_code == 400 - # Pipeline name starts with a letter but contains invalid @ character - # Check for the actual error message from the validator - assert (b'can only contain letters' in response.content or - b'underscores, dashes, hyphens' in response.content) - - def test_pipeline_name_empty_invalid(self, authenticated_client, test_connection): - """Test that pipeline name cannot be empty""" - response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { - 'es_id': test_connection.id, - 'pipeline': '' - }) - - assert response.status_code == 400 - assert b'Pipeline name cannot be empty' in response.content - - -# ============================================================================ -# Security & Error Handling Tests -# ============================================================================ - -@pytest.mark.django_db -class TestSecurityAndErrorHandling: - """Test security features and error handling""" - - def test_get_pipelines_invalid_connection_id(self, authenticated_client): - """Test GetPipelines with non-existent connection ID raises proper error""" - response = authenticated_client.get('/ConnectionManager/GetPipelines/99999/') - - # Should return 500 or 404, not crash - assert response.status_code in [404, 500] - - @patch('PipelineManager.views.test_connectivity') - def test_connectivity_error_message_escaped(self, mock_test_connectivity, authenticated_client, test_connection): - """Test that error messages in TestConnectivity are HTML-escaped to prevent XSS""" - # Mock connectivity test with XSS attempt in error message - xss_payload = "" - mock_test_connectivity.return_value = (False, xss_payload) - - response = authenticated_client.get(f'/ConnectionManager/TestConnectivity?test={test_connection.id}') - - assert response.status_code == 200 - # Verify the script tag is escaped, not executed - content = response.content.decode('utf-8') - assert '<script>' in content - assert ' - """) - - -def GetPipelines(request, connection_id): - context = {} - try: - connection = ConnectionTable.objects.get(pk=connection_id) - except ConnectionTable.DoesNotExist: - return HttpResponse( - '
Connection not found
', - status=404 - ) - - logstash_pipelines = [] - if connection.connection_type == "CENTRALIZED": - # --- Gets our pipelines from the connection - try: - es = get_elastic_connection(connection.id) - pipelines = es.logstash.get_pipeline() - - for pipeline_name, pipeline_data in pipelines.items(): - # Format last_modified timestamp - last_modified_str = pipeline_data.get("last_modified", "") - formatted_date = "" - if last_modified_str: - try: - # Parse ISO 8601 format: 2025-11-23T05:30:52.421Z - dt = datetime.fromisoformat(last_modified_str.replace('Z', '+00:00')) - # Format as "Tuesday, January 14th 2025" - day = dt.day - suffix = 'th' if 11 <= day <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') - formatted_date = dt.strftime(f'%A, %B {day}{suffix} %Y') - except Exception: - formatted_date = last_modified_str # Fallback to original if parsing fails - - logstash_pipelines.append( - { - "es_id": connection.id, - "es_name": connection.name, - "name": pipeline_name, - "description": pipeline_data.get("description", ""), - "last_modified": formatted_date - } - ) - - except Exception as e: - logger.exception("Couldn't connect to Elastic") - - context['pipelines'] = logstash_pipelines - context['es_id'] = connection.id - - logstash_template = get_template("components/pipeline_manager/collapsible_row.html") - html = logstash_template.render(context) - return HttpResponse(html) - - -@require_admin_role -def UpdatePipelineSettings(request): - if request.method == "POST": - try: - es_id = request.POST.get("es_id") - pipeline_name = request.POST.get("pipeline") - - # Validate required fields - if not es_id or not pipeline_name: - return HttpResponse( - '
Error: Missing pipeline ID or connection ID
', - status=400 - ) - - # Validate pipeline name - is_valid, error_msg = validate_pipeline_name(pipeline_name) - if not is_valid: - return HttpResponse( - f'
{error_msg}
', - status=400 - ) - - # Get form values - description = request.POST.get("description", "") - pipeline_workers = request.POST.get("pipeline_workers") - pipeline_batch_size = request.POST.get("pipeline_batch_size") - pipeline_batch_delay = request.POST.get("pipeline_batch_delay") - queue_type = request.POST.get("queue_type") - queue_max_bytes = request.POST.get("queue_max_bytes") - queue_max_bytes_unit = request.POST.get("queue_max_bytes_unit") - queue_checkpoint_writes = request.POST.get("queue_checkpoint_writes") - - # Build settings body - only include non-empty values - current_pipeline_config = get_logstash_pipeline(es_id, pipeline_name) - settings_body = { - "pipeline": current_pipeline_config['pipeline'], - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": { - "version": current_pipeline_config['pipeline_metadata']['version'] + 1, - "type": "logstash_pipeline" - }, - "username": "LogstashUI", - "pipeline_settings": {}, - - } - - if 'description' in current_pipeline_config: - settings_body['description'] = current_pipeline_config['description'] - - if description: - settings_body["description"] = description - if pipeline_workers: - settings_body['pipeline_settings']["pipeline.workers"] = int(pipeline_workers) - if pipeline_batch_size: - settings_body['pipeline_settings']["pipeline.batch.size"] = int(pipeline_batch_size) - if pipeline_batch_delay: - settings_body['pipeline_settings']["pipeline.batch.delay"] = int(pipeline_batch_delay) - if queue_type: - settings_body['pipeline_settings']["queue.type"] = queue_type - if queue_max_bytes: - settings_body['pipeline_settings']["queue.max_bytes"] = f"{queue_max_bytes}{queue_max_bytes_unit}" - if queue_checkpoint_writes: - settings_body['pipeline_settings']["queue.checkpoint.writes"] = int(queue_checkpoint_writes) - - # Get Elasticsearch connection and update pipeline settings - es = get_elastic_connection(es_id) - es.logstash.put_pipeline(id=pipeline_name, body=settings_body) - - logger.info( - f"User '{request.user.username}' updated settings for pipeline '{pipeline_name}' (Connection ID: {es_id})") - # Return empty response - toast notification handled by JavaScript - return HttpResponse('', status=200) - - except Exception as e: - # Return simple error message - toast notification handled by JavaScript - logger.error(traceback.format_exc()) - return HttpResponse(str(e), status=500) - - return HttpResponse('Invalid request method', status=405) - - -@require_admin_role -def CreatePipeline(request, simulate=False, pipeline_name=None, pipeline_config=None): - """ - Create a pipeline in Elasticsearch or LogstashAgent. - - Args: - request: Django request object - simulate: If True, send to LogstashAgent instead of Elasticsearch - pipeline_name: Pipeline name (used when called directly for simulation) - pipeline_config: Pipeline config string (used when called directly for simulation) - """ - - if request.method == "POST" or simulate: - # Get parameters from POST or function arguments - if not simulate: - es_id = request.POST.get("es_id") - pipeline_name = request.POST.get("pipeline") - pipeline_config = request.POST.get("pipeline_config", "").strip() - - # Validate pipeline name - is_valid, error_msg = validate_pipeline_name(pipeline_name) - if not is_valid: - return HttpResponse(error_msg, status=400) - - # Use provided config or default empty config - if pipeline_config: - pipeline_content = pipeline_config - else: - pipeline_content = "input {}\nfilter {}\noutput {}" - - # Build the pipeline body - pipeline_body = { - "pipeline": pipeline_content, - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": { - "version": 1, - "type": "logstash_pipeline" - }, - "username": "LogstashUI", - "pipeline_settings": { - "pipeline.batch.delay": 50, - "pipeline.batch.size": 125, - "pipeline.workers": 1, - "queue.checkpoint.writes": 1024, - "queue.max_bytes": "1gb", - "queue.type": "memory" - }, - "description": "" - } - - if simulate: - # Send to LogstashAgent - logstash_agent_url = f"{settings.LOGSTASH_AGENT_URL}/_logstash/pipeline/{pipeline_name}" - - try: - response = requests.put( - logstash_agent_url, - json=pipeline_body, - verify=False, # --insecure equivalent - timeout=10 - ) - response.raise_for_status() - logger.info( - f"User '{request.user.username}' created simulation pipeline '{pipeline_name}' in LogstashAgent") - return HttpResponse("Simulation pipeline created successfully!", status=200) - except requests.exceptions.RequestException as e: - logger.error(f"Failed to create simulation pipeline in LogstashAgent: {e}") - return HttpResponse(f"Failed to create simulation pipeline: {str(e)}", status=500) - else: - # Send to Elasticsearch - es = get_elastic_connection(es_id) - pipeline_doc = es.logstash.put_pipeline( - id=pipeline_name, - body=pipeline_body - ) - - logger.info( - f"User '{request.user.username}' created new pipeline '{pipeline_name}' (Connection ID: {es_id})") - response = HttpResponse("Pipeline created successfully!") - response['HX-Redirect'] = f'/ConnectionManager/Pipelines/Editor/?es_id={es_id}&pipeline={pipeline_name}' - return response - - -@require_admin_role -def DeletePipeline(request): - if request.method == "POST": - # Handle both JSON and form data - if request.content_type == 'application/json': - data = json.loads(request.body) - es_id = data.get("es_id") - pipeline_name = data.get("pipeline") - else: - es_id = request.POST.get("es_id") - pipeline_name = request.POST.get("pipeline") - - # Validate pipeline name - is_valid, error_msg = validate_pipeline_name(pipeline_name) - if not is_valid: - return HttpResponse(error_msg, status=400) - - es = get_elastic_connection(es_id) - es.logstash.delete_pipeline(id=pipeline_name) - - logger.warning(f"User '{request.user.username}' deleted pipeline '{pipeline_name}' (Connection ID: {es_id})") - return HttpResponse(status=204) # No content - prevents text from being inserted into page - - -@require_admin_role -def ClonePipeline(request): - if request.method == "POST": - es_id = request.POST.get("es_id") - source_pipeline = request.POST.get("source_pipeline") - new_pipeline = request.POST.get("new_pipeline") - - # Validate source pipeline name - is_valid, error_msg = validate_pipeline_name(source_pipeline) - if not is_valid: - return HttpResponse(f"Invalid source pipeline name: {error_msg}", status=400) - - # Validate new pipeline name - is_valid, error_msg = validate_pipeline_name(new_pipeline) - if not is_valid: - return HttpResponse(error_msg, status=400) - - try: - es = get_elastic_connection(es_id) - - # Get the source pipeline configuration - source_config = es.logstash.get_pipeline(id=source_pipeline) - - if source_pipeline not in source_config: - return HttpResponse(f"Source pipeline '{source_pipeline}' not found", status=404) - - source_data = source_config[source_pipeline] - - # Check if new pipeline name already exists - existing_pipelines = es.logstash.get_pipeline() - if new_pipeline in existing_pipelines: - return HttpResponse(f"Pipeline '{new_pipeline}' already exists. Please choose a different name.", - status=400) - - # Create the new pipeline with the same configuration as the source - es.logstash.put_pipeline( - id=new_pipeline, - body={ - "pipeline": source_data['pipeline'], - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": { - "version": 1, - "type": "logstash_pipeline" - }, - "username": "LogstashUI", - "pipeline_settings": source_data.get('pipeline_settings', {}), - "description": source_data.get('description', f"Cloned from {source_pipeline}") - } - ) - - logger.info( - f"User '{request.user.username}' cloned pipeline '{source_pipeline}' to '{new_pipeline}' (Connection ID: {es_id})") - - # Close the modal and refresh the pipeline list - response = HttpResponse(""" - - """) - return response - - except Exception as e: - logger.error(f"Error cloning pipeline: {str(e)}") - return HttpResponse(f"Error cloning pipeline: {str(e)}", status=500) - - -def GetPipeline(request): - if request.method == "GET": - es_id = request.GET.get("es_id") - pipeline_name = request.GET.get("pipeline") - - # Validate required parameters - if not es_id or not pipeline_name: - return JsonResponse({"error": "Missing required parameters: es_id and pipeline"}, status=400) - - pipeline_config = get_logstash_pipeline(es_id, pipeline_name) - - # Handle case where pipeline couldn't be fetched - if not pipeline_config: - return JsonResponse({"error": f"Could not fetch pipeline '{pipeline_name}' from connection {es_id}"}, status=400) - - pipeline_string = pipeline_config['pipeline'] - - return JsonResponse({"code": pipeline_string}) - - -def GetCurrentPipelineCode(request, components={}): - if not components: - data = json.loads(request.POST.get("components")) - else: - data = components - parser = logstash_config_parse.ComponentToPipeline(data) - config = parser.components_to_logstash_config() - - # Return the code wrapped in a pre tag with proper formatting - return HttpResponse( - f'
{escape(config)}
', - content_type="text/html" - ) - - -@require_admin_role -def SavePipeline(request): - if "save_pipeline" in request.POST: - pipeline_name = request.POST.get("pipeline") - - # Validate pipeline name - is_valid, error_msg = validate_pipeline_name(pipeline_name) - if not is_valid: - return HttpResponse( - f'

{error_msg}

', - status=400 - ) - - # Check if we have raw pipeline config (from Text mode) or components (from UI mode) - pipeline_config = request.POST.get("pipeline_config") - - if pipeline_config: - # Use the raw pipeline config directly from Text mode - config = pipeline_config - logger.info(f"Saving pipeline from Text mode (raw config)") - else: - # Generate config from components (UI mode) - components_json = request.POST.get("components") - if not components_json: - return HttpResponse( - f'

Missing pipeline configuration

', - status=400 - ) - - data = json.loads(components_json) - add_ids = request.POST.get("add_ids", "false").lower() == "true" - parser = logstash_config_parse.ComponentToPipeline(data, add_ids=add_ids) - config = parser.components_to_logstash_config() - - # Validate that the generated config can be converted back to components - try: - logstash_config_parse.logstash_config_to_components(config) - except Exception as e: - # If conversion fails, return detailed error to user - error_message = escape(str(e)) - return HttpResponse( - f"""
-

We're sorry! Something went wrong in the conversion of your pipeline!

-
-{error_message} -
-

Please report this issue to us so we can fix it!!

-
""", - status=400 - ) - - es = get_elastic_connection(request.POST.get("es_id")) - current_pipeline_config = es.logstash.get_pipeline(id=pipeline_name) - - pipeline_data = current_pipeline_config.get(pipeline_name, {}) - - es.logstash.put_pipeline(id=pipeline_name, body={ - "pipeline": config, - "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "pipeline_metadata": pipeline_data.get('pipeline_metadata', {"version": 1, "type": "logstash_pipeline"}), - "username": "LogstashUI", - "pipeline_settings": pipeline_data.get('pipeline_settings', {}), - "description": pipeline_data.get('description', '') - } - ) - - logger.info( - f"User '{request.user.username}' saved pipeline '{pipeline_name}' (Connection ID: {request.POST.get('es_id')})") - return HttpResponse("Pipeline saved successfully!") - - return HttpResponse("Invalid request", status=400) - - -def ComponentsToConfig(request): - """Convert components JSON to Logstash configuration text""" - if request.method == "POST": - try: - components_json = request.POST.get("components") - if not components_json: - return HttpResponse("No components provided", status=400) - - # Parse components - components = json.loads(components_json) - - # Convert to config using the same logic as SavePipeline - parser = logstash_config_parse.ComponentToPipeline(components, add_ids=False) - config = parser.components_to_logstash_config() - - # Return plain text config - return HttpResponse(config, content_type="text/plain") - except Exception as e: - logger.error(f"Error converting components to config: {str(e)}") - return HttpResponse(f"Error: {str(e)}", status=500) - - return HttpResponse("Method not allowed", status=405) - - -def ConfigToComponents(request): - """Convert Logstash configuration text to components JSON""" - if request.method == "POST": - try: - config_text = request.POST.get("config_text") - if not config_text: - return JsonResponse({"error": "No config text provided"}, status=400) - - # Parse config text to components - components = logstash_config_parse.logstash_config_to_components(config_text) - - # Return components as JSON with safe=False to allow nested structures - return JsonResponse(components, safe=False) - except Exception as e: - logger.error(f"Error converting config to components: {str(e)}") - return JsonResponse({"error": str(e)}, status=500) - - return JsonResponse({"error": "Method not allowed"}, status=405) - - -def GetDiff(request): - """Generate a unified diff between current and new pipeline configurations""" - if request.method == "POST": - es_id = request.POST.get("es_id") - pipeline_name = request.POST.get("pipeline") - pipeline_text = request.POST.get("pipeline_text") # Raw text from Text mode - components_json = request.POST.get("components") - add_ids = request.POST.get("add_ids", "false").lower() == "true" - - # Need either pipeline_text or components - if not es_id or not pipeline_name or (not pipeline_text and not components_json): - return JsonResponse({"error": "Missing required parameters"}, status=400) - - try: - # Get the current pipeline from Elasticsearch - current_pipeline = get_logstash_pipeline(es_id, pipeline_name)['pipeline'] - - # Generate the new pipeline - either from raw text or from components - if pipeline_text: - # Use the raw text directly from Text mode - new_pipeline = pipeline_text - else: - # Generate from components (UI mode) - components = json.loads(components_json) - parser = logstash_config_parse.ComponentToPipeline(components, add_ids=add_ids) - new_pipeline = parser.components_to_logstash_config() - - # Generate unified diff - diff = difflib.unified_diff( - current_pipeline.splitlines(keepends=True), - new_pipeline.splitlines(keepends=True), - fromfile='Current Pipeline', - tofile='New Pipeline (After Save)', - lineterm='' - ) - - # Convert to string - diff_text = ''.join(diff) - - # Calculate stats - current_lines = len(current_pipeline.splitlines()) - new_lines = len(new_pipeline.splitlines()) - line_diff = new_lines - current_lines - diff_sign = '+' if line_diff > 0 else '' - stats = f"Current: {current_lines} lines | New: {new_lines} lines ({diff_sign}{line_diff})" - - return JsonResponse({ - 'diff': diff_text, - 'stats': stats, - 'current': current_pipeline, - 'new': new_pipeline - }) - - except Exception as e: - logger.error(f"Error generating diff: {str(e)}") - return JsonResponse({"error": f"Error generating diff: {str(e)}"}, status=500) - - return JsonResponse({"error": "Method not allowed"}, status=405) - - -def GetElasticsearchConnections(request): - """ - Get all Elasticsearch connections for simulation input - """ - try: - # Use existing function that returns connections with ES clients - connections_list = get_elastic_connections_from_list() - - # Format for dropdown: extract id and name - connections = [{'id': conn['id'], 'name': conn['name']} for conn in connections_list] - - return JsonResponse({"connections": connections}) - except Exception as e: - logger.error(f"Error fetching Elasticsearch connections: {e}") - return JsonResponse({"error": str(e)}, status=500) - - -def GetElasticsearchIndices(request): - """ - Get Elasticsearch indices with typeahead support - """ - - connection_id = request.GET.get("connection_id") - pattern = request.GET.get("pattern", "*") - - if not connection_id: - return JsonResponse({"error": "connection_id is required"}, status=400) - - try: - indices = get_elasticsearch_indices(connection_id, pattern) - return JsonResponse({"indices": indices}) - except Exception as e: - logger.error(f"Error fetching Elasticsearch indices: {e}") - return JsonResponse({"error": str(e)}, status=500) - - -def GetElasticsearchFields(request): - """ - Get field mappings from an Elasticsearch index - """ - - connection_id = request.GET.get("connection_id") - index = request.GET.get("index") - - if not connection_id or not index: - return JsonResponse({"error": "connection_id and index are required"}, status=400) - - try: - fields = get_elasticsearch_field_mappings(connection_id, index) - return JsonResponse({"fields": fields}) - except Exception as e: - logger.error(f"Error fetching Elasticsearch fields: {e}") - return JsonResponse({"error": str(e)}, status=500) - - -def QueryElasticsearchDocuments(request): - """ - Query Elasticsearch documents for simulation - """ - - connection_id = request.POST.get("connection_id") - index = request.POST.get("index") - query_method = request.POST.get("query_method") # 'field' or 'docid' - - if not connection_id or not index: - return JsonResponse({"error": "connection_id and index are required"}, status=400) - - try: - if query_method == "docid": - doc_ids = request.POST.get("doc_ids", "").strip().split("\n") - doc_ids = [d.strip() for d in doc_ids if d.strip()] - documents = query_elasticsearch_documents(connection_id, index, doc_ids=doc_ids) - elif query_method == "entire": - # Entire document - fetch with all fields - size = int(request.POST.get("size", 10)) - query = request.POST.get("query", "") - documents = query_elasticsearch_documents( - connection_id, index, field=None, size=size, query_string=query - ) - else: # field method - field = request.POST.get("field") - size = int(request.POST.get("size", 10)) - query = request.POST.get("query", "") - - if not field: - return JsonResponse({"error": "field is required for field-based queries"}, status=400) - - documents = query_elasticsearch_documents( - connection_id, index, field=field, size=size, query_string=query - ) - - return JsonResponse({"documents": documents}) - except Exception as e: - logger.error(f"Error querying Elasticsearch documents: {e}") - return JsonResponse({"error": str(e)}, status=500) - - -def GetPluginDocumentation(request): - """ - Securely proxy plugin documentation URLs with allowlist validation. - Only allows documentation from trusted Elastic/Logstash domains. - """ - plugin_type = request.GET.get("type") - plugin_name = request.GET.get("name") - - if not plugin_type or not plugin_name: - return JsonResponse({"error": "type and name are required"}, status=400) - - try: - # Load plugin data to get the documentation URL - plugin_data = _load_plugin_data() - - if plugin_type not in plugin_data: - return JsonResponse({"error": f"Invalid plugin type: {plugin_type}"}, status=400) - - if plugin_name not in plugin_data[plugin_type]: - return JsonResponse({"error": f"Plugin not found: {plugin_name}"}, status=404) - - plugin = plugin_data[plugin_type][plugin_name] - doc_url = plugin.get("link") - - if not doc_url: - return JsonResponse({"error": "No documentation URL available for this plugin"}, status=404) - - # Allowlist of trusted documentation domains - ALLOWED_DOC_DOMAINS = [ - "www.elastic.co", - "elastic.co", - "github.com", - "rubydoc.info" - ] - - # Parse and validate the URL - from urllib.parse import urlparse - parsed_url = urlparse(doc_url) - - # Check if domain is in allowlist - if not any(parsed_url.netloc.endswith(domain) or parsed_url.netloc == domain - for domain in ALLOWED_DOC_DOMAINS): - logger.warning(f"Blocked documentation URL from untrusted domain: {doc_url}") - return JsonResponse({"error": "Documentation URL is not from a trusted domain"}, status=403) - - # Return the validated URL (frontend will use it in iframe) - return JsonResponse({ - "url": doc_url, - "plugin_name": plugin_name, - "plugin_type": plugin_type - }) - - except Exception as e: - logger.error(f"Error fetching plugin documentation: {e}") - return JsonResponse({"error": str(e)}, status=500) diff --git a/LogstashUI/Site/static/images/ElasticIcon.png b/LogstashUI/Site/static/images/ElasticIcon.png deleted file mode 100644 index 3273ba3b..00000000 Binary files a/LogstashUI/Site/static/images/ElasticIcon.png and /dev/null differ diff --git a/LogstashUI/pytest.ini b/LogstashUI/pytest.ini deleted file mode 100644 index a154df1c..00000000 --- a/LogstashUI/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = LogstashUI.settings -python_files = tests.py test_*.py *_tests.py -pythonpath = . -testpaths = */tests/ -addopts = --import-mode=importlib \ No newline at end of file diff --git a/LogstashUI/requirements.txt b/LogstashUI/requirements.txt deleted file mode 100644 index 4d4064aa..00000000 Binary files a/LogstashUI/requirements.txt and /dev/null differ diff --git a/LogstashUI/theme/static/images/favicon.ico b/LogstashUI/theme/static/images/favicon.ico deleted file mode 100644 index d62d4cca..00000000 Binary files a/LogstashUI/theme/static/images/favicon.ico and /dev/null differ diff --git a/NOTICE.txt b/NOTICE.txt index 0920fdd0..3b44cf04 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,30 +1,210 @@ -LogstashUI -Copyright 2024-2026 Elasticsearch B.V. --------------- @tailwindcss/postcss -------------- -MIT License +-------------- cryptography -------------- -Copyright (c) Tailwind Labs, Inc. + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "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 authorized by + the copyright owner that is 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 this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) 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 + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (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 reproduce, 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, patent, trademark, and + attribution 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 statement to Your modifications and + may provide additional or different 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. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. 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. + + 8. 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. + + 9. Accepting Warranty or Additional Liability. While 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. --------------- Django -------------- + 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 "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + https://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. + + +-------------- django -------------- Copyright (c) Django Software Foundation and individual contributors. All rights reserved. @@ -53,16 +233,18 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------- PyYAML -------------- -Copyright (c) 2017-2021 Ingy döt Net -Copyright (c) 2006-2016 Kirill Simonov -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: +-------------- django-browser-reload -------------- +MIT License + +Copyright (c) 2021 Adam Johnson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -75,10 +257,11 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------- annotated-doc -------------- -The MIT License (MIT) -Copyright (c) 2025 Sebastián Ramírez +-------------- django-htmx -------------- +MIT License + +Copyright (c) 2020 Adam Johnson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -87,21 +270,22 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. --------------- annotated-types -------------- -The MIT License (MIT) -Copyright (c) 2022 the contributors +-------------- django-login-required-middleware -------------- +The MIT License + +Copyright (c) 2019 Cleiton de Lima Pinto Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -110,355 +294,19 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. --------------- anyio -------------- -The MIT License (MIT) - -Copyright (c) 2018 Alex Grönholm - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- asgiref -------------- -Copyright (c) Django Software Foundation and individual contributors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither the name of Django nor the names of its contributors may be used - to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- certifi -------------- -This package contains a modified version of ca-bundle.crt: - -ca-bundle.crt -- Bundle of CA Root Certificates - -This is a bundle of X.509 certificates of public Certificate Authorities -(CA). These were automatically extracted from Mozilla's root certificates -file (certdata.txt). This file can be found in the mozilla source tree: -https://hg.mozilla.org/mozilla-central/file/tip/security/nss/lib/ckfw/builtins/certdata.txt -It contains the certificates in PEM format and therefore -can be directly used with curl / libcurl / php_curl, or with -an Apache+mod_ssl webserver for SSL client authentication. -Just configure this file as the SSLCACertificateFile.# - -***** BEGIN LICENSE BLOCK ***** -This Source Code Form is subject to the terms of the Mozilla Public License, -v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain -one at http://mozilla.org/MPL/2.0/. - -***** END LICENSE BLOCK ***** -@(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $ - --------------- cffi -------------- - -Except when otherwise stated (look for LICENSE files in directories or -information at the beginning of each file) all software and -documentation is licensed as follows: - - MIT No Attribution - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the - Software is furnished to do so. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - DEALINGS IN THE SOFTWARE. - - --------------- charset-normalizer -------------- -MIT License - -Copyright (c) 2025 TAHRI Ahmed R. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- click -------------- -Copyright 2014 Pallets - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- codemirror -------------- -MIT License - -Copyright (C) 2017 by Marijn Haverbeke and others - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - --------------- colorama -------------- -Copyright (c) 2010 Jonathan Hartley -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of the copyright holders, nor those of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- cross-env -------------- -The MIT License (MIT) -Copyright (c) 2017-2025 Kent C. Dodds - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- cryptography -------------- -This software is made available under the terms of *either* of the licenses -found in LICENSE.APACHE or LICENSE.BSD. Contributions to cryptography are made -under the terms of *both* these licenses. - --------------- d3 -------------- -Copyright 2010-2023 Mike Bostock - -Permission to use, copy, modify, and/or distribute this software for any purpose -with or without fee is hereby granted, provided that the above copyright notice -and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF -THIS SOFTWARE. - --------------- daisyui -------------- -MIT License - -Copyright (c) 2020 Pouya Saadeghi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- django-browser-reload -------------- -MIT License - -Copyright (c) 2021 Adam Johnson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- django-htmx -------------- -MIT License - -Copyright (c) 2020 Adam Johnson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- django-login-required-middleware -------------- -The MIT License - -Copyright (c) 2019 Cleiton de Lima Pinto - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - --------------- django-tailwind -------------- +-------------- django-tailwind -------------- The MIT License (MIT) Copyright (c) 2019 Tim Kamanin @@ -481,7 +329,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------- elastic-transport -------------- + +-------------- elasticsearch -------------- Apache License Version 2.0, January 2004 @@ -659,15 +508,63 @@ THE SOFTWARE. of your accepting any such warranty or additional liability. --------------- elasticsearch -------------- - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +-------------- gunicorn -------------- +2009-2026 (c) Benoît Chesneau +2009-2015 (c) Paul J. Davis - 1. Definitions. +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +-------------- lark -------------- +Copyright © 2017 Erez Shinan + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +-------------- packaging -------------- + + 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. @@ -836,415 +733,13 @@ THE SOFTWARE. incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + END OF TERMS AND CONDITIONS + --------------- exceptiongroup -------------- -The MIT License (MIT) - -Copyright (c) 2022 Alex Grönholm - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -This project contains code copied from the Python standard library. -The following is the required license notice for those parts. - -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. - --------------- fastapi -------------- -The MIT License (MIT) - -Copyright (c) 2018 Sebastián Ramírez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - --------------- gunicorn -------------- -2009-2026 (c) Benoît Chesneau -2009-2015 (c) Paul J. Davis - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - --------------- h11 -------------- -The MIT License (MIT) - -Copyright (c) 2016 Nathaniel J. Smith and other contributors - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- htmx -------------- -Zero-Clause BSD -============= - -Permission to use, copy, modify, and/or distribute this software for -any purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL -WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES -OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE -FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY -DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN -AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT -OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - --------------- httpcore -------------- -Copyright © 2020, [Encode OSS Ltd](https://www.encode.io/). -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- httpx -------------- -Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/). -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- idna -------------- -BSD 3-Clause License - -Copyright (c) 2013-2025, Kim Davies and contributors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- lark -------------- -Copyright © 2017 Erez Shinan - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- packaging -------------- -This software is made available under the terms of *either* of the licenses -found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made -under the terms of *both* these licenses. - --------------- postcss -------------- -The MIT License (MIT) - -Copyright 2013 Andrey Sitnik - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- postcss-cli -------------- -License (MIT) - -Copyright (c) 2016 Michael Ciniawsky - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- postcss-nested -------------- -The MIT License (MIT) - -Copyright 2014 Andrey Sitnik - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- postcss-simple-vars -------------- -The MIT License (MIT) - -Copyright 2015 Andrey Sitnik - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- pycparser -------------- -pycparser -- A C parser in Python - -Copyright (c) 2008-2022, Eli Bendersky -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -* Neither the name of the copyright holder nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE -GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- pydantic -------------- -The MIT License (MIT) - -Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------- pydantic_core -------------- +-------------- pygrok -------------- The MIT License (MIT) -Copyright (c) 2022 Samuel Colvin +Copyright (c) 2014 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1264,17 +759,16 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------- pygrok -------------- -The MIT License (MIT) - -Copyright (c) 2014 +-------------- pyyaml -------------- +Copyright (c) 2017-2021 Ingy döt Net +Copyright (c) 2006-2016 Kirill Simonov -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -1287,69 +781,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------- python-dateutil -------------- -Copyright 2017- Paul Ganssle -Copyright 2017- dateutil contributors (see AUTHORS file) - - 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. - -The above license applies to all contributions after 2017-12-01, as well as -all contributions that have been re-licensed (see AUTHORS file for the list of -contributors who have re-licensed their code). --------------------------------------------------------------------------------- -dateutil - Extensions to the standard Python datetime module. - -Copyright (c) 2003-2011 - Gustavo Niemeyer -Copyright (c) 2012-2014 - Tomi Pieviläinen -Copyright (c) 2014-2016 - Yaron de Leeuw -Copyright (c) 2015- - Paul Ganssle -Copyright (c) 2015- - dateutil contributors (see AUTHORS file) - -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The above BSD License Applies to all code, even that also covered by Apache 2.0. - --------------- regex -------------- -This work was derived from the 're' module of CPython 2.6 and CPython 3.1, -copyright (c) 1998-2001 by Secret Labs AB and licensed under CNRI's Python 1.6 -license. - -All additions and alterations are licensed under the Apache 2.0 License. +-------------- requests -------------- Apache License Version 2.0, January 2004 @@ -1526,211 +959,195 @@ All additions and alterations are licensed under the Apache 2.0 License. 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. +-------------- whitenoise -------------- +The MIT License (MIT) - 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 "printed page" as the copyright notice for easier - identification within third-party archives. +Copyright (c) 2013 David Evans - Copyright 2020 Matthew Barnett +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: - 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 +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - http://www.apache.org/licenses/LICENSE-2.0 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - 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. --------------- requests -------------- +-------------- @tailwindcss/postcss -------------- +MIT License - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Copyright (c) Tailwind Labs, Inc. - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - 1. Definitions. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is 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 this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. +-------------- daisyui -------------- +MIT License - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. +Copyright (c) 2020 Pouya Saadeghi - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - "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. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - "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 - (an example is provided in the Appendix below). +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (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." +-------------- cross-env -------------- +The MIT License (MIT) +Copyright (c) 2017-2025 Kent C. Dodds - "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. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - 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 reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - 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. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. - 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 +-------------- postcss -------------- +The MIT License (MIT) + +Copyright 2013 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +-------------- postcss-cli -------------- +License (MIT) + +Copyright (c) 2016 Michael Ciniawsky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +-------------- postcss-nested -------------- +The MIT License (MIT) + +Copyright 2014 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - (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 statement to Your modifications and - may provide additional or different 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. +-------------- postcss-simple-vars -------------- +The MIT License (MIT) - 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. +Copyright 2015 Andrey Sitnik - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: - 7. 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. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - 8. 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. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - 9. Accepting Warranty or Additional Liability. While 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. --------------- rimraf -------------- +-------------- rimraf -------------- # Blue Oak Model License Version 1.0.0 @@ -1787,83 +1204,8 @@ without any warranty or condition, and no contributor will be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** --------------- six -------------- -Copyright (c) 2010-2024 Benjamin Peterson - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------- sqlparse -------------- -Copyright (c) 2016, Andi Albrecht -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of the authors nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------- starlette -------------- -Copyright © 2018, [Encode OSS Ltd](https://www.encode.io/). -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------- tailwindcss -------------- +-------------- tailwindcss -------------- MIT License Copyright (c) Tailwind Labs, Inc. @@ -1886,330 +1228,52 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------- typing-inspection -------------- -The MIT License (MIT) - -Copyright (c) 2017-2019 Ivan Levkivskyi -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +-------------- d3 -------------- +Copyright 2010-2023 Mike Bostock -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. --------------- typing_extensions -------------- -A. HISTORY OF THE SOFTWARE -========================== - -Python was created in the early 1990s by Guido van Rossum at Stichting -Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands -as a successor of a language called ABC. Guido remains Python's -principal author, although it includes many contributions from others. - -In 1995, Guido continued his work on Python at the Corporation for -National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) -in Reston, Virginia where he released several versions of the -software. - -In May 2000, Guido and the Python core development team moved to -BeOpen.com to form the BeOpen PythonLabs team. In October of the same -year, the PythonLabs team moved to Digital Creations, which became -Zope Corporation. In 2001, the Python Software Foundation (PSF, see -https://www.python.org/psf/) was formed, a non-profit organization -created specifically to own Python-related Intellectual Property. -Zope Corporation was a sponsoring member of the PSF. - -All Python releases are Open Source (see https://opensource.org for -the Open Source Definition). Historically, most, but not all, Python -releases have also been GPL-compatible; the table below summarizes -the various releases. - - Release Derived Year Owner GPL- - from compatible? (1) - - 0.9.0 thru 1.2 1991-1995 CWI yes - 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes - 1.6 1.5.2 2000 CNRI no - 2.0 1.6 2000 BeOpen.com no - 1.6.1 1.6 2001 CNRI yes (2) - 2.1 2.0+1.6.1 2001 PSF no - 2.0.1 2.0+1.6.1 2001 PSF yes - 2.1.1 2.1+2.0.1 2001 PSF yes - 2.1.2 2.1.1 2002 PSF yes - 2.1.3 2.1.2 2002 PSF yes - 2.2 and above 2.1.1 2001-now PSF yes - -Footnotes: - -(1) GPL-compatible doesn't mean that we're distributing Python under - the GPL. All Python licenses, unlike the GPL, let you distribute - a modified version without making your changes open source. The - GPL-compatible licenses make it possible to combine Python with - other software that is released under the GPL; the others don't. - -(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, - because its license has a choice of law clause. According to - CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 - is "not incompatible" with the GPL. - -Thanks to the many outside volunteers who have worked under Guido's -direction to make these releases possible. - - -B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON -=============================================================== - -Python software and documentation are licensed under the -Python Software Foundation License Version 2. - -Starting with Python 3.8.6, examples, recipes, and other code in -the documentation are dual licensed under the PSF License Version 2 -and the Zero-Clause BSD license. - -Some software incorporated into Python is under different licenses. -The licenses are listed with code falling under that license. - - -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. - - -BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 -------------------------------------------- - -BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 - -1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an -office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the -Individual or Organization ("Licensee") accessing and otherwise using -this software in source or binary form and its associated -documentation ("the Software"). - -2. Subject to the terms and conditions of this BeOpen Python License -Agreement, BeOpen hereby grants Licensee a non-exclusive, -royalty-free, world-wide license to reproduce, analyze, test, perform -and/or display publicly, prepare derivative works, distribute, and -otherwise use the Software alone or in any derivative version, -provided, however, that the BeOpen Python License is retained in the -Software, alone or in any derivative version prepared by Licensee. - -3. BeOpen is making the Software available to Licensee on an "AS IS" -basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE -SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS -AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY -DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -5. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -6. This License Agreement shall be governed by and interpreted in all -respects by the law of the State of California, excluding conflict of -law provisions. Nothing in this License Agreement shall be deemed to -create any relationship of agency, partnership, or joint venture -between BeOpen and Licensee. This License Agreement does not grant -permission to use BeOpen trademarks or trade names in a trademark -sense to endorse or promote products or services of Licensee, or any -third party. As an exception, the "BeOpen Python" logos available at -http://www.pythonlabs.com/logos.html may be used according to the -permissions granted on that web page. - -7. By copying, installing or otherwise using the software, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. - - -CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 ---------------------------------------- - -1. This LICENSE AGREEMENT is between the Corporation for National -Research Initiatives, having an office at 1895 Preston White Drive, -Reston, VA 20191 ("CNRI"), and the Individual or Organization -("Licensee") accessing and otherwise using Python 1.6.1 software in -source or binary form and its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, CNRI -hereby grants Licensee a nonexclusive, royalty-free, world-wide -license to reproduce, analyze, test, perform and/or display publicly, -prepare derivative works, distribute, and otherwise use Python 1.6.1 -alone or in any derivative version, provided, however, that CNRI's -License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) -1995-2001 Corporation for National Research Initiatives; All Rights -Reserved" are retained in Python 1.6.1 alone or in any derivative -version prepared by Licensee. Alternately, in lieu of CNRI's License -Agreement, Licensee may substitute the following text (omitting the -quotes): "Python 1.6.1 is made available subject to the terms and -conditions in CNRI's License Agreement. This Agreement together with -Python 1.6.1 may be located on the internet using the following -unique, persistent identifier (known as a handle): 1895.22/1013. This -Agreement may also be obtained from a proxy server on the internet -using the following URL: http://hdl.handle.net/1895.22/1013". - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python 1.6.1 or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python 1.6.1. - -4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" -basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. This License Agreement shall be governed by the federal -intellectual property law of the United States, including without -limitation the federal copyright law, and, to the extent such -U.S. federal law does not apply, by the law of the Commonwealth of -Virginia, excluding Virginia's conflict of law provisions. -Notwithstanding the foregoing, with regard to derivative works based -on Python 1.6.1 that incorporate non-separable material that was -previously distributed under the GNU General Public License (GPL), the -law of the Commonwealth of Virginia shall govern this License -Agreement only as to issues arising under or with respect to -Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this -License Agreement shall be deemed to create any relationship of -agency, partnership, or joint venture between CNRI and Licensee. This -License Agreement does not grant permission to use CNRI trademarks or -trade name in a trademark sense to endorse or promote products or -services of Licensee, or any third party. - -8. By clicking on the "ACCEPT" button where indicated, or by copying, -installing or otherwise using Python 1.6.1, Licensee agrees to be -bound by the terms and conditions of this License Agreement. - - ACCEPT - - -CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 --------------------------------------------------- - -Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, -The Netherlands. All rights reserved. - -Permission to use, copy, modify, and distribute this software and its -documentation for any purpose and without fee is hereby granted, -provided that the above copyright notice appear in all copies and that -both that copyright notice and this permission notice appear in -supporting documentation, and that the name of Stichting Mathematisch -Centrum or CWI not be used in advertising or publicity pertaining to -distribution of the software without specific, written prior -permission. - -STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO -THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE -FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT -OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION ----------------------------------------------------------------------- - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. +-------------- codemirror -------------- +MIT License --------------- tzdata -------------- -Apache Software License 2.0 +Copyright (C) 2018 by Marijn Haverbeke , Adrian +Heine , and others -Copyright (c) 2020, Paul Ganssle (Google) +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -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 +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -http://www.apache.org/licenses/LICENSE-2.0 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -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. --------------- urllib3 -------------- -MIT License +-------------- js-yaml -------------- +(The MIT License) -Copyright (c) 2008-2020 Andrey Petrov and contributors. +Copyright (C) 2011-2015 by Vitaly Puzrin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2218,34 +1282,228 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. --------------- uvicorn -------------- -Copyright © 2017-present, [Encode OSS Ltd](https://www.encode.io/). -All rights reserved. + +-------------- concurrent-log-handler -------------- +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 authorized by the copyright +owner that is 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 this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) 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 (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (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 +reproduce, 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: + + 1. You must give any other recipients of the Work or Derivative Works a copy + of this License; and + + 2. You must cause any modified files to carry prominent notices stating that + You changed the files; and + + 3. You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + 4. 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 statement to Your modifications and may provide +additional or different 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. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. 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. + +8. 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. + +9. Accepting Warranty or Additional Liability. While 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 + + +-------------- gevent -------------- +MIT License + +Except when otherwise stated (look at the beginning of each file) the software +and the documentation in this project are copyrighted by: + + Denis Bilenko and the contributors, http://www.gevent.org + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +-------------- markdown -------------- +BSD 3-Clause License + +Copyright 2007, 2008 The Python Markdown Project (v. 1.7 and later) +Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +Copyright 2004 Manfred Stienstra (the original version) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. -* Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE @@ -2258,25 +1516,3 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------- whitenoise -------------- -The MIT License (MIT) - -Copyright (c) 2013 David Evans - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/README.md b/README.md index 0b21f1b2..ad4ecccc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LogstashUI -> A visual tool for authoring, simulating, and managing Logstash pipelines. +> A control plane and visual editor for Logstash, built for managing Logstash nodes and authoring, simulating, and deploying pipelines. > > ⚠️ **Beta Release** - This project is under active development. Features may change. @@ -15,38 +15,44 @@ Instead of editing configuration files manually, pipelines can be authored visua ## Features
-Visual Pipeline Editor — Author pipelines in three modes: an inline graphical interface, raw text editor, and a full visual graph for building pipelines by connecting nodes. Switch between modes seamlessly on any pipeline. - -![Visual Pipeline Editor](docs/images/graph.gif) + Control Plane for Logstash — Centrally manage Logstash nodes with Centralized Pipeline Management and Logstash Agent +![Control Plane for Logstash](/docs/images/control_plane.png)
-Pipeline Simulation — Execute pipelines against sample events and inspect transformations step-by-step + Visual Pipeline Editor — Author pipelines in three modes: an inline graphical interface, raw text editor, and a full visual graph for building pipelines by connecting nodes. Switch between modes seamlessly on any pipeline. -![Pipeline Simulation](docs/images/simulate.gif) +![Visual Pipeline Editor](/docs/images/graph.gif) +
+
+ Pipeline Simulation — Execute pipelines against sample events and inspect transformations step-by-step + +![Pipeline Simulation](/docs/images/simulate.gif)
-Multi-Instance Management — Manage pipelines across multiple Logstash nodes using Centralized Pipeline Management - + Monitoring — View metrics and performance for running pipelines and Logstash nodes + +![Pipeline Monitoring](/docs/images/monitoring.png)
-Pipeline Monitoring — View metrics and performance for running pipelines +SNMP Pipeline Management — Configure polling, traps, discovery, credentials, devices, networks, and profiles through the UI. -![Pipeline Monitoring](docs/images/monitoring.png) +![SNMP Support](/docs/images/snmp.png)
-
-SNMP Support — Configure polling, traps, and discovery through a web interface +--- -![SNMP Support](docs/images/snmp.gif) +## Documentation -
+- **[LogstashUI Documentation](docs/docs/logstashui/index.md)** - Installation, configuration, and usage guides for LogstashUI +- **[LogstashAgent Documentation](docs/docs/logstashagent/index.md)** - Setup and configuration for LogstashAgent +--- ## Requirements @@ -60,7 +66,7 @@ Instead of editing configuration files manually, pipelines can be authored visua #### For Embedded mode (See Quick Start) - [Docker](https://www.docker.com/get-started/) -#### For [Host mode](docs/host_mode.md) (If you have a simulation-heavy use case) +#### For [Host mode](docs/docs/beta/PipelineEditor/host_mode.md) (If you have a simulation-heavy use case) - [Docker](https://www.docker.com/get-started/) - [Python 3.12+](https://www.python.org/downloads/) - [Logstash 8.x, 9.x](https://www.elastic.co/docs/reference/logstash/installing-logstash) @@ -75,11 +81,11 @@ Instead of editing configuration files manually, pipelines can be authored visua ## Quick Start - Embedded Mode > [!TIP] -> If you plan on doing a lot of simulations, consider using [host mode](docs/host_mode.md). It's more performant. +> If you plan on doing a lot of simulations, consider using [host mode](docs/docs/beta/PipelineEditor/host_mode.md). It's more performant. ### Download LogstashUI ```bash git clone https://github.com/elastic/LogstashUI.git -cd LogstashUI/bin +cd logstashui/bin ```` ### Run LogstashUI @@ -125,7 +131,7 @@ To update LogstashUI to the latest version: #### Linux ```bash -cd LogstashUI/bin +cd logstashui/bin ./start_logstashui.sh --update ``` @@ -146,13 +152,6 @@ input { } ``` -## Roadmap -- Reusable grok and regex patterns -- Git backups for configuration -- Loggy AI Assistant for pipeline failure analysis -- Management of Logstash Nodes via external agent -- Logstash Keystore management -- Expression editor for conditions ## Reporting Issues diff --git a/bin/start_logstashui.bat b/bin/start_logstashui.bat index 41b23532..7e3a9e4c 100644 --- a/bin/start_logstashui.bat +++ b/bin/start_logstashui.bat @@ -12,6 +12,42 @@ REM start_logstashui.bat --update - Pull latest code and images, then start REM IMPORTANT: Don't enable delayed expansion yet - it breaks paths with exclamation marks setlocal disabledelayedexpansion +REM Check for required dependencies +echo Checking dependencies... +set MISSING_DEPS= + +REM Check for Docker +docker --version >nul 2>&1 +if errorlevel 1 ( + set MISSING_DEPS=%MISSING_DEPS% docker +) + +REM Check for Git +git --version >nul 2>&1 +if errorlevel 1 ( + set MISSING_DEPS=%MISSING_DEPS% git +) + +if not "%MISSING_DEPS%"=="" ( + echo. + echo ERROR: Missing required dependencies:%MISSING_DEPS% + echo. + echo Please install the following: + echo %MISSING_DEPS% | findstr "docker" >nul + if not errorlevel 1 ( + echo - Docker Desktop: https://www.docker.com/get-started/ + ) + echo %MISSING_DEPS% | findstr "git" >nul + if not errorlevel 1 ( + echo - Git: https://git-scm.com/download/win + ) + echo. + pause + exit /b 1 +) +echo Dependencies check passed. +echo. + REM Detect docker-compose command (hyphen vs space) docker-compose version >nul 2>&1 if %errorlevel% equ 0 ( @@ -45,6 +81,18 @@ if %UPDATE_MODE%==1 ( echo ======================================== echo UPDATE MODE echo ======================================== + echo Switching to main branch... + echo. + + git checkout main + if errorlevel 1 ( + echo WARNING: Failed to switch to main branch. Continuing anyway... + echo. + ) else ( + echo Switched to main branch successfully! + echo. + ) + echo Pulling latest code from git... echo. @@ -95,25 +143,25 @@ echo. REM Ensure logstashui.yml exists (required for Docker volume mount) REM If it doesn't exist, create a copy from logstashui.example.yml -if not exist "logstashui.yml" ( - if exist "logstashui.example.yml" ( +if not exist "src\logstashui\logstashui.yml" ( + if exist "src\logstashui\logstashui.example.yml" ( echo Creating logstashui.yml copy from logstashui.example.yml - copy logstashui.example.yml logstashui.yml >nul + copy src\logstashui\logstashui.example.yml src\logstashui\logstashui.yml >nul ) else ( - echo ERROR: logstashui.example.yml not found! + echo ERROR: src\logstashui\logstashui.example.yml not found! echo Current directory: %CD% exit /b 1 ) ) REM Check for config file (logstashui.yml first, fallback to logstashui.example.yml) -if exist "logstashui.yml" ( - set CONFIG_FILE=logstashui.yml -) else if exist "logstashui.example.yml" ( - set CONFIG_FILE=logstashui.example.yml +if exist "src\logstashui\logstashui.yml" ( + set CONFIG_FILE=src\logstashui\logstashui.yml +) else if exist "src\logstashui\logstashui.example.yml" ( + set CONFIG_FILE=src\logstashui\logstashui.example.yml ) else ( echo ERROR: No config file found! - echo Expected logstashui.yml or logstashui.example.yml in project root. + echo Expected logstashui.yml or logstashui.example.yml in src\logstashui\ echo Current directory: %CD% echo. echo Directory contents: @@ -156,57 +204,59 @@ echo Starting LogstashAgent natively on Windows echo This allows the agent to control your host Logstash instance. echo. -REM Check if Python is available -python --version >nul 2>&1 +REM Check if uv is available +uv --version >nul 2>&1 if errorlevel 1 ( - echo ERROR: Python not found in PATH! - echo Please install Python 3.9+ and ensure it's in your PATH. + echo ERROR: uv not found in PATH! + echo Please install uv from: https://docs.astral.sh/uv/getting-started/installation/ + echo. + echo Quick install: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 ^| iex" exit /b 1 ) -REM Setup virtual environment for LogstashAgent -if not exist "LogstashAgent\.venv" ( - echo Creating virtual environment in LogstashAgent\.venv - python -m venv LogstashAgent\.venv +REM Clone LogstashAgent if it doesn't exist +if not exist "LogstashAgent" ( + echo LogstashAgent directory not found, cloning from GitHub... + echo. + git clone https://github.com/elastic/LogstashAgent.git if errorlevel 1 ( - echo ERROR: Failed to create virtual environment! - echo Please ensure Python venv module is available + echo ERROR: Failed to clone LogstashAgent repository! + echo Please check your internet connection and Git installation. exit /b 1 ) + echo LogstashAgent cloned successfully! + echo. +) else ( + echo LogstashAgent directory found. + echo. ) -echo Activating virtual environment -call LogstashAgent\.venv\Scripts\activate.bat - -REM Install/update Python dependencies for LogstashAgent -echo Installing Python dependencies for LogstashAgent -pip install -r LogstashAgent\requirements.txt -if errorlevel 1 ( - echo ERROR: Failed to install dependencies! - echo Please check that Python and pip are working correctly. - call LogstashAgent\.venv\Scripts\deactivate.bat - exit /b 1 -) -echo Dependencies installed successfully - echo. echo Preparing LogstashAgent configuration -REM Copy logstash_agent config from logstashui.yml to LogstashAgent/logstashagent.yml +REM Copy logstash_agent config from logstashui.yml to LogstashAgent/src/logstashagent/logstashagent.yml python bin\sync_config.py if errorlevel 1 ( echo WARNING: Could not update agent config automatically - echo Please ensure LogstashAgent\logstashagent.yml has correct paths + echo Please ensure LogstashAgent\src\logstashagent\logstashagent.yml has correct paths ) +REM Install/update Python dependencies for LogstashAgent using uv +echo Installing Python dependencies for LogstashAgent with uv +cd LogstashAgent +uv sync +if errorlevel 1 ( + echo ERROR: Failed to install dependencies with uv! + echo Please check that uv is working correctly. + exit /b 1 +) +echo Dependencies installed successfully + echo Starting LogstashAgent on port 9501 (localhost only) cd LogstashAgent -REM Start uvicorn using the virtual environment's Python -start "LogstashAgent" cmd /K ".venv\Scripts\python.exe -m uvicorn main:app --host 127.0.0.1 --port 9501" +REM Start uvicorn using uv run +start "LogstashAgent" cmd /K "uv run uvicorn logstashagent.main:app --host 127.0.0.1 --port 9501" cd .. -REM Deactivate virtual environment (agent is running in separate window) -call LogstashAgent\.venv\Scripts\deactivate.bat - echo Waiting 5 seconds for agent to initialize ping 127.0.0.1 -n 6 >nul @@ -220,12 +270,14 @@ echo. REM Ensure agent container is stopped in host mode echo Stopping any existing containers +cd docker %DOCKER_COMPOSE% stop logstashagent 2>nul %DOCKER_COMPOSE% rm -f logstashagent 2>nul REM Start only logstashui and nginx in detached mode REM Nginx will detect host mode and proxy to host.docker.internal:9501 %DOCKER_COMPOSE% up -d %REBUILD_FLAG% logstashui nginx +cd .. goto END_MODE_SELECTION :EMBEDDED_MODE @@ -237,7 +289,9 @@ echo Logstash will run inside the agent container. echo. REM Start all containers in detached mode with embedded profile +cd docker %DOCKER_COMPOSE% --profile embedded up -d %REBUILD_FLAG% +cd .. goto END_MODE_SELECTION :END_MODE_SELECTION diff --git a/bin/start_logstashui.sh b/bin/start_logstashui.sh index d28a80db..19723d88 100755 --- a/bin/start_logstashui.sh +++ b/bin/start_logstashui.sh @@ -1,5 +1,5 @@ #!/bin/bash -# LogstashUI Startup Script +# logstashui Startup Script # Detects mode from logstashui.example.yml and starts accordingly # - Host mode: Starts native Python agent on Linux, then containers (without agent container) # - Embedded mode: Starts all containers including agent @@ -11,6 +11,40 @@ set -e # Exit on error +# Check for required dependencies +check_dependencies() { + local missing_deps=() + + # Check for Docker + if ! command -v docker &> /dev/null; then + missing_deps+=("docker") + fi + + # Check for Git + if ! command -v git &> /dev/null; then + missing_deps+=("git") + fi + + if [ ${#missing_deps[@]} -gt 0 ]; then + echo "ERROR: Missing required dependencies: ${missing_deps[*]}" + echo "" + echo "Please install the following:" + for dep in "${missing_deps[@]}"; do + if [ "$dep" == "docker" ]; then + echo " - Docker: https://docs.docker.com/engine/install/" + echo " (For Debian/Ubuntu: apt-get install docker.io)" + echo " (For RHEL/CentOS: yum install docker)" + elif [ "$dep" == "git" ]; then + echo " - Git: apt-get install git | yum install git" + fi + done + exit 1 + fi +} + +# Run dependency check +check_dependencies + # Detect docker-compose command (hyphen vs space) if command -v docker-compose &> /dev/null; then DOCKER_COMPOSE="docker-compose" @@ -44,6 +78,18 @@ if [ $UPDATE_MODE -eq 1 ]; then echo "========================================" echo "UPDATE MODE" echo "========================================" + echo "Switching to main branch..." + echo "" + + git checkout main + if [ $? -ne 0 ]; then + echo "WARNING: Failed to switch to main branch. Continuing anyway..." + echo "" + else + echo "Switched to main branch successfully!" + echo "" + fi + echo "Pulling latest code from git..." echo "" @@ -97,25 +143,25 @@ echo "" # Ensure logstashui.yml exists (required for Docker volume mount) # If it doesn't exist, create a symlink to logstashui.example.yml -if [ ! -f "logstashui.yml" ]; then - if [ -f "logstashui.example.yml" ]; then +if [ ! -f "src/logstashui/logstashui.yml" ]; then + if [ -f "src/logstashui/logstashui.example.yml" ]; then echo "Creating logstashui.yml symlink to logstashui.example.yml" - ln -s logstashui.example.yml logstashui.yml + ln -s logstashui.example.yml src/logstashui/logstashui.yml else - echo "ERROR: logstashui.example.yml not found!" + echo "ERROR: src/logstashui/logstashui.example.yml not found!" echo "Current directory: $(pwd)" exit 1 fi fi # Check for config file (logstashui.yml first, fallback to logstashui.example.yml) -if [ -f "logstashui.yml" ]; then - CONFIG_FILE="logstashui.yml" -elif [ -f "logstashui.example.yml" ]; then - CONFIG_FILE="logstashui.example.yml" +if [ -f "src/logstashui/logstashui.yml" ]; then + CONFIG_FILE="src/logstashui/logstashui.yml" +elif [ -f "src/logstashui/logstashui.example.yml" ]; then + CONFIG_FILE="src/logstashui/logstashui.example.yml" else echo "ERROR: No config file found!" - echo "Expected logstashui.yml or logstashui.example.yml in project root." + echo "Expected logstashui.yml or logstashui.example.yml in src/logstashui/" echo "Current directory: $(pwd)" echo "" echo "Directory contents:" @@ -145,48 +191,54 @@ if [ "$MODE" == "host" ]; then echo "This allows the agent to control your host Logstash instance." echo "" - # Check if Python is available - if ! command -v python3 &> /dev/null; then - echo "ERROR: Python3 not found in PATH!" - echo "Please install Python 3.9+ and ensure it's in your PATH." + # Check if uv is available + if ! command -v uv &> /dev/null; then + echo "ERROR: uv not found in PATH!" + echo "Please install uv from: https://docs.astral.sh/uv/getting-started/installation/" + echo "" + echo "Quick install: curl -LsSf https://astral.sh/uv/install.sh | sh" exit 1 fi - # Setup virtual environment for LogstashAgent - VENV_PATH="$PROJECT_ROOT/LogstashAgent/.venv" - if [ ! -d "$VENV_PATH" ]; then - echo "Creating virtual environment in $VENV_PATH" - python3 -m venv "$VENV_PATH" + # Clone LogstashAgent if it doesn't exist + if [ ! -d "$PROJECT_ROOT/LogstashAgent" ]; then + echo "LogstashAgent directory not found, cloning from GitHub..." + echo "" + cd "$PROJECT_ROOT" + git clone https://github.com/elastic/LogstashAgent.git if [ $? -ne 0 ]; then - echo "ERROR: Failed to create virtual environment!" - echo "Please ensure python3-venv is installed (apt-get install python3.12-venv)" + echo "ERROR: Failed to clone LogstashAgent repository!" + echo "Please check your internet connection and Git installation." exit 1 fi + echo "LogstashAgent cloned successfully!" + echo "" + else + echo "LogstashAgent directory found." + echo "" fi - echo "Activating virtual environment" - source "$VENV_PATH/bin/activate" - - # Install/update Python dependencies for LogstashAgent - echo "Installing Python dependencies for LogstashAgent" - pip install -r LogstashAgent/requirements.txt - if [ $? -ne 0 ]; then - echo "ERROR: Failed to install dependencies!" - echo "Please check that Python and pip are working correctly." - deactivate - exit 1 - fi - echo "Dependencies installed successfully" - echo "" echo "Preparing LogstashAgent configuration" - # Copy logstash_agent config from logstashui.example.yml to LogstashAgent/logstashagent.yml + # Copy logstash_agent config from logstashui.yml to LogstashAgent/src/logstashagent/logstashagent.yml + cd "$PROJECT_ROOT" python3 bin/sync_config.py if [ $? -ne 0 ]; then echo "WARNING: Could not update agent config automatically" - echo "Please ensure LogstashAgent/logstashagent.yml has correct paths" + echo "Please ensure LogstashAgent/src/logstashagent/logstashagent.yml has correct paths" fi + # Install/update Python dependencies for logstashagent using uv + echo "Installing Python dependencies for LogstashAgent with uv" + cd "$PROJECT_ROOT/LogstashAgent" + uv sync + if [ $? -ne 0 ]; then + echo "ERROR: Failed to install dependencies with uv!" + echo "Please check that uv is working correctly." + exit 1 + fi + echo "Dependencies installed successfully" + echo "" echo "Setting Logstash directory ownership for logstash user" # Detect Logstash home from config - default to /usr/share/logstash @@ -225,16 +277,12 @@ if [ "$MODE" == "host" ]; then echo "Starting LogstashAgent on port 9501 (accessible remotely)" cd "$PROJECT_ROOT/LogstashAgent" - # Start in background using nohup - bind to 0.0.0.0 for remote access - # Run uvicorn in the activated virtual environment context - nohup "$VENV_PATH/bin/python" -m uvicorn main:app --host 0.0.0.0 --port 9501 > "$PROJECT_ROOT/logstashagent.log" 2>&1 & + # Start in background using nohup with uv run - bind to 0.0.0.0 for remote access + nohup uv run uvicorn logstashagent.main:app --host 0.0.0.0 --port 9501 > "$PROJECT_ROOT/logstashagent.log" 2>&1 & AGENT_PID=$! echo $AGENT_PID > "$PROJECT_ROOT/logstashagent.pid" cd "$PROJECT_ROOT" - # Deactivate virtual environment (agent is running in background) - deactivate - echo "LogstashAgent started with PID: $AGENT_PID" echo "Waiting 5 seconds for agent to initialize" sleep 5 @@ -249,6 +297,7 @@ if [ "$MODE" == "host" ]; then # Ensure agent container is stopped in host mode echo "Stopping any existing containers" + cd "$PROJECT_ROOT/docker" $DOCKER_COMPOSE stop logstashagent 2>/dev/null || true $DOCKER_COMPOSE rm -f logstashagent 2>/dev/null || true @@ -259,6 +308,7 @@ if [ "$MODE" == "host" ]; then else $DOCKER_COMPOSE up -d logstashui nginx fi + cd "$PROJECT_ROOT" else echo "========================================" @@ -271,6 +321,9 @@ else # Force remove any existing logstashagent container to prevent stale network references docker rm -f logstashui-logstashagent-1 2>/dev/null || true + # Change to docker directory for docker-compose commands + cd "$PROJECT_ROOT/docker" + # Start all containers in detached mode with embedded profile # Retry once if network failure occurs if [ -n "$REBUILD_FLAG" ]; then @@ -290,6 +343,7 @@ else $DOCKER_COMPOSE --profile embedded up -d } fi + cd "$PROJECT_ROOT" fi echo "" diff --git a/bin/stop_logstashui.bat b/bin/stop_logstashui.bat index 114f638c..65847956 100644 --- a/bin/stop_logstashui.bat +++ b/bin/stop_logstashui.bat @@ -31,13 +31,13 @@ REM Save current directory and change to project root pushd "%~dp0.." REM Check for config file (logstashui.yml first, fallback to logstashui.example.yml) -if exist "logstashui.yml" ( - set CONFIG_FILE=logstashui.yml -) else if exist "logstashui.example.yml" ( - set CONFIG_FILE=logstashui.example.yml +if exist "src\logstashui\logstashui.yml" ( + set CONFIG_FILE=src\logstashui\logstashui.yml +) else if exist "src\logstashui\logstashui.example.yml" ( + set CONFIG_FILE=src\logstashui\logstashui.example.yml ) else ( echo ERROR: No config file found! - echo Expected logstashui.yml or logstashui.example.yml in project root. + echo Expected logstashui.yml or logstashui.example.yml in src\logstashui\ exit /b 1 ) @@ -78,7 +78,9 @@ if /i "!MODE!"=="host" ( echo. echo Stopping Docker containers (UI + Nginx) + cd docker %DOCKER_COMPOSE% down --remove-orphans + cd .. REM Force remove agent container if it exists echo Removing any stray agent containers @@ -93,7 +95,9 @@ if /i "!MODE!"=="host" ( REM Force remove logstashagent container first (prevents stale network references) docker rm -f logstashui-logstashagent-1 2>nul + cd docker %DOCKER_COMPOSE% down --remove-orphans + cd .. ) echo. diff --git a/bin/stop_logstashui.sh b/bin/stop_logstashui.sh index e2308507..fe85a56c 100755 --- a/bin/stop_logstashui.sh +++ b/bin/stop_logstashui.sh @@ -1,6 +1,6 @@ #!/bin/bash # ======================================== -# LogstashUI Shutdown Script +# logstashui Shutdown Script # ======================================== # Note: We don't use 'set -e' here because we want to attempt all cleanup steps @@ -28,13 +28,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR/.." # Check for config file (logstashui.yml first, fallback to logstashui.example.yml) -if [ -f "logstashui.yml" ]; then - CONFIG_FILE="logstashui.yml" -elif [ -f "logstashui.example.yml" ]; then - CONFIG_FILE="logstashui.example.yml" +if [ -f "src/logstashui/logstashui.yml" ]; then + CONFIG_FILE="src/logstashui/logstashui.yml" +elif [ -f "src/logstashui/logstashui.example.yml" ]; then + CONFIG_FILE="src/logstashui/logstashui.example.yml" else echo "ERROR: No config file found!" - echo "Expected logstashui.yml or logstashui.example.yml in project root." + echo "Expected logstashui.yml or logstashui.example.yml in src/logstashui/" exit 1 fi @@ -100,7 +100,9 @@ if [ "$MODE" == "host" ]; then echo "" echo "Stopping Docker containers (UI + Nginx)" + cd docker $DOCKER_COMPOSE down --remove-orphans + cd .. # Force remove agent container if it exists echo "Removing any stray agent containers" @@ -115,7 +117,9 @@ else # Force remove logstashagent container first (prevents stale network references) docker rm -f logstashui-logstashagent-1 2>/dev/null || true + cd docker $DOCKER_COMPOSE down --remove-orphans + cd .. fi echo "" diff --git a/bin/sync_config.py b/bin/sync_config.py index d8b56223..84311fa0 100644 --- a/bin/sync_config.py +++ b/bin/sync_config.py @@ -1,16 +1,19 @@ """ -Sync LogstashAgent config from logstashui.yml (or logstashui.example.yml) to LogstashAgent/logstashagent.yml +Sync logstashagent config from logstashui.yml (or logstashui.example.yml) to LogstashAgent/src/logstashagent/logstashagent.yml """ import yaml import sys import os +from pathlib import Path try: # Check for logstashui.yml first, fallback to logstashui.example.yml - if os.path.exists('logstashui.yml'): - config_file = 'logstashui.yml' + if os.path.exists('src/logstashui/logstashui.yml'): + config_file = 'src/logstashui/logstashui.yml' + elif os.path.exists('src/logstashui/logstashui.example.yml'): + config_file = 'src/logstashui/logstashui.example.yml' else: - config_file = 'logstashui.example.yml' + raise FileNotFoundError("No config file found in src/logstashui/") # Read main config with open(config_file, 'r') as f: @@ -20,11 +23,18 @@ agent_config = config.get('simulation', {}).get('logstash_agent', {}) agent_config['simulation_mode'] = 'host' + # Ensure LogstashAgent directory exists + agent_config_dir = Path('LogstashAgent/src/logstashagent') + if not agent_config_dir.exists(): + print(f"Warning: {agent_config_dir} does not exist. Agent may not be cloned yet.") + sys.exit(1) + # Write to agent config file - with open('LogstashAgent/logstashagent.yml', 'w') as f: + agent_config_path = agent_config_dir / 'config' / 'logstashagent.yml' + with open(agent_config_path, 'w') as f: yaml.dump(agent_config, f) - print("Config synced successfully") + print(f"Config synced successfully to {agent_config_path}") sys.exit(0) except Exception as e: print(f"Warning: Could not sync config: {e}") diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..006ebd9e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,51 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + nodejs \ + npm \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for dependency management +RUN pip install --no-cache-dir uv + +# Copy dependency files +COPY pyproject.toml uv.lock /app/ + +# Install Python dependencies +RUN uv pip install --system --no-cache -r pyproject.toml + +# Copy the entire src directory +COPY src/ /app/src/ + +# Set PYTHONPATH so Django can find LogstashUI module +ENV PYTHONPATH=/app/src/logstashui + +# Build Tailwind CSS +WORKDIR /app/src/logstashui/theme/static_src +RUN rm -rf node_modules && npm install + +# Build Tailwind and collect static files +WORKDIR /app/src/logstashui +RUN python manage.py tailwind build +RUN python manage.py collectstatic --noinput --clear + +# Create app user with home directory and set permissions +RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser +RUN mkdir -p /app/data && \ + chown -R appuser:appuser /app /home/appuser && \ + chmod -R 755 /app + +# Copy entrypoint script +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh && chown appuser:appuser /entrypoint.sh + +USER appuser +EXPOSE 8080 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["gunicorn","LogstashUI.wsgi:application","--bind","0.0.0.0:8080","--workers","2","--worker-class","gevent","--worker-connections","1000","--timeout","60","--access-logfile","-","--error-logfile","-"] diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 77% rename from docker-compose.yml rename to docker/docker-compose.yml index c2c6e240..dbc8da4a 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,16 +1,17 @@ +name: LogstashUI + services: logstashui: - image: codyjackson032/logstashui:latest + #image: codyjackson032/logstashui:latest # Uncomment below to build locally instead of pulling from Docker Hub - #build: - # context: ./LogstashUI - # dockerfile: Dockerfile + build: + context: .. + dockerfile: docker/Dockerfile expose: - "8080" volumes: - - logstashui_data:/app/data - - ./logstashui.yml:/app/logstashui.yml:ro - - ./logstashui.example.yml:/app/logstashui.example.yml:ro + - logstashui_data:/app/src/logstashui/data + - ../src/logstashui/logstashui.yml:/app/src/logstashui/logstashui.yml:ro environment: - DEBUG=False # SECRET_KEY: Optional - will auto-generate and persist in data/.django_secret_key if not set @@ -20,8 +21,8 @@ services: - ALLOWED_HOSTS=${ALLOWED_HOSTS:-*} - CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:-} # LOGSTASHUI_CONFIG: Path to configuration file for simulation mode detection - # Prefers logstashui.yml, falls back to logstashui.example.yml in config.py - - LOGSTASHUI_CONFIG=/app/logstashui.yml + # Leave unset to use default config in settings.py + # - LOGSTASHUI_CONFIG=/app/src/logstashui/logstashui.yml # LOGSTASH_AGENT_URL: Automatically set in settings.py based on simulation mode # Host mode: http://host.docker.internal:9500 (native agent on host) # Embedded mode + DEBUG=True: http://logstashagent:9500 (direct) @@ -34,11 +35,7 @@ services: - logstashui_network logstashagent: - image: codyjackson032/logstashagent:latest - # Uncomment below to build locally instead of pulling from Docker Hub - #build: - # context: ./LogstashAgent - # dockerfile: Dockerfile + image: codyjackson032/logstash-agent:latest profiles: - embedded # Only start this container in embedded mode ports: @@ -46,10 +43,10 @@ services: - "9449:9449" # Logstash HTTP input (for sample events) expose: - "9500" # FastAPI sidecar API (internal only, accessed via nginx) - volumes: - # Mount config files for dynamic runtime config (read-only) - - ./logstashui.yml:/app/logstashui.yml:ro - - ./logstashui.example.yml:/app/logstashui.example.yml:ro + # Optional: Mount config files for dynamic runtime config (read-only) + # volumes: + # - ../logstashui.yml:/app/logstashui.yml:ro + # - ../logstashui.example.yml:/app/logstashui.example.yml:ro environment: # Override default LOGSTASH_URL (default: http://host.docker.internal:8080 for local dev) # In docker-compose, use nginx for HTTPS routing @@ -66,13 +63,14 @@ services: nginx: image: nginx:alpine ports: - - "443:443" + - "0.0.0.0:443:443" - "80:80" - - "9500:9500" # LogstashAgent API proxy (HTTPS) + - "9500:9500" # logstashagent API proxy (HTTPS) volumes: - - ./LogstashUI/nginx.conf:/etc/nginx/nginx.conf.template:ro - - ./logstashui.yml:/etc/logstashui.yml:ro - - ./logstashui.example.yml:/etc/logstashui.example.yml:ro + - ./nginx.conf:/etc/nginx/nginx.conf.template:ro + # Optional: Mount config files if they exist + # - ../logstashui.yml:/etc/logstashui.yml:ro + # - ../logstashui.example.yml:/etc/logstashui.example.yml:ro - nginx_certs:/etc/nginx/certs depends_on: - logstashui diff --git a/LogstashUI/entrypoint.sh b/docker/entrypoint.sh similarity index 97% rename from LogstashUI/entrypoint.sh rename to docker/entrypoint.sh index eb5796cf..c9841320 100644 --- a/LogstashUI/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/bash set -e +# Change to Django project directory +cd /app/src/logstashui + # Create data directory if it doesn't exist mkdir -p /app/data diff --git a/LogstashUI/nginx.conf b/docker/nginx.conf similarity index 100% rename from LogstashUI/nginx.conf rename to docker/nginx.conf diff --git a/docs/build.md b/docs/build.md deleted file mode 100644 index 7b71a00a..00000000 --- a/docs/build.md +++ /dev/null @@ -1,33 +0,0 @@ -# There are a few different ways to run this project - ---- -## Using docker compose -> Use this mode with defaults when you want to run LogstashUI and you're okay with less performant simulations. Note: You can change simulation.mode to 'host' to use a non-running instance of Logstash that is installed on the host. -```bash -docker compose up -d -``` ---- - -## Running locally (For Development) - -### Running LogstashUI - -#### Linux -```bash -cd LogstashUI/LogstashUI -LOGSTASH_CONFIG=../logstashui.example.yml python manage.py runserver 0.0.0.0:8080 -``` -#### Windows -```bash -cd LogstashUI/LogstashUI -$env:LOGSTASH_CONFIG="../logstashui.yml" -python manage.py runserver 0.0.0.0:8080 -``` -### Running the simulation agent -```bash -cd LogstashUI/LogstashAgent -docker build --no-cache -t logstashagent:latest . --build-arg LOGSTASH_CONFIG=../logstashui.example.yml -docker run -p 9600:9600 -p 9449:9449 -p 9500:9500 -e LOGSTASH_URL=http://localhost:8080 logstashagent:latest - -``` - diff --git a/docs/docs/getting_started.md b/docs/docs/getting_started.md new file mode 100644 index 00000000..df18672d --- /dev/null +++ b/docs/docs/getting_started.md @@ -0,0 +1,72 @@ +# Getting Started + +## Requirements + +### System Requirements +**Minimum:** +- 8 GB RAM +- 4 CPU Cores + +### Software + +#### For Embedded mode (See Quick Start) +- [Docker](https://www.docker.com/get-started/) + +#### For [Host mode](logstashui/configuration/host_mode.md) (If you have a simulation-heavy use case) +- [Docker](https://www.docker.com/get-started/) +- [Python 3.12+](https://www.python.org/downloads/) +- [Logstash 8.x, 9.x](https://www.elastic.co/docs/reference/logstash/installing-logstash) + +### For Local Development +- [Python 3.12+](https://www.python.org/downloads/) +- [Node.js & npm (for building Tailwind CSS assets)](https://nodejs.org/en/download) +- [Elasticsearch 8.x or later](https://cloud.elastic.co) +- [Docker](https://www.docker.com/get-started/) + +## Quick Start +> [!TIP] +> If you plan on doing a lot of simulations, consider using [host mode](logstashui/configuration/host_mode.md). It's more performant. + +### Download LogstashUI +```bash +git clone https://github.com/elastic/LogstashUI.git +cd logstashui/bin +``` + +### Run LogstashUI + +**Linux:** +```bash +./start_logstashui.sh +``` + +**Windows:** +```cmd +start_logstashui.bat +``` + +Once the containers are running, navigate to your host in your browser: + +``` +https:// +``` + +And that's it! + +--- + +## Add Your First Connection + +### 1. Create an initial user +![Login](/docs/images/login.png) + +### 2. Add a connection +![New Connection](/docs/images/new_connection.gif) + +### 3. Start managing pipelines! +![Simulate](/docs/images/simulate.gif) + +### Optional: Add monitoring to your connections +Use [this guide](https://www.elastic.co/docs/reference/logstash/monitoring-with-elastic-agent) to set up the Elastic Agent's Logstash integration. Once Logstash monitoring data is indexed into Elasticsearch, metrics and logs will appear in the UI. + +![Monitoring](/docs/images/monitoring.png) diff --git a/docs/docs/index.md b/docs/docs/index.md new file mode 100644 index 00000000..b0f24b95 --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,60 @@ +# Welcome to the LogstashUI Documentation! + +> ⚠️ **Beta Release** - This project is under active development. Features may change. + +This documentation covers two complementary projects for managing and simulating Logstash pipelines: + +--- + +## LogstashUI + +**A visual interface for designing, testing, and operating Logstash pipelines.** + +LogstashUI provides a web-based platform for authoring pipelines visually, simulating them against sample events, and deploying them to multiple Logstash nodes from a single interface. + +- **Control Plane for Logstash** - Centrally manage Logstash nodes with Centralized Pipeline Management and Logstash Agent +- **Visual Pipeline Editor** - Author pipelines in three modes: graphical interface, text editor, and visual graph +- **Pipeline Simulation** - Execute pipelines against sample events and inspect transformations step-by-step +- **Monitoring** - View metrics and performance for running pipelines and Logstash nodes +- **SNMP Pipeline Management** - Configure polling, traps, discovery, credentials, devices, networks, and profiles through the UI + +![LogstashUI Simulation](/docs/images/simulate.png) + +**📖 [View LogstashUI Documentation →](logstashui/index.md)** + +--- + +## LogstashAgent + +**A native agent for managing and controlling Logstash instances.** + +LogstashAgent is installed on your Logstash nodes and provides complete control over the instance through policy-based management. It handles pipeline deployment, configuration management, and high-performance simulation capabilities. + +- **Policy-Based Management** - Control Logstash instances through centralized policies +- **Pipeline Deployment** - Automatically deploy and update pipeline configurations +- **High-Performance Simulation** - Native execution for fast, reliable pipeline testing +- **Complete Instance Control** - Manages Logstash process, configuration, and monitoring + +**📖 [View LogstashAgent Documentation →](logstashagent/index.md)** + +--- + +## Quick Start + +New to LogstashUI? Start here: + +**[Getting Started Guide →](getting_started.md)** + +--- + +## Reporting Issues + +Found a bug or have a feature request? [Open an issue](https://github.com/elastic/LogstashUI/issues/new?template=issue.md). + +--- + +## License + +Copyright 2024–2026 Elasticsearch and contributors. + +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE.txt) for details. diff --git a/docs/docs/logstashagent/configuration/index.md b/docs/docs/logstashagent/configuration/index.md new file mode 100644 index 00000000..9bb6327e --- /dev/null +++ b/docs/docs/logstashagent/configuration/index.md @@ -0,0 +1,27 @@ +# Configuration + +LogstashAgent configuration for simulation mode and host management. + +--- + +## Configuration Files + +### **[logstashagent.yml](logstashagent.yml.md)** + +> **Note:** This file only applies when LogstashAgent is running in simulation mode. It is unused when running as an agent to control Logstash instances. + +The configuration file for LogstashAgent when used for pipeline simulation. + +**Key settings:** +- Agent mode (`simulation` vs `host`) +- Simulation mode (`embedded` vs `host`) +- Logstash installation paths + +**📖 [View full logstashagent.yml documentation →](logstashagent.yml.md)** + +--- + +## Quick Links + +- **[LogstashAgent Overview](../index.md)** - Feature overview and introduction +- **[LogstashUI Configuration](../../logstashui/configuration/index.md)** - Main LogstashUI configuration diff --git a/docs/docs/logstashagent/configuration/logstashagent.yml.md b/docs/docs/logstashagent/configuration/logstashagent.yml.md new file mode 100644 index 00000000..16ef263c --- /dev/null +++ b/docs/docs/logstashagent/configuration/logstashagent.yml.md @@ -0,0 +1,131 @@ +# logstashagent.yml Configuration + +> **Note:** `logstashagent.yml` ONLY applies for simulation mode. It is unused when running this as an agent to control Logstash instances. + +The `logstashagent.yml` file configures the LogstashAgent component, which is responsible for managing Logstash processes for pipeline simulation. + +--- + +## File Location + +The configuration file should be placed in the LogstashAgent directory: + +``` +LogstashAgent/ +├── logstashagent.yml +├── src/ +└── ... +``` + +--- + +## Configuration Sections + +### `mode` + +Determines if the agent is used for simulating in LogstashUI, or for controlling an actual Logstash host. + +```yaml +mode: simulation # simulation | host +``` + +**Options:** +- `simulation` - Agent manages Logstash for pipeline simulation in LogstashUI +- `host` - Agent controls an actual Logstash host instance + +--- + +### `simulation_mode` + +Controls how the simulation Logstash instance runs. Only applies if `mode` is set to `simulation`. + +```yaml +simulation_mode: embedded # embedded | host +``` + +**Options:** +- `embedded` - Runs Logstash in a local container (slower, easier setup) +- `host` - Runs Logstash natively on the host machine (faster, requires Logstash installation) + +**Quick Comparison:** + +| Feature | Embedded Mode | Host Mode | +|---------|---------------|-----------| +| **Performance** | Error prone with large pipelines | Highly reliable | +| **Setup** | Simple - no dependencies | Requires Logstash installation | +| **Best For** | Quick start, occasional simulations | Heavy simulation workloads | + +--- + +### Logstash Paths + +Configures the paths to the Logstash installation. These settings are used when `simulation_mode: host`. + +```yaml +logstash_binary: /usr/share/logstash/bin/logstash +logstash_settings: /etc/logstash +logstash_log_path: /var/log/logstash +``` + +**Settings:** + +- **`logstash_binary`** - Path to the Logstash executable + - Linux (default): `/usr/share/logstash/bin/logstash` + - Windows example: `C:\logstash-9.3.1\logstash-9.3.1\bin\logstash.bat` + +- **`logstash_settings`** - Path to Logstash configuration directory + - Linux (default): `/etc/logstash` + - Windows example: `C:\logstash-9.3.1\logstash-9.3.1\config` + +- **`logstash_log_path`** - Path to Logstash log directory + - Linux (default): `/var/log/logstash` + - Windows example: `C:\logstash-9.3.1\logstash-9.3.1\logs` + +> **IMPORTANT:** When using host mode, Logstash is fully managed by LogstashAgent. Logstash should not be started manually, and your configuration files will be modified. + +--- + +## Complete Examples + +### Linux (Embedded Mode) + +```yaml +mode: simulation +simulation_mode: embedded + +logstash_binary: /usr/share/logstash/bin/logstash +logstash_settings: /etc/logstash +logstash_log_path: /var/log/logstash +``` + +### Linux (Host Mode) + +```yaml +mode: simulation +simulation_mode: host + +# Linux paths (adjust if Logstash is installed in a custom location) +logstash_binary: /usr/share/logstash/bin/logstash +logstash_settings: /etc/logstash +logstash_log_path: /var/log/logstash +``` + +### Windows (Host Mode) + +```yaml +mode: simulation +simulation_mode: host + +# Windows paths - adjust to match your Logstash installation +logstash_binary: C:\logstash-9.3.1\logstash-9.3.1\bin\logstash.bat +logstash_settings: C:\logstash-9.3.1\logstash-9.3.1\config +logstash_log_path: C:\logstash-9.3.1\logstash-9.3.1\logs +``` + +--- + +## Related Documentation + +- **[LogstashUI Configuration](../../logstashui/configuration/logstashui.yml)** - Main LogstashUI configuration +- **[Host Mode Setup](../../logstashui/configuration/host_mode)** - Complete guide to setting up host mode +- **[Simulation Configuration](../../logstashui/configuration/simulation)** - Detailed simulation modes diff --git a/docs/docs/logstashagent/general/build.md b/docs/docs/logstashagent/general/build.md new file mode 100644 index 00000000..1ee14b18 --- /dev/null +++ b/docs/docs/logstashagent/general/build.md @@ -0,0 +1,134 @@ +# Building and Running LogstashAgent + +LogstashAgent can be run in multiple ways depending on your use case. + +--- + +## Using Docker Compose (Recommended for Testing) + +The easiest way to run LogstashAgent standalone for testing: + +```bash +cd LogstashAgent/docker +docker-compose up --build +``` + +This will: +- Build the LogstashAgent Docker image with Python 3.12 +- Start Logstash with the agent supervisor +- Expose ports: + - `9600`: Logstash API + - `9449`: Logstash HTTP input (for simulation) + - `9500`: FastAPI sidecar API + +### Configuration + +Set the LogstashUI URL via environment variable: + +```bash +LOGSTASH_URL=http://your-logstashui:8080 docker-compose up --build +``` + +Or mount a custom config: + +```yaml +# docker-compose.yml +volumes: + - ./logstashui.yml:/app/logstashui.yml:ro +``` + +--- + +## Building Docker Image Manually + +Build the image: + +```bash +cd LogstashAgent +docker build -t logstashagent:latest -f docker/Dockerfile . +``` + +Run the container: + +```bash +docker run -p 9600:9600 -p 9449:9449 -p 9500:9500 \ + -e LOGSTASH_URL=http://host.docker.internal:8080 \ + --add-host host.docker.internal:host-gateway \ + logstashagent:latest +``` + +--- + +## Running Locally (Development) + +### Prerequisites + +- Python 3.12+ +- Logstash 9.x installed locally +- `uv` (recommended) or `pip` + +### Install Dependencies + +```bash +cd LogstashAgent +uv sync +``` + +Or with pip: + +```bash +cd LogstashAgent +pip install -e . +``` + +### Configure + +Copy and edit the example config: + +```bash +cp src/logstashagent/config/logstashagent.example.yml src/logstashagent/config/logstashagent.yml +``` + +Edit `logstashagent.yml` to point to your local Logstash installation. + +### Run + +Start the agent in simulation mode (default): + +```bash +python src/logstashagent/main.py +``` + +The agent will be available at: +- FastAPI API: http://localhost:9500 +- Logstash API: http://localhost:9600 +- Simulation Input: http://localhost:9449 + +--- + +## Enrollment Mode (Controller) + +To enroll the agent with LogstashUI: + +```bash +python src/logstashagent/main.py --enroll= --logstash-ui-url=http://localhost:8080 +``` + +Then run in controller mode: + +```bash +python src/logstashagent/main.py --run +``` + +--- + +## Docker Build Notes + +The Dockerfile: +- Starts from the official Logstash 9.3.1 image +- Compiles Python 3.12 from source (microdnf only provides Python 3.9) +- Installs dependencies using `uv` for faster resolution +- Copies the entire `src/` directory for proper package structure +- Sets `PYTHONPATH=/app/src` for module imports + +Build time is approximately 5-10 minutes due to Python compilation. diff --git a/docs/docs/logstashagent/general/index.md b/docs/docs/logstashagent/general/index.md new file mode 100644 index 00000000..1cd3eb44 --- /dev/null +++ b/docs/docs/logstashagent/general/index.md @@ -0,0 +1,25 @@ +# General + +General guides for building, running, and deploying LogstashAgent. + +--- + +## **[Building and Running](build.md)** + +Instructions for running LogstashAgent in different modes. + +**Covers:** +- Docker Compose deployment (testing/standalone) +- Running natively with Python +- Enrollment and controller modes +- Development setup + +**📖 [View build guide →](build.md)** + +--- + +## Quick Links + +- **[LogstashAgent Overview](../index.md)** - Feature overview and introduction +- **[Configuration](../configuration/index.md)** - Configure LogstashAgent settings +- **[LogstashUI Documentation](../../logstashui/index.md)** - Main LogstashUI documentation diff --git a/docs/docs/logstashagent/index.md b/docs/docs/logstashagent/index.md new file mode 100644 index 00000000..880f1413 --- /dev/null +++ b/docs/docs/logstashagent/index.md @@ -0,0 +1,160 @@ +# LogstashAgent + +> A control-plane agent for LogstashUI that fully manages the Logstash instance it runs alongside. +> +> ⚠️ **Beta Release** - This project is under active development. Features may change. + +## Overview + +LogstashAgent is the host-side runtime for LogstashUI-managed instances. + +It enrolls with LogstashUI, persists local agent state, checks in for policy and configuration changes, and applies those changes directly to the local Logstash installation. + +--- + +### Enrollment + Reconciliation Loop +Enroll with LogstashUI and continuously reconcile desired state to the local Logstash instance. + +- **Enrollment mode**: `python src/logstashagent/main.py --enroll= --logstash-ui-url=` +- **Controller mode**: `python src/logstashagent/main.py --run` +- Agent state includes enrollment identity, policy assignment, and revision tracking + +### Pipeline Management API +Create, update, delete, validate, and inspect Logstash pipelines. + +- Endpoints: `/_logstash/pipeline`, `/_logstash/pipeline/{pipeline_id}`, `/_logstash/pipeline/{pipeline_id}/logs`, `/_logstash/pipelines/status` +- Config persistence backed by `pipelines.yml`, `conf.d`, and metadata files + +### Host Configuration Management +Apply managed configuration to local Logstash runtime files and secure settings. + +- Controller updates `logstash.yml`, `jvm.options`, `log4j2.properties`, and keystore entries +- Supports reconciliation and service restart flows for managed updates + +### Local State + Credential Protection +Persist agent identity and encrypted sensitive fields under package-local data storage. + +- State file: `src/logstashagent/data/state.json` +- Encryption key: `src/logstashagent/data/.secret_key` +- Log file: `src/logstashagent/data/logs/logstashagent.log` + +--- + +## Requirements + +### Software + +**For Managed Agent mode:** +- [Python 3.12+](https://www.python.org/downloads/) +- [Logstash 8.x, 9.x](https://www.elastic.co/docs/reference/logstash/installing-logstash) + +**For Enrolled Controller mode (`--run`):** +- [Python 3.12+](https://www.python.org/downloads/) +- Access to managed Logstash settings/log paths +- Network reachability to your LogstashUI instance + +**For Local Development:** +- [Python 3.12+](https://www.python.org/downloads/) +- `uv` (recommended) or `pip` + +--- + +## Quick Start - Agent Mode + +> [!TIP] +> Use `--run` only after successful enrollment, because controller mode requires persisted enrollment state. + +### 1. Install + +```bash +cd LogstashAgent +uv sync +``` + +### 2. Configure + +Copy and adjust the example config: + +```bash +cp src/logstashagent/config/logstashagent.example.yml src/logstashagent/config/logstashagent.yml +``` + +### 3. Run agent process + +```bash +python src/logstashagent/main.py +``` + +By default this starts the agent service (including management API) on `0.0.0.0:9600` unless overridden in config. + +--- + +## Enroll and Run Controller + +### 1. Enroll the agent + +```bash +python src/logstashagent/main.py --enroll= --logstash-ui-url=http://localhost:8080 +``` + +### 2. Start controller mode + +```bash +python src/logstashagent/main.py --run +``` + +### 3. Verify state files + +- `src/logstashagent/data/state.json` +- `src/logstashagent/data/.secret_key` + +--- + +## Updating + +Pull latest source and resync dependencies: + +```bash +git pull +uv sync +``` + +Then restart the running agent process. + +--- + +## Limitations + +- Controller behavior depends on available host service managers (`systemctl` or `service`) for restart operations +- Host filesystem permissions must allow managed writes to Logstash settings and metadata paths + +--- + +## Roadmap + +- Hardened host-mode lifecycle and service integration +- Expanded policy diff/apply visibility and diagnostics +- Additional keystore and secret-management workflows +- Broader automated test coverage around simulation and controller reconciliation paths + +--- + +## Reporting Issues + +Found a bug or have a feature request? [Open an issue](https://github.com/elastic/LogstashUI/issues/new?template=issue.md). + +--- + +## Contributing + +Contributions are welcome. + +Please open an issue to discuss large changes before submitting a pull request. + +--- + +## License + +Copyright 2024–2026 Elasticsearch and contributors. + +Licensed under the Apache License, Version 2.0. See [LICENSE](../../LICENSE.txt) for details. diff --git a/docs/host_mode.md b/docs/docs/logstashui/configuration/host_mode.md similarity index 99% rename from docs/host_mode.md rename to docs/docs/logstashui/configuration/host_mode.md index 478cd778..b063a7ac 100644 --- a/docs/host_mode.md +++ b/docs/docs/logstashui/configuration/host_mode.md @@ -84,7 +84,7 @@ python3 --version ```bash git clone https://github.com/elastic/LogstashUI.git -cd LogstashUI +cd logstashui ``` --- @@ -142,7 +142,7 @@ start_logstashui.bat ### Linux ```bash -cd LogstashUI/bin +cd logstashui/bin ./start_logstashui.sh ``` diff --git a/docs/docs/logstashui/configuration/index.md b/docs/docs/logstashui/configuration/index.md new file mode 100644 index 00000000..4e88543a --- /dev/null +++ b/docs/docs/logstashui/configuration/index.md @@ -0,0 +1,52 @@ +# Configuration + +LogstashUI is configured through the `logstashui.yml` file in the project root. This section covers the main configuration options and setup guides. + +--- + +## Configuration Files + +### **[logstashui.yml](logstashui.yml.md)** + +The main configuration file for LogstashUI. Controls authentication, simulation mode, and Logstash agent settings. + +**Key settings:** +- Authentication (`no_auth`) +- Simulation mode (embedded vs host) +- Logstash agent paths and configuration + +**📖 [View full logstashui.yml documentation →](logstashui.yml.md)** + +--- + +## Simulation Configuration + +### **[Simulation Modes](simulation.md)** + +LogstashUI supports two simulation modes for testing pipelines: + +- **Embedded Mode** - Runs Logstash in a Docker container (simple setup, slower performance) +- **Host Mode** - Runs Logstash natively on your host machine (requires setup, high performance) + +**📖 [View simulation configuration guide →](simulation.md)** + +--- + +### **[Host Mode Setup](host_mode.md)** + +Complete guide for setting up host mode for high-performance pipeline simulations. + +**Covers:** +- Prerequisites and system requirements +- Logstash installation (Windows & Linux) +- Configuration and startup +- Troubleshooting common issues + +**📖 [View host mode setup guide →](host_mode.md)** + +--- + +## Quick Links + +- **[Getting Started](../../getting_started.md)** - Initial setup and first steps +- **[LogstashUI Overview](../index.md)** - Feature overview and introduction diff --git a/docs/docs/logstashui/configuration/logstashui.yml.md b/docs/docs/logstashui/configuration/logstashui.yml.md new file mode 100644 index 00000000..7aadb3a0 --- /dev/null +++ b/docs/docs/logstashui/configuration/logstashui.yml.md @@ -0,0 +1,165 @@ +# logstashui.yml Configuration + +The `logstashui.yml` file is the main configuration file for LogstashUI. It controls authentication, simulation behavior, and Logstash agent settings. + +--- + +## File Location + +The configuration file should be placed in the project root: + +``` +LogstashUI/ +├── logstashui.yml +├── bin/ +├── src/ +└── ... +``` + +--- + +## Configuration Sections + +### `no_auth` + +Controls authentication for the LogstashUI application. + +```yaml +no_auth: + enabled: false # true | false +``` + +**Options:** +- `enabled: false` (default) - Authentication is required. Users must log in. +- `enabled: true` - **Disables all authentication**. Anyone can access the application. + +> **WARNING:** Enabling `no_auth` disables all authentication. Only use in sandbox/development environments. **Never enable in production.** + +--- + +### `simulation` + +Controls pipeline simulation behavior. See the [full simulation documentation](simulation.md) for detailed information. + +```yaml +simulation: + mode: embedded # embedded | host +``` + +**Options:** +- `embedded` - Runs Logstash in a local container (slower, easier setup) +- `host` - Runs Logstash natively on the host machine (faster, requires Logstash installation) + +**Quick Comparison:** + +| Feature | Embedded Mode | Host Mode | +|---------|---------------|-----------| +| **Performance** | Error prone with large pipelines | Highly reliable | +| **Setup** | Simple - no dependencies | Requires Logstash installation | +| **Best For** | Quick start, occasional simulations | Heavy simulation workloads | + +📖 **Learn more:** [Simulation Configuration](simulation.md) + +--- + +### `logstash_agent` + +Configures the Logstash agent used for pipeline simulation. This section is only relevant when using `simulation.mode: host`. + +```yaml +logstash_agent: + mode: simulation + + # Logstash installation paths + logstash_binary: /usr/share/logstash/bin/logstash + logstash_settings: /etc/logstash + logstash_log_path: /var/log/logstash +``` + +**Settings:** + +- **`mode`** - Agent operation mode + - `simulation` - Agent manages Logstash for pipeline simulation + +- **`logstash_binary`** - Path to the Logstash executable + - Linux (default): `/usr/share/logstash/bin/logstash` + - Windows example: `C:\logstash-9.3.1\logstash-9.3.1\bin\logstash.bat` + +- **`logstash_settings`** - Path to Logstash configuration directory + - Linux (default): `/etc/logstash` + - Windows example: `C:\logstash-9.3.1\logstash-9.3.1\config` + +- **`logstash_log_path`** - Path to Logstash log directory + - Linux (default): `/var/log/logstash` + - Windows example: `C:\logstash-9.3.1\logstash-9.3.1\logs` + +> **IMPORTANT:** When using host mode, Logstash is fully managed by LogstashAgent. Logstash should not be started manually, and your configuration files will be modified. + +📖 **Learn more:** [Host Mode Setup Guide](host_mode.md) + +--- + +## Complete Example + +### Linux (Embedded Mode) + +```yaml +# WARNING: Enabling no_auth disables all authentication. +# Only use in sandbox/development environments. Never enable in production. +no_auth: + enabled: false # true | false + +simulation: + mode: embedded # embedded | host + + logstash_agent: + mode: simulation + + logstash_binary: /usr/share/logstash/bin/logstash + logstash_settings: /etc/logstash + logstash_log_path: /var/log/logstash +``` + +### Linux (Host Mode) + +```yaml +no_auth: + enabled: false + +simulation: + mode: host # Change to 'host' for better performance + + logstash_agent: + mode: simulation + + # Linux paths (adjust if Logstash is installed in a custom location) + logstash_binary: /usr/share/logstash/bin/logstash + logstash_settings: /etc/logstash + logstash_log_path: /var/log/logstash +``` + +### Windows (Host Mode) + +```yaml +no_auth: + enabled: false + +simulation: + mode: host + + logstash_agent: + mode: simulation + + # Windows paths - adjust to match your Logstash installation + logstash_binary: C:\logstash-9.3.1\logstash-9.3.1\bin\logstash.bat + logstash_settings: C:\logstash-9.3.1\logstash-9.3.1\config + logstash_log_path: C:\logstash-9.3.1\logstash-9.3.1\logs +``` + +--- + +## Related Documentation + +- **[Simulation Configuration](simulation.md)** - Detailed simulation modes and settings +- **[Host Mode Setup](host_mode.md)** - Complete guide to setting up host mode for high-performance simulations +- **[Getting Started](../getting_started)** - Quick start guide for LogstashUI diff --git a/docs/simulation.md b/docs/docs/logstashui/configuration/simulation.md similarity index 100% rename from docs/simulation.md rename to docs/docs/logstashui/configuration/simulation.md diff --git a/docs/docs/logstashui/general/build.md b/docs/docs/logstashui/general/build.md new file mode 100644 index 00000000..55a18389 --- /dev/null +++ b/docs/docs/logstashui/general/build.md @@ -0,0 +1,47 @@ +# Building and Running LogstashUI + +There are a few different ways to run this project. + +--- + +## Using Docker Compose + +Use this mode when you want to run LogstashUI with an embedded LogstashAgent for simulation features. + +```bash +docker compose up -d +``` + +This will start: +- LogstashUI web application +- LogstashAgent (for pipeline simulation) + +--- + +## Running Locally (For Development) + +### Prerequisites + +- Python 3.11+ +- Django dependencies +- LogstashAgent running separately (see LogstashAgent/docs/build.md) + +### Running LogstashUI + +#### Linux +```bash +cd src/logstashui +LOGSTASH_CONFIG=../logstashui.example.yml python manage.py runserver 0.0.0.0:8080 +``` + +#### Windows +```bash +cd src/logstashui +$env:LOGSTASH_CONFIG="../logstashui.example.yml" +python manage.py runserver 0.0.0.0:8080 +``` + +### Running LogstashAgent + +LogstashAgent is now a separate project. See `LogstashAgent/docs/build.md` for build and run instructions. + diff --git a/docs/docs/logstashui/general/index.md b/docs/docs/logstashui/general/index.md new file mode 100644 index 00000000..87b49eb7 --- /dev/null +++ b/docs/docs/logstashui/general/index.md @@ -0,0 +1,38 @@ +# General + +General guides for building, running, and maintaining LogstashUI. + +--- + +## **[Building and Running](build.md)** + +Instructions for running LogstashUI in different modes. + +**Covers:** +- Docker Compose deployment (production/testing) +- Local development setup +- Prerequisites and dependencies +- Running the development server + +**📖 [View build guide →](build.md)** + +--- + +## **[Updating LogstashUI](updating.md)** + +Keep LogstashUI up to date with the latest features and fixes. + +**How to update:** +- LogstashUI notifies you when updates are available +- Simple one-command update process +- Works on both Windows and Linux + +**📖 [View update guide →](updating.md)** + +--- + +## Quick Links + +- **[Getting Started](../../getting_started.md)** - Initial setup and first steps +- **[Configuration](../configuration/index.md)** - Configure LogstashUI settings +- **[LogstashUI Overview](../index.md)** - Feature overview and introduction diff --git a/docs/docs/logstashui/general/updating.md b/docs/docs/logstashui/general/updating.md new file mode 100644 index 00000000..60086577 --- /dev/null +++ b/docs/docs/logstashui/general/updating.md @@ -0,0 +1,19 @@ +# Updating + +LogstashUI will notify you when a new version is available via a banner in the navigation sidebar. + +To update LogstashUI to the latest version: + +## Linux + +```bash +cd logstashui/bin +./start_logstashui.sh --update +``` + +## Windows + +```cmd +cd LogstashUI\bin +start_logstashui.bat --update +``` diff --git a/docs/docs/logstashui/index.md b/docs/docs/logstashui/index.md new file mode 100644 index 00000000..a8f1d216 --- /dev/null +++ b/docs/docs/logstashui/index.md @@ -0,0 +1,71 @@ +# LogstashUI + +> A visual tool for authoring, simulating, and managing Logstash pipelines. +> +> ⚠️ **Beta Release** - This project is under active development. Features may change. + +![LogstashUI Simulation](/docs/images/simulate.png) + +## Overview + +LogstashUI provides a visual interface for designing, testing, and operating Logstash pipelines. + +Instead of editing configuration files manually, pipelines can be authored visually, simulated against sample events, and deployed to multiple Logstash nodes from a single interface. + +### Visual Pipeline Editor +Author pipelines in three modes: an inline graphical interface, raw text editor, and a full visual graph for building pipelines by connecting nodes. Switch between modes seamlessly on any pipeline. + +![Visual Pipeline Editor](/docs/images/graph.gif) + +### Pipeline Simulation +Execute pipelines against sample events and inspect transformations step-by-step. + +![Pipeline Simulation](/docs/images/simulate.gif) + +### Multi-Instance Management +Manage pipelines across multiple Logstash nodes using Centralized Pipeline Management. + +### Pipeline Monitoring +View metrics and performance for running pipelines. + +![Pipeline Monitoring](/docs/images/monitoring.png) + +### SNMP Support +Configure polling, traps, and discovery through a web interface. + +![SNMP Support](/docs/images/snmp.gif) + +--- + +## Documentation + +- **[LogstashUI Documentation](logstashui/index.md)** - Installation, configuration, and usage guides for LogstashUI +- **[LogstashAgent Documentation](logstashagent/index.md)** - Setup and configuration for LogstashAgent + +--- + +## Limitations + +Currently, the translation engine cannot process comments inside plugin blocks. For example: + +``` +input { + udp { # Translation engine doesn't like this + port => 5119 # This is a comment that we can't convert + } +} +``` + +--- + +## Reporting Issues + +Found a bug or have a feature request? [Open an issue](https://github.com/elastic/LogstashUI/issues/new?template=issue.md). + +--- + +## License + +Copyright 2024–2026 Elasticsearch and contributors. + +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE.txt) for details. diff --git a/docs/images/LogstashIcon.png b/docs/images/LogstashIcon.png new file mode 100644 index 00000000..87667ee0 Binary files /dev/null and b/docs/images/LogstashIcon.png differ diff --git a/docs/images/control_plane.png b/docs/images/control_plane.png new file mode 100644 index 00000000..6073e7ae Binary files /dev/null and b/docs/images/control_plane.png differ diff --git a/docs/images/snmp.png b/docs/images/snmp.png new file mode 100644 index 00000000..b2b3e8f3 Binary files /dev/null and b/docs/images/snmp.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..47fd1a03 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[project] +name = "LogstashUI" +version = "0.4.0" +description = "A control plane and UI for Logstash!" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "concurrent-log-handler>=0.9.25", + "cryptography>=46.0.6", + "django>=6.0.3", + "django-browser-reload>=1.21.0", + "django-htmx>=1.27.0", + "django-login-required-middleware>=0.9.0", + "django-tailwind>=4.4.2", + "elasticsearch>=9.3.0", + "gevent>=26.4.0", + "gunicorn>=25.3.0", + "lark>=1.3.1", + "markdown>=3.10.2", + "packaging>=26.0", + "pygrok>=1.0.0", + "pyyaml>=6.0.3", + "requests>=2.33.0", + "whitenoise>=6.12.0", +] + +[dependency-groups] +dev = [ + "pre-commit>=4.5.1", + "pytest>=9.0.2", + "pytest-cov>=7.1.0", + "pytest-django>=4.10.0", +] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "LogstashUI.settings" +python_files = ["tests.py", "test_*.py", "*_tests.py"] +pythonpath = "." +testpaths = ["*/tests/"] +addopts = [ + "--import-mode=importlib", + "--cov", + "--cov-report=term-missing", + "--cov-report=xml", + "--cov-report=html", +] + +[tool.coverage.run] +source = ["logstashui"] +branch = true + +[tool.coverage.report] +skip_empty = true +show_missing = true +omit = [ + "*/migrations/*", + "*/tests/*", + "manage.py", +] diff --git a/Scripts/add_license_headers.py b/scripts/add_license_headers.py similarity index 96% rename from Scripts/add_license_headers.py rename to scripts/add_license_headers.py index 9ea6fc4a..2a00ace9 100644 --- a/Scripts/add_license_headers.py +++ b/scripts/add_license_headers.py @@ -5,7 +5,6 @@ import os import sys import argparse -from pathlib import Path ELASTIC_LICENSE_TEXT = """Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. Licensed under the Elastic License; @@ -49,6 +48,7 @@ 'apps.py', 'postcss.config.js', 'tailwind.config.js', + 'yaml.js', } EXCLUDED_PATTERNS = [ @@ -56,6 +56,8 @@ 'monokai.', 'show-hint.', 'd3.', + '.min.js', + 'js-yaml', ] @@ -219,7 +221,7 @@ def main(): '--root', type=str, default=None, - help='Root directory to crawl (defaults to LogstashUI directory)' + help='Root directory to crawl (defaults to src/logstashui directory)' ) args = parser.parse_args() @@ -228,9 +230,9 @@ def main(): if args.root: root_dir = args.root else: - # Assume script is in Scripts/ subdirectory + # Assume script is in scripts/ subdirectory script_dir = os.path.dirname(os.path.abspath(__file__)) - root_dir = os.path.join(script_dir, '..', 'LogstashAgent') + root_dir = os.path.join(script_dir, '..', 'src', 'logstashui') root_dir = os.path.abspath(root_dir) diff --git a/scripts/generate_notice.py b/scripts/generate_notice.py new file mode 100644 index 00000000..919fa858 --- /dev/null +++ b/scripts/generate_notice.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to check dependency licenses. + +This script checks both Python (pyproject.toml) and Node.js (package.json) dependencies +to ensure they are properly documented in NOTICE.txt. +""" + +import json +import re +import requests +import subprocess +import sys +import tomllib +from pathlib import Path + +# Custom dependencies that are always checked (in addition to pyproject.toml and package.json) +# These packages will be treated as if they were in a dependency file +# Format: "package_name": "github_repo_path_or_url" +CUSTOM_DEPENDENCIES = { + # Add custom dependencies here that should always be checked + # Example: "my-custom-package": "owner/repo" or "https://github.com/owner/repo/blob/main/LICENSE" + "d3": "https://github.com/d3/d3/blob/main/LICENSE", + "codemirror": "https://github.com/codemirror/dev/blob/main/LICENSE", + "js-yaml": "https://github.com/nodeca/js-yaml/blob/master/LICENSE" +} + +# Repository mappings for dependencies (fallback when automatic lookup fails) +# Format: "package_name": "github_repo_path" (e.g., "owner/repo") +REPOSITORY_MAPPINGS = { + # Add package repository mappings here + # Example: "some-package": "owner/repo" + 'cryptography': 'https://github.com/pyca/cryptography/blob/main/LICENSE.APACHE', + 'gunicorn': 'https://github.com/benoitc/gunicorn/blob/master/LICENSE', + 'packaging': 'https://github.com/pypa/packaging/blob/main/LICENSE.APACHE', + 'pyyaml': 'https://github.com/yaml/pyyaml/blob/main/LICENSE', + 'daisyui': 'https://github.com/saadeghi/daisyui/blob/master/LICENSE', + 'postcss-cli': 'https://github.com/postcss/postcss-cli/blob/master/LICENSE', + 'gevent': 'https://github.com/gevent/gevent/blob/master/LICENSE' +} + +# License validation patterns +LICENSE_PATTERNS = { + "Public Domain", + "BSD-2-Clause", + "BSD-3-Clause", + "BSD", + "BSD License", + "BSD 2-Clause", + "BSD 3-Clause", + "Eclipse Distribution License - v 1.0", + "Eclipse Distribution License", + "MIT", + "MIT License", + "Bouncy Castle License", + "CMU License", + "MIT-CMU", + "curl", + "curl License", + "Apache-1.1", + "Apache-2.0", + "Apache License 2.0", + "Apache Software License", + "Apache License", + "Artistic-1.0", + "Artistic License 1.0", + "PHP-3.0", + "PHP-3.01", + "PHP License", + "PSF-2.0", + "Python Software Foundation License", + "Python Software Foundation License 2.0", + "Zlib", + "zlib License", + "zlib-acknowledgement", + "zlib/libpng License", + "BSL-1.0", + "Boost Software License", + "Boost Software License 1.0", + "OpenSSL", + "OpenSSL License", + "WTFPL", + "CC0-1.0", + "CC-BY-4.0", + "Creative Commons Zero v1.0 Universal", + "Creative Commons Attribution 4.0 International", + "Unlicense", + "The Unlicense", + "ISC", + "ISC License", + "ICU", + "ICU License", + "0BSD", + "BSD Zero Clause License", + "HPND", + "Historical Permission Notice and Disclaimer", + "BlueOak-1.0.0", + "Blue Oak Model License 1.0.0", + "ZPL-2.1", + "Zope Public License 2.1", + "Unicode-3.0", + "Unicode License v3", + "MPL-2.0", + "Mozilla Public License 2.0", + "Mozilla Public License 2.0 (MPL 2.0)", + "MIT No Attribution", +} + + +def get_project_root(): + """Get the project root directory.""" + return Path(__file__).parent.parent + + +def fetch_license_from_pypi(package_name): + """Fetch license information from PyPI.""" + api_url = f"https://pypi.org/pypi/{package_name}/json" + + try: + response = requests.get(api_url, timeout=10) + + if response.status_code == 200: + data = response.json() + info = data.get('info', {}) + + # Get repository URL from project URLs + project_urls = info.get('project_urls', {}) + repo_url = project_urls.get('Source') or project_urls.get('Repository') or project_urls.get('Homepage') + + if repo_url and 'github.com' in repo_url: + # Extract owner/repo from GitHub URL + match = re.search(r'github\.com/([^/]+/[^/]+)', repo_url) + if match: + return match.group(1).rstrip('.git') + + return None + + except Exception as e: + print(f"Error fetching PyPI metadata for {package_name}: {e}") + return None + + +def fetch_license_from_npm(package_name): + """Fetch license information from npm registry.""" + api_url = f"https://registry.npmjs.org/{package_name}" + + try: + response = requests.get(api_url, timeout=10) + + if response.status_code == 200: + data = response.json() + + # Get repository URL from latest version + repository = data.get('repository', {}) + if isinstance(repository, dict): + repo_url = repository.get('url', '') + else: + repo_url = repository + + if repo_url and 'github.com' in repo_url: + # Extract owner/repo from GitHub URL + match = re.search(r'github\.com/([^/]+/[^/]+)', repo_url) + if match: + return match.group(1).rstrip('.git') + + return None + + except Exception as e: + print(f"Error fetching npm metadata for {package_name}: {e}") + return None + + +def fetch_license_from_url(license_url, dep_name): + """Fetch license text directly from a URL.""" + try: + # Convert GitHub blob URLs to raw URLs + if 'github.com' in license_url and '/blob/' in license_url: + license_url = license_url.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/') + + response = requests.get(license_url, timeout=10) + + if response.status_code == 200: + # Extract license name from content if possible + license_text = response.text + license_name = "Unknown" + + # Try to detect license type from content + for pattern in LICENSE_PATTERNS: + if pattern.lower() in license_text.lower(): + license_name = pattern + break + + return { + 'text': license_text, + 'name': license_name, + 'url': license_url + } + + print(f"Warning: Could not fetch license from {license_url}") + return None + + except Exception as e: + print(f"Error fetching license from URL for {dep_name}: {e}") + return None + + +def fetch_license_from_github(repo_path, dep_name): + """Fetch license text and metadata from GitHub repository.""" + api_url = f"https://api.github.com/repos/{repo_path}/license" + + try: + response = requests.get(api_url, timeout=10) + + if response.status_code == 200: + data = response.json() + license_name = data.get('license', {}).get('name', '') + license_url = data.get('download_url') + + if license_url: + license_response = requests.get(license_url, timeout=10) + if license_response.status_code == 200: + return { + 'text': license_response.text, + 'name': license_name, + 'url': license_url + } + + print(f"Warning: Could not fetch license for {dep_name} ({repo_path})") + return None + + except Exception as e: + print(f"Error fetching license for {dep_name}: {e}") + return None + + +def validate_license(license_name): + """Validate if a license matches known patterns.""" + if not license_name: + return False + + license_name = license_name.strip() + + # Direct match + if license_name in LICENSE_PATTERNS: + return True + + # Partial match for common variations + for pattern in LICENSE_PATTERNS: + if pattern.lower() in license_name.lower() or license_name.lower() in pattern.lower(): + return True + + return False + + +def read_notice_file(): + """Read the NOTICE.txt file and return its contents.""" + notice_path = get_project_root() / "NOTICE.txt" + if notice_path.exists(): + return notice_path.read_text(encoding="utf-8") + return "" + + +def is_package_in_notice(package_name, notice_content): + """Check if a package is already mentioned in NOTICE.txt.""" + # Look for package name in section headers like "-------------- package-name --------------" + pattern = rf"-+\s*{re.escape(package_name)}\s*-+" + return bool(re.search(pattern, notice_content, re.IGNORECASE)) + + +def append_to_notice(package_name, license_text): + """Append a package entry with license text to NOTICE.txt.""" + notice_path = get_project_root() / "NOTICE.txt" + + entry = f"\n-------------- {package_name} -------------- \n" + entry += license_text + entry += "\n" + + with open(notice_path, "a", encoding="utf-8") as f: + f.write(entry) + + print(f"Added {package_name} to NOTICE.txt") + + + + +def get_python_dependencies(): + """Get Python dependencies from pyproject.toml.""" + pyproject_path = get_project_root() / "pyproject.toml" + + if not pyproject_path.exists(): + print("pyproject.toml not found") + return [] + + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + + dependencies = pyproject.get("project", {}).get("dependencies", []) + + # Extract package names (remove version specifiers) + package_names = [] + for dep in dependencies: + package_name = re.split(r"[><=!]", dep)[0].strip() + package_names.append((package_name, 'python')) + + return package_names + + +def get_nodejs_dependencies(): + """Get Node.js dependencies from package.json.""" + package_json_path = get_project_root() / "src" / "logstashui" / "theme" / "static_src" / "package.json" + + if not package_json_path.exists(): + print("package.json not found") + return [] + + with open(package_json_path, "r", encoding="utf-8") as f: + package_data = json.load(f) + + # Get both dependencies and devDependencies + dependencies = {} + dependencies.update(package_data.get("dependencies", {})) + dependencies.update(package_data.get("devDependencies", {})) + + return [(name, 'nodejs') for name in dependencies.keys()] + + +def main(): + """Main entry point for the license checker.""" + print("Checking dependency licenses...") + + # Collect all dependencies + print("\nCollecting dependencies...") + python_deps = get_python_dependencies() + nodejs_deps = get_nodejs_dependencies() + + # Add custom dependencies (treat them as 'custom' type) + custom_deps = [(name, 'custom') for name in CUSTOM_DEPENDENCIES.keys()] + + all_deps = python_deps + nodejs_deps + custom_deps + + print(f"Found {len(all_deps)} total dependencies ({len(custom_deps)} custom)") + + # First, check if all dependencies are in NOTICE.txt + notice_content = read_notice_file() + missing_deps = [] + + for dep_name, dep_type in all_deps: + if not is_package_in_notice(dep_name, notice_content): + missing_deps.append((dep_name, dep_type)) + + # If all dependencies are in NOTICE.txt, we're done + if not missing_deps: + print("\n[OK] All dependencies are documented in NOTICE.txt") + return 0 + + print(f"\nFound {len(missing_deps)} dependencies missing from NOTICE.txt") + print("Fetching licenses...\n") + + # Fetch licenses for missing dependencies + errors = [] + for dep_name, dep_type in missing_deps: + license_data = None + + # Check CUSTOM_DEPENDENCIES first (for custom dependencies) + if dep_type == 'custom' and dep_name in CUSTOM_DEPENDENCIES: + mapping_value = CUSTOM_DEPENDENCIES[dep_name] + + # If it's a direct URL, fetch from that URL + if mapping_value.startswith('https://'): + print(f"Fetching license for {dep_name} (custom) from direct URL...") + license_data = fetch_license_from_url(mapping_value, dep_name) + else: + # It's a repo path, use GitHub API + print(f"Fetching license for {dep_name} (custom) from {mapping_value}...") + license_data = fetch_license_from_github(mapping_value, dep_name) + # Check REPOSITORY_MAPPINGS for manual overrides + elif dep_name in REPOSITORY_MAPPINGS: + mapping_value = REPOSITORY_MAPPINGS[dep_name] + + # If it's a direct URL, fetch from that URL + if mapping_value.startswith('https://'): + print(f"Fetching license for {dep_name} from direct URL...") + license_data = fetch_license_from_url(mapping_value, dep_name) + else: + # It's a repo path, use GitHub API + print(f"Fetching license for {dep_name} from {mapping_value}...") + license_data = fetch_license_from_github(mapping_value, dep_name) + else: + # Try to fetch repository URL from package registry + print(f"Looking up {dep_name}...") + repo_path = None + + if dep_type == 'python': + repo_path = fetch_license_from_pypi(dep_name) + elif dep_type == 'nodejs': + repo_path = fetch_license_from_npm(dep_name) + + if repo_path: + print(f"Fetching license for {dep_name} from {repo_path}...") + license_data = fetch_license_from_github(repo_path, dep_name) + else: + errors.append(f"No repository found for {dep_name}") + continue + + if not license_data: + errors.append(f"Could not fetch license for {dep_name}") + continue + + # Validate the license + if not validate_license(license_data['name']): + errors.append(f"License '{license_data['name']}' for {dep_name} is not in the approved list") + continue + + # Add to NOTICE.txt + append_to_notice(dep_name, license_data['text']) + + # Report errors + if errors: + print("\n[ERROR] License check failed with the following errors:") + for error in errors: + print(f" - {error}") + print("\nPlease add missing repository mappings to REPOSITORY_MAPPINGS in scripts/generate_notice.py") + return 1 + + print("\n[OK] All dependency licenses checked and updated successfully!") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/LogstashUI/Common/__init__.py b/src/logstashui/Common/__init__.py similarity index 100% rename from LogstashUI/Common/__init__.py rename to src/logstashui/Common/__init__.py diff --git a/LogstashUI/Common/context_processors.py b/src/logstashui/Common/context_processors.py similarity index 100% rename from LogstashUI/Common/context_processors.py rename to src/logstashui/Common/context_processors.py diff --git a/LogstashUI/Common/decorators.py b/src/logstashui/Common/decorators.py similarity index 100% rename from LogstashUI/Common/decorators.py rename to src/logstashui/Common/decorators.py diff --git a/LogstashUI/Common/elastic_utils.py b/src/logstashui/Common/elastic_utils.py similarity index 88% rename from LogstashUI/Common/elastic_utils.py rename to src/logstashui/Common/elastic_utils.py index 1321bd65..77a49536 100644 --- a/LogstashUI/Common/elastic_utils.py +++ b/src/logstashui/Common/elastic_utils.py @@ -16,7 +16,11 @@ def test_elastic_connectivity(elastic_connection): return json.dumps(dict(elastic_connection.info()), indent=4) def get_elastic_connections_from_list(): - es_connections = list(ConnectionTable.objects.values("connection_type", "name", "host", "cloud_id", "cloud_url", "pk")) + # Only query CENTRALIZED connections (not AGENT connections) + # AGENT connections don't have Elasticsearch endpoints to connect to + es_connections = list(ConnectionTable.objects.filter( + connection_type=ConnectionTable.ConnectionType.CENTRALIZED + ).values("connection_type", "name", "host", "cloud_id", "cloud_url", "pk")) return [{ "es": get_elastic_connection(es_connection['pk']), @@ -37,7 +41,12 @@ def _get_creds(connection_id): if connection.cloud_id: connection_data['cloud_id'] = connection.cloud_id else: - connection_data['hosts'] = f"{connection.host}:{connection.port}" + # For CENTRALIZED connections, combine host and port fields + # The frontend stores them separately, so we need to recombine them + if connection.port: + connection_data['hosts'] = f"{connection.host}:{connection.port}" + else: + connection_data['hosts'] = connection.host if connection.api_key: connection_data['api_key'] = connection.get_api_key() diff --git a/LogstashUI/Common/encryption.py b/src/logstashui/Common/encryption.py similarity index 100% rename from LogstashUI/Common/encryption.py rename to src/logstashui/Common/encryption.py diff --git a/LogstashUI/Common/error_handlers.py b/src/logstashui/Common/error_handlers.py similarity index 100% rename from LogstashUI/Common/error_handlers.py rename to src/logstashui/Common/error_handlers.py diff --git a/LogstashUI/Common/formatters.py b/src/logstashui/Common/formatters.py similarity index 100% rename from LogstashUI/Common/formatters.py rename to src/logstashui/Common/formatters.py diff --git a/LogstashUI/Common/logstash_config_parse.py b/src/logstashui/Common/logstash_config_parse.py similarity index 100% rename from LogstashUI/Common/logstash_config_parse.py rename to src/logstashui/Common/logstash_config_parse.py diff --git a/LogstashUI/Common/logstash_utils.py b/src/logstashui/Common/logstash_utils.py similarity index 100% rename from LogstashUI/Common/logstash_utils.py rename to src/logstashui/Common/logstash_utils.py diff --git a/src/logstashui/Common/middleware.py b/src/logstashui/Common/middleware.py new file mode 100644 index 00000000..df1d7c9f --- /dev/null +++ b/src/logstashui/Common/middleware.py @@ -0,0 +1,101 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + + +class NoAuthMiddleware: + """ + Sandbox middleware: auto-authenticates every request as the first active user. + If no users exist, creates a default admin account automatically. + Only active when NO_AUTH_MODE is enabled in config. Never use in production. + """ + + _no_auth_user_cache = None + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.user.is_authenticated: + user = self._get_or_create_no_auth_user() + if user: + request.user = user + return self.get_response(request) + + def _get_or_create_no_auth_user(self): + import logging + logger = logging.getLogger(__name__) + + if NoAuthMiddleware._no_auth_user_cache is not None: + return NoAuthMiddleware._no_auth_user_cache + + try: + from django.contrib.auth import get_user_model + User = get_user_model() + + user = User.objects.filter(is_active=True).first() + if user is None: + user = self._create_no_auth_admin(User, logger) + + NoAuthMiddleware._no_auth_user_cache = user + return user + except Exception: + # DB may not be ready (e.g. pre-migration). Fall through gracefully. + return None + + def _create_no_auth_admin(self, User, logger): + from django.db import transaction + try: + with transaction.atomic(): + # Re-check inside the transaction to avoid races + if User.objects.exists(): + return User.objects.filter(is_active=True).first() + user = User.objects.create_superuser( + username='admin', + email='', + password=None, # unusable password — login form won't work + ) + logger.warning( + "NO_AUTH MODE: No users found — created default 'admin' account " + "with an unusable password." + ) + return user + except Exception as e: + logger.error(f"NO_AUTH MODE: Failed to create default admin user: {e}") + return None + + +class SecurityHeadersMiddleware: + """ + Middleware to add Content Security Policy and other security headers. + Restricts iframe sources to trusted documentation domains. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + # Content Security Policy + # frame-src: Controls which URLs can be loaded in iframes + # 'self': Allow iframes from same origin + # Trusted documentation domains for plugin docs + csp_directives = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", # unsafe-inline/eval needed for htmx and dynamic JS + "style-src 'self' 'unsafe-inline'", # unsafe-inline needed for Tailwind + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-src 'self' https://www.elastic.co https://elastic.co https://github.com https://rubydoc.info", + "frame-ancestors 'self'", # Prevent this site from being framed by others + ] + + response['Content-Security-Policy'] = "; ".join(csp_directives) + + # Additional security headers + response['X-Content-Type-Options'] = 'nosniff' + response['Referrer-Policy'] = 'no-referrer-when-downgrade' + + return response diff --git a/LogstashUI/Common/test_resources.py b/src/logstashui/Common/test_resources.py similarity index 100% rename from LogstashUI/Common/test_resources.py rename to src/logstashui/Common/test_resources.py diff --git a/LogstashUI/Common/tests/__init__.py b/src/logstashui/Common/tests/__init__.py similarity index 100% rename from LogstashUI/Common/tests/__init__.py rename to src/logstashui/Common/tests/__init__.py diff --git a/LogstashUI/Common/tests/conversion_data/components/ls-repo-apache2.json b/src/logstashui/Common/tests/conversion_data/components/ls-repo-apache2.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/ls-repo-apache2.json rename to src/logstashui/Common/tests/conversion_data/components/ls-repo-apache2.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-asa-new.json b/src/logstashui/Common/tests/conversion_data/components/test-asa-new.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-asa-new.json rename to src/logstashui/Common/tests/conversion_data/components/test-asa-new.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-asa.json b/src/logstashui/Common/tests/conversion_data/components/test-asa.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-asa.json rename to src/logstashui/Common/tests/conversion_data/components/test-asa.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-complex2.json b/src/logstashui/Common/tests/conversion_data/components/test-complex2.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-complex2.json rename to src/logstashui/Common/tests/conversion_data/components/test-complex2.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-complex3.json b/src/logstashui/Common/tests/conversion_data/components/test-complex3.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-complex3.json rename to src/logstashui/Common/tests/conversion_data/components/test-complex3.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-data-types.json b/src/logstashui/Common/tests/conversion_data/components/test-data-types.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-data-types.json rename to src/logstashui/Common/tests/conversion_data/components/test-data-types.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-datatypes.json b/src/logstashui/Common/tests/conversion_data/components/test-datatypes.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-datatypes.json rename to src/logstashui/Common/tests/conversion_data/components/test-datatypes.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-devopsschool-1.json b/src/logstashui/Common/tests/conversion_data/components/test-devopsschool-1.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-devopsschool-1.json rename to src/logstashui/Common/tests/conversion_data/components/test-devopsschool-1.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-devopsschool-2.json b/src/logstashui/Common/tests/conversion_data/components/test-devopsschool-2.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-devopsschool-2.json rename to src/logstashui/Common/tests/conversion_data/components/test-devopsschool-2.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-devopsschool-4.json b/src/logstashui/Common/tests/conversion_data/components/test-devopsschool-4.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-devopsschool-4.json rename to src/logstashui/Common/tests/conversion_data/components/test-devopsschool-4.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-devopsschool-5.json b/src/logstashui/Common/tests/conversion_data/components/test-devopsschool-5.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-devopsschool-5.json rename to src/logstashui/Common/tests/conversion_data/components/test-devopsschool-5.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-elasticdocs-apache.json b/src/logstashui/Common/tests/conversion_data/components/test-elasticdocs-apache.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-elasticdocs-apache.json rename to src/logstashui/Common/tests/conversion_data/components/test-elasticdocs-apache.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-elasticdocs-configuring_filters.json b/src/logstashui/Common/tests/conversion_data/components/test-elasticdocs-configuring_filters.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-elasticdocs-configuring_filters.json rename to src/logstashui/Common/tests/conversion_data/components/test-elasticdocs-configuring_filters.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-elasticdocs-syslog.json b/src/logstashui/Common/tests/conversion_data/components/test-elasticdocs-syslog.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-elasticdocs-syslog.json rename to src/logstashui/Common/tests/conversion_data/components/test-elasticdocs-syslog.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-es-input.json b/src/logstashui/Common/tests/conversion_data/components/test-es-input.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-es-input.json rename to src/logstashui/Common/tests/conversion_data/components/test-es-input.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-ls-repo-mysql.json b/src/logstashui/Common/tests/conversion_data/components/test-ls-repo-mysql.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-ls-repo-mysql.json rename to src/logstashui/Common/tests/conversion_data/components/test-ls-repo-mysql.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-ls-repo-nginx.json b/src/logstashui/Common/tests/conversion_data/components/test-ls-repo-nginx.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-ls-repo-nginx.json rename to src/logstashui/Common/tests/conversion_data/components/test-ls-repo-nginx.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-ls-repo-system.json b/src/logstashui/Common/tests/conversion_data/components/test-ls-repo-system.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-ls-repo-system.json rename to src/logstashui/Common/tests/conversion_data/components/test-ls-repo-system.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-sample-nginx.json b/src/logstashui/Common/tests/conversion_data/components/test-sample-nginx.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-sample-nginx.json rename to src/logstashui/Common/tests/conversion_data/components/test-sample-nginx.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-snmp-v0.2.json b/src/logstashui/Common/tests/conversion_data/components/test-snmp-v0.2.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-snmp-v0.2.json rename to src/logstashui/Common/tests/conversion_data/components/test-snmp-v0.2.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test-twitter.json b/src/logstashui/Common/tests/conversion_data/components/test-twitter.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test-twitter.json rename to src/logstashui/Common/tests/conversion_data/components/test-twitter.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test_complex1.json b/src/logstashui/Common/tests/conversion_data/components/test_complex1.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test_complex1.json rename to src/logstashui/Common/tests/conversion_data/components/test_complex1.json diff --git a/LogstashUI/Common/tests/conversion_data/components/test_elasticdocs-conditional.json b/src/logstashui/Common/tests/conversion_data/components/test_elasticdocs-conditional.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/test_elasticdocs-conditional.json rename to src/logstashui/Common/tests/conversion_data/components/test_elasticdocs-conditional.json diff --git a/LogstashUI/Common/tests/conversion_data/components/text-complex4.json b/src/logstashui/Common/tests/conversion_data/components/text-complex4.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/text-complex4.json rename to src/logstashui/Common/tests/conversion_data/components/text-complex4.json diff --git a/LogstashUI/Common/tests/conversion_data/components/text-complex5.json b/src/logstashui/Common/tests/conversion_data/components/text-complex5.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/text-complex5.json rename to src/logstashui/Common/tests/conversion_data/components/text-complex5.json diff --git a/LogstashUI/Common/tests/conversion_data/components/text-complex6.json b/src/logstashui/Common/tests/conversion_data/components/text-complex6.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/text-complex6.json rename to src/logstashui/Common/tests/conversion_data/components/text-complex6.json diff --git a/LogstashUI/Common/tests/conversion_data/components/text-complex7.json b/src/logstashui/Common/tests/conversion_data/components/text-complex7.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/text-complex7.json rename to src/logstashui/Common/tests/conversion_data/components/text-complex7.json diff --git a/LogstashUI/Common/tests/conversion_data/components/text-ls-repo-nginx-error.json b/src/logstashui/Common/tests/conversion_data/components/text-ls-repo-nginx-error.json similarity index 100% rename from LogstashUI/Common/tests/conversion_data/components/text-ls-repo-nginx-error.json rename to src/logstashui/Common/tests/conversion_data/components/text-ls-repo-nginx-error.json diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/ls-repo-apache2.conf b/src/logstashui/Common/tests/conversion_data/pipelines/ls-repo-apache2.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/ls-repo-apache2.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/ls-repo-apache2.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-asa-new.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-asa-new.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-asa-new.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-asa-new.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-asa.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-asa.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-asa.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-asa.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-complex2.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-complex2.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-complex2.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-complex2.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-complex3.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-complex3.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-complex3.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-complex3.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-data-types.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-data-types.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-data-types.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-data-types.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-datatypes.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-datatypes.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-datatypes.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-datatypes.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-1.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-1.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-1.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-1.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-2.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-2.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-2.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-2.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-4.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-4.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-4.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-4.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-5.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-5.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-devopsschool-5.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-devopsschool-5.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-elasticdocs-apache.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-elasticdocs-apache.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-elasticdocs-apache.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-elasticdocs-apache.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-elasticdocs-configuring_filters.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-elasticdocs-configuring_filters.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-elasticdocs-configuring_filters.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-elasticdocs-configuring_filters.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-elasticdocs-syslog.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-elasticdocs-syslog.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-elasticdocs-syslog.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-elasticdocs-syslog.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-es-input.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-es-input.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-es-input.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-es-input.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-ls-repo-mysql.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-ls-repo-mysql.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-ls-repo-mysql.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-ls-repo-mysql.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-ls-repo-nginx.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-ls-repo-nginx.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-ls-repo-nginx.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-ls-repo-nginx.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-ls-repo-system.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-ls-repo-system.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-ls-repo-system.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-ls-repo-system.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-sample-nginx.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-sample-nginx.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-sample-nginx.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-sample-nginx.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-snmp-v0.2.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-snmp-v0.2.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-snmp-v0.2.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-snmp-v0.2.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test-twitter.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test-twitter.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test-twitter.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test-twitter.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test_complex1.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test_complex1.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test_complex1.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test_complex1.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/test_elasticdocs-conditional.conf b/src/logstashui/Common/tests/conversion_data/pipelines/test_elasticdocs-conditional.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/test_elasticdocs-conditional.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/test_elasticdocs-conditional.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/text-complex4.conf b/src/logstashui/Common/tests/conversion_data/pipelines/text-complex4.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/text-complex4.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/text-complex4.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/text-complex5.conf b/src/logstashui/Common/tests/conversion_data/pipelines/text-complex5.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/text-complex5.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/text-complex5.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/text-complex6.conf b/src/logstashui/Common/tests/conversion_data/pipelines/text-complex6.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/text-complex6.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/text-complex6.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/text-complex7.conf b/src/logstashui/Common/tests/conversion_data/pipelines/text-complex7.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/text-complex7.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/text-complex7.conf diff --git a/LogstashUI/Common/tests/conversion_data/pipelines/text-ls-repo-nginx-error.conf b/src/logstashui/Common/tests/conversion_data/pipelines/text-ls-repo-nginx-error.conf similarity index 100% rename from LogstashUI/Common/tests/conversion_data/pipelines/text-ls-repo-nginx-error.conf rename to src/logstashui/Common/tests/conversion_data/pipelines/text-ls-repo-nginx-error.conf diff --git a/LogstashUI/Common/tests/test_components_to_pipeline.py b/src/logstashui/Common/tests/test_components_to_pipeline.py similarity index 100% rename from LogstashUI/Common/tests/test_components_to_pipeline.py rename to src/logstashui/Common/tests/test_components_to_pipeline.py diff --git a/LogstashUI/Common/tests/test_context_processors.py b/src/logstashui/Common/tests/test_context_processors.py similarity index 100% rename from LogstashUI/Common/tests/test_context_processors.py rename to src/logstashui/Common/tests/test_context_processors.py diff --git a/LogstashUI/Common/tests/test_decorators.py b/src/logstashui/Common/tests/test_decorators.py similarity index 100% rename from LogstashUI/Common/tests/test_decorators.py rename to src/logstashui/Common/tests/test_decorators.py diff --git a/LogstashUI/Common/tests/test_elastic_utils.py b/src/logstashui/Common/tests/test_elastic_utils.py similarity index 100% rename from LogstashUI/Common/tests/test_elastic_utils.py rename to src/logstashui/Common/tests/test_elastic_utils.py diff --git a/LogstashUI/Common/tests/test_encryption.py b/src/logstashui/Common/tests/test_encryption.py similarity index 100% rename from LogstashUI/Common/tests/test_encryption.py rename to src/logstashui/Common/tests/test_encryption.py diff --git a/LogstashUI/Common/tests/test_error_handlers.py b/src/logstashui/Common/tests/test_error_handlers.py similarity index 100% rename from LogstashUI/Common/tests/test_error_handlers.py rename to src/logstashui/Common/tests/test_error_handlers.py diff --git a/LogstashUI/Common/tests/test_formatters.py b/src/logstashui/Common/tests/test_formatters.py similarity index 100% rename from LogstashUI/Common/tests/test_formatters.py rename to src/logstashui/Common/tests/test_formatters.py diff --git a/LogstashUI/Common/tests/test_logstash_config_parse.py b/src/logstashui/Common/tests/test_logstash_config_parse.py similarity index 100% rename from LogstashUI/Common/tests/test_logstash_config_parse.py rename to src/logstashui/Common/tests/test_logstash_config_parse.py diff --git a/LogstashUI/Common/tests/test_logstash_utils.py b/src/logstashui/Common/tests/test_logstash_utils.py similarity index 100% rename from LogstashUI/Common/tests/test_logstash_utils.py rename to src/logstashui/Common/tests/test_logstash_utils.py diff --git a/LogstashUI/Common/tests/test_middleware.py b/src/logstashui/Common/tests/test_middleware.py similarity index 100% rename from LogstashUI/Common/tests/test_middleware.py rename to src/logstashui/Common/tests/test_middleware.py diff --git a/LogstashUI/Common/tests/test_pipeline_to_components.py b/src/logstashui/Common/tests/test_pipeline_to_components.py similarity index 100% rename from LogstashUI/Common/tests/test_pipeline_to_components.py rename to src/logstashui/Common/tests/test_pipeline_to_components.py diff --git a/LogstashUI/Common/tests/test_validators.py b/src/logstashui/Common/tests/test_validators.py similarity index 100% rename from LogstashUI/Common/tests/test_validators.py rename to src/logstashui/Common/tests/test_validators.py diff --git a/LogstashUI/Common/validators.py b/src/logstashui/Common/validators.py similarity index 100% rename from LogstashUI/Common/validators.py rename to src/logstashui/Common/validators.py diff --git a/LogstashUI/LogstashUI/__init__.py b/src/logstashui/Documentation/__init__.py similarity index 100% rename from LogstashUI/LogstashUI/__init__.py rename to src/logstashui/Documentation/__init__.py diff --git a/src/logstashui/Documentation/admin.py b/src/logstashui/Documentation/admin.py new file mode 100644 index 00000000..35dd454d --- /dev/null +++ b/src/logstashui/Documentation/admin.py @@ -0,0 +1,7 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from django.contrib import admin + +# Register your models here. diff --git a/src/logstashui/Documentation/apps.py b/src/logstashui/Documentation/apps.py new file mode 100644 index 00000000..c9848d6e --- /dev/null +++ b/src/logstashui/Documentation/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DocumentationConfig(AppConfig): + name = 'Documentation' diff --git a/LogstashUI/Management/__init__.py b/src/logstashui/Documentation/migrations/__init__.py similarity index 100% rename from LogstashUI/Management/__init__.py rename to src/logstashui/Documentation/migrations/__init__.py diff --git a/src/logstashui/Documentation/models.py b/src/logstashui/Documentation/models.py new file mode 100644 index 00000000..5aee5a09 --- /dev/null +++ b/src/logstashui/Documentation/models.py @@ -0,0 +1,7 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from django.db import models + +# Create your models here. diff --git a/src/logstashui/Documentation/static/css/markdown.css b/src/logstashui/Documentation/static/css/markdown.css new file mode 100644 index 00000000..ead23e39 --- /dev/null +++ b/src/logstashui/Documentation/static/css/markdown.css @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* Markdown Content Styling */ +.markdown-content { + width: 100%; + padding: 0; + line-height: 1.6; + color: inherit; +} + +/* Headings */ +.markdown-content h1 { + font-size: 2.5rem; + font-weight: 700; + margin-top: 2rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid rgba(168, 85, 247, 0.3); +} + +.markdown-content h2 { + font-size: 2rem; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 1rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.markdown-content h3 { + font-size: 1.5rem; + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +.markdown-content h4 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.markdown-content h5, +.markdown-content h6 { + font-size: 1rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +/* Paragraphs */ +.markdown-content p { + margin-bottom: 1rem; +} + +/* Blockquotes */ +.markdown-content blockquote { + margin: 1.5rem 0; + padding: 0.75rem 1.5rem; + border-left: 4px solid rgba(168, 85, 247, 0.5); + background: rgba(168, 85, 247, 0.05); + border-radius: 0 0.375rem 0.375rem 0; +} + +.markdown-content blockquote p { + margin: 0.5rem 0; +} + +.markdown-content blockquote p:first-child { + margin-top: 0; +} + +.markdown-content blockquote p:last-child { + margin-bottom: 0; +} + +/* Lists */ +.markdown-content ul, +.markdown-content ol { + margin: 1rem 0; + padding-left: 2rem; +} + +.markdown-content ul { + list-style-type: disc; +} + +.markdown-content ol { + list-style-type: decimal; +} + +.markdown-content li { + margin: 0.5rem 0; +} + +.markdown-content ul ul, +.markdown-content ol ul, +.markdown-content ul ol, +.markdown-content ol ol { + margin: 0.25rem 0; +} + +/* Code */ +.markdown-content code { + background: rgba(148, 163, 184, 0.15); + padding: 0.2rem 0.4rem; + border-radius: 0.25rem; + font-family: 'Courier New', Courier, monospace; + font-size: 0.9em; +} + +.markdown-content pre { + background: rgba(0, 0, 0, 0.3); + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 1.5rem 0; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.markdown-content pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.875rem; + line-height: 1.5; +} + +/* Links */ +.markdown-content a { + color: rgba(168, 85, 247, 0.9); + text-decoration: underline; + transition: color 0.2s ease; +} + +.markdown-content a:hover { + color: rgba(168, 85, 247, 1); + text-decoration: none; +} + +/* Images */ +.markdown-content img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 1.5rem 0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +/* Tables */ +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; + overflow: hidden; + border-radius: 0.5rem; +} + +.markdown-content th, +.markdown-content td { + padding: 0.75rem 1rem; + text-align: left; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.markdown-content th { + background: rgba(168, 85, 247, 0.15); + font-weight: 600; +} + +.markdown-content tr:nth-child(even) { + background: rgba(148, 163, 184, 0.05); +} + +/* Horizontal Rules */ +.markdown-content hr { + border: none; + height: 1px; + background: linear-gradient( + to right, + transparent, + rgba(148, 163, 184, 0.3) 20%, + rgba(148, 163, 184, 0.3) 80%, + transparent + ); + margin: 2rem 0; +} + +/* Details/Summary (collapsible sections) */ +.markdown-content details { + margin: 1rem 0; + padding: 1rem; + background: rgba(148, 163, 184, 0.05); + border-radius: 0.5rem; + border: 1px solid rgba(148, 163, 184, 0.15); +} + +.markdown-content summary { + cursor: pointer; + font-weight: 600; + margin: -1rem; + padding: 1rem; + border-radius: 0.5rem; + transition: background 0.2s ease; +} + +.markdown-content summary:hover { + background: rgba(168, 85, 247, 0.08); +} + +.markdown-content details[open] summary { + margin-bottom: 1rem; +} + +/* Strong/Bold */ +.markdown-content strong, +.markdown-content b { + font-weight: 600; +} + +/* Emphasis/Italic */ +.markdown-content em, +.markdown-content i { + font-style: italic; +} + +/* Warning/Info boxes (using blockquote with emoji) */ +.markdown-content blockquote:has(p:first-child:contains("⚠️")) { + border-left-color: rgba(234, 179, 8, 0.5); + background: rgba(234, 179, 8, 0.05); +} + +/* Documentation container */ +.documentation-container { + min-height: calc(100vh - 4rem); + padding: 0; +} + +.documentation-container > h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid rgba(168, 85, 247, 0.3); +} diff --git a/src/logstashui/Documentation/templates/documentation.html b/src/logstashui/Documentation/templates/documentation.html new file mode 100644 index 00000000..44d7d846 --- /dev/null +++ b/src/logstashui/Documentation/templates/documentation.html @@ -0,0 +1,154 @@ + + +{% extends "base.html" %} +{% load static %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+ {{ content|safe }} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/logstashui/Documentation/templates/nav_item.html b/src/logstashui/Documentation/templates/nav_item.html new file mode 100644 index 00000000..c327441b --- /dev/null +++ b/src/logstashui/Documentation/templates/nav_item.html @@ -0,0 +1,28 @@ + + +
  • + {% if item.children %} + +
    + + + {{ item.title }} + + +
      + {% for child in item.children %} + {% include 'nav_item.html' with item=child %} + {% endfor %} +
    +
    + {% else %} + + + {{ item.title }} + + {% endif %} +
  • diff --git a/src/logstashui/Documentation/urls.py b/src/logstashui/Documentation/urls.py new file mode 100644 index 00000000..2160dbe9 --- /dev/null +++ b/src/logstashui/Documentation/urls.py @@ -0,0 +1,11 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from django.urls import path +from . import views + +urlpatterns = [ + path("", views.documentation_home, name="DocumentationHome"), + path("/", views.render_documentation, name="RenderDocumentation"), +] diff --git a/src/logstashui/Documentation/views.py b/src/logstashui/Documentation/views.py new file mode 100644 index 00000000..bc3b6468 --- /dev/null +++ b/src/logstashui/Documentation/views.py @@ -0,0 +1,363 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from django.shortcuts import render +from django.conf import settings +from pathlib import Path +import markdown +import re +import yaml + +DOCS_BASE_DIR = settings.PROJECT_ROOT +DOCS_DIR = DOCS_BASE_DIR / "docs" / "docs" +CHANGELOG_PATH = DOCS_BASE_DIR / "CHANGELOG.md" + +# Manual title overrides for specific files/folders +TITLE_OVERRIDES = { + 'logstashui': 'LogstashUI', + 'logstashagent': 'LogstashAgent', + 'logstashagent.yml': 'logstashagent.yml', + 'logstashui.yml': 'logstashui.yml', +} + +def get_display_title(filename): + """ + Get display title for a file or folder. + First checks manual overrides, then applies smart formatting. + """ + # Remove .md extension if present for checking overrides + name = filename.replace('.md', '') + + # Check manual overrides first (against name without .md) + if name in TITLE_OVERRIDES: + return TITLE_OVERRIDES[name] + + # Also check the original filename + if filename in TITLE_OVERRIDES: + return TITLE_OVERRIDES[filename] + + # Keep .yml files lowercase + if name.endswith('.yml'): + return name.lower() + + # Default: title case with dash/underscore handling + return name.replace('-', ' ').replace('_', ' ').title() + +def build_nav_tree(base_path, current_path=""): + """ + Recursively build navigation tree from docs directory structure. + Returns a list of navigation items with title, url, and children. + Uses filename for title (no file reading). + """ + nav_items = [] + + try: + # Custom sort order for top-level items + def sort_key(item): + # Files (except index.md) come first + if not item.is_dir() and item.name != 'index.md': + return (0, item.name) + + # Then specific folders in order + if item.is_dir(): + if item.name == 'logstashui': + return (1, 0, item.name) + elif item.name == 'logstashagent': + return (1, 1, item.name) + else: + return (1, 2, item.name) + + # index.md is handled separately (not in nav tree) + return (2, item.name) + + items = sorted(base_path.iterdir(), key=sort_key) + + for item in items: + # Skip hidden files, images folder, and Jekyll files + if item.name.startswith(('.', '_')) or item.name == 'images': + continue + + if item.is_dir(): + # Check if directory has an index.md + index_file = item / "index.md" + if index_file.exists(): + # Get display title + title = get_display_title(item.name) + + # Build URL path + url_path = f"{current_path}/{item.name}" if current_path else item.name + + # Recursively get children + children = build_nav_tree(item, url_path) + + nav_items.append({ + 'title': title, + 'url': f"/Documentation/{url_path}/", + 'children': children, + 'is_folder': True + }) + elif item.suffix == '.md' and item.name != 'index.md': + # Individual markdown file (not index.md) + # Get display title + title = get_display_title(item.name) + + # Build URL path (remove .md extension) + url_path = f"{current_path}/{item.stem}" if current_path else item.stem + + nav_items.append({ + 'title': title, + 'url': f"/Documentation/{url_path}/", + 'children': [], + 'is_folder': False + }) + except Exception as e: + print(f"Error building nav tree: {e}") + + return nav_items + +def rewrite_image_paths(html_content): + """ + Rewrite any image paths containing /images/ to /static/ + Handles docs/images/, ../images/, ../../images/, etc. + """ + # Match any path that contains /images/ and extract just the filename + # This handles: docs/images/file.png, ../images/file.png, ../../images/file.png + html_content = re.sub( + r'src="[^"]*?/images/([^"]+)"', + r'src="/static/\1"', + html_content + ) + + # Also handle single quotes + html_content = re.sub( + r"src='[^']*?/images/([^']+)'", + r"src='/static/\1'", + html_content + ) + + return html_content + +def convert_github_alerts(html_content): + """ + Convert GitHub-style alerts [!TIP], [!NOTE], [!WARNING], etc. to styled divs + """ + alert_types = { + 'TIP': {'icon': '💡', 'color': 'rgba(34, 197, 94, 0.15)', 'border': 'rgba(34, 197, 94, 0.5)'}, + 'NOTE': {'icon': 'ℹ️', 'color': 'rgba(59, 130, 246, 0.15)', 'border': 'rgba(59, 130, 246, 0.5)'}, + 'WARNING': {'icon': '⚠️', 'color': 'rgba(234, 179, 8, 0.15)', 'border': 'rgba(234, 179, 8, 0.5)'}, + 'IMPORTANT': {'icon': '❗', 'color': 'rgba(168, 85, 247, 0.15)', 'border': 'rgba(168, 85, 247, 0.5)'}, + 'CAUTION': {'icon': '🔥', 'color': 'rgba(239, 68, 68, 0.15)', 'border': 'rgba(239, 68, 68, 0.5)'}, + } + + for alert_type, style in alert_types.items(): + pattern = rf'
    \s*

    \[!{alert_type}\](.*?)

    \s*
    ' + replacement = ( + f'
    ' + f'
    ' + f'{style["icon"]}' + f'
    {alert_type.title()}\\1
    ' + f'
    ' + ) + html_content = re.sub(pattern, replacement, html_content, flags=re.DOTALL) + + return html_content + +def rewrite_doc_links(html_content): + """ + Rewrite documentation links from .md files to Django URLs + Examples: + - docs/docs/logstashui/index.md -> /Documentation/logstashui/ + - logstashui/index.md -> /Documentation/logstashui/ + """ + # Pattern 1: docs/docs/path/index.md -> /Documentation/path/ + html_content = re.sub( + r'href="docs/docs/([^"]+)/index\.md"', + r'href="/Documentation/\1/"', + html_content + ) + + # Pattern 2: path/index.md -> /Documentation/path/ + html_content = re.sub( + r'href="([^"]+)/index\.md"', + r'href="/Documentation/\1/"', + html_content + ) + + # Pattern 3: docs/docs/path/file.md -> /Documentation/path/file/ + html_content = re.sub( + r'href="docs/docs/([^"]+)\.md"', + r'href="/Documentation/\1/"', + html_content + ) + + # Pattern 4: path/file.md -> /Documentation/path/file/ + html_content = re.sub( + r'href="([^"]+)\.md"', + r'href="/Documentation/\1/"', + html_content + ) + + return html_content + +def documentation_home(request): + index_path = DOCS_DIR / "index.md" + + if index_path.exists(): + with open(index_path, 'r', encoding='utf-8') as f: + markdown_content = f.read() + + # Add markdown="1" attribute to details tags so md_in_html processes them + markdown_content = re.sub( + r'
    ', + r'
    ', + markdown_content + ) + + md = markdown.Markdown(extensions=[ + 'markdown.extensions.fenced_code', + 'markdown.extensions.tables', + 'markdown.extensions.toc', + 'markdown.extensions.md_in_html', + ]) + html_content = md.convert(markdown_content) + + html_content = rewrite_image_paths(html_content) + html_content = convert_github_alerts(html_content) + html_content = rewrite_doc_links(html_content) + else: + html_content = "

    README.md not found

    " + + # Build navigation tree + nav_tree = build_nav_tree(DOCS_DIR) + + # Add CHANGELOG as the last item + nav_tree.append({ + 'title': 'Changelog', + 'url': '/Documentation/changelog/', + 'children': [], + 'is_folder': False + }) + + context = { + 'content': html_content, + 'title': 'Documentation Home', + 'nav_tree': nav_tree, + } + + return render(request, 'documentation.html', context) + +def render_documentation(request, doc_path): + """ + Render a specific documentation page based on the URL path. + Examples: + - /Documentation/logstashagent/configuration/ -> docs/docs/logstashagent/configuration/index.md + - /Documentation/logstashui/general/build/ -> docs/docs/logstashui/general/build.md + - /Documentation/changelog/ -> CHANGELOG.md + """ + # Special case for CHANGELOG + if doc_path == "changelog": + if CHANGELOG_PATH.exists(): + with open(CHANGELOG_PATH, 'r', encoding='utf-8') as f: + markdown_content = f.read() + + md = markdown.Markdown(extensions=[ + 'markdown.extensions.fenced_code', + 'markdown.extensions.tables', + 'markdown.extensions.toc', + ]) + html_content = md.convert(markdown_content) + html_content = convert_github_alerts(html_content) + + nav_tree = build_nav_tree(DOCS_DIR) + nav_tree.append({ + 'title': 'Changelog', + 'url': '/Documentation/changelog/', + 'children': [], + 'is_folder': False + }) + + return render(request, 'documentation.html', { + 'content': html_content, + 'title': 'Changelog', + 'nav_tree': nav_tree, + }) + else: + html_content = "

    CHANGELOG.md not found

    " + title = "Changelog Not Found" + nav_tree = build_nav_tree(DOCS_DIR) + nav_tree.append({ + 'title': 'Changelog', + 'url': '/Documentation/changelog/', + 'children': [], + 'is_folder': False + }) + return render(request, 'documentation.html', { + 'content': html_content, + 'title': title, + 'nav_tree': nav_tree, + }) + + # Try to find the markdown file + # First try: path/index.md (for folders) + md_path = DOCS_DIR / doc_path / "index.md" + + if not md_path.exists(): + # Second try: path.md (for individual files) + md_path = DOCS_DIR / f"{doc_path}.md" + + if not md_path.exists(): + # File not found + html_content = f"

    Documentation not found: {doc_path}

    " + title = "Not Found" + else: + # Read and render the markdown file + try: + with open(md_path, 'r', encoding='utf-8') as f: + markdown_content = f.read() + + # Add markdown="1" attribute to details tags + markdown_content = re.sub( + r'
    ', + r'
    ', + markdown_content + ) + + md = markdown.Markdown(extensions=[ + 'markdown.extensions.fenced_code', + 'markdown.extensions.tables', + 'markdown.extensions.toc', + 'markdown.extensions.md_in_html', + ]) + html_content = md.convert(markdown_content) + + # Apply transformations + html_content = rewrite_image_paths(html_content) + html_content = convert_github_alerts(html_content) + html_content = rewrite_doc_links(html_content) + + # Extract title from path + title = doc_path.split('/')[-1].replace('-', ' ').replace('_', ' ').title() + except Exception as e: + html_content = f"

    Error rendering documentation: {e}

    " + title = "Error" + + # Build navigation tree + nav_tree = build_nav_tree(DOCS_DIR) + + # Add CHANGELOG as the last item + nav_tree.append({ + 'title': 'Changelog', + 'url': '/Documentation/changelog/', + 'children': [], + 'is_folder': False + }) + + context = { + 'content': html_content, + 'title': title, + 'nav_tree': nav_tree, + } + + return render(request, 'documentation.html', context) diff --git a/LogstashUI/Management/migrations/__init__.py b/src/logstashui/LogstashUI/__init__.py similarity index 100% rename from LogstashUI/Management/migrations/__init__.py rename to src/logstashui/LogstashUI/__init__.py diff --git a/LogstashUI/LogstashUI/asgi.py b/src/logstashui/LogstashUI/asgi.py similarity index 90% rename from LogstashUI/LogstashUI/asgi.py rename to src/logstashui/LogstashUI/asgi.py index d9af582e..45fa77ae 100644 --- a/LogstashUI/LogstashUI/asgi.py +++ b/src/logstashui/LogstashUI/asgi.py @@ -1,5 +1,5 @@ """ -ASGI config for LogstashUI project. +ASGI config for logstashui project. It exposes the ASGI callable as a module-level variable named ``application``. diff --git a/LogstashUI/LogstashUI/config.py b/src/logstashui/LogstashUI/config.py similarity index 81% rename from LogstashUI/LogstashUI/config.py rename to src/logstashui/LogstashUI/config.py index 3fe882a0..4e79e595 100644 --- a/LogstashUI/LogstashUI/config.py +++ b/src/logstashui/LogstashUI/config.py @@ -18,6 +18,9 @@ "logstash_settings": "/etc/logstash", "logstash_log_path": "/var/log/logstash" } + }, + "no_auth": { + "enabled": False } } @@ -48,7 +51,7 @@ def deep_merge(base: dict, override: dict) -> dict: def load_config() -> dict: """ - Load LogstashUI configuration from YAML file specified in LOGSTASHUI_CONFIG env var. + Load logstashui configuration from YAML file specified in LOGSTASHUI_CONFIG env var. Falls back to DEFAULT_CONFIG if env var is not set or file doesn't exist. Returns: @@ -57,11 +60,17 @@ def load_config() -> dict: config = DEFAULT_CONFIG.copy() config_path = os.environ.get('LOGSTASHUI_CONFIG') - + if not config_path: - logger.info("LOGSTASHUI_CONFIG environment variable not set, using default configuration") - return config - + # Fall back to logstashui.yml adjacent to the project directory + default_config_path = Path(__file__).resolve().parent.parent / 'logstashui.yml' + if default_config_path.exists(): + logger.info(f"LOGSTASHUI_CONFIG not set, using default path: {default_config_path}") + config_path = str(default_config_path) + else: + logger.info("LOGSTASHUI_CONFIG not set and no logstashui.yml found, using default configuration") + return config + config_file = Path(config_path) if not config_file.exists(): diff --git a/LogstashUI/LogstashUI/settings.py b/src/logstashui/LogstashUI/settings.py similarity index 83% rename from LogstashUI/LogstashUI/settings.py rename to src/logstashui/LogstashUI/settings.py index 9a92eaae..ab16ec45 100644 --- a/LogstashUI/LogstashUI/settings.py +++ b/src/logstashui/LogstashUI/settings.py @@ -1,5 +1,5 @@ """ -Django settings for LogstashUI project. +Django settings for logstashui project. Generated by 'django-admin startproject' using Django 5.2.6. @@ -12,13 +12,17 @@ from pathlib import Path import os, platform +from importlib.metadata import version, PackageNotFoundError from Common.encryption import get_django_secret_key from .config import CONFIG # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -# LogstashUI Runtime Configuration +# Project root is 2 levels up from BASE_DIR (src/logstashui/) +PROJECT_ROOT = BASE_DIR.parent.parent + +# logstashui Runtime Configuration # Loaded from YAML file specified in LOGSTASHUI_CONFIG environment variable # Falls back to DEFAULT_CONFIG if not specified LOGSTASHUI_CONFIG = CONFIG @@ -39,7 +43,25 @@ # Example: ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',') -__VERSION__ = "0.3.5" +def _get_version(): + """Get version from installed package metadata or pyproject.toml""" + try: + return version("LogstashUI") + except PackageNotFoundError: + try: + import tomllib + pyproject_path = PROJECT_ROOT / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as f: + pyproject_data = tomllib.load(f) + return pyproject_data.get("project", {}).get("version", "0.0.0+unknown") + except Exception: + pass + return "0.0.0+unknown" + +__VERSION__ = _get_version() +__PREFERRED_LS_AGENT_VERSION__ = "0.2.6" + # Application definition INSTALLED_APPS = [ @@ -51,13 +73,14 @@ 'django.contrib.messages', 'django.contrib.staticfiles', - # Apps of LogstashUI + # Apps of logstashui 'PipelineManager', 'Management', 'Utilities', 'SNMP', 'Monitoring', 'Site', + 'Documentation', # Frameworks 'django_htmx', @@ -163,7 +186,8 @@ STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [ - BASE_DIR / "Site/static" + BASE_DIR / "Site/static", + PROJECT_ROOT / "docs" / "images", ] if platform.system() == "Windows": @@ -179,6 +203,17 @@ ] +NO_AUTH_MODE = LOGSTASHUI_CONFIG.get('no_auth', {}).get('enabled', False) + +if NO_AUTH_MODE: + import logging + logging.getLogger(__name__).warning( + "*** NO_AUTH MODE IS ENABLED — All authentication is bypassed. " + "Do not use in production! ***" + ) + _auth_idx = MIDDLEWARE.index('django.contrib.auth.middleware.AuthenticationMiddleware') + MIDDLEWARE.insert(_auth_idx + 1, 'Common.middleware.NoAuthMiddleware') + LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/Management/Login/" LOGIN_URL = "/Management/Login/" @@ -189,7 +224,13 @@ "/static/", "/health/", "/ConnectionManager/StreamSimulate/", - "/ConnectionManager/StreamSimulate" + "/ConnectionManager/StreamSimulate", + "/ConnectionManager/Enroll/", + "/ConnectionManager/Enroll", + "/ConnectionManager/CheckIn/", + "/ConnectionManager/CheckIn", + "/ConnectionManager/GetConfigChanges/", + "/ConnectionManager/GetConfigChanges" ] # Session Configuration @@ -251,8 +292,8 @@ SECURE_SSL_REDIRECT = False X_FRAME_OPTIONS = 'SAMEORIGIN' -# LogstashAgent Configuration -# URL for the LogstashAgent API +# logstashagent Configuration +# URL for the logstashagent API # Can be overridden with LOGSTASH_AGENT_URL environment variable # # Routing based on simulation mode: @@ -298,7 +339,7 @@ }, 'file': { 'level': 'INFO', - 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'concurrent_log_handler.ConcurrentRotatingFileHandler', 'filename': LOGS_DIR / 'logstashui.log', 'maxBytes': 1024 * 1024 * 10, # 10 MB 'backupCount': 5, diff --git a/LogstashUI/LogstashUI/urls.py b/src/logstashui/LogstashUI/urls.py similarity index 93% rename from LogstashUI/LogstashUI/urls.py rename to src/logstashui/LogstashUI/urls.py index bd0f4219..a38cff48 100644 --- a/LogstashUI/LogstashUI/urls.py +++ b/src/logstashui/LogstashUI/urls.py @@ -3,7 +3,7 @@ #you may not use this file except in compliance with the Elastic License. """ -URL configuration for LogstashUI project. +URL configuration for logstashui project. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/5.2/topics/http/urls/ @@ -18,11 +18,11 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin + from django.urls import path, include from django.conf import settings from django.conf.urls.static import static -from django.contrib.auth import views as auth_views + # Custom error handlers handler400 = 'Common.error_handlers.handler400' @@ -40,6 +40,7 @@ def crash(request): path('SNMP/', include('SNMP.urls')), path('Monitoring/', include('Monitoring.urls')), path('', include('Site.urls')), + path('Documentation/', include("Documentation.urls")), #path('Crash', crash) ] diff --git a/LogstashUI/LogstashUI/wsgi.py b/src/logstashui/LogstashUI/wsgi.py similarity index 90% rename from LogstashUI/LogstashUI/wsgi.py rename to src/logstashui/LogstashUI/wsgi.py index c939c39e..d43ae1ef 100644 --- a/LogstashUI/LogstashUI/wsgi.py +++ b/src/logstashui/LogstashUI/wsgi.py @@ -1,5 +1,5 @@ """ -WSGI config for LogstashUI project. +WSGI config for logstashui project. It exposes the WSGI callable as a module-level variable named ``application``. diff --git a/LogstashUI/Monitoring/__init__.py b/src/logstashui/Management/__init__.py similarity index 100% rename from LogstashUI/Monitoring/__init__.py rename to src/logstashui/Management/__init__.py diff --git a/LogstashUI/Management/apps.py b/src/logstashui/Management/apps.py similarity index 100% rename from LogstashUI/Management/apps.py rename to src/logstashui/Management/apps.py diff --git a/LogstashUI/Management/migrations/0001_initial.py b/src/logstashui/Management/migrations/0001_initial.py similarity index 100% rename from LogstashUI/Management/migrations/0001_initial.py rename to src/logstashui/Management/migrations/0001_initial.py diff --git a/LogstashUI/Monitoring/migrations/__init__.py b/src/logstashui/Management/migrations/__init__.py similarity index 100% rename from LogstashUI/Monitoring/migrations/__init__.py rename to src/logstashui/Management/migrations/__init__.py diff --git a/LogstashUI/Management/models.py b/src/logstashui/Management/models.py similarity index 100% rename from LogstashUI/Management/models.py rename to src/logstashui/Management/models.py diff --git a/LogstashUI/Management/templates/components/user_row.html b/src/logstashui/Management/templates/components/user_row.html similarity index 100% rename from LogstashUI/Management/templates/components/user_row.html rename to src/logstashui/Management/templates/components/user_row.html diff --git a/LogstashUI/Management/templates/logs.html b/src/logstashui/Management/templates/logs.html similarity index 100% rename from LogstashUI/Management/templates/logs.html rename to src/logstashui/Management/templates/logs.html diff --git a/LogstashUI/Management/templates/management.html b/src/logstashui/Management/templates/management.html similarity index 100% rename from LogstashUI/Management/templates/management.html rename to src/logstashui/Management/templates/management.html diff --git a/LogstashUI/Management/templates/registration/login.html b/src/logstashui/Management/templates/registration/login.html similarity index 92% rename from LogstashUI/Management/templates/registration/login.html rename to src/logstashui/Management/templates/registration/login.html index 73a9dc7c..116d26ed 100644 --- a/LogstashUI/Management/templates/registration/login.html +++ b/src/logstashui/Management/templates/registration/login.html @@ -11,6 +11,7 @@ Login - Logstash UI + {% tailwind_css %} + +
    + + + + +
    + +
    +
    +
    + + + +
    + + +
    + + + + +
    +
    + + + + + + + + +
    +
    + + +
    +
    +
    + + + +
    +
    +
    Revision
    +
    +
    +
    +
    +
    + + + +
    +
    +
    Managed Instances
    +
    +
    +
    +
    +
    + + + +
    +
    +
    Last Deployed
    +
    +
    +
    +
    +
    + + + +
    +
    +
    Undeployed Changes
    +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    + + +
    + +
    +
    + +
    + + + + logstash.yml +
    +
    + + + + jvm.options +
    +
    + + + + log4j2.properties +
    + + +
    + + +
    + + + + Pipelines +
    +
    + + + + Keystore +
    + + +
    + + +
    + + + + Enrollment Tokens +
    +
    + + +
    + Mode: +
    + + +
    +
    +
    + + +
    + +
    + + + + + + + + + + + + + + + + Documentation + + +
    + + + + + +
    +
    + + +
    + +
    +

    + + + + Node Identity +

    +
    +
    + + +

    Descriptive name for the node (defaults to hostname if omitted)

    +
    +
    +
    + + +
    +

    + + + + Data Path +

    +
    +
    + + +

    Directory for persistent data storage

    +
    +
    +
    +
    + + +
    + +
    +

    + + + + Pipeline Settings +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    + + +
    +

    + + + + API Settings +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    +
    + + +
    + +
    +

    + + + + Queue Settings +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    + + +
    +

    + + + + Dead Letter Queue +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    +
    + + +
    + +
    +

    + + + + + Pipeline Configuration +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    + + +
    +

    + + + + Logging Settings +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    +
    + + +
    + +
    +

    + + + + X-Pack Monitoring +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    + + +
    +

    + + + + + X-Pack Management +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + +
    +
    +
    + + +
    + +
    +

    + + + + Other Settings +

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    + + + + X-Pack GeoIP +

    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + +
    +
    +
    + + + + + + + + + +{% include 'popup.html' %} + + +{% include 'components/pipeline_manager/policy_deploy_modal.html' %} + + +{% include 'components/pipeline_manager/keystore_modal.html' %} + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/change_policy_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/change_policy_modal.html new file mode 100644 index 00000000..62186a5e --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/change_policy_modal.html @@ -0,0 +1,210 @@ + + + + + + diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/collapsible_row.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/collapsible_row.html new file mode 100644 index 00000000..8f053c78 --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/collapsible_row.html @@ -0,0 +1,11 @@ + + + + + + {% include 'components/pipeline_manager/pipeline_list_content.html' %} + \ No newline at end of file diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/connection_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/connection_modal.html new file mode 100644 index 00000000..d2d327ad --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/connection_modal.html @@ -0,0 +1,1008 @@ + + + + + + diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/keystore_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/keystore_modal.html new file mode 100644 index 00000000..4864bf15 --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/keystore_modal.html @@ -0,0 +1,269 @@ + + + + + + diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html new file mode 100644 index 00000000..9359d5c8 --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html @@ -0,0 +1,291 @@ + + +
    + +
    +
    + + + +
    +

    About Centralized Pipeline Management

    +

    Configure Logstash to retrieve pipeline configurations from Elasticsearch, enabling centralized management of your pipelines.

    +
    +
    +
    + + +
    +
    + + + +
    +

    Important Notice

    +

    When Centralized Pipeline Management is enabled, pipeline definitions are no longer managed through Agent Policy. Pipelines will be controlled by Centralized Pipeline Management instead. LogstashUI will continue managing logstash.yml, jvm.options, log4j2.properties, and keystore settings for Logstash instances enrolled with LogstashAgent.

    +
    +
    +
    + + +
    +

    + + + + + Essential Settings +

    + +
    + + +
    +
    +

    X-Pack Management Settings

    +
    + + +
    + +
    + + +

    + Enabling xpack management tells Logstash to use CPM. + Click here to set the value to True +

    +
    + + +
    + + +

    + CPM requires you to specify which pipelines you want to run. Use an * to pull all pipelines stored in Elastic CPM. + Click here to set the value to * +

    +
    +
    + + +
    + +
    + +
    + + +
    + + +
    +
    + + +

    + This value can be found by clicking "Manage" on your Elastic Cloud Hosted deployment. + Click here to go to cloud.elastic.co +

    +
    +
    + + + +
    + + +
    + +
    + + +
    + + +
    +
    + + +

    + You can create an API key by following + this documentation +

    +
    +
    + + + +
    +
    +
    + + +
    +
    +

    Log Settings

    +
    +
    + +
    + + +

    + LogstashAgent needs informational level logs. + Click here to set the value to Info +

    +
    + + +
    + + +

    + LogstashAgent needs logs to be in JSON format. + Click here to set the value to JSON +

    +
    + + +
    + + +

    + Log path must match the defined Policy config. + Click here to set it to that value +

    +
    +
    +
    + + +
    +
    +

    Pipeline Configuration Settings

    +
    +
    + +
    + + +

    + Enables automatic pipeline reloading when config changes. + Click here to set the value to True +

    +
    + + +
    + + +

    + How often Logstash checks for config changes. + Click here to set the value to 10s +

    +
    +
    +
    + + +
    +
    +

    API Settings

    +
    +
    + +
    + + +

    + LogstashAgent needs to be able to communicate with the Logstash API. + Click here to set the value to True +

    +
    + + +
    + + +

    + Click here to set the value to 127.0.0.1 so that only applications local to the Logstash instance can communicate with it. + Click here to set the value to 0.0.0.0 so that external sources can communicate with Logstash's API. +

    +
    + + + +
    +
    + +
    + + +
    +
    + + + + +
    +
    +
    +
    diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/default.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/default.html new file mode 100644 index 00000000..7c2bc249 --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/default.html @@ -0,0 +1,166 @@ + + +
    + +
    +
    + + + +
    +

    About Default Configuration

    +

    This is the minimal configuration you need to run Logstash using LogstashUI and LogstashAgent.

    +
    +
    +
    + + +
    +

    + + + + + Essential Settings +

    + +
    + +
    +
    +

    Log Settings

    +
    +
    + +
    + + +

    + LogstashAgent needs informational level logs. + Click here to set the value to Info +

    +
    + + +
    + + +

    + LogstashAgent needs logs to be in JSON format. + Click here to set the value to JSON +

    +
    + + +
    + + +

    + Log path must match the defined Policy config. + Click here to set it to that value +

    +
    +
    +
    + + +
    +
    +

    Pipeline Configuration Settings

    +
    +
    + +
    + + +

    + Enables automatic pipeline reloading when config changes. + Click here to set the value to True +

    +
    + + +
    + + +

    + How often Logstash checks for config changes. + Click here to set the value to 10s +

    +
    +
    +
    + + +
    +
    +

    API Settings

    +
    +
    + +
    + + +

    + LogstashAgent needs to be able to communicate with the Logstash API. + Click here to set the value to True +

    +
    + + +
    + + +

    + Click here to set the value to 127.0.0.1 so that only applications local to the Logstash instance can communicate with it. + Click here to set the value to 0.0.0.0 so that external sources can communicate with Logstash's API. +

    +
    + + + +
    +
    +
    + + +
    +
    + + + + +
    +
    +
    +
    diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/pipeline_list_content.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/pipeline_list_content.html new file mode 100644 index 00000000..71490d68 --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/pipeline_list_content.html @@ -0,0 +1,585 @@ + + + +
    + + + +
    + +
    + + {% if es_id %} + + + {% endif %} +
    + +{% if es_id %} + +{% endif %} + + + + +
    + + + + + + + + + + + {% for pipeline in pipelines %} + + + + + + + {% endfor %} + +
    Pipeline NameDescriptionLast UpdatedActions
    + {{ pipeline.name }} + + {% if pipeline.description %} + {{ pipeline.description|truncatechars:80 }} + {% else %} + No description + {% endif %} + + {% if pipeline.last_modified %} + {{ pipeline.last_modified }} + {% else %} + N/A + {% endif %} + +
    + + +
    +
    +
    + + +
    +
    +
    + Showing 0 to 0 of 0 pipelines +
    + +
    +
    + + Page 1 of 1 + +
    +
    + + + +
    + + +
    + + + +
    + +
    + + + +
    + +
    + + + +
    + +
    + + diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/policy_deploy_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/policy_deploy_modal.html new file mode 100644 index 00000000..4db798fd --- /dev/null +++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/policy_deploy_modal.html @@ -0,0 +1,238 @@ + + +{% load static %} + + + + + + \ No newline at end of file diff --git a/LogstashUI/PipelineManager/templates/pipeline_editor.html b/src/logstashui/PipelineManager/templates/pipeline_editor.html similarity index 95% rename from LogstashUI/PipelineManager/templates/pipeline_editor.html rename to src/logstashui/PipelineManager/templates/pipeline_editor.html index cfa248ea..87878d15 100644 --- a/LogstashUI/PipelineManager/templates/pipeline_editor.html +++ b/src/logstashui/PipelineManager/templates/pipeline_editor.html @@ -29,6 +29,7 @@ {{ component_data|json_script:"component-data" }} {{ plugin_data|json_script:"plugin-data" }} +{{ keystore_keys|default:"[]"|json_script:"keystore-keys" }} + + + + + +{% endblock %} \ No newline at end of file diff --git a/LogstashUI/PipelineManager/templates/pipeline_text_editor.html b/src/logstashui/PipelineManager/templates/pipeline_text_editor.html similarity index 100% rename from LogstashUI/PipelineManager/templates/pipeline_text_editor.html rename to src/logstashui/PipelineManager/templates/pipeline_text_editor.html diff --git a/LogstashUI/PipelineManager/templates/settings.html b/src/logstashui/PipelineManager/templates/settings.html similarity index 100% rename from LogstashUI/PipelineManager/templates/settings.html rename to src/logstashui/PipelineManager/templates/settings.html diff --git a/LogstashUI/Site/__init__.py b/src/logstashui/PipelineManager/tests/__init__.py similarity index 100% rename from LogstashUI/Site/__init__.py rename to src/logstashui/PipelineManager/tests/__init__.py diff --git a/src/logstashui/PipelineManager/tests/test_agent_api.py b/src/logstashui/PipelineManager/tests/test_agent_api.py new file mode 100644 index 00000000..963f1c73 --- /dev/null +++ b/src/logstashui/PipelineManager/tests/test_agent_api.py @@ -0,0 +1,969 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from Common.test_resources import test_user +from PipelineManager.models import ( + ApiKey, Connection, EnrollmentToken, Keystore, Pipeline, Policy +) + +from datetime import datetime, timezone +from unittest.mock import patch +import base64 +import json +import pytest + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def test_policy(db): + """Create a test policy""" + policy = Policy.objects.create( + name='Test Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash', + keystore_password='test_password' + ) + return policy + + +@pytest.fixture +def test_enrollment_token(db, test_policy): + """Create a test enrollment token""" + token = EnrollmentToken.objects.create( + policy=test_policy, + name='test_token', + token='test_enrollment_token_12345' + ) + return token + + +@pytest.fixture +def test_agent_connection(db, test_policy): + """Create a test agent connection""" + connection = Connection.objects.create( + name='Test Agent', + connection_type='AGENT', + host='agent.example.com', + agent_id='test-agent-001', + is_active=True, + policy=test_policy + ) + return connection + + +@pytest.fixture +def test_api_key(db, test_agent_connection): + """Create a test API key for agent authentication""" + raw_key = 'test_api_key_12345' + api_key = ApiKey.objects.create( + connection=test_agent_connection, + api_key=raw_key + ) + return raw_key + + +@pytest.fixture +def test_pipeline(db, test_policy): + """Create a test pipeline""" + pipeline = Pipeline.objects.create( + policy=test_policy, + name='test_pipeline', + lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }', + pipeline_workers=2, + pipeline_batch_size=256 + ) + return pipeline + + +@pytest.fixture +def test_keystore_entry(db, test_policy): + """Create a test keystore entry""" + entry = Keystore.objects.create( + policy=test_policy, + key_name='test_key', + key_value='test_value' + ) + return entry + + +# ============================================================================ +# Enroll Endpoint Tests +# ============================================================================ + +@pytest.mark.django_db +class TestEnrollEndpoint: + """Tests for the /enroll agent API endpoint""" + + def test_enroll_success(self, client, test_enrollment_token): + """Test successful agent enrollment""" + token_payload = {'enrollment_token': test_enrollment_token.token} + encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode() + + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': encoded_token, + 'host': 'new-agent.example.com', + 'agent_id': 'new-agent-001' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'api_key' in data + assert data['policy_id'] == test_enrollment_token.policy.id + assert 'connection_id' in data + assert 'policy_config' in data + + # Verify connection was created + assert Connection.objects.filter(agent_id='new-agent-001').exists() + connection = Connection.objects.get(agent_id='new-agent-001') + assert connection.name == 'new-agent.example.com' + assert connection.host == 'new-agent.example.com' + assert connection.connection_type == 'AGENT' + assert connection.policy == test_enrollment_token.policy + + # Verify API key was created + assert connection.api_keys.exists() + + def test_enroll_reenrollment_deletes_old_connection(self, client, test_enrollment_token, test_agent_connection): + """Test that re-enrolling an agent deletes the old connection""" + old_connection_id = test_agent_connection.id + agent_id = test_agent_connection.agent_id + + token_payload = {'enrollment_token': test_enrollment_token.token} + encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode() + + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': encoded_token, + 'host': 'updated-agent.example.com', + 'agent_id': agent_id + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Old connection should be deleted + assert not Connection.objects.filter(id=old_connection_id).exists() + + # New connection should exist with same agent_id + assert Connection.objects.filter(agent_id=agent_id).exists() + new_connection = Connection.objects.get(agent_id=agent_id) + assert new_connection.id != old_connection_id + assert new_connection.host == 'updated-agent.example.com' + + def test_enroll_missing_enrollment_token(self, client): + """Test enrollment with missing enrollment_token""" + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'host': 'agent.example.com', + 'agent_id': 'agent-001' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Missing required fields' in data['error'] + + def test_enroll_missing_host(self, client, test_enrollment_token): + """Test enrollment with missing host""" + token_payload = {'enrollment_token': test_enrollment_token.token} + encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode() + + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': encoded_token, + 'agent_id': 'agent-001' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Missing required fields' in data['error'] + + def test_enroll_missing_agent_id(self, client, test_enrollment_token): + """Test enrollment with missing agent_id""" + token_payload = {'enrollment_token': test_enrollment_token.token} + encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode() + + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': encoded_token, + 'host': 'agent.example.com' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Missing required fields' in data['error'] + + def test_enroll_invalid_json(self, client): + """Test enrollment with invalid JSON""" + response = client.post( + '/ConnectionManager/Enroll/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + def test_enroll_invalid_token_format(self, client): + """Test enrollment with invalid base64 token format""" + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': 'not-valid-base64!!!', + 'host': 'agent.example.com', + 'agent_id': 'agent-001' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid enrollment token format' in data['error'] + + def test_enroll_invalid_token_payload(self, client): + """Test enrollment with token missing enrollment_token field""" + token_payload = {'wrong_field': 'value'} + encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode() + + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': encoded_token, + 'host': 'agent.example.com', + 'agent_id': 'agent-001' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid token payload' in data['error'] + + def test_enroll_nonexistent_token(self, client): + """Test enrollment with non-existent enrollment token""" + token_payload = {'enrollment_token': 'nonexistent_token'} + encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode() + + response = client.post( + '/ConnectionManager/Enroll/', + data=json.dumps({ + 'enrollment_token': encoded_token, + 'host': 'agent.example.com', + 'agent_id': 'agent-001' + }), + content_type='application/json' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Invalid enrollment token' in data['error'] + + def test_enroll_wrong_http_method(self, client): + """Test that GET requests are rejected""" + response = client.get('/ConnectionManager/Enroll/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + +# ============================================================================ +# CheckIn Endpoint Tests +# ============================================================================ + +@pytest.mark.django_db +class TestCheckInEndpoint: + """Tests for the /check-in agent API endpoint""" + + def test_checkin_success(self, client, test_agent_connection, test_api_key): + """Test successful agent check-in""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['message'] == 'Check-in successful' + assert 'timestamp' in data + assert data['current_revision_number'] == test_agent_connection.policy.current_revision_number + assert data['settings_path'] == test_agent_connection.policy.settings_path + assert data['restart'] is False + + # Verify last_check_in was updated + test_agent_connection.refresh_from_db() + assert test_agent_connection.last_check_in is not None + + def test_checkin_with_status_blob(self, client, test_agent_connection, test_api_key): + """Test check-in with status blob update""" + status_blob = { + 'logstash_api': {'accessible': True, 'status': 'green'}, + 'health_report': {'status': 'green'} + } + + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'status_blob': status_blob + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify status_blob was saved + test_agent_connection.refresh_from_db() + assert test_agent_connection.status_blob == status_blob + + def test_checkin_restart_flag(self, client, test_agent_connection, test_api_key): + """Test check-in with restart flag set""" + test_agent_connection.restart_on_next_checkin = True + test_agent_connection.save() + + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['restart'] is True + + # Verify restart flag was cleared + test_agent_connection.refresh_from_db() + assert test_agent_connection.restart_on_next_checkin is False + + def test_checkin_missing_authorization_header(self, client, test_agent_connection): + """Test check-in without Authorization header""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Missing or invalid Authorization header' in data['error'] + + def test_checkin_invalid_authorization_format(self, client, test_agent_connection): + """Test check-in with invalid Authorization header format""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION='Bearer invalid_format' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Missing or invalid Authorization header' in data['error'] + + def test_checkin_empty_api_key(self, client, test_agent_connection): + """Test check-in with empty API key""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION='ApiKey ' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'API key is empty' in data['error'] + + def test_checkin_missing_connection_id(self, client, test_api_key): + """Test check-in without connection_id""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({}), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Missing connection_id' in data['error'] + + def test_checkin_invalid_connection_id(self, client, test_api_key): + """Test check-in with non-existent connection_id""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': 99999 + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Invalid connection_id' in data['error'] + + def test_checkin_invalid_api_key(self, client, test_agent_connection): + """Test check-in with wrong API key""" + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION='ApiKey wrong_api_key' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Invalid API key' in data['error'] + + def test_checkin_no_policy_assigned(self, client, test_api_key): + """Test check-in when connection has no policy""" + # Create connection without policy + connection = Connection.objects.create( + name='No Policy Agent', + connection_type='AGENT', + host='nopolicy.example.com', + agent_id='nopolicy-001', + is_active=True, + policy=None + ) + raw_key = 'nopolicy_api_key' + ApiKey.objects.create(connection=connection, api_key=raw_key) + + response = client.post( + '/ConnectionManager/CheckIn/', + data=json.dumps({ + 'connection_id': connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {raw_key}' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'No policy assigned' in data['error'] + + def test_checkin_wrong_http_method(self, client): + """Test that GET requests are rejected""" + response = client.get('/ConnectionManager/CheckIn/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_checkin_invalid_json(self, client, test_api_key): + """Test check-in with invalid JSON""" + response = client.post( + '/ConnectionManager/CheckIn/', + data='not valid json', + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# GetConfigChanges Endpoint Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetConfigChangesEndpoint: + """Tests for the /get-config-changes agent API endpoint""" + + def test_get_config_changes_no_changes(self, client, test_agent_connection, test_api_key, test_policy): + """Test config changes when everything is in sync""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['logstash_yml'] is False + assert data['changes']['jvm_options'] is False + assert data['changes']['log4j2_properties'] is False + assert data['changes']['settings_path'] is False + assert data['changes']['logs_path'] is False + assert data['changes']['binary_path'] is False + assert data['changes']['keystore'] is False + assert data['changes']['pipelines'] is False + + def test_get_config_changes_logstash_yml_changed(self, client, test_agent_connection, test_api_key, test_policy): + """Test detection of logstash.yml changes""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': 'wrong_hash', + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['logstash_yml'] == test_policy.logstash_yml + assert data['changes']['jvm_options'] is False + + def test_get_config_changes_all_config_files_changed(self, client, test_agent_connection, test_api_key, test_policy): + """Test detection of all config file changes""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': 'wrong1', + 'jvm_options_hash': 'wrong2', + 'log4j2_properties_hash': 'wrong3', + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['logstash_yml'] == test_policy.logstash_yml + assert data['changes']['jvm_options'] == test_policy.jvm_options + assert data['changes']['log4j2_properties'] == test_policy.log4j2_properties + + def test_get_config_changes_paths_changed(self, client, test_agent_connection, test_api_key, test_policy): + """Test detection of path changes""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': '/wrong/settings', + 'logs_path': '/wrong/logs', + 'binary_path': '/wrong/binary', + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['settings_path'] == test_policy.settings_path + assert data['changes']['logs_path'] == test_policy.logs_path + assert data['changes']['binary_path'] == test_policy.binary_path + + def test_get_config_changes_keystore_new_entry(self, client, test_agent_connection, test_api_key, test_policy, test_keystore_entry): + """Test detection of new keystore entry""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['keystore'] is not False + assert 'test_key' in data['changes']['keystore']['set'] + assert len(data['changes']['keystore']['delete']) == 0 + + def test_get_config_changes_keystore_delete_entry(self, client, test_agent_connection, test_api_key, test_policy): + """Test detection of keystore entry to delete""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {'old_key': 'some_hash'}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['keystore'] is not False + assert 'old_key' in data['changes']['keystore']['delete'] + + def test_get_config_changes_keystore_password_changed(self, client, test_agent_connection, test_api_key, test_policy, test_keystore_entry): + """Test detection of keystore password change""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': 'wrong_password_hash', + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['keystore_password'] is not False + # When password changes, all keystore entries are re-encrypted + assert data['changes']['keystore'] is not False + + def test_get_config_changes_pipeline_new(self, client, test_agent_connection, test_api_key, test_policy, test_pipeline): + """Test detection of new pipeline""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['pipelines'] is not False + assert 'test_pipeline' in data['changes']['pipelines']['set'] + assert data['changes']['pipelines']['set']['test_pipeline']['lscl'] == test_pipeline.lscl + + def test_get_config_changes_pipeline_delete(self, client, test_agent_connection, test_api_key, test_policy): + """Test detection of pipeline to delete""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {'old_pipeline': {'config_hash': 'some_hash'}} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['pipelines'] is not False + assert 'old_pipeline' in data['changes']['pipelines']['delete'] + + def test_get_config_changes_pipeline_updated(self, client, test_agent_connection, test_api_key, test_policy, test_pipeline): + """Test detection of pipeline config change""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id, + 'logstash_yml_hash': test_policy.logstash_yml_hash, + 'jvm_options_hash': test_policy.jvm_options_hash, + 'log4j2_properties_hash': test_policy.log4j2_properties_hash, + 'settings_path': test_policy.settings_path, + 'logs_path': test_policy.logs_path, + 'binary_path': test_policy.binary_path, + 'keystore_password_hash': test_policy.keystore_password_hash, + 'keystore': {}, + 'pipelines': {'test_pipeline': {'config_hash': 'wrong_hash'}} + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['changes']['pipelines'] is not False + assert 'test_pipeline' in data['changes']['pipelines']['set'] + + def test_get_config_changes_missing_connection_id(self, client, test_api_key): + """Test get-config-changes without connection_id""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({}), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Connection ID is required' in data['error'] + + def test_get_config_changes_missing_authorization(self, client, test_agent_connection): + """Test get-config-changes without Authorization header""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Invalid authorization header' in data['error'] + + def test_get_config_changes_invalid_authorization_format(self, client, test_agent_connection): + """Test get-config-changes with invalid Authorization format""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION='Bearer wrong_format' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Invalid authorization header' in data['error'] + + def test_get_config_changes_invalid_connection_id(self, client, test_api_key): + """Test get-config-changes with non-existent connection_id""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': 99999 + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Connection not found' in data['error'] + + def test_get_config_changes_invalid_api_key(self, client, test_agent_connection): + """Test get-config-changes with wrong API key""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': test_agent_connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION='ApiKey wrong_key' + ) + + assert response.status_code == 401 + data = response.json() + assert data['success'] is False + assert 'Invalid API key' in data['error'] + + def test_get_config_changes_no_policy(self, client, test_api_key): + """Test get-config-changes when connection has no policy""" + connection = Connection.objects.create( + name='No Policy Agent', + connection_type='AGENT', + host='nopolicy.example.com', + agent_id='nopolicy-002', + is_active=True, + policy=None + ) + raw_key = 'nopolicy_api_key_2' + ApiKey.objects.create(connection=connection, api_key=raw_key) + + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data=json.dumps({ + 'connection_id': connection.id + }), + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {raw_key}' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'No policy assigned' in data['error'] + + def test_get_config_changes_wrong_http_method(self, client): + """Test that GET requests are rejected""" + response = client.get('/ConnectionManager/GetConfigChanges/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_get_config_changes_invalid_json(self, client, test_api_key): + """Test get-config-changes with invalid JSON""" + response = client.post( + '/ConnectionManager/GetConfigChanges/', + data='not valid json', + content_type='application/json', + HTTP_AUTHORIZATION=f'ApiKey {test_api_key}' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# Helper Function Tests +# ============================================================================ + +@pytest.mark.django_db +class TestEncryptForAgent: + """Tests for the _encrypt_for_agent helper function""" + + def test_encrypt_decrypt_roundtrip(self): + """Test that encryption/decryption works correctly""" + from PipelineManager.agent_api import _encrypt_for_agent + from cryptography.fernet import Fernet + import hashlib + + raw_api_key = 'test_key_12345' + plaintext = 'secret_value' + + encrypted = _encrypt_for_agent(raw_api_key, plaintext) + + # Decrypt using same key + key = base64.urlsafe_b64encode(hashlib.sha256(raw_api_key.encode('utf-8')).digest()) + decrypted = Fernet(key).decrypt(encrypted.encode('utf-8')).decode('utf-8') + + assert decrypted == plaintext + + def test_different_keys_produce_different_ciphertext(self): + """Test that different API keys produce different ciphertext""" + from PipelineManager.agent_api import _encrypt_for_agent + + plaintext = 'secret_value' + encrypted1 = _encrypt_for_agent('key1', plaintext) + encrypted2 = _encrypt_for_agent('key2', plaintext) + + assert encrypted1 != encrypted2 diff --git a/src/logstashui/PipelineManager/tests/test_agent_policies.py b/src/logstashui/PipelineManager/tests/test_agent_policies.py new file mode 100644 index 00000000..7b8542f9 --- /dev/null +++ b/src/logstashui/PipelineManager/tests/test_agent_policies.py @@ -0,0 +1,1033 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from Common.test_resources import authenticated_client, test_user +from PipelineManager.models import Connection, Keystore, Pipeline, Policy, Revision + +from datetime import datetime, timezone +from unittest.mock import patch +import json +import pytest + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def test_policy(db): + """Create a test policy""" + policy = Policy.objects.create( + name='Test Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash', + keystore_password='test_password' + ) + return policy + + +@pytest.fixture +def test_policy_with_revision(db, test_user): + """Create a test policy with an existing revision""" + policy = Policy.objects.create( + name='Policy With Revision', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash', + current_revision_number=1 + ) + + # Create a revision + Revision.objects.create( + policy=policy, + revision_number=1, + snapshot_json={ + 'logstash_yml': 'http.host: "0.0.0.0"', + 'jvm_options': '-Xms1g\n-Xmx1g', + 'log4j2_properties': 'logger.logstash.name = logstash', + 'settings_path': '/etc/logstash/', + 'logs_path': '/var/log/logstash', + 'binary_path': '/usr/share/logstash/bin', + 'pipelines': [], + 'keystore': [], + 'keystore_password_hash': '' + }, + created_by=test_user.username + ) + + return policy + + +@pytest.fixture +def test_pipeline(db, test_policy): + """Create a test pipeline""" + pipeline = Pipeline.objects.create( + policy=test_policy, + name='test_pipeline', + lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }', + pipeline_workers=2, + pipeline_batch_size=256 + ) + return pipeline + + +@pytest.fixture +def test_keystore_entry(db, test_policy): + """Create a test keystore entry""" + entry = Keystore.objects.create( + policy=test_policy, + key_name='test_key', + key_value='test_value' + ) + return entry + + +@pytest.fixture +def test_agent_connection(db, test_policy): + """Create a test agent connection""" + connection = Connection.objects.create( + name='Test Agent', + connection_type='AGENT', + host='agent.example.com', + agent_id='test-agent-001', + is_active=True, + policy=test_policy + ) + return connection + + +# ============================================================================ +# Generate Enrollment Token Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGenerateEnrollmentToken: + """Tests for the generate_enrollment_token endpoint""" + + def test_generate_enrollment_token_success(self, authenticated_client): + """Test successful enrollment token generation""" + response = authenticated_client.post( + '/ConnectionManager/GenerateEnrollmentToken/', + data=json.dumps({ + 'policy_name': 'Test Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'enrollment_token' in data + assert len(data['enrollment_token']) > 0 + + def test_generate_enrollment_token_default_policy(self, authenticated_client): + """Test enrollment token generation with default policy name""" + response = authenticated_client.post( + '/ConnectionManager/GenerateEnrollmentToken/', + data=json.dumps({}), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'enrollment_token' in data + + def test_generate_enrollment_token_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/GenerateEnrollmentToken/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_generate_enrollment_token_invalid_json(self, authenticated_client): + """Test enrollment token generation with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/GenerateEnrollmentToken/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + def test_generate_enrollment_token_requires_auth(self, client): + """Test that authentication is required""" + response = client.post( + '/ConnectionManager/GenerateEnrollmentToken/', + data=json.dumps({'policy_name': 'Test'}), + content_type='application/json' + ) + + # Should redirect to login or return 403 + assert response.status_code in [302, 403] + + +# ============================================================================ +# Deploy Policy Tests +# ============================================================================ + +@pytest.mark.django_db +class TestDeployPolicy: + """Tests for the deploy_policy endpoint""" + + def test_deploy_policy_success(self, authenticated_client, test_policy): + """Test successful policy deployment""" + initial_revision = test_policy.current_revision_number + + response = authenticated_client.post( + '/ConnectionManager/DeployPolicy/', + data=json.dumps({ + 'policy_id': test_policy.id + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['revision_number'] == initial_revision + 1 + assert data['policy_name'] == test_policy.name + + # Verify policy was updated + test_policy.refresh_from_db() + assert test_policy.current_revision_number == initial_revision + 1 + assert test_policy.last_deployed_at is not None + + # Verify revision was created + assert Revision.objects.filter( + policy=test_policy, + revision_number=initial_revision + 1 + ).exists() + + def test_deploy_policy_creates_snapshot(self, authenticated_client, test_policy, test_pipeline, test_keystore_entry): + """Test that deployment creates proper snapshot""" + response = authenticated_client.post( + '/ConnectionManager/DeployPolicy/', + data=json.dumps({ + 'policy_id': test_policy.id + }), + content_type='application/json' + ) + + assert response.status_code == 200 + + # Get the created revision + revision = Revision.objects.filter(policy=test_policy).first() + assert revision is not None + + # Verify snapshot contains all data + snapshot = revision.snapshot_json + assert snapshot['logstash_yml'] == test_policy.logstash_yml + assert snapshot['jvm_options'] == test_policy.jvm_options + assert snapshot['log4j2_properties'] == test_policy.log4j2_properties + assert snapshot['settings_path'] == test_policy.settings_path + assert len(snapshot['pipelines']) == 1 + assert snapshot['pipelines'][0]['name'] == 'test_pipeline' + assert len(snapshot['keystore']) == 1 + assert snapshot['keystore'][0]['key_name'] == 'test_key' + + def test_deploy_policy_missing_policy_id(self, authenticated_client): + """Test deployment without policy_id""" + response = authenticated_client.post( + '/ConnectionManager/DeployPolicy/', + data=json.dumps({}), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_deploy_policy_nonexistent_policy(self, authenticated_client): + """Test deployment with non-existent policy""" + response = authenticated_client.post( + '/ConnectionManager/DeployPolicy/', + data=json.dumps({ + 'policy_id': 99999 + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_deploy_policy_wrong_method(self, authenticated_client, test_policy): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/DeployPolicy/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_deploy_policy_invalid_json(self, authenticated_client): + """Test deployment with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/DeployPolicy/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# Get Policy Diff Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPolicyDiff: + """Tests for the get_policy_diff endpoint""" + + def test_get_policy_diff_no_revision(self, authenticated_client, test_policy): + """Test getting diff when no revision exists""" + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyDiff/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['policy_name'] == test_policy.name + assert data['current_revision'] == 0 + assert data['last_deployed_revision'] == 0 + assert 'current' in data + assert 'previous' in data + + def test_get_policy_diff_with_revision(self, authenticated_client, test_policy_with_revision): + """Test getting diff when revision exists""" + # Modify the policy + test_policy_with_revision.logstash_yml = 'http.host: "127.0.0.1"' + test_policy_with_revision.save() + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyDiff/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['current_revision'] == 1 + assert data['last_deployed_revision'] == 1 + assert data['current']['logstash_yml'] == 'http.host: "127.0.0.1"' + assert data['previous']['logstash_yml'] == 'http.host: "0.0.0.0"' + + def test_get_policy_diff_missing_policy_id(self, authenticated_client): + """Test getting diff without policy_id""" + response = authenticated_client.get('/ConnectionManager/GetPolicyDiff/') + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_get_policy_diff_nonexistent_policy(self, authenticated_client): + """Test getting diff for non-existent policy""" + response = authenticated_client.get('/ConnectionManager/GetPolicyDiff/?policy_id=99999') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_get_policy_diff_wrong_method(self, authenticated_client, test_policy): + """Test that POST requests are rejected""" + response = authenticated_client.post( + '/ConnectionManager/GetPolicyDiff/', + data=json.dumps({'policy_id': test_policy.id}), + content_type='application/json' + ) + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + +# ============================================================================ +# Get Policy Agent Count Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPolicyAgentCount: + """Tests for the get_policy_agent_count endpoint""" + + def test_get_policy_agent_count_zero(self, authenticated_client, test_policy): + """Test agent count when no agents are assigned""" + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['agent_count'] == 0 + assert data['policy_name'] == test_policy.name + + def test_get_policy_agent_count_with_agents(self, authenticated_client, test_policy, test_agent_connection): + """Test agent count with assigned agents""" + # Create another agent + Connection.objects.create( + name='Test Agent 2', + connection_type='AGENT', + host='agent2.example.com', + agent_id='test-agent-002', + is_active=True, + policy=test_policy + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['agent_count'] == 2 + + def test_get_policy_agent_count_excludes_inactive(self, authenticated_client, test_policy): + """Test that inactive agents are not counted""" + Connection.objects.create( + name='Inactive Agent', + connection_type='AGENT', + host='inactive.example.com', + agent_id='inactive-001', + is_active=False, + policy=test_policy + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['agent_count'] == 0 + + def test_get_policy_agent_count_excludes_centralized(self, authenticated_client, test_policy): + """Test that centralized connections are not counted""" + Connection.objects.create( + name='Centralized', + connection_type='CENTRALIZED', + host='https://localhost:9200', + username='elastic', + password='changeme', + is_active=True + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['agent_count'] == 0 + + def test_get_policy_agent_count_missing_policy_id(self, authenticated_client): + """Test agent count without policy_id""" + response = authenticated_client.get('/ConnectionManager/GetPolicyAgentCount/') + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_get_policy_agent_count_nonexistent_policy(self, authenticated_client): + """Test agent count for non-existent policy""" + response = authenticated_client.get('/ConnectionManager/GetPolicyAgentCount/?policy_id=99999') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + +# ============================================================================ +# Get Policy Change Count Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPolicyChangeCount: + """Tests for the get_policy_change_count endpoint""" + + def test_get_policy_change_count_no_changes(self, authenticated_client, test_policy_with_revision): + """Test change count when no changes exist""" + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['pending_changes'] == 0 + + def test_get_policy_change_count_config_file_changes(self, authenticated_client, test_policy_with_revision): + """Test change count with config file changes""" + test_policy_with_revision.logstash_yml = 'http.host: "127.0.0.1"' + test_policy_with_revision.jvm_options = '-Xms2g\n-Xmx2g' + test_policy_with_revision.save() + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['pending_changes'] == 2 # logstash_yml + jvm_options + + def test_get_policy_change_count_pipeline_changes(self, authenticated_client, test_policy_with_revision): + """Test change count with pipeline changes""" + Pipeline.objects.create( + policy=test_policy_with_revision, + name='new_pipeline', + lscl='input {} filter {} output {}' + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['pending_changes'] == 1 # pipelines changed + + def test_get_policy_change_count_keystore_changes(self, authenticated_client, test_policy_with_revision): + """Test change count with keystore changes""" + Keystore.objects.create( + policy=test_policy_with_revision, + key_name='new_key', + key_value='new_value' + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['pending_changes'] == 1 # keystore changed + + def test_get_policy_change_count_global_settings_changes(self, authenticated_client, test_policy_with_revision): + """Test change count with global settings changes""" + test_policy_with_revision.settings_path = '/new/settings' + test_policy_with_revision.save() + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert data['pending_changes'] == 1 # global_settings changed + + def test_get_policy_change_count_all_changes(self, authenticated_client, test_policy_with_revision): + """Test change count with all types of changes""" + # Config files + test_policy_with_revision.logstash_yml = 'http.host: "127.0.0.1"' + test_policy_with_revision.jvm_options = '-Xms2g\n-Xmx2g' + test_policy_with_revision.log4j2_properties = 'logger.logstash.level = debug' + # Global settings + test_policy_with_revision.settings_path = '/new/settings' + # Keystore password + test_policy_with_revision.keystore_password = 'new_password' + test_policy_with_revision.save() + + # Pipeline + Pipeline.objects.create( + policy=test_policy_with_revision, + name='new_pipeline', + lscl='input {} filter {} output {}' + ) + + # Keystore + Keystore.objects.create( + policy=test_policy_with_revision, + key_name='new_key', + key_value='new_value' + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + # 3 config files + 1 pipelines + 1 keystore + 1 keystore_password + 1 global_settings = 7 + assert data['pending_changes'] == 7 + + def test_get_policy_change_count_no_revision(self, authenticated_client, test_policy): + """Test change count when no revision exists (all changes)""" + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + # Should count all sections as changed when no revision exists + assert data['pending_changes'] > 0 + + def test_get_policy_change_count_missing_policy_id(self, authenticated_client): + """Test change count without policy_id""" + response = authenticated_client.get('/ConnectionManager/GetPolicyChangeCount/') + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_get_policy_change_count_nonexistent_policy(self, authenticated_client): + """Test change count for non-existent policy""" + response = authenticated_client.get('/ConnectionManager/GetPolicyChangeCount/?policy_id=99999') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + +# ============================================================================ +# Get Keystore Entries Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetKeystoreEntries: + """Tests for the get_keystore_entries endpoint""" + + def test_get_keystore_entries_empty(self, authenticated_client, test_policy): + """Test getting keystore entries when none exist""" + response = authenticated_client.get( + f'/ConnectionManager/GetKeystoreEntries/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['entries']) == 0 + assert data['has_keystore_password'] is True # test_policy has password + + def test_get_keystore_entries_with_entries(self, authenticated_client, test_policy, test_keystore_entry): + """Test getting keystore entries""" + response = authenticated_client.get( + f'/ConnectionManager/GetKeystoreEntries/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['entries']) == 1 + assert data['entries'][0]['key_name'] == 'test_key' + assert 'id' in data['entries'][0] + assert 'last_updated' in data['entries'][0] + + def test_get_keystore_entries_missing_policy_id(self, authenticated_client): + """Test getting entries without policy_id""" + response = authenticated_client.get('/ConnectionManager/GetKeystoreEntries/') + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_get_keystore_entries_nonexistent_policy(self, authenticated_client): + """Test getting entries for non-existent policy""" + response = authenticated_client.get('/ConnectionManager/GetKeystoreEntries/?policy_id=99999') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + +# ============================================================================ +# Set Keystore Password Tests +# ============================================================================ + +@pytest.mark.django_db +class TestSetKeystorePassword: + """Tests for the set_keystore_password endpoint""" + + def test_set_keystore_password_success(self, authenticated_client, test_policy): + """Test setting keystore password""" + response = authenticated_client.post( + '/ConnectionManager/SetKeystorePassword/', + data=json.dumps({ + 'policy_id': test_policy.id, + 'password': 'new_secure_password' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'Keystore password updated' in data['message'] + + # Verify password was set and encrypted + test_policy.refresh_from_db() + assert test_policy.keystore_password is not None + assert test_policy.keystore_password != 'new_secure_password' # Should be encrypted + assert test_policy.has_undeployed_changes is True + + def test_set_keystore_password_missing_policy_id(self, authenticated_client): + """Test setting password without policy_id""" + response = authenticated_client.post( + '/ConnectionManager/SetKeystorePassword/', + data=json.dumps({ + 'password': 'test_password' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_set_keystore_password_missing_password(self, authenticated_client, test_policy): + """Test setting password without password field""" + response = authenticated_client.post( + '/ConnectionManager/SetKeystorePassword/', + data=json.dumps({ + 'policy_id': test_policy.id + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Password cannot be empty' in data['error'] + + def test_set_keystore_password_nonexistent_policy(self, authenticated_client): + """Test setting password for non-existent policy""" + response = authenticated_client.post( + '/ConnectionManager/SetKeystorePassword/', + data=json.dumps({ + 'policy_id': 99999, + 'password': 'test_password' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_set_keystore_password_wrong_method(self, authenticated_client, test_policy): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/SetKeystorePassword/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_set_keystore_password_invalid_json(self, authenticated_client): + """Test setting password with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/SetKeystorePassword/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# Create Keystore Entry Tests +# ============================================================================ + +@pytest.mark.django_db +class TestCreateKeystoreEntry: + """Tests for the create_keystore_entry endpoint""" + + def test_create_keystore_entry_success(self, authenticated_client, test_policy): + """Test creating a keystore entry""" + response = authenticated_client.post( + '/ConnectionManager/CreateKeystoreEntry/', + data=json.dumps({ + 'policy_id': test_policy.id, + 'key_name': 'new_key', + 'key_value': 'secret_value' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'created successfully' in data['message'] + assert 'entry_id' in data + + # Verify entry was created + assert Keystore.objects.filter( + policy=test_policy, + key_name='new_key' + ).exists() + + def test_create_keystore_entry_duplicate_key(self, authenticated_client, test_policy, test_keystore_entry): + """Test creating a duplicate keystore entry""" + response = authenticated_client.post( + '/ConnectionManager/CreateKeystoreEntry/', + data=json.dumps({ + 'policy_id': test_policy.id, + 'key_name': 'test_key', # Already exists + 'key_value': 'another_value' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'already exists' in data['error'] + + def test_create_keystore_entry_missing_fields(self, authenticated_client, test_policy): + """Test creating entry with missing fields""" + response = authenticated_client.post( + '/ConnectionManager/CreateKeystoreEntry/', + data=json.dumps({ + 'policy_id': test_policy.id, + 'key_name': 'test_key' + # Missing key_value + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'required' in data['error'] + + def test_create_keystore_entry_nonexistent_policy(self, authenticated_client): + """Test creating entry for non-existent policy""" + response = authenticated_client.post( + '/ConnectionManager/CreateKeystoreEntry/', + data=json.dumps({ + 'policy_id': 99999, + 'key_name': 'test_key', + 'key_value': 'test_value' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_create_keystore_entry_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/CreateKeystoreEntry/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_create_keystore_entry_invalid_json(self, authenticated_client): + """Test creating entry with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/CreateKeystoreEntry/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# Update Keystore Entry Tests +# ============================================================================ + +@pytest.mark.django_db +class TestUpdateKeystoreEntry: + """Tests for the update_keystore_entry endpoint""" + + def test_update_keystore_entry_success(self, authenticated_client, test_keystore_entry): + """Test updating a keystore entry""" + response = authenticated_client.post( + '/ConnectionManager/UpdateKeystoreEntry/', + data=json.dumps({ + 'entry_id': test_keystore_entry.id, + 'key_value': 'updated_value' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'updated successfully' in data['message'] + + # Verify entry was updated + test_keystore_entry.refresh_from_db() + # Value should be encrypted, so we can't compare directly + assert test_keystore_entry.key_value != 'test_value' + + def test_update_keystore_entry_missing_fields(self, authenticated_client, test_keystore_entry): + """Test updating entry with missing fields""" + response = authenticated_client.post( + '/ConnectionManager/UpdateKeystoreEntry/', + data=json.dumps({ + 'entry_id': test_keystore_entry.id + # Missing key_value + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'required' in data['error'] + + def test_update_keystore_entry_nonexistent(self, authenticated_client): + """Test updating non-existent entry""" + response = authenticated_client.post( + '/ConnectionManager/UpdateKeystoreEntry/', + data=json.dumps({ + 'entry_id': 99999, + 'key_value': 'test_value' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'not found' in data['error'] + + def test_update_keystore_entry_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/UpdateKeystoreEntry/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_update_keystore_entry_invalid_json(self, authenticated_client): + """Test updating entry with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/UpdateKeystoreEntry/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# Delete Keystore Entry Tests +# ============================================================================ + +@pytest.mark.django_db +class TestDeleteKeystoreEntry: + """Tests for the delete_keystore_entry endpoint""" + + def test_delete_keystore_entry_success(self, authenticated_client, test_keystore_entry): + """Test deleting a keystore entry""" + entry_id = test_keystore_entry.id + + response = authenticated_client.post( + '/ConnectionManager/DeleteKeystoreEntry/', + data=json.dumps({ + 'entry_id': entry_id + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'deleted successfully' in data['message'] + + # Verify entry was deleted + assert not Keystore.objects.filter(id=entry_id).exists() + + def test_delete_keystore_entry_missing_entry_id(self, authenticated_client): + """Test deleting entry without entry_id""" + response = authenticated_client.post( + '/ConnectionManager/DeleteKeystoreEntry/', + data=json.dumps({}), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Entry ID is required' in data['error'] + + def test_delete_keystore_entry_nonexistent(self, authenticated_client): + """Test deleting non-existent entry""" + response = authenticated_client.post( + '/ConnectionManager/DeleteKeystoreEntry/', + data=json.dumps({ + 'entry_id': 99999 + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'not found' in data['error'] + + def test_delete_keystore_entry_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/DeleteKeystoreEntry/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_delete_keystore_entry_invalid_json(self, authenticated_client): + """Test deleting entry with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/DeleteKeystoreEntry/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] diff --git a/src/logstashui/PipelineManager/tests/test_connections_crud.py b/src/logstashui/PipelineManager/tests/test_connections_crud.py new file mode 100644 index 00000000..a9358c3a --- /dev/null +++ b/src/logstashui/PipelineManager/tests/test_connections_crud.py @@ -0,0 +1,603 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from Common.test_resources import authenticated_client, test_connection, test_user +from PipelineManager.models import Connection, Policy, Pipeline + +from unittest.mock import patch, MagicMock +from django.conf import settings +import json +import pytest + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def test_policy(db): + """Create a test policy""" + policy = Policy.objects.create( + name='Test Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash' + ) + return policy + + +@pytest.fixture +def test_agent_connection(db, test_policy): + """Create a test agent connection""" + connection = Connection.objects.create( + name='Test Agent', + connection_type='AGENT', + host='agent.example.com', + agent_id='test-agent-001', + is_active=True, + policy=test_policy + ) + return connection + + +@pytest.fixture +def test_pipeline(db, test_policy): + """Create a test pipeline""" + pipeline = Pipeline.objects.create( + policy=test_policy, + name='test_pipeline', + lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }', + description='Test pipeline description' + ) + return pipeline + + +# ============================================================================ +# GetConnections Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetConnections: + """Tests for the GetConnections view""" + + def test_returns_json_list(self, authenticated_client, test_connection): + """Returns a JSON list of connection dicts""" + response = authenticated_client.get('/ConnectionManager/GetConnections/') + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # At least our test_connection + ids = [c['id'] for c in data] + assert test_connection.id in ids + + def test_returns_expected_fields(self, authenticated_client, test_connection): + """Each connection dict has id, name, connection_type""" + response = authenticated_client.get('/ConnectionManager/GetConnections/') + item = response.json()[0] + assert 'id' in item + assert 'name' in item + assert 'connection_type' in item + + def test_returns_all_connections(self, authenticated_client, test_connection, test_agent_connection): + """Returns all connections regardless of type""" + response = authenticated_client.get('/ConnectionManager/GetConnections/') + assert response.status_code == 200 + data = response.json() + assert len(data) >= 2 + ids = [c['id'] for c in data] + assert test_connection.id in ids + assert test_agent_connection.id in ids + + def test_handles_empty_connections(self, authenticated_client): + """Returns empty list when no connections exist""" + Connection.objects.all().delete() + response = authenticated_client.get('/ConnectionManager/GetConnections/') + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + +# ============================================================================ +# AddConnection Tests +# ============================================================================ + +@pytest.mark.django_db +class TestAddConnection: + """Test Connection Create operations""" + + def test_add_connection_requires_authentication(self, client): + """Test that adding a connection requires authentication""" + response = client.post('/ConnectionManager/AddConnection', { + 'name': 'Test', + 'connection_type': 'CENTRALIZED', + 'host': 'https://localhost:9200' + }) + # Should redirect to login + assert response.status_code == 302 + assert '/Management/Login/' in response.url + + @patch('PipelineManager.manager_views.test_connectivity') + def test_add_connection_success(self, mock_test_connectivity, authenticated_client): + """Test successful connection creation""" + # Mock successful connectivity test + mock_test_connectivity.return_value = (True, "Connection successful") + + response = authenticated_client.post('/ConnectionManager/AddConnection', { + 'name': 'Test Connection', + 'connection_type': 'CENTRALIZED', + 'host': 'https://localhost:9200', + 'username': 'elastic', + 'password': 'changeme' + }) + + assert response.status_code == 200 + response_data = json.loads(response.content) + assert response_data['success'] is True + assert 'Connection created and tested successfully!' in response_data['message'] + assert 'connection_id' in response_data + + # Verify connection was created + assert Connection.objects.filter(name='Test Connection').exists() + + @patch('PipelineManager.manager_views.test_connectivity') + def test_add_connection_failed_connectivity(self, mock_test_connectivity, authenticated_client): + """Test connection creation with failed connectivity test""" + # Mock failed connectivity test + mock_test_connectivity.return_value = (False, "Connection failed: Timeout") + + response = authenticated_client.post('/ConnectionManager/AddConnection', { + 'name': 'Bad Connection', + 'connection_type': 'CENTRALIZED', + 'host': 'https://invalid:9200', + 'username': 'elastic', + 'password': 'wrong' + }) + + assert response.status_code == 200 + response_data = json.loads(response.content) + assert response_data['success'] is False + assert 'Connection failed: Timeout' in response_data['error'] + + # Verify connection was NOT created (deleted after failed test) + assert not Connection.objects.filter(name='Bad Connection').exists() + + def test_add_connection_invalid_form(self, authenticated_client): + """Test connection creation with invalid form data""" + response = authenticated_client.post('/ConnectionManager/AddConnection', { + 'name': '', # Empty name should fail validation + 'connection_type': 'CENTRALIZED' + }) + + assert response.status_code == 200 + response_data = json.loads(response.content) + assert response_data['success'] is False + assert 'error' in response_data + # Check that the error contains form validation messages + assert 'name' in response_data['error'] or 'This field is required' in response_data['error'] + + def test_add_connection_get_returns_405(self, authenticated_client): + """AddConnection only accepts POST — GET returns 405""" + response = authenticated_client.get('/ConnectionManager/AddConnection') + assert response.status_code == 405 + + +# ============================================================================ +# DeleteConnection Tests +# ============================================================================ + +@pytest.mark.django_db +class TestDeleteConnection: + """Test Connection Delete operations""" + + def test_delete_connection(self, authenticated_client, test_connection): + """Test connection deletion""" + connection_id = test_connection.id + + response = authenticated_client.post(f'/ConnectionManager/DeleteConnection/{connection_id}/') + + assert response.status_code == 200 + assert b'Connection deleted successfully!' in response.content + + # Verify connection was deleted + assert not Connection.objects.filter(id=connection_id).exists() + + def test_delete_nonexistent_connection(self, authenticated_client): + """Test deleting a connection that doesn't exist""" + response = authenticated_client.post('/ConnectionManager/DeleteConnection/99999/') + + assert response.status_code == 404 + assert b'Connection not found' in response.content + + def test_delete_connection_missing_id(self, authenticated_client): + """Test deleting without connection_id""" + response = authenticated_client.post('/ConnectionManager/DeleteConnection/') + + assert response.status_code in [400, 404] # Either bad request or not found + + def test_delete_connection_get_returns_405(self, authenticated_client, test_connection): + """DeleteConnection only accepts POST — GET returns 405""" + response = authenticated_client.get( + f'/ConnectionManager/DeleteConnection/{test_connection.id}/' + ) + assert response.status_code == 405 + + +# ============================================================================ +# UpgradeAgent Tests +# ============================================================================ + +@pytest.mark.django_db +class TestUpgradeAgent: + """Tests for the UpgradeAgent endpoint""" + + def test_upgrade_agent_success(self, authenticated_client, test_agent_connection): + """Test successful agent upgrade request""" + response = authenticated_client.post( + f'/ConnectionManager/UpgradeAgent/{test_agent_connection.id}/' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'upgrade' in data['message'].lower() + + # Verify desired_agent_version was set + test_agent_connection.refresh_from_db() + assert test_agent_connection.desired_agent_version == settings.__PREFERRED_LS_AGENT_VERSION__ + + def test_upgrade_agent_missing_id(self, authenticated_client): + """Test upgrade without connection_id""" + response = authenticated_client.post('/ConnectionManager/UpgradeAgent/') + + assert response.status_code in [400, 404] + if response.status_code == 400: + data = response.json() + assert data['success'] is False + assert 'Connection ID is required' in data['error'] + + def test_upgrade_agent_nonexistent(self, authenticated_client): + """Test upgrade for non-existent connection""" + response = authenticated_client.post('/ConnectionManager/UpgradeAgent/99999/') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Connection not found' in data['error'] + + def test_upgrade_agent_centralized_connection(self, authenticated_client, test_connection): + """Test that centralized connections cannot be upgraded""" + response = authenticated_client.post( + f'/ConnectionManager/UpgradeAgent/{test_connection.id}/' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Only agent connections can be upgraded' in data['error'] + + def test_upgrade_agent_wrong_method(self, authenticated_client, test_agent_connection): + """Test that GET requests are rejected""" + response = authenticated_client.get( + f'/ConnectionManager/UpgradeAgent/{test_agent_connection.id}/' + ) + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + +# ============================================================================ +# ChangeConnectionPolicy Tests +# ============================================================================ + +@pytest.mark.django_db +class TestChangeConnectionPolicy: + """Tests for the change_connection_policy endpoint""" + + def test_change_policy_success(self, authenticated_client, test_agent_connection, test_policy): + """Test successful policy change""" + # Create a new policy + new_policy = Policy.objects.create( + name='New Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "127.0.0.1"', + jvm_options='-Xms2g\n-Xmx2g', + log4j2_properties='logger.logstash.name = logstash' + ) + + response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', { + 'connection_id': test_agent_connection.id, + 'policy_id': new_policy.id + }) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify policy was changed + test_agent_connection.refresh_from_db() + assert test_agent_connection.policy == new_policy + + def test_change_policy_missing_connection_id(self, authenticated_client, test_policy): + """Test policy change without connection_id""" + response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', { + 'policy_id': test_policy.id + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'connection not found' in data['error'].lower() + + def test_change_policy_missing_policy_id(self, authenticated_client, test_agent_connection): + """Test policy change without policy_id""" + response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', { + 'connection_id': test_agent_connection.id + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_change_policy_nonexistent_connection(self, authenticated_client, test_policy): + """Test policy change for non-existent connection""" + response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', { + 'connection_id': 99999, + 'policy_id': test_policy.id + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'connection not found' in data['error'].lower() + + def test_change_policy_nonexistent_policy(self, authenticated_client, test_agent_connection): + """Test policy change to non-existent policy""" + response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', { + 'connection_id': test_agent_connection.id, + 'policy_id': 99999 + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_change_policy_centralized_connection(self, authenticated_client, test_connection, test_policy): + """Test that centralized connections cannot have policy changed""" + response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', { + 'connection_id': test_connection.id, + 'policy_id': test_policy.id + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + + def test_change_policy_wrong_method(self, authenticated_client, test_agent_connection, test_policy): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/ChangeConnectionPolicy/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + +# ============================================================================ +# RestartLogstash Tests +# ============================================================================ + +@pytest.mark.django_db +class TestRestartLogstash: + """Tests for the restart_logstash endpoint""" + + def test_restart_logstash_success(self, authenticated_client, test_agent_connection): + """Test successful restart request""" + response = authenticated_client.post('/ConnectionManager/RestartLogstash/', { + 'connection_id': test_agent_connection.id + }) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify restart flag was set + test_agent_connection.refresh_from_db() + assert test_agent_connection.restart_on_next_checkin is True + + def test_restart_logstash_missing_connection_id(self, authenticated_client): + """Test restart without connection_id""" + response = authenticated_client.post('/ConnectionManager/RestartLogstash/', {}) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'connection not found' in data['error'].lower() + + def test_restart_logstash_nonexistent_connection(self, authenticated_client): + """Test restart for non-existent connection""" + response = authenticated_client.post('/ConnectionManager/RestartLogstash/', { + 'connection_id': 99999 + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'connection not found' in data['error'].lower() + + def test_restart_logstash_centralized_connection(self, authenticated_client, test_connection): + """Test that centralized connections cannot be restarted""" + response = authenticated_client.post('/ConnectionManager/RestartLogstash/', { + 'connection_id': test_connection.id + }) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + + def test_restart_logstash_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/RestartLogstash/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + +# ============================================================================ +# GetPipelines Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPipelines: + """Tests for the GetPipelines endpoint""" + + @patch('PipelineManager.connections_crud.get_elastic_connection') + def test_get_pipelines_centralized_success(self, mock_get_es, authenticated_client, test_connection): + """Test getting pipelines for centralized connection""" + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.get_pipeline.return_value = { + 'test_pipeline': { + 'description': 'Test pipeline', + 'last_modified': '2025-01-14T12:00:00.000Z' + } + } + mock_get_es.return_value = mock_es + + response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{test_connection.id}/') + + assert response.status_code == 200 + assert b'test_pipeline' in response.content + + def test_get_pipelines_agent_success(self, authenticated_client, test_agent_connection, test_pipeline): + """Test getting pipelines for agent connection""" + response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{test_agent_connection.id}/') + + assert response.status_code == 200 + assert b'test_pipeline' in response.content + assert b'Test pipeline description' in response.content + + def test_get_pipelines_agent_no_policy(self, authenticated_client): + """Test getting pipelines for agent without policy""" + agent = Connection.objects.create( + name='No Policy Agent', + connection_type='AGENT', + host='nopolicy.example.com', + agent_id='nopolicy-001', + is_active=True, + policy=None + ) + + response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{agent.id}/') + + assert response.status_code == 200 + # Should return empty pipelines list + + def test_get_pipelines_nonexistent_connection(self, authenticated_client): + """Test getting pipelines for non-existent connection""" + response = authenticated_client.get('/ConnectionManager/GetPipelines/99999/') + + assert response.status_code == 404 + assert b'Connection not found' in response.content + + @patch('PipelineManager.connections_crud.get_elastic_connection') + def test_get_pipelines_centralized_connection_error(self, mock_get_es, authenticated_client, test_connection): + """Test handling of Elasticsearch connection errors""" + # Mock connection error + mock_get_es.side_effect = Exception("Connection failed") + + response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{test_connection.id}/') + + # Should still return 200 but with empty pipelines + assert response.status_code == 200 + + +# ============================================================================ +# GetPolicyPipelines Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPolicyPipelines: + """Tests for the GetPolicyPipelines endpoint""" + + def test_get_policy_pipelines_success(self, authenticated_client, test_policy, test_pipeline): + """Test getting pipelines for a policy""" + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyPipelines/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['pipelines']) == 1 + assert data['pipelines'][0]['name'] == 'test_pipeline' + assert data['pipelines'][0]['description'] == 'Test pipeline description' + + def test_get_policy_pipelines_empty(self, authenticated_client, test_policy): + """Test getting pipelines when policy has none""" + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyPipelines/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['pipelines']) == 0 + + def test_get_policy_pipelines_missing_policy_id(self, authenticated_client): + """Test getting pipelines without policy_id""" + response = authenticated_client.get('/ConnectionManager/GetPolicyPipelines/') + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_get_policy_pipelines_nonexistent_policy(self, authenticated_client): + """Test getting pipelines for non-existent policy""" + response = authenticated_client.get('/ConnectionManager/GetPolicyPipelines/?policy_id=99999') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'not found' in data['error'] + + def test_get_policy_pipelines_multiple(self, authenticated_client, test_policy): + """Test getting multiple pipelines for a policy""" + # Create multiple pipelines + Pipeline.objects.create( + policy=test_policy, + name='pipeline1', + lscl='input {} filter {} output {}', + description='Pipeline 1' + ) + Pipeline.objects.create( + policy=test_policy, + name='pipeline2', + lscl='input {} filter {} output {}', + description='Pipeline 2' + ) + + response = authenticated_client.get( + f'/ConnectionManager/GetPolicyPipelines/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['pipelines']) == 2 + names = [p['name'] for p in data['pipelines']] + assert 'pipeline1' in names + assert 'pipeline2' in names diff --git a/src/logstashui/PipelineManager/tests/test_editor_views.py b/src/logstashui/PipelineManager/tests/test_editor_views.py new file mode 100644 index 00000000..25471cad --- /dev/null +++ b/src/logstashui/PipelineManager/tests/test_editor_views.py @@ -0,0 +1,751 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from Common.test_resources import authenticated_client, test_connection, test_user + +from unittest.mock import patch, MagicMock + +import json +import pytest + + +# ============================================================================ +# RBAC Tests for Simulation and Pipeline Editor Endpoints +# ============================================================================ + +@pytest.mark.django_db +class TestRBACSimulationEndpoints: + """Test RBAC (Role-Based Access Control) for simulation endpoints""" + + def test_readonly_user_cannot_simulate_pipeline(self, client): + """Test that readonly user cannot access SimulatePipeline""" + from django.contrib.auth.models import User + from Management.models import UserProfile + + readonly_user = User.objects.create_user( + username='readonly_simulate', + password='testpass123', + is_staff=False + ) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_simulate', password='testpass123') + + components = { + "input": [], + "filter": [{"id": "filter_1", "plugin": "mutate", "config": {}}], + "output": [] + } + + response = client.post('/ConnectionManager/SimulatePipeline/', { + 'components': json.dumps(components), + 'log_text': '{"message": "test"}' + }) + + assert response.status_code == 403 + + def test_readonly_user_cannot_upload_file(self, client): + """Test that readonly user cannot upload files""" + from django.contrib.auth.models import User + from Management.models import UserProfile + from django.core.files.uploadedfile import SimpleUploadedFile + + readonly_user = User.objects.create_user( + username='readonly_upload_rbac', + password='testpass123', + is_staff=False + ) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_upload_rbac', password='testpass123') + + file_content = b'test content' + uploaded_file = SimpleUploadedFile("test.txt", file_content) + + response = client.post('/ConnectionManager/UploadFile/', { + 'file': uploaded_file, + 'filename': 'test.txt' + }) + + assert response.status_code == 403 + + def test_readonly_user_can_view_simulation_results(self, authenticated_client): + """Test that readonly users can view simulation results (read-only operation)""" + # GetSimulationResults doesn't have @require_admin_role, so readonly users can access + response = authenticated_client.get('/ConnectionManager/GetSimulationResults/?run_id=test-123') + + # Should work (returns 200 with empty results) + assert response.status_code == 200 + + def test_readonly_user_can_check_pipeline_loaded(self, authenticated_client): + """Test that readonly users can check if pipeline is loaded (read-only operation)""" + # CheckIfPipelineLoaded has @login_required but not @require_admin_role + response = authenticated_client.get('/ConnectionManager/CheckIfPipelineLoaded/?pipeline_name=test') + + # Should work (may return error about missing pipeline, but not 403) + assert response.status_code in [200, 400, 500] + + def test_readonly_user_can_get_related_logs(self, authenticated_client): + """Test that readonly users can get related logs (read-only operation)""" + # GetRelatedLogs has @login_required but not @require_admin_role + response = authenticated_client.get('/ConnectionManager/GetRelatedLogs/?slot_id=1') + + # Should work (may return error, but not 403) + assert response.status_code in [200, 400, 500] + + def test_admin_user_can_simulate_pipeline(self, authenticated_client): + """Test that admin user can access SimulatePipeline""" + components = { + "input": [], + "filter": [{"id": "filter_1", "plugin": "mutate", "config": {}}], + "output": [] + } + + with patch('PipelineManager.simulation.requests.post') as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {'slot_id': 1, 'reused': False} + + response = authenticated_client.post('/ConnectionManager/SimulatePipeline/', { + 'components': json.dumps(components), + 'log_text': '{"message": "test"}' + }) + + # Should work for admin + assert response.status_code == 200 + + def test_unauthenticated_user_cannot_simulate(self, client): + """Test that unauthenticated users cannot access SimulatePipeline""" + components = { + "input": [], + "filter": [{"id": "filter_1", "plugin": "mutate", "config": {}}], + "output": [] + } + + response = client.post('/ConnectionManager/SimulatePipeline/', { + 'components': json.dumps(components), + 'log_text': '{"message": "test"}' + }) + + # Should redirect to login + assert response.status_code == 302 + assert '/Management/Login/' in response.url + + +@pytest.mark.django_db +class TestRBACPipelineEditorEndpoints: + """Test RBAC for pipeline editor endpoints""" + + @patch('PipelineManager.editor_views.get_elastic_connection') + @patch('PipelineManager.editor_views.get_logstash_pipeline') + def test_readonly_user_cannot_save_pipeline(self, mock_get_pipeline, mock_get_es, client, test_connection): + """Test that readonly user cannot save pipelines""" + from django.contrib.auth.models import User + from Management.models import UserProfile + + readonly_user = User.objects.create_user( + username='readonly_save', + password='testpass123', + is_staff=False + ) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_save', password='testpass123') + + mock_get_pipeline.return_value = { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + 'pipeline_settings': {}, + 'description': '' + } + + mock_es = MagicMock() + mock_es.logstash.get_pipeline.return_value = { + 'test_pipeline': { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + 'pipeline_settings': {}, + 'description': '' + } + } + mock_get_es.return_value = mock_es + + components = {"input": [], "filter": [], "output": []} + + response = client.post('/ConnectionManager/SavePipeline/', { + 'save_pipeline': 'true', + 'es_id': test_connection.id, + 'pipeline': 'test_pipeline', + 'components': json.dumps(components) + }) + + assert response.status_code == 403 + + @patch('PipelineManager.manager_views.get_elastic_connection') + def test_readonly_user_cannot_clone_pipeline(self, mock_get_es, client, test_connection): + """Test that readonly user cannot clone pipelines""" + from django.contrib.auth.models import User + from Management.models import UserProfile + + readonly_user = User.objects.create_user( + username='readonly_clone', + password='testpass123', + is_staff=False + ) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_clone', password='testpass123') + + response = client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'source', + 'new_pipeline': 'cloned' + }) + + assert response.status_code == 403 + + @patch('PipelineManager.editor_views.get_logstash_pipeline') + def test_readonly_user_can_view_pipeline_editor(self, mock_get_pipeline, client, test_connection): + """Test that readonly user can view pipeline editor (read-only)""" + from django.contrib.auth.models import User + from Management.models import UserProfile + + readonly_user = User.objects.create_user( + username='readonly_view', + password='testpass123', + is_staff=False + ) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_view', password='testpass123') + + mock_get_pipeline.return_value = { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_settings': {}, + 'description': '' + } + + response = client.get( + f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=test_pipeline' + ) + + # PipelineEditor doesn't have @require_admin_role, so readonly can view + assert response.status_code == 200 + + +# ============================================================================ +# PipelineEditor page +# ============================================================================ + +@pytest.mark.django_db +class TestPipelineEditorPage: + """Tests for the PipelineEditor GET view""" + + def test_missing_params_returns_400(self, authenticated_client): + """GET without es_id or pipeline returns 400""" + response = authenticated_client.get('/ConnectionManager/Pipelines/Editor/') + assert response.status_code == 400 + + def test_missing_pipeline_param_returns_400(self, authenticated_client, test_connection): + response = authenticated_client.get( + f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}' + ) + assert response.status_code == 400 + + @patch('PipelineManager.editor_views.get_logstash_pipeline', return_value=None) + def test_pipeline_not_found_returns_400(self, mock_glp, authenticated_client, test_connection): + """When pipeline fetch returns None, view returns 400""" + response = authenticated_client.get( + f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=nope' + ) + assert response.status_code == 400 + + @patch('PipelineManager.editor_views.get_logstash_pipeline') + def test_successful_load_200(self, mock_glp, authenticated_client, test_connection): + mock_glp.return_value = { + 'pipeline': 'input {} filter {} output {}', + 'pipeline_settings': {}, + 'description': 'test', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + } + response = authenticated_client.get( + f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=mypipe' + ) + assert response.status_code == 200 + + @patch('PipelineManager.editor_views.get_logstash_pipeline') + def test_parse_error_captured_in_context(self, mock_glp, authenticated_client, test_connection): + """If config parsing fails, parsing_error is set in context (no 500)""" + mock_glp.return_value = { + 'pipeline': '<<< INVALID >>>', + 'pipeline_settings': {}, + 'description': '', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + } + response = authenticated_client.get( + f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=bad' + ) + assert response.status_code == 200 + assert response.context.get('parsing_error') is not None + + +# ============================================================================ +# GetCurrentPipelineCode endpoint +# ============================================================================ + +@pytest.mark.django_db +class TestGetCurrentPipelineCode: + """Tests for the GetCurrentPipelineCode view""" + + def test_returns_html_pre_block(self, authenticated_client): + components = {"input": [], "filter": [], "output": []} + response = authenticated_client.post( + '/ConnectionManager/GetCurrentPipelineCode/', + {'components': json.dumps(components)} + ) + assert response.status_code == 200 + assert b'alert' not in content + + @patch('PipelineManager.manager_views.test_connectivity') + def test_readonly_user_cannot_add_connection(self, mock_test_connectivity, client, test_user): + """Test that readonly (non-admin) user cannot add connections""" + # Mock connectivity test + mock_test_connectivity.return_value = (True, "Connection successful") + + # Create a readonly user (not admin) + from django.contrib.auth.models import User + from Management.models import UserProfile + + readonly_user = User.objects.create_user( + username='readonly_add', + password='testpass123', + is_staff=False + ) + # Update the auto-created profile to readonly role + # (post_save signal creates profile with 'admin' role by default) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_add', password='testpass123') + + response = client.post('/ConnectionManager/AddConnection', { + 'name': 'Test Connection', + 'connection_type': 'CENTRALIZED', + 'host': 'https://localhost:9200', + 'username': 'elastic', + 'password': 'changeme' + }) + + # Should be forbidden (403) due to @require_admin_role decorator + assert response.status_code == 403 + + def test_readonly_user_cannot_delete_connection(self, client, test_connection): + """Test that readonly (non-admin) user cannot delete connections""" + # Create a readonly user (not admin) + from django.contrib.auth.models import User + from Management.models import UserProfile + + readonly_user = User.objects.create_user( + username='readonly_delete', + password='testpass123', + is_staff=False + ) + # Update the auto-created profile to readonly role + # (post_save signal creates profile with 'admin' role by default) + readonly_user.profile.role = 'readonly' + readonly_user.profile.save() + client.login(username='readonly_delete', password='testpass123') + + response = client.post(f'/ConnectionManager/DeleteConnection/{test_connection.id}/') + + # Should be forbidden (403) due to @require_admin_role decorator + assert response.status_code == 403 + + # Verify connection was NOT deleted + assert Connection.objects.filter(id=test_connection.id).exists() + + +# ============================================================================ +# test_connectivity() pure function +# ============================================================================ + +class TestConnectivityHelper: + """Unit tests for the test_connectivity() pure helper function""" + + def test_no_connection_id_returns_false(self): + """Empty connection_id immediately returns (False, message)""" + from PipelineManager.manager_views import test_connectivity + success, msg = test_connectivity("") + assert success is False + assert "No connection ID" in msg + + @patch('PipelineManager.manager_views.get_elastic_connection') + @patch('PipelineManager.manager_views.test_elastic_connectivity') + def test_success_returns_true_and_result(self, mock_test_elastic, mock_get_es): + from PipelineManager.manager_views import test_connectivity + mock_get_es.return_value = MagicMock() + mock_test_elastic.return_value = "Connected!" + success, msg = test_connectivity("42") + assert success is True + assert msg == "Connected!" + + @patch('PipelineManager.manager_views.get_elastic_connection', side_effect=Exception("timeout")) + def test_exception_returns_false(self, mock_get_es): + from PipelineManager.manager_views import test_connectivity + success, msg = test_connectivity("42") + assert success is False + assert "timeout" in msg + + +# ============================================================================ +# TestConnectivity VIEW — additional paths +# ============================================================================ + +@pytest.mark.django_db +class TestTestConnectivityView: + """Tests for the TestConnectivity view""" + + def test_no_test_id_returns_400(self, authenticated_client): + """GET without `test` param returns 400""" + response = authenticated_client.get('/ConnectionManager/TestConnectivity') + assert response.status_code == 400 + assert b'No connection ID' in response.content + + @patch('PipelineManager.manager_views.test_connectivity', return_value=(True, "All good!")) + def test_success_renders_green_div(self, mock_tc, authenticated_client, test_connection): + """Successful connection renders a green-coloured div""" + response = authenticated_client.get( + f'/ConnectionManager/TestConnectivity?test={test_connection.id}' + ) + assert response.status_code == 200 + content = response.content.decode() + assert 'green' in content + assert 'All good!' in content + + +# ============================================================================ +# GetConnections VIEW +# ============================================================================ + +@pytest.mark.django_db +class TestGetConnections: + """Tests for the GetConnections view""" + + def test_returns_json_list(self, authenticated_client, test_connection): + """Returns a JSON list of connection dicts""" + response = authenticated_client.get('/ConnectionManager/GetConnections/') + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # At least our test_connection + ids = [c['id'] for c in data] + assert test_connection.id in ids + + def test_returns_expected_fields(self, authenticated_client, test_connection): + """Each connection dict has id, name, connection_type""" + response = authenticated_client.get('/ConnectionManager/GetConnections/') + item = response.json()[0] + assert 'id' in item + assert 'name' in item + assert 'connection_type' in item + + +# ============================================================================ +# AddConnection / DeleteConnection method guards +# ============================================================================ + +@pytest.mark.django_db +class TestConnectionMethodGuards: + """Test HTTP method enforcement on connection endpoints""" + + def test_add_connection_get_returns_405(self, authenticated_client): + """AddConnection only accepts POST — GET returns 405""" + response = authenticated_client.get('/ConnectionManager/AddConnection') + assert response.status_code == 405 + + def test_delete_connection_get_returns_405(self, authenticated_client, test_connection): + """DeleteConnection only accepts POST — GET returns 405""" + response = authenticated_client.get( + f'/ConnectionManager/DeleteConnection/{test_connection.id}/' + ) + assert response.status_code == 405 + + +# ============================================================================ +# PipelineManager page +# ============================================================================ + +@pytest.mark.django_db +class TestPipelineManagerPage: + """Tests for the PipelineManager view""" + + def test_page_loads(self, authenticated_client): + response = authenticated_client.get('/ConnectionManager/') + assert response.status_code == 200 + + def test_context_has_connections(self, authenticated_client, test_connection): + response = authenticated_client.get('/ConnectionManager/') + assert response.status_code == 200 + assert 'connections' in response.context + assert 'has_connections' in response.context + assert response.context['has_connections'] is True + + +# ============================================================================ +# GetPipeline endpoint +# ============================================================================ + +@pytest.mark.django_db +class TestGetPipelineEndpoint: + """Tests for the GetPipeline JSON view""" + + def test_missing_params_returns_400(self, authenticated_client): + response = authenticated_client.get('/ConnectionManager/GetPipeline/') + assert response.status_code == 400 + assert 'error' in response.json() + + def test_missing_pipeline_returns_400(self, authenticated_client, test_connection): + response = authenticated_client.get( + f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}' + ) + assert response.status_code == 400 + + @patch('PipelineManager.pipelines_crud.get_logstash_pipeline', return_value=None) + def test_pipeline_not_found_returns_400(self, mock_glp, authenticated_client, test_connection): + response = authenticated_client.get( + f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=missing' + ) + assert response.status_code == 400 + assert 'error' in response.json() + + @patch('PipelineManager.pipelines_crud.get_logstash_pipeline') + def test_success_returns_code(self, mock_glp, authenticated_client, test_connection): + mock_glp.return_value = {'pipeline': 'input {} filter {} output {}'} + response = authenticated_client.get( + f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=mypipe' + ) + assert response.status_code == 200 + assert response.json()['code'] == 'input {} filter {} output {}' + + +# ============================================================================ +# ClonePipeline error paths +# ============================================================================ + +@pytest.mark.django_db +class TestClonePipelineEdgeCases: + """Tests for ClonePipeline error paths""" + + def test_invalid_source_name_returns_400(self, authenticated_client, test_connection): + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': '123bad', + 'new_pipeline': 'newpipe', + }) + assert response.status_code == 400 + + def test_invalid_new_name_returns_400(self, authenticated_client, test_connection): + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'valid_source', + 'new_pipeline': '123bad', + }) + assert response.status_code == 400 + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_source_pipeline_not_found_returns_404(self, mock_get_es, authenticated_client, test_connection): + mock_es = MagicMock() + # get_pipeline returns dict that does NOT contain source_pipeline key + mock_es.logstash.get_pipeline.return_value = {} + mock_get_es.return_value = mock_es + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'missing_pipe', + 'new_pipeline': 'new_pipe', + }) + assert response.status_code == 404 + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_new_pipeline_name_already_exists_returns_400(self, mock_get_es, authenticated_client, test_connection): + mock_es = MagicMock() + mock_es.logstash.get_pipeline.side_effect = [ + # First call: get source pipeline + {'source_pipe': {'pipeline': 'input {} filter {} output {}', + 'pipeline_settings': {}, 'description': ''}}, + # Second call: get all pipelines — new_pipe already in there + {'source_pipe': {}, 'new_pipe': {}}, + ] + mock_get_es.return_value = mock_es + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'source_pipe', + 'new_pipeline': 'new_pipe', + }) + assert response.status_code == 400 + assert b'already exists' in response.content + + @patch('PipelineManager.pipelines_crud.get_elastic_connection', side_effect=Exception("ES down")) + def test_clone_exception_returns_500(self, mock_get_es, authenticated_client, test_connection): + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'source_pipe', + 'new_pipeline': 'new_pipe', + }) + assert response.status_code == 500 + + +# ============================================================================ +# DeletePipeline — additional paths +# ============================================================================ + +@pytest.mark.django_db +class TestDeletePipelineEdgeCases: + """Extra DeletePipeline tests""" + + def test_invalid_pipeline_name_returns_400(self, authenticated_client, test_connection): + response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '123invalid', + }) + assert response.status_code == 400 + + +# ============================================================================ +# Integration Tests +# ============================================================================ + +@pytest.mark.django_db +class TestIntegration: + """Integration tests for complete workflows""" + + @patch('PipelineManager.manager_views.test_connectivity') + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_full_pipeline_lifecycle(self, mock_get_es, mock_test_connectivity, authenticated_client): + """Test complete pipeline lifecycle: create connection, create pipeline, update, delete""" + # Step 1: Create connection + mock_test_connectivity.return_value = (True, "Connection successful") + + conn_response = authenticated_client.post('/ConnectionManager/AddConnection', { + 'name': 'Integration Test Connection', + 'connection_type': 'CENTRALIZED', + 'host': 'https://localhost:9200', + 'username': 'elastic', + 'password': 'changeme' + }) + assert conn_response.status_code == 200 + + connection = Connection.objects.get(name='Integration Test Connection') + + # Step 2: Create pipeline + mock_es = MagicMock() + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + create_response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': connection.id, + 'pipeline': 'integration_test_pipeline' + }) + assert create_response.status_code == 200 + + # Step 3: Delete pipeline + delete_response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { + 'es_id': connection.id, + 'pipeline': 'integration_test_pipeline' + }) + assert delete_response.status_code == 204 + + # Step 4: Delete connection + delete_conn_response = authenticated_client.post(f'/ConnectionManager/DeleteConnection/{connection.id}/') + assert delete_conn_response.status_code == 200 + assert not Connection.objects.filter(id=connection.id).exists() + + +# ============================================================================ +# CreatePipeline — simulate path and default config +# ============================================================================ + +@pytest.mark.django_db +class TestCreatePipelineAdditional: + """Additional CreatePipeline tests""" + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_creates_default_empty_config_when_no_pipeline_config( + self, mock_get_es, authenticated_client, test_connection): + """When no pipeline_config is given, the default 'input {} filter {} output {}' is used""" + mock_es = MagicMock() + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': 'default_pipe', + # no pipeline_config + }) + assert response.status_code == 200 + call_body = mock_es.logstash.put_pipeline.call_args[1]['body'] + assert 'input {}' in call_body['pipeline'] + + @patch('PipelineManager.pipelines_crud.requests.put') + def test_simulate_mode_success(self, mock_put, authenticated_client, settings): + """CreatePipeline in simulate=True mode sends a PUT to logstashagent""" + settings.LOGSTASH_AGENT_URL = 'http://localhost:8080' + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_put.return_value = mock_response + + from PipelineManager.pipelines_crud import CreatePipeline + from django.test import RequestFactory + from django.contrib.auth.models import User + + rf = RequestFactory() + user = User.objects.get(username='testuser') + request = rf.get('/') + request.user = user + + response = CreatePipeline( + request, + simulate=True, + pipeline_name='sim_pipe', + pipeline_config='input {} filter {} output {}' + ) + assert response.status_code == 200 + assert b'Simulation pipeline created successfully' in response.content + + @patch('PipelineManager.pipelines_crud.requests.put', + side_effect=__import__('requests').exceptions.ConnectionError("agent down")) + def test_simulate_mode_failure_returns_500(self, mock_put, authenticated_client, settings): + """CreatePipeline simulate=True with agent failure returns 500. + + The view only catches requests.exceptions.RequestException — a generic + Exception would propagate uncaught, so we use a concrete subclass here. + """ + settings.LOGSTASH_AGENT_URL = 'http://localhost:8080' + + from PipelineManager.pipelines_crud import CreatePipeline + from django.test import RequestFactory + from django.contrib.auth.models import User + + rf = RequestFactory() + user = User.objects.get(username='testuser') + request = rf.get('/') + request.user = user + + response = CreatePipeline( + request, + simulate=True, + pipeline_name='sim_pipe', + pipeline_config='input {} filter {} output {}' + ) + assert response.status_code == 500 diff --git a/LogstashUI/PipelineManager/tests/test_pipeline_editor.py b/src/logstashui/PipelineManager/tests/test_pipeline_editor.py similarity index 94% rename from LogstashUI/PipelineManager/tests/test_pipeline_editor.py rename to src/logstashui/PipelineManager/tests/test_pipeline_editor.py index 1594ec72..5ce40d5c 100644 --- a/LogstashUI/PipelineManager/tests/test_pipeline_editor.py +++ b/src/logstashui/PipelineManager/tests/test_pipeline_editor.py @@ -20,7 +20,7 @@ class TestPipelineEditorView: """Test PipelineEditor view""" - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.get_logstash_pipeline') def test_pipeline_editor_success(self, mock_get_pipeline, authenticated_client, test_connection): """Test successful pipeline editor load""" mock_get_pipeline.return_value = { @@ -44,22 +44,22 @@ def test_pipeline_editor_success(self, mock_get_pipeline, authenticated_client, assert b'test_pipeline' in response.content assert b'Test pipeline' in response.content - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.get_logstash_pipeline') def test_pipeline_editor_missing_es_id(self, mock_get_pipeline, authenticated_client): """Test PipelineEditor with missing es_id parameter""" response = authenticated_client.get('/ConnectionManager/Pipelines/Editor/?pipeline=test_pipeline') assert response.status_code == 400 assert b'Missing required parameters' in response.content - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.get_logstash_pipeline') def test_pipeline_editor_missing_pipeline_param(self, mock_get_pipeline, authenticated_client, test_connection): """Test PipelineEditor with missing pipeline parameter""" response = authenticated_client.get(f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}') assert response.status_code == 400 assert b'Missing required parameters' in response.content - @patch('PipelineManager.views.get_logstash_pipeline') - @patch('PipelineManager.views.logstash_config_parse.logstash_config_to_components') + @patch('PipelineManager.editor_views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.logstash_config_parse.logstash_config_to_components') def test_pipeline_editor_with_parsing_error(self, mock_parse, mock_get_pipeline, authenticated_client, test_connection): """Test pipeline editor when parsing fails""" mock_get_pipeline.return_value = { @@ -86,7 +86,7 @@ def test_pipeline_editor_with_parsing_error(self, mock_parse, mock_get_pipeline, class TestGetPipeline: """Test GetPipeline view""" - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.pipelines_crud.get_logstash_pipeline') def test_get_pipeline_success(self, mock_get_pipeline, authenticated_client, test_connection): """Test successful pipeline retrieval""" pipeline_config = 'input { stdin {} }\nfilter { mutate { add_field => { "test" => "value" } } }\noutput { stdout {} }' @@ -170,7 +170,7 @@ def test_components_to_config_invalid_json(self, authenticated_client): assert response.status_code == 500 assert b'Error' in response.content - @patch('PipelineManager.views.logstash_config_parse.logstash_config_to_components') + @patch('PipelineManager.editor_views.logstash_config_parse.logstash_config_to_components') def test_config_to_components_success(self, mock_parse, authenticated_client): """Test successful config to components conversion""" config_text = 'input { stdin {} }\nfilter {}\noutput { stdout {} }' @@ -202,7 +202,7 @@ def test_config_to_components_no_config(self, authenticated_client): assert 'error' in data assert 'No config text provided' in data['error'] - @patch('PipelineManager.views.logstash_config_parse.logstash_config_to_components') + @patch('PipelineManager.editor_views.logstash_config_parse.logstash_config_to_components') def test_config_to_components_parse_error(self, mock_parse, authenticated_client): """Test ConfigToComponents with parsing error""" mock_parse.side_effect = Exception("Invalid syntax") @@ -274,7 +274,7 @@ def test_components_config_roundtrip(self, authenticated_client): class TestGetDiff: """Test GetDiff view""" - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.get_logstash_pipeline') def test_get_diff_with_matching_configs(self, mock_get_pipeline, authenticated_client, test_connection): """Test GetDiff when configs are identical""" current_config = 'input {}\nfilter {}\noutput {}' @@ -302,7 +302,7 @@ def test_get_diff_with_matching_configs(self, mock_get_pipeline, authenticated_c assert 'filter' in data['current'] and 'filter' in data['new'] assert 'output' in data['current'] and 'output' in data['new'] - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.get_logstash_pipeline') def test_get_diff_with_different_configs(self, mock_get_pipeline, authenticated_client, test_connection): """Test GetDiff when configs differ""" current_config = 'input {}\nfilter {}\noutput {}' @@ -334,7 +334,7 @@ def test_get_diff_with_different_configs(self, mock_get_pipeline, authenticated_ # Should show addition of stdin input assert 'stdin' in data['new'] - @patch('PipelineManager.views.get_logstash_pipeline') + @patch('PipelineManager.editor_views.get_logstash_pipeline') def test_get_diff_with_text_mode(self, mock_get_pipeline, authenticated_client, test_connection): """Test GetDiff using raw pipeline text instead of components""" current_config = 'input {}\nfilter {}\noutput {}' @@ -441,7 +441,7 @@ def test_get_current_pipeline_code_mutable_default_safety(self, authenticated_cl class TestClonePipeline: """Test ClonePipeline view""" - @patch('PipelineManager.views.get_elastic_connection') + @patch('PipelineManager.pipelines_crud.get_elastic_connection') def test_clone_pipeline_success(self, mock_get_es, authenticated_client, test_connection): """Test successful pipeline cloning""" mock_es = MagicMock() @@ -470,10 +470,11 @@ def test_clone_pipeline_success(self, mock_get_es, authenticated_client, test_co }) assert response.status_code == 200 - # Should contain script to close modal and refresh - assert b'clonePipelineModal' in response.content or b'script' in response.content + assert b'Pipeline cloned successfully!' in response.content + # Should have HX-Trigger header for HTMX + assert 'HX-Trigger' in response - @patch('PipelineManager.views.get_elastic_connection') + @patch('PipelineManager.pipelines_crud.get_elastic_connection') def test_clone_pipeline_duplicate_name(self, mock_get_es, authenticated_client, test_connection): """Test cloning with duplicate pipeline name""" mock_es = MagicMock() @@ -528,7 +529,7 @@ def test_clone_pipeline_invalid_new_name(self, authenticated_client, test_connec assert response.status_code == 400 assert b'Pipeline' in response.content and (b'invalid' in response.content.lower() or b'error' in response.content.lower()) - @patch('PipelineManager.views.get_elastic_connection') + @patch('PipelineManager.pipelines_crud.get_elastic_connection') def test_clone_pipeline_source_not_found(self, mock_get_es, authenticated_client, test_connection): """Test cloning when source pipeline doesn't exist""" mock_es = MagicMock() diff --git a/src/logstashui/PipelineManager/tests/test_pipelines_crud.py b/src/logstashui/PipelineManager/tests/test_pipelines_crud.py new file mode 100644 index 00000000..e94304bc --- /dev/null +++ b/src/logstashui/PipelineManager/tests/test_pipelines_crud.py @@ -0,0 +1,775 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from Common.test_resources import authenticated_client, test_connection, test_user +from PipelineManager.models import Connection, Policy, Pipeline + +from unittest.mock import patch, MagicMock +import json +import pytest + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def test_policy(db): + """Create a test policy""" + policy = Policy.objects.create( + name='Test Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash' + ) + return policy + + +@pytest.fixture +def test_pipeline(db, test_policy): + """Create a test pipeline""" + pipeline = Pipeline.objects.create( + policy=test_policy, + name='test_pipeline', + lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }', + description='Test pipeline description' + ) + return pipeline + + +# ============================================================================ +# CreatePipeline Tests +# ============================================================================ + +@pytest.mark.django_db +class TestCreatePipeline: + """Test Pipeline Create operations""" + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_create_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection): + """Test successful pipeline creation for centralized connection""" + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': 'test_pipeline', + 'pipeline_config': 'input {}\nfilter {}\noutput {}' + }) + + assert response.status_code == 200 + assert b'Pipeline created successfully!' in response.content + assert 'HX-Redirect' in response + + # Verify put_pipeline was called + mock_es.logstash.put_pipeline.assert_called_once() + call_args = mock_es.logstash.put_pipeline.call_args + assert call_args[1]['id'] == 'test_pipeline' + assert call_args[1]['body']['pipeline'] == 'input {}\nfilter {}\noutput {}' + + def test_create_pipeline_agent_success(self, authenticated_client, test_policy): + """Test successful pipeline creation for agent policy""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'policy_id': test_policy.id, + 'pipeline': 'new_pipeline', + 'pipeline_config': 'input {}\nfilter {}\noutput {}' + }) + + assert response.status_code == 200 + assert b'Pipeline created successfully!' in response.content + assert 'HX-Redirect' in response + + # Verify pipeline was created + assert Pipeline.objects.filter(policy=test_policy, name='new_pipeline').exists() + + # Verify policy marked as changed + test_policy.refresh_from_db() + assert test_policy.has_undeployed_changes is True + + def test_create_pipeline_agent_duplicate_name(self, authenticated_client, test_policy, test_pipeline): + """Test creating pipeline with duplicate name in agent policy""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'policy_id': test_policy.id, + 'pipeline': 'test_pipeline', # Already exists + 'pipeline_config': 'input {}\nfilter {}\noutput {}' + }) + + assert response.status_code == 400 + assert b'already exists' in response.content + + def test_create_pipeline_invalid_name(self, authenticated_client, test_connection): + """Test pipeline creation with invalid name""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '123invalid', # Can't start with number + }) + + assert response.status_code == 400 + assert b'Pipeline ID must begin with a letter or underscore' in response.content + + def test_create_pipeline_empty_name(self, authenticated_client, test_connection): + """Test pipeline creation with empty name""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '', + }) + + assert response.status_code == 400 + assert b'Pipeline name cannot be empty' in response.content + + def test_create_pipeline_nonexistent_policy(self, authenticated_client): + """Test creating pipeline for non-existent policy""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'policy_id': 99999, + 'pipeline': 'test_pipeline', + 'pipeline_config': 'input {}\nfilter {}\noutput {}' + }) + + assert response.status_code == 404 + assert b'not found' in response.content + + def test_create_pipeline_no_context(self, authenticated_client): + """Test creating pipeline without es_id or policy_id""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'pipeline': 'test_pipeline', + 'pipeline_config': 'input {}\nfilter {}\noutput {}' + }) + + assert response.status_code == 400 + assert b'neither policy_id nor es_id provided' in response.content + + +# ============================================================================ +# DeletePipeline Tests +# ============================================================================ + +@pytest.mark.django_db +class TestDeletePipeline: + """Test Pipeline Delete operations""" + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_delete_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection): + """Test successful pipeline deletion for centralized connection""" + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { + 'es_id': test_connection.id, + 'pipeline': 'test_pipeline' + }) + + assert response.status_code == 204 + + # Verify delete_pipeline was called + mock_es.logstash.delete_pipeline.assert_called_once_with(id='test_pipeline') + + def test_delete_pipeline_agent_success(self, authenticated_client, test_policy, test_pipeline): + """Test successful pipeline deletion for agent policy""" + response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { + 'policy_id': test_policy.id, + 'pipeline': 'test_pipeline' + }) + + assert response.status_code == 204 + + # Verify pipeline was deleted + assert not Pipeline.objects.filter(policy=test_policy, name='test_pipeline').exists() + + # Verify policy marked as changed + test_policy.refresh_from_db() + assert test_policy.has_undeployed_changes is True + + def test_delete_pipeline_agent_json_format(self, authenticated_client, test_policy, test_pipeline): + """Test deleting pipeline with JSON request body""" + response = authenticated_client.post( + '/ConnectionManager/DeletePipeline/', + data=json.dumps({ + 'policy_id': test_policy.id, + 'pipeline': 'test_pipeline' + }), + content_type='application/json' + ) + + assert response.status_code == 204 + + # Verify pipeline was deleted + assert not Pipeline.objects.filter(policy=test_policy, name='test_pipeline').exists() + + def test_delete_pipeline_invalid_name(self, authenticated_client, test_connection): + """Test deleting pipeline with invalid name""" + response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '123invalid' + }) + + assert response.status_code == 400 + assert b'must begin with a letter or underscore' in response.content + + def test_delete_pipeline_nonexistent_agent(self, authenticated_client, test_policy): + """Test deleting non-existent pipeline from agent policy""" + response = authenticated_client.post('/ConnectionManager/DeletePipeline/', { + 'policy_id': test_policy.id, + 'pipeline': 'nonexistent' + }) + + assert response.status_code == 404 + assert b'not found' in response.content + + +# ============================================================================ +# UpdatePipelineSettings Tests +# ============================================================================ + +@pytest.mark.django_db +class TestUpdatePipelineSettings: + """Test Pipeline Settings Update operations""" + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + @patch('PipelineManager.pipelines_crud.get_logstash_pipeline') + def test_update_pipeline_settings_centralized_success(self, mock_get_pipeline, mock_get_es, authenticated_client, test_connection): + """Test successful pipeline settings update for centralized connection""" + # Mock existing pipeline + mock_get_pipeline.return_value = { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + 'pipeline_settings': {}, + 'description': '' + } + + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { + 'es_id': test_connection.id, + 'pipeline': 'test_pipeline', + 'description': 'Updated description', + 'pipeline_workers': '2', + 'pipeline_batch_size': '250' + }) + + assert response.status_code == 200 + + # Verify put_pipeline was called with updated settings + mock_es.logstash.put_pipeline.assert_called() + call_args = mock_es.logstash.put_pipeline.call_args + assert call_args[1]['body']['description'] == 'Updated description' + assert call_args[1]['body']['pipeline_settings']['pipeline.workers'] == 2 + assert call_args[1]['body']['pipeline_settings']['pipeline.batch.size'] == 250 + + def test_update_pipeline_settings_agent_success(self, authenticated_client, test_policy, test_pipeline): + """Test successful pipeline settings update for agent policy""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { + 'ls_id': test_policy.id, + 'pipeline': 'test_pipeline', + 'description': 'Updated description', + 'pipeline_workers': '4', + 'pipeline_batch_size': '512' + }) + + assert response.status_code == 200 + + # Verify pipeline was updated + test_pipeline.refresh_from_db() + assert test_pipeline.description == 'Updated description' + assert test_pipeline.pipeline_workers == 4 + assert test_pipeline.pipeline_batch_size == 512 + + # Verify policy marked as changed + test_policy.refresh_from_db() + assert test_policy.has_undeployed_changes is True + + def test_update_pipeline_settings_queue_settings(self, authenticated_client, test_policy, test_pipeline): + """Test updating queue settings""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { + 'ls_id': test_policy.id, + 'pipeline': 'test_pipeline', + 'queue_type': 'persisted', + 'queue_max_bytes': '10', + 'queue_max_bytes_unit': 'gb', + 'queue_checkpoint_writes': '2048' + }) + + assert response.status_code == 200 + + # Verify pipeline was updated + test_pipeline.refresh_from_db() + assert test_pipeline.queue_type == 'persisted' + assert test_pipeline.queue_max_bytes == '10gb' + assert test_pipeline.queue_checkpoint_writes == 2048 + + def test_update_pipeline_settings_missing_pipeline_id(self, authenticated_client): + """Test updating settings without pipeline ID or connection ID""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { + 'pipeline': 'test_pipeline', + 'description': 'Updated' + }) + + assert response.status_code == 400 + assert b'Missing pipeline ID or connection ID' in response.content + + def test_update_pipeline_settings_invalid_name(self, authenticated_client, test_connection): + """Test updating settings with invalid pipeline name""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { + 'es_id': test_connection.id, + 'pipeline': '123invalid' + }) + + assert response.status_code == 400 + assert b'must begin with a letter or underscore' in response.content + + def test_update_pipeline_settings_nonexistent_agent(self, authenticated_client, test_policy): + """Test updating settings for non-existent agent pipeline""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', { + 'ls_id': test_policy.id, + 'pipeline': 'nonexistent', + 'description': 'Test' + }) + + assert response.status_code == 404 + assert b'not found' in response.content + + def test_update_pipeline_settings_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/UpdatePipelineSettings/') + + assert response.status_code == 405 + assert b'Invalid request method' in response.content + + +# ============================================================================ +# ClonePipeline Tests +# ============================================================================ + +@pytest.mark.django_db +class TestClonePipeline: + """Test Pipeline Clone operations""" + + def test_clone_pipeline_agent_success(self, authenticated_client, test_policy, test_pipeline): + """Test successful pipeline cloning for agent policy""" + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'test_pipeline', + 'new_pipeline': 'cloned_pipeline' + }) + + assert response.status_code == 200 + assert b'Pipeline cloned successfully!' in response.content + assert 'HX-Trigger' in response + + # Verify cloned pipeline was created + cloned = Pipeline.objects.get(policy=test_policy, name='cloned_pipeline') + assert cloned.lscl == test_pipeline.lscl + assert cloned.description == 'Cloned from test_pipeline' + + # Verify policy marked as changed + test_policy.refresh_from_db() + assert test_policy.has_undeployed_changes is True + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_clone_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection): + """Test successful pipeline cloning for centralized connection""" + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.get_pipeline.return_value = { + 'source_pipeline': { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + 'pipeline_settings': {'pipeline.workers': 2}, + 'description': 'Source pipeline' + } + } + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'source_pipeline', + 'new_pipeline': 'cloned_pipeline' + }) + + assert response.status_code == 200 + assert b'Pipeline cloned successfully!' in response.content + + # Verify put_pipeline was called + mock_es.logstash.put_pipeline.assert_called_once() + call_args = mock_es.logstash.put_pipeline.call_args + assert call_args[1]['id'] == 'cloned_pipeline' + + def test_clone_pipeline_duplicate_name(self, authenticated_client, test_policy, test_pipeline): + """Test cloning pipeline with duplicate name""" + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'test_pipeline', + 'new_pipeline': 'test_pipeline' # Same name + }) + + assert response.status_code == 400 + assert b'already exists' in response.content + + def test_clone_pipeline_invalid_source_name(self, authenticated_client, test_policy): + """Test cloning with invalid source pipeline name""" + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': '123invalid', + 'new_pipeline': 'new_pipeline' + }) + + assert response.status_code == 400 + assert b'Invalid source pipeline name' in response.content + + def test_clone_pipeline_invalid_new_name(self, authenticated_client, test_policy, test_pipeline): + """Test cloning with invalid new pipeline name""" + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'test_pipeline', + 'new_pipeline': '123invalid' + }) + + assert response.status_code == 400 + assert b'must begin with a letter or underscore' in response.content + + def test_clone_pipeline_nonexistent_source(self, authenticated_client, test_policy): + """Test cloning non-existent source pipeline""" + response = authenticated_client.post('/ConnectionManager/ClonePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'nonexistent', + 'new_pipeline': 'new_pipeline' + }) + + assert response.status_code == 404 + assert b'not found' in response.content + + +# ============================================================================ +# RenamePipeline Tests +# ============================================================================ + +@pytest.mark.django_db +class TestRenamePipeline: + """Test Pipeline Rename operations""" + + def test_rename_pipeline_agent_success(self, authenticated_client, test_policy, test_pipeline): + """Test successful pipeline renaming for agent policy""" + response = authenticated_client.post('/ConnectionManager/RenamePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'test_pipeline', + 'new_pipeline': 'renamed_pipeline' + }) + + assert response.status_code == 200 + assert b'Pipeline renamed successfully!' in response.content + assert 'HX-Trigger' in response + + # Verify renamed pipeline exists + assert Pipeline.objects.filter(policy=test_policy, name='renamed_pipeline').exists() + + # Verify original pipeline was deleted + assert not Pipeline.objects.filter(policy=test_policy, name='test_pipeline').exists() + + # Verify policy marked as changed + test_policy.refresh_from_db() + assert test_policy.has_undeployed_changes is True + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_rename_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection): + """Test successful pipeline renaming for centralized connection""" + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.get_pipeline.return_value = { + 'source_pipeline': { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + 'pipeline_settings': {'pipeline.workers': 2}, + 'description': 'Source pipeline' + } + } + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/RenamePipeline/', { + 'es_id': test_connection.id, + 'source_pipeline': 'source_pipeline', + 'new_pipeline': 'renamed_pipeline' + }) + + assert response.status_code == 200 + assert b'Pipeline renamed successfully!' in response.content + + # Verify put_pipeline and delete_pipeline were called + mock_es.logstash.put_pipeline.assert_called_once() + mock_es.logstash.delete_pipeline.assert_called_once_with(id='source_pipeline') + + def test_rename_pipeline_duplicate_name(self, authenticated_client, test_policy): + """Test renaming pipeline to existing name""" + # Create two pipelines + Pipeline.objects.create( + policy=test_policy, + name='pipeline1', + lscl='input {} filter {} output {}' + ) + Pipeline.objects.create( + policy=test_policy, + name='pipeline2', + lscl='input {} filter {} output {}' + ) + + response = authenticated_client.post('/ConnectionManager/RenamePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'pipeline1', + 'new_pipeline': 'pipeline2' # Already exists + }) + + assert response.status_code == 400 + assert b'already exists' in response.content + + def test_rename_pipeline_invalid_source_name(self, authenticated_client, test_policy): + """Test renaming with invalid source pipeline name""" + response = authenticated_client.post('/ConnectionManager/RenamePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': '123invalid', + 'new_pipeline': 'new_pipeline' + }) + + assert response.status_code == 400 + assert b'Invalid source pipeline name' in response.content + + def test_rename_pipeline_invalid_new_name(self, authenticated_client, test_policy, test_pipeline): + """Test renaming with invalid new pipeline name""" + response = authenticated_client.post('/ConnectionManager/RenamePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'test_pipeline', + 'new_pipeline': '123invalid' + }) + + assert response.status_code == 400 + assert b'must begin with a letter or underscore' in response.content + + def test_rename_pipeline_nonexistent_source(self, authenticated_client, test_policy): + """Test renaming non-existent source pipeline""" + response = authenticated_client.post('/ConnectionManager/RenamePipeline/', { + 'policy_id': test_policy.id, + 'source_pipeline': 'nonexistent', + 'new_pipeline': 'new_pipeline' + }) + + assert response.status_code == 404 + assert b'not found' in response.content + + +# ============================================================================ +# UpdatePipelineDescription Tests +# ============================================================================ + +@pytest.mark.django_db +class TestUpdatePipelineDescription: + """Test Pipeline Description Update operations""" + + def test_update_description_agent_success(self, authenticated_client, test_policy, test_pipeline): + """Test successful description update for agent policy""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', { + 'policy_id': test_policy.id, + 'pipeline_name': 'test_pipeline', + 'description': 'Updated description' + }) + + assert response.status_code == 200 + assert b'Pipeline description updated successfully!' in response.content + assert 'HX-Trigger' in response + + # Verify description was updated + test_pipeline.refresh_from_db() + assert test_pipeline.description == 'Updated description' + + # Verify policy marked as changed + test_policy.refresh_from_db() + assert test_policy.has_undeployed_changes is True + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_update_description_centralized_success(self, mock_get_es, authenticated_client, test_connection): + """Test successful description update for centralized connection""" + # Mock Elasticsearch connection + mock_es = MagicMock() + mock_es.logstash.get_pipeline.return_value = { + 'test_pipeline': { + 'pipeline': 'input {}\nfilter {}\noutput {}', + 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'}, + 'pipeline_settings': {}, + 'description': 'Old description' + } + } + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', { + 'es_id': test_connection.id, + 'pipeline_name': 'test_pipeline', + 'description': 'New description' + }) + + assert response.status_code == 200 + assert b'Pipeline description updated successfully!' in response.content + + # Verify put_pipeline was called with new description + mock_es.logstash.put_pipeline.assert_called_once() + call_args = mock_es.logstash.put_pipeline.call_args + assert call_args[1]['body']['description'] == 'New description' + + def test_update_description_invalid_name(self, authenticated_client, test_policy): + """Test updating description with invalid pipeline name""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', { + 'policy_id': test_policy.id, + 'pipeline_name': '123invalid', + 'description': 'Test' + }) + + assert response.status_code == 400 + assert b'Invalid pipeline name' in response.content + + def test_update_description_nonexistent_agent(self, authenticated_client, test_policy): + """Test updating description for non-existent agent pipeline""" + response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', { + 'policy_id': test_policy.id, + 'pipeline_name': 'nonexistent', + 'description': 'Test' + }) + + assert response.status_code == 404 + assert b'not found' in response.content + + +# ============================================================================ +# GetPipeline Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPipeline: + """Test GetPipeline endpoint""" + + @patch('PipelineManager.pipelines_crud.get_logstash_pipeline') + def test_get_pipeline_success(self, mock_get_pipeline, authenticated_client, test_connection): + """Test successful pipeline retrieval""" + mock_get_pipeline.return_value = { + 'pipeline': 'input {}\nfilter {}\noutput {}' + } + + response = authenticated_client.get( + f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=test_pipeline' + ) + + assert response.status_code == 200 + data = response.json() + assert 'code' in data + assert data['code'] == 'input {}\nfilter {}\noutput {}' + + def test_get_pipeline_missing_es_id(self, authenticated_client): + """Test getting pipeline without es_id""" + response = authenticated_client.get('/ConnectionManager/GetPipeline/?pipeline=test_pipeline') + + assert response.status_code == 400 + data = response.json() + assert 'error' in data + assert 'Missing required parameters' in data['error'] + + def test_get_pipeline_missing_pipeline_name(self, authenticated_client, test_connection): + """Test getting pipeline without pipeline name""" + response = authenticated_client.get(f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}') + + assert response.status_code == 400 + data = response.json() + assert 'error' in data + assert 'Missing required parameters' in data['error'] + + @patch('Common.logstash_utils.get_logstash_pipeline') + def test_get_pipeline_not_found(self, mock_get_pipeline, authenticated_client, test_connection): + """Test getting non-existent pipeline""" + mock_get_pipeline.return_value = None + + response = authenticated_client.get( + f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=nonexistent' + ) + + assert response.status_code == 400 + data = response.json() + assert 'error' in data + assert 'Could not fetch pipeline' in data['error'] + + +# ============================================================================ +# Pipeline Name Validation Tests +# ============================================================================ + +@pytest.mark.django_db +class TestPipelineNameValidation: + """Test pipeline name validation""" + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_pipeline_name_starts_with_letter(self, mock_get_es, authenticated_client, test_connection): + """Test that pipeline name can start with a letter""" + mock_es = MagicMock() + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': 'valid_pipeline' + }) + + assert response.status_code == 200 + + @patch('PipelineManager.pipelines_crud.get_elastic_connection') + def test_pipeline_name_starts_with_underscore(self, mock_get_es, authenticated_client, test_connection): + """Test that pipeline name can start with underscore""" + mock_es = MagicMock() + mock_es.logstash.put_pipeline.return_value = {'acknowledged': True} + mock_get_es.return_value = mock_es + + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '_valid_pipeline' + }) + + assert response.status_code == 200 + + def test_pipeline_name_starts_with_number_invalid(self, authenticated_client, test_connection): + """Test that pipeline name cannot start with a number""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '123invalid' + }) + + assert response.status_code == 400 + assert b'must begin with a letter or underscore' in response.content + + def test_pipeline_name_special_chars_invalid(self, authenticated_client, test_connection): + """Test that pipeline name cannot contain special characters""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': 'invalid@pipeline' + }) + + assert response.status_code == 400 + # Pipeline name starts with a letter but contains invalid @ character + # Check for the actual error message from the validator + assert (b'can only contain letters' in response.content or + b'underscores, dashes, hyphens' in response.content) + + def test_pipeline_name_empty_invalid(self, authenticated_client, test_connection): + """Test that pipeline name cannot be empty""" + response = authenticated_client.post('/ConnectionManager/CreatePipeline/', { + 'es_id': test_connection.id, + 'pipeline': '' + }) + + assert response.status_code == 400 + assert b'Pipeline name cannot be empty' in response.content diff --git a/src/logstashui/PipelineManager/tests/test_policies_crud.py b/src/logstashui/PipelineManager/tests/test_policies_crud.py new file mode 100644 index 00000000..23c3ca68 --- /dev/null +++ b/src/logstashui/PipelineManager/tests/test_policies_crud.py @@ -0,0 +1,860 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from Common.test_resources import authenticated_client, test_user +from PipelineManager.models import Connection, Policy, Pipeline, Keystore, EnrollmentToken + +from unittest.mock import patch +import json +import pytest + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def test_policy(db): + """Create a test policy""" + policy = Policy.objects.create( + name='Test Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash' + ) + return policy + + +@pytest.fixture +def test_policy_with_pipelines(db): + """Create a test policy with pipelines""" + policy = Policy.objects.create( + name='Policy With Pipelines', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin', + logstash_yml='http.host: "0.0.0.0"', + jvm_options='-Xms1g\n-Xmx1g', + log4j2_properties='logger.logstash.name = logstash' + ) + + # Create pipelines + Pipeline.objects.create( + policy=policy, + name='pipeline1', + lscl='input {} filter {} output {}' + ) + Pipeline.objects.create( + policy=policy, + name='pipeline2', + lscl='input {} filter {} output {}' + ) + + # Create keystore entries + Keystore.objects.create( + policy=policy, + key_name='key1', + key_value='value1' + ) + + return policy + + +@pytest.fixture +def test_enrollment_token(db, test_policy): + """Create a test enrollment token""" + token = EnrollmentToken.objects.create( + policy=test_policy, + name='test_token', + token='test_token_value_123' + ) + return token + + +# ============================================================================ +# GetPolicies Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetPolicies: + """Tests for the get_policies endpoint""" + + def test_get_policies_success(self, authenticated_client, test_policy): + """Test successful retrieval of policies""" + response = authenticated_client.get('/ConnectionManager/GetPolicies/') + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'policies' in data + assert len(data['policies']) >= 1 + + # Find our test policy + policy_names = [p['name'] for p in data['policies']] + assert 'Test Policy' in policy_names + + def test_get_policies_includes_connection_count(self, authenticated_client, test_policy): + """Test that policies include connection count""" + # Create an agent connection + Connection.objects.create( + name='Test Agent', + connection_type='AGENT', + host='agent.example.com', + agent_id='test-001', + is_active=True, + policy=test_policy + ) + + response = authenticated_client.get('/ConnectionManager/GetPolicies/') + + assert response.status_code == 200 + data = response.json() + + # Find our test policy + test_policy_data = next(p for p in data['policies'] if p['name'] == 'Test Policy') + assert test_policy_data['connection_count'] == 1 + + def test_get_policies_empty(self, authenticated_client): + """Test getting policies when none exist""" + Policy.objects.all().delete() + + response = authenticated_client.get('/ConnectionManager/GetPolicies/') + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['policies']) == 0 + + +# ============================================================================ +# AddPolicy Tests +# ============================================================================ + +@pytest.mark.django_db +class TestAddPolicy: + """Tests for the add_policy endpoint""" + + def test_add_policy_success(self, authenticated_client): + """Test successful policy creation""" + response = authenticated_client.post( + '/ConnectionManager/AddPolicy/', + data=json.dumps({ + 'name': 'New Policy', + 'settings_path': '/etc/logstash/', + 'logs_path': '/var/log/logstash', + 'binary_path': '/usr/share/logstash/bin', + 'logstash_yml': 'http.host: "0.0.0.0"', + 'jvm_options': '-Xms1g\n-Xmx1g', + 'log4j2_properties': 'logger.logstash.name = logstash' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'policy_id' in data + assert data['policy_name'] == 'New Policy' + + # Verify policy was created + assert Policy.objects.filter(name='New Policy').exists() + + # Verify enrollment token was created + policy = Policy.objects.get(name='New Policy') + assert EnrollmentToken.objects.filter(policy=policy, name='default').exists() + + def test_add_policy_with_defaults(self, authenticated_client): + """Test policy creation with default values""" + response = authenticated_client.post( + '/ConnectionManager/AddPolicy/', + data=json.dumps({ + 'name': 'Minimal Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify policy was created with defaults + policy = Policy.objects.get(name='Minimal Policy') + assert policy.settings_path == '/etc/logstash/' + assert policy.logs_path == '/var/log/logstash' + assert policy.binary_path == '/usr/share/logstash/bin' + assert len(policy.logstash_yml) > 0 # Should have default config + assert len(policy.jvm_options) > 0 + assert len(policy.log4j2_properties) > 0 + + def test_add_policy_missing_name(self, authenticated_client): + """Test policy creation without name""" + response = authenticated_client.post( + '/ConnectionManager/AddPolicy/', + data=json.dumps({}), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy name is required' in data['error'] + + def test_add_policy_duplicate_name(self, authenticated_client, test_policy): + """Test creating policy with duplicate name""" + response = authenticated_client.post( + '/ConnectionManager/AddPolicy/', + data=json.dumps({ + 'name': 'Test Policy' # Already exists + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'already exists' in data['error'] + + def test_add_policy_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/AddPolicy/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_add_policy_invalid_json(self, authenticated_client): + """Test policy creation with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/AddPolicy/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# UpdatePolicy Tests +# ============================================================================ + +@pytest.mark.django_db +class TestUpdatePolicy: + """Tests for the update_policy endpoint""" + + def test_update_policy_success(self, authenticated_client, test_policy): + """Test successful policy update""" + response = authenticated_client.post( + '/ConnectionManager/UpdatePolicy/', + data=json.dumps({ + 'policy_name': 'Test Policy', + 'settings_path': '/new/settings', + 'logstash_yml': 'http.host: "127.0.0.1"' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify policy was updated + test_policy.refresh_from_db() + assert test_policy.settings_path == '/new/settings' + assert test_policy.logstash_yml == 'http.host: "127.0.0.1"' + + def test_update_policy_partial_update(self, authenticated_client, test_policy): + """Test updating only some fields""" + original_logs_path = test_policy.logs_path + + response = authenticated_client.post( + '/ConnectionManager/UpdatePolicy/', + data=json.dumps({ + 'policy_name': 'Test Policy', + 'settings_path': '/updated/settings' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + + # Verify only specified field was updated + test_policy.refresh_from_db() + assert test_policy.settings_path == '/updated/settings' + assert test_policy.logs_path == original_logs_path # Unchanged + + def test_update_policy_missing_name(self, authenticated_client): + """Test updating policy without name""" + response = authenticated_client.post( + '/ConnectionManager/UpdatePolicy/', + data=json.dumps({ + 'settings_path': '/new/settings' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy name is required' in data['error'] + + def test_update_policy_nonexistent(self, authenticated_client): + """Test updating non-existent policy""" + response = authenticated_client.post( + '/ConnectionManager/UpdatePolicy/', + data=json.dumps({ + 'policy_name': 'Nonexistent Policy', + 'settings_path': '/new/settings' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'not found' in data['error'] + + def test_update_policy_default_policy_forbidden(self, authenticated_client): + """Test that Default Policy cannot be updated""" + # Create Default Policy + Policy.objects.create( + name='Default Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin' + ) + + response = authenticated_client.post( + '/ConnectionManager/UpdatePolicy/', + data=json.dumps({ + 'policy_name': 'Default Policy', + 'settings_path': '/new/settings' + }), + content_type='application/json' + ) + + assert response.status_code == 403 + data = response.json() + assert data['success'] is False + assert 'Cannot update Default Policy' in data['error'] + + def test_update_policy_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/UpdatePolicy/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_update_policy_invalid_json(self, authenticated_client): + """Test policy update with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/UpdatePolicy/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# DeletePolicy Tests +# ============================================================================ + +@pytest.mark.django_db +class TestDeletePolicy: + """Tests for the delete_policy endpoint""" + + def test_delete_policy_success(self, authenticated_client, test_policy): + """Test successful policy deletion""" + response = authenticated_client.post( + '/ConnectionManager/DeletePolicy/', + data=json.dumps({ + 'policy_name': 'Test Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify policy was deleted + assert not Policy.objects.filter(name='Test Policy').exists() + + def test_delete_policy_missing_name(self, authenticated_client): + """Test deleting policy without name""" + response = authenticated_client.post( + '/ConnectionManager/DeletePolicy/', + data=json.dumps({}), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy name is required' in data['error'] + + def test_delete_policy_nonexistent(self, authenticated_client): + """Test deleting non-existent policy""" + response = authenticated_client.post( + '/ConnectionManager/DeletePolicy/', + data=json.dumps({ + 'policy_name': 'Nonexistent Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'not found' in data['error'] + + def test_delete_policy_default_policy_forbidden(self, authenticated_client): + """Test that Default Policy cannot be deleted""" + # Create Default Policy + Policy.objects.create( + name='Default Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin' + ) + + response = authenticated_client.post( + '/ConnectionManager/DeletePolicy/', + data=json.dumps({ + 'policy_name': 'Default Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 403 + data = response.json() + assert data['success'] is False + assert 'Cannot delete Default Policy' in data['error'] + + def test_delete_policy_in_use(self, authenticated_client, test_policy): + """Test that policy in use cannot be deleted""" + # Create a connection using this policy + Connection.objects.create( + name='Test Agent', + connection_type='AGENT', + host='agent.example.com', + agent_id='test-001', + is_active=True, + policy=test_policy + ) + + response = authenticated_client.post( + '/ConnectionManager/DeletePolicy/', + data=json.dumps({ + 'policy_name': 'Test Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'currently assigned to' in data['error'] + + # Verify policy was not deleted + assert Policy.objects.filter(name='Test Policy').exists() + + def test_delete_policy_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/DeletePolicy/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_delete_policy_invalid_json(self, authenticated_client): + """Test policy deletion with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/DeletePolicy/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# ClonePolicy Tests +# ============================================================================ + +@pytest.mark.django_db +class TestClonePolicy: + """Tests for the clone_policy endpoint""" + + def test_clone_policy_success(self, authenticated_client, test_policy_with_pipelines): + """Test successful policy cloning""" + response = authenticated_client.post( + '/ConnectionManager/ClonePolicy/', + data=json.dumps({ + 'source_policy_id': test_policy_with_pipelines.id, + 'new_policy_name': 'Cloned Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'policy_id' in data + assert data['policy_name'] == 'Cloned Policy' + + # Verify new policy was created + cloned_policy = Policy.objects.get(name='Cloned Policy') + assert cloned_policy.settings_path == test_policy_with_pipelines.settings_path + assert cloned_policy.logstash_yml == test_policy_with_pipelines.logstash_yml + + # Verify pipelines were cloned + assert Pipeline.objects.filter(policy=cloned_policy).count() == 2 + assert Pipeline.objects.filter(policy=cloned_policy, name='pipeline1').exists() + assert Pipeline.objects.filter(policy=cloned_policy, name='pipeline2').exists() + + # Verify keystore entries were cloned + assert Keystore.objects.filter(policy=cloned_policy).count() == 1 + assert Keystore.objects.filter(policy=cloned_policy, key_name='key1').exists() + + # Verify enrollment token was created + assert EnrollmentToken.objects.filter(policy=cloned_policy, name='default').exists() + + def test_clone_policy_missing_source_id(self, authenticated_client): + """Test cloning without source policy ID""" + response = authenticated_client.post( + '/ConnectionManager/ClonePolicy/', + data=json.dumps({ + 'new_policy_name': 'Cloned Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Source policy ID is required' in data['error'] + + def test_clone_policy_missing_new_name(self, authenticated_client, test_policy): + """Test cloning without new policy name""" + response = authenticated_client.post( + '/ConnectionManager/ClonePolicy/', + data=json.dumps({ + 'source_policy_id': test_policy.id + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'New policy name is required' in data['error'] + + def test_clone_policy_duplicate_name(self, authenticated_client, test_policy): + """Test cloning to existing policy name""" + # Create another policy + Policy.objects.create( + name='Existing Policy', + settings_path='/etc/logstash/', + logs_path='/var/log/logstash', + binary_path='/usr/share/logstash/bin' + ) + + response = authenticated_client.post( + '/ConnectionManager/ClonePolicy/', + data=json.dumps({ + 'source_policy_id': test_policy.id, + 'new_policy_name': 'Existing Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'already exists' in data['error'] + + def test_clone_policy_nonexistent_source(self, authenticated_client): + """Test cloning non-existent source policy""" + response = authenticated_client.post( + '/ConnectionManager/ClonePolicy/', + data=json.dumps({ + 'source_policy_id': 99999, + 'new_policy_name': 'Cloned Policy' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Source policy not found' in data['error'] + + def test_clone_policy_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/ClonePolicy/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_clone_policy_invalid_json(self, authenticated_client): + """Test policy cloning with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/ClonePolicy/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# GetEnrollmentTokens Tests +# ============================================================================ + +@pytest.mark.django_db +class TestGetEnrollmentTokens: + """Tests for the get_enrollment_tokens endpoint""" + + def test_get_enrollment_tokens_success(self, authenticated_client, test_policy, test_enrollment_token): + """Test successful retrieval of enrollment tokens""" + response = authenticated_client.get( + f'/ConnectionManager/GetEnrollmentTokens/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['tokens']) >= 1 + + # Verify token data + token_data = next(t for t in data['tokens'] if t['name'] == 'test_token') + assert token_data['raw_token'] == 'test_token_value_123' + assert 'encoded_token' in token_data + + def test_get_enrollment_tokens_empty(self, authenticated_client, test_policy): + """Test getting tokens when none exist""" + response = authenticated_client.get( + f'/ConnectionManager/GetEnrollmentTokens/?policy_id={test_policy.id}' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert len(data['tokens']) == 0 + + def test_get_enrollment_tokens_missing_policy_id(self, authenticated_client): + """Test getting tokens without policy_id""" + response = authenticated_client.get('/ConnectionManager/GetEnrollmentTokens/') + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_get_enrollment_tokens_nonexistent_policy(self, authenticated_client): + """Test getting tokens for non-existent policy""" + response = authenticated_client.get('/ConnectionManager/GetEnrollmentTokens/?policy_id=99999') + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + +# ============================================================================ +# AddEnrollmentToken Tests +# ============================================================================ + +@pytest.mark.django_db +class TestAddEnrollmentToken: + """Tests for the add_enrollment_token endpoint""" + + def test_add_enrollment_token_success(self, authenticated_client, test_policy): + """Test successful enrollment token creation""" + response = authenticated_client.post( + '/ConnectionManager/AddEnrollmentToken/', + data=json.dumps({ + 'policy_id': test_policy.id, + 'name': 'new_token' + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + assert 'token_id' in data + + # Verify token was created + assert EnrollmentToken.objects.filter(policy=test_policy, name='new_token').exists() + + def test_add_enrollment_token_default_name(self, authenticated_client, test_policy): + """Test token creation with default name""" + response = authenticated_client.post( + '/ConnectionManager/AddEnrollmentToken/', + data=json.dumps({ + 'policy_id': test_policy.id + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify token was created with default name + assert EnrollmentToken.objects.filter(policy=test_policy, name='default').exists() + + def test_add_enrollment_token_missing_policy_id(self, authenticated_client): + """Test token creation without policy_id""" + response = authenticated_client.post( + '/ConnectionManager/AddEnrollmentToken/', + data=json.dumps({ + 'name': 'new_token' + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Policy ID is required' in data['error'] + + def test_add_enrollment_token_nonexistent_policy(self, authenticated_client): + """Test token creation for non-existent policy""" + response = authenticated_client.post( + '/ConnectionManager/AddEnrollmentToken/', + data=json.dumps({ + 'policy_id': 99999, + 'name': 'new_token' + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Policy not found' in data['error'] + + def test_add_enrollment_token_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/AddEnrollmentToken/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_add_enrollment_token_invalid_json(self, authenticated_client): + """Test token creation with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/AddEnrollmentToken/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] + + +# ============================================================================ +# DeleteEnrollmentToken Tests +# ============================================================================ + +@pytest.mark.django_db +class TestDeleteEnrollmentToken: + """Tests for the delete_enrollment_token endpoint""" + + def test_delete_enrollment_token_success(self, authenticated_client, test_enrollment_token): + """Test successful enrollment token deletion""" + token_id = test_enrollment_token.id + + response = authenticated_client.post( + '/ConnectionManager/DeleteEnrollmentToken/', + data=json.dumps({ + 'token_id': token_id + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json() + assert data['success'] is True + + # Verify token was deleted + assert not EnrollmentToken.objects.filter(id=token_id).exists() + + def test_delete_enrollment_token_missing_token_id(self, authenticated_client): + """Test deleting token without token_id""" + response = authenticated_client.post( + '/ConnectionManager/DeleteEnrollmentToken/', + data=json.dumps({}), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Token ID is required' in data['error'] + + def test_delete_enrollment_token_nonexistent(self, authenticated_client): + """Test deleting non-existent token""" + response = authenticated_client.post( + '/ConnectionManager/DeleteEnrollmentToken/', + data=json.dumps({ + 'token_id': 99999 + }), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.json() + assert data['success'] is False + assert 'Enrollment token not found' in data['error'] + + def test_delete_enrollment_token_wrong_method(self, authenticated_client): + """Test that GET requests are rejected""" + response = authenticated_client.get('/ConnectionManager/DeleteEnrollmentToken/') + + assert response.status_code == 405 + data = response.json() + assert data['success'] is False + assert 'Method not allowed' in data['error'] + + def test_delete_enrollment_token_invalid_json(self, authenticated_client): + """Test token deletion with invalid JSON""" + response = authenticated_client.post( + '/ConnectionManager/DeleteEnrollmentToken/', + data='not valid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.json() + assert data['success'] is False + assert 'Invalid JSON data' in data['error'] diff --git a/LogstashUI/PipelineManager/tests/test_simulation.py b/src/logstashui/PipelineManager/tests/test_simulation.py similarity index 99% rename from LogstashUI/PipelineManager/tests/test_simulation.py rename to src/logstashui/PipelineManager/tests/test_simulation.py index 5a522f62..b77448d0 100644 --- a/LogstashUI/PipelineManager/tests/test_simulation.py +++ b/src/logstashui/PipelineManager/tests/test_simulation.py @@ -445,7 +445,7 @@ def test_check_pipeline_loaded_no_pipeline_name(self, authenticated_client): @patch('PipelineManager.simulation.requests.get') def test_check_pipeline_loaded_service_unavailable(self, mock_get, authenticated_client): - """Test CheckIfPipelineLoaded when LogstashAgent is unavailable""" + """Test CheckIfPipelineLoaded when logstashagent is unavailable""" mock_get.side_effect = Exception("Connection refused") response = authenticated_client.get('/ConnectionManager/CheckIfPipelineLoaded/?pipeline_name=slot1-filter1') @@ -538,7 +538,7 @@ def test_get_related_logs_with_filters(self, mock_get, authenticated_client): @patch('PipelineManager.simulation.requests.get') def test_get_related_logs_service_unavailable(self, mock_get, authenticated_client): - """Test GetRelatedLogs when LogstashAgent is unavailable""" + """Test GetRelatedLogs when logstashagent is unavailable""" mock_get.side_effect = Exception("Connection refused") response = authenticated_client.get('/ConnectionManager/GetRelatedLogs/?slot_id=1') @@ -634,7 +634,7 @@ def test_upload_file_oversized(self, mock_post, authenticated_client): @patch('PipelineManager.simulation.requests.post') def test_upload_file_agent_failure(self, mock_post, authenticated_client): - """Test UploadFile when LogstashAgent fails""" + """Test UploadFile when logstashagent fails""" mock_post.side_effect = Exception("Connection refused") file_content = b'test content' diff --git a/src/logstashui/PipelineManager/urls.py b/src/logstashui/PipelineManager/urls.py new file mode 100644 index 00000000..673b03d6 --- /dev/null +++ b/src/logstashui/PipelineManager/urls.py @@ -0,0 +1,90 @@ +#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +#or more contributor license agreements. Licensed under the Elastic License; +#you may not use this file except in compliance with the Elastic License. + +from django.urls import path +from . import agent_api, simulation, manager_views, editor_views, policies_crud, agent_policies, connections_crud, \ + pipelines_crud + +urlpatterns = [ + + path("", manager_views.PipelineManager, name="PipelineManager"), + path("Pipelines/Editor/", editor_views.PipelineEditor, name="PipelineEditor"), + path("AgentPolicies/", manager_views.AgentPolicies, name="AgentPolicies"), + + path("SimulatePipeline/", simulation.SimulatePipeline, name="SimulatePipeline"), + path("StreamSimulate/", simulation.StreamSimulate, name="StreamSimulate"), + path("GetSimulationResults/", simulation.GetSimulationResults, name="GetSimulationResults"), + path("CheckIfPipelineLoaded/", simulation.CheckIfPipelineLoaded, name="CheckIfPipelineLoaded"), + path("GetRelatedLogs/", simulation.GetRelatedLogs, name="GetRelatedLogs"), + path("UploadFile/", simulation.UploadFile, name="UploadFile"), + path("GetSimulationNodeStatus/", simulation.GetSimulationNodeStatus, name="GetSimulationNodeStatus"), + path("GetSimulationNodeHealth/", simulation.GetSimulationNodeHealth, name="GetSimulationNodeHealth"), + path("ValidateLogstashConfig/", simulation.ValidateLogstashConfig, name="ValidateLogstashConfig"), + + path('TestConnectivity', manager_views.TestConnectivity, name='TestConnectivity'), + + path("GetConnections/", connections_crud.GetConnections, name="GetConnections"), + path("AddConnection", connections_crud.AddConnection, name="AddConnection"), + path("DeleteConnection//", connections_crud.DeleteConnection, name="DeleteConnection"), + path("UpgradeAgent//", connections_crud.UpgradeAgent, name="UpgradeAgent"), + path("ChangeConnectionPolicy/", connections_crud.change_connection_policy, name="ChangeConnectionPolicy"), + path("RestartLogstash/", connections_crud.restart_logstash, name="RestartLogstash"), + path("GetPipelines//", connections_crud.GetPipelines, name="GetPipelines"), + path("GetPolicyPipelines/", connections_crud.GetPolicyPipelines, name="GetPolicyPipelines"), + + path("GetPolicies/", policies_crud.get_policies, name="GetPolicies"), + path("AddPolicy/", policies_crud.add_policy, name="AddPolicy"), + path("UpdatePolicy/", policies_crud.update_policy, name="UpdatePolicy"), + path("DeletePolicy/", policies_crud.delete_policy, name="DeletePolicy"), + path("ClonePolicy/", policies_crud.clone_policy, name="ClonePolicy"), + path("GetEnrollmentTokens/", policies_crud.get_enrollment_tokens, name="GetEnrollmentTokens"), + path("AddEnrollmentToken/", policies_crud.add_enrollment_token, name="AddEnrollmentToken"), + path("DeleteEnrollmentToken/", policies_crud.delete_enrollment_token, name="DeleteEnrollmentToken"), + + path("GetPolicyDiff/", agent_policies.get_policy_diff, name="GetPolicyDiff"), + path("GetPolicyAgentCount/", agent_policies.get_policy_agent_count, name="GetPolicyAgentCount"), + path("GetPolicyChangeCount/", agent_policies.get_policy_change_count, name="GetPolicyChangeCount"), + path("DeployPolicy/", agent_policies.deploy_policy, name="DeployPolicy"), + path("GenerateEnrollmentToken/", agent_policies.generate_enrollment_token, name="GenerateEnrollmentToken"), + + path("Enroll/", agent_api.enroll, name="Enroll"), + path("CheckIn/", agent_api.check_in, name="CheckIn"), + path("GetConfigChanges/", agent_api.get_config_changes, name="GetConfigChanges"), + + path("GetKeystoreEntries/", agent_policies.get_keystore_entries, name="GetKeystoreEntries"), + path("CreateKeystoreEntry/", agent_policies.create_keystore_entry, name="CreateKeystoreEntry"), + path("UpdateKeystoreEntry/", agent_policies.update_keystore_entry, name="UpdateKeystoreEntry"), + path("DeleteKeystoreEntry/", agent_policies.delete_keystore_entry, name="DeleteKeystoreEntry"), + path("SetKeystorePassword/", agent_policies.set_keystore_password, name="SetKeystorePassword"), + + path("GetCurrentPipelineCode/", editor_views.GetCurrentPipelineCode, name="GetCurrentPipelineCode"), + path("GetDiff/", editor_views.GetDiff, name="GetDiff"), + path("SavePipeline/", editor_views.SavePipeline, name="SavePipeline"), + path("ComponentsToConfig/", editor_views.ComponentsToConfig, name="ComponentsToConfig"), + path("ConfigToComponents/", editor_views.ConfigToComponents, name="ConfigToComponents"), + + path("UpdatePipelineSettings/", pipelines_crud.UpdatePipelineSettings, name="UpdatePipelineSettings"), + path("CreatePipeline/", pipelines_crud.CreatePipeline, name="CreatePipeline"), + path("DeletePipeline/", pipelines_crud.DeletePipeline, name="DeletePipeline"), + path("ClonePipeline/", pipelines_crud.ClonePipeline, name="ClonePipeline"), + path("RenamePipeline/", pipelines_crud.RenamePipeline, name="RenamePipeline"), + path("UpdatePipelineDescription/", pipelines_crud.UpdatePipelineDescription, name="UpdatePipelineDescription"), + path("GetPipeline/", pipelines_crud.GetPipeline, name="GetPipeline"), + + # Elasticsearch simulation endpoints + path("GetElasticsearchConnections/", editor_views.GetElasticsearchConnections, name="GetElasticsearchConnections"), + path("GetElasticsearchIndices/", editor_views.GetElasticsearchIndices, name="GetElasticsearchIndices"), + path("GetElasticsearchFields/", editor_views.GetElasticsearchFields, name="GetElasticsearchFields"), + path("QueryElasticsearchDocuments/", editor_views.QueryElasticsearchDocuments, name="QueryElasticsearchDocuments"), + + # Plugin documentation endpoint + path("GetPluginDocumentation/", editor_views.GetPluginDocumentation, name="GetPluginDocumentation"), + + # Agent inspect modal — fresh data on each open + path("AgentInspect//", manager_views.get_agent_inspect, name="AgentInspect"), + + # SSE: real-time agent status stream + path("AgentStatusStream/", manager_views.agent_status_stream, name="AgentStatusStream") + +] diff --git a/LogstashUI/Site/migrations/__init__.py b/src/logstashui/SNMP/__init__.py similarity index 100% rename from LogstashUI/Site/migrations/__init__.py rename to src/logstashui/SNMP/__init__.py diff --git a/LogstashUI/SNMP/apps.py b/src/logstashui/SNMP/apps.py similarity index 100% rename from LogstashUI/SNMP/apps.py rename to src/logstashui/SNMP/apps.py diff --git a/LogstashUI/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json b/src/logstashui/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json rename to src/logstashui/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json diff --git a/LogstashUI/SNMP/data/official_profiles/cdp.json b/src/logstashui/SNMP/data/official_profiles/cdp.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/cdp.json rename to src/logstashui/SNMP/data/official_profiles/cdp.json diff --git a/LogstashUI/SNMP/data/official_profiles/cisco_sensors_and_fans.json b/src/logstashui/SNMP/data/official_profiles/cisco_sensors_and_fans.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/cisco_sensors_and_fans.json rename to src/logstashui/SNMP/data/official_profiles/cisco_sensors_and_fans.json diff --git a/LogstashUI/SNMP/data/official_profiles/cisco_system_metrics.json b/src/logstashui/SNMP/data/official_profiles/cisco_system_metrics.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/cisco_system_metrics.json rename to src/logstashui/SNMP/data/official_profiles/cisco_system_metrics.json diff --git a/LogstashUI/SNMP/data/official_profiles/dell_idrac.json b/src/logstashui/SNMP/data/official_profiles/dell_idrac.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/dell_idrac.json rename to src/logstashui/SNMP/data/official_profiles/dell_idrac.json diff --git a/LogstashUI/SNMP/data/official_profiles/generic_metrics.json b/src/logstashui/SNMP/data/official_profiles/generic_metrics.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/generic_metrics.json rename to src/logstashui/SNMP/data/official_profiles/generic_metrics.json diff --git a/LogstashUI/SNMP/data/official_profiles/hpe_nimble_san.json b/src/logstashui/SNMP/data/official_profiles/hpe_nimble_san.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/hpe_nimble_san.json rename to src/logstashui/SNMP/data/official_profiles/hpe_nimble_san.json diff --git a/LogstashUI/SNMP/data/official_profiles/lldp.json b/src/logstashui/SNMP/data/official_profiles/lldp.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/lldp.json rename to src/logstashui/SNMP/data/official_profiles/lldp.json diff --git a/LogstashUI/SNMP/data/official_profiles/network_interfaces.json b/src/logstashui/SNMP/data/official_profiles/network_interfaces.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/network_interfaces.json rename to src/logstashui/SNMP/data/official_profiles/network_interfaces.json diff --git a/LogstashUI/SNMP/data/official_profiles/system.json b/src/logstashui/SNMP/data/official_profiles/system.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/system.json rename to src/logstashui/SNMP/data/official_profiles/system.json diff --git a/LogstashUI/SNMP/data/official_profiles/traps.json b/src/logstashui/SNMP/data/official_profiles/traps.json similarity index 100% rename from LogstashUI/SNMP/data/official_profiles/traps.json rename to src/logstashui/SNMP/data/official_profiles/traps.json diff --git a/LogstashUI/SNMP/management/__init__.py b/src/logstashui/SNMP/management/__init__.py similarity index 100% rename from LogstashUI/SNMP/management/__init__.py rename to src/logstashui/SNMP/management/__init__.py diff --git a/LogstashUI/SNMP/management/commands/__init__.py b/src/logstashui/SNMP/management/commands/__init__.py similarity index 100% rename from LogstashUI/SNMP/management/commands/__init__.py rename to src/logstashui/SNMP/management/commands/__init__.py diff --git a/LogstashUI/SNMP/management/commands/load_test_snmp_data.py b/src/logstashui/SNMP/management/commands/load_test_snmp_data.py similarity index 100% rename from LogstashUI/SNMP/management/commands/load_test_snmp_data.py rename to src/logstashui/SNMP/management/commands/load_test_snmp_data.py diff --git a/LogstashUI/SNMP/migrations/0001_initial.py b/src/logstashui/SNMP/migrations/0001_initial.py similarity index 98% rename from LogstashUI/SNMP/migrations/0001_initial.py rename to src/logstashui/SNMP/migrations/0001_initial.py index 3360cf3e..06f7ab39 100644 --- a/LogstashUI/SNMP/migrations/0001_initial.py +++ b/src/logstashui/SNMP/migrations/0001_initial.py @@ -25,8 +25,8 @@ class Migration(migrations.Migration): ('security_level', models.CharField(blank=True, choices=[('noAuthNoPriv', 'No Authentication, No Privacy'), ('authNoPriv', 'Authentication, No Privacy'), ('authPriv', 'Authentication and Privacy')], help_text='SNMPv3 security level', max_length=20)), ('auth_protocol', models.CharField(blank=True, choices=[('md5', 'MD5'), ('sha', 'SHA'), ('sha2', 'SHA2'), ('hmac128sha224', 'HMAC128-SHA224'), ('hmac192sha256', 'HMAC192-SHA256'), ('hmac256sha384', 'HMAC256-SHA384'), ('hmac384sha512', 'HMAC384-SHA512')], help_text='SNMPv3 authentication protocol', max_length=20)), ('auth_pass', models.CharField(blank=True, help_text='SNMPv3 authentication passphrase or password', max_length=255)), - ('priv_protocol', models.CharField(blank=True, choices=[('des', 'DES'), ('3des', '3DES'), ('aes', 'AES'), ('aes128', 'AES128'), ('aes192', 'AES192'), ('aes256', 'AES256'), ('aes256with3desKey', 'AES256 with 3DES Key')], help_text='SNMPv3 privacy/encryption protocol', max_length=20)), - ('priv_pass', models.CharField(blank=True, help_text='SNMPv3 encryption password', max_length=255)), + ('priv_protocol', models.CharField(blank=True, choices=[('des', 'DES'), ('3des', '3DES'), ('aes', 'AES'), ('aes128', 'AES128'), ('aes192', 'AES192'), ('aes256', 'AES256'), ('aes256with3desKey', 'AES256 with 3DES Key')], help_text='SNMPv3 privacy/encryption.py protocol', max_length=20)), + ('priv_pass', models.CharField(blank=True, help_text='SNMPv3 encryption.py password', max_length=255)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ], diff --git a/LogstashUI/Site/templatetags/__init__.py b/src/logstashui/SNMP/migrations/__init__.py similarity index 100% rename from LogstashUI/Site/templatetags/__init__.py rename to src/logstashui/SNMP/migrations/__init__.py diff --git a/LogstashUI/SNMP/models.py b/src/logstashui/SNMP/models.py similarity index 99% rename from LogstashUI/SNMP/models.py rename to src/logstashui/SNMP/models.py index d3dbf4d8..020e5990 100644 --- a/LogstashUI/SNMP/models.py +++ b/src/logstashui/SNMP/models.py @@ -293,13 +293,13 @@ class Credential(models.Model): max_length=20, choices=PRIV_PROTOCOL_CHOICES, blank=True, - help_text="SNMPv3 privacy/encryption protocol" + help_text="SNMPv3 privacy/encryption.py protocol" ) priv_pass = models.CharField( max_length=255, blank=True, - help_text="SNMPv3 encryption password" + help_text="SNMPv3 encryption.py password" ) created_at = models.DateTimeField(auto_now_add=True) diff --git a/LogstashUI/SNMP/snmp_crud.py b/src/logstashui/SNMP/snmp_crud.py similarity index 99% rename from LogstashUI/SNMP/snmp_crud.py rename to src/logstashui/SNMP/snmp_crud.py index a33282fc..f049dbe6 100644 --- a/LogstashUI/SNMP/snmp_crud.py +++ b/src/logstashui/SNMP/snmp_crud.py @@ -94,7 +94,7 @@ def AddCredential(request): credential.priv_protocol = request.POST.get('priv_protocol') credential.priv_pass = request.POST.get('priv_pass') - # Save (this will trigger validation and encryption) + # Save (this will trigger validation and encryption.py) credential.save() return JsonResponse({'id': credential.id, 'message': 'Credential created successfully!'}, status=200) @@ -151,7 +151,7 @@ def UpdateCredential(request, credential_id): if priv_pass: credential.priv_pass = priv_pass - # Save (this will trigger validation and encryption) + # Save (this will trigger validation and encryption.py) credential.save() return JsonResponse({'id': credential.id, 'message': 'Credential updated successfully!'}, status=200) @@ -291,7 +291,7 @@ def _create_or_update_pipeline(es_connection, pipeline_name, pipeline_content, d pipeline_body = { "pipeline": pipeline_content, "last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', - "username": "LogstashUI", + "username": "logstashui", "description": description } @@ -2727,7 +2727,17 @@ def GetDiscoveredDevices(request): if network_name: try: + # Try exact match first network_obj = Network.objects.filter(name=network_name).first() + + # If no exact match, try case-insensitive match + if not network_obj: + network_obj = Network.objects.filter(name__iexact=network_name).first() + + # If still no match, try case-insensitive contains (for cases like 'Homelab' -> 'homelab-segment1') + if not network_obj: + network_obj = Network.objects.filter(name__icontains=network_name).first() + if network_obj: network_id = network_obj.id if network_obj.discovery_credential: diff --git a/LogstashUI/SNMP/static/js/devices_table.js b/src/logstashui/SNMP/static/js/devices_table.js similarity index 100% rename from LogstashUI/SNMP/static/js/devices_table.js rename to src/logstashui/SNMP/static/js/devices_table.js diff --git a/LogstashUI/SNMP/static/js/discovered_devices.js b/src/logstashui/SNMP/static/js/discovered_devices.js similarity index 100% rename from LogstashUI/SNMP/static/js/discovered_devices.js rename to src/logstashui/SNMP/static/js/discovered_devices.js diff --git a/LogstashUI/SNMP/static/js/networks_table.js b/src/logstashui/SNMP/static/js/networks_table.js similarity index 100% rename from LogstashUI/SNMP/static/js/networks_table.js rename to src/logstashui/SNMP/static/js/networks_table.js diff --git a/LogstashUI/SNMP/static/js/snmp_commit_modal.js b/src/logstashui/SNMP/static/js/snmp_commit_modal.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_commit_modal.js rename to src/logstashui/SNMP/static/js/snmp_commit_modal.js diff --git a/LogstashUI/SNMP/static/js/snmp_credentials_modal.js b/src/logstashui/SNMP/static/js/snmp_credentials_modal.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_credentials_modal.js rename to src/logstashui/SNMP/static/js/snmp_credentials_modal.js diff --git a/LogstashUI/SNMP/static/js/snmp_device_visual_preview.js b/src/logstashui/SNMP/static/js/snmp_device_visual_preview.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_device_visual_preview.js rename to src/logstashui/SNMP/static/js/snmp_device_visual_preview.js diff --git a/LogstashUI/SNMP/static/js/snmp_devices_modal.js b/src/logstashui/SNMP/static/js/snmp_devices_modal.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_devices_modal.js rename to src/logstashui/SNMP/static/js/snmp_devices_modal.js diff --git a/LogstashUI/SNMP/static/js/snmp_diff_modal.js b/src/logstashui/SNMP/static/js/snmp_diff_modal.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_diff_modal.js rename to src/logstashui/SNMP/static/js/snmp_diff_modal.js diff --git a/LogstashUI/SNMP/static/js/snmp_network_modal.js b/src/logstashui/SNMP/static/js/snmp_network_modal.js similarity index 98% rename from LogstashUI/SNMP/static/js/snmp_network_modal.js rename to src/logstashui/SNMP/static/js/snmp_network_modal.js index aa436b6f..c0f478cf 100644 --- a/LogstashUI/SNMP/static/js/snmp_network_modal.js +++ b/src/logstashui/SNMP/static/js/snmp_network_modal.js @@ -77,7 +77,11 @@ function loadConnections(selectedConnectionId = null) { connectionSelect.innerHTML = ''; connectionSelect.innerHTML += ''; - connections.forEach(connection => { + const centralizedConnections = connections.filter(connection => + connection.connection_type === 'CENTRALIZED' + ); + + centralizedConnections.forEach(connection => { const option = document.createElement('option'); option.value = connection.id; option.textContent = `${connection.name} (${connection.connection_type})`; diff --git a/LogstashUI/SNMP/static/js/snmp_profile_modal.js b/src/logstashui/SNMP/static/js/snmp_profile_modal.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_profile_modal.js rename to src/logstashui/SNMP/static/js/snmp_profile_modal.js diff --git a/LogstashUI/SNMP/static/js/snmp_profile_modal_helpers.js b/src/logstashui/SNMP/static/js/snmp_profile_modal_helpers.js similarity index 100% rename from LogstashUI/SNMP/static/js/snmp_profile_modal_helpers.js rename to src/logstashui/SNMP/static/js/snmp_profile_modal_helpers.js diff --git a/LogstashUI/SNMP/templates/Credentials.html b/src/logstashui/SNMP/templates/Credentials.html similarity index 98% rename from LogstashUI/SNMP/templates/Credentials.html rename to src/logstashui/SNMP/templates/Credentials.html index fe110787..873d02e3 100644 --- a/LogstashUI/SNMP/templates/Credentials.html +++ b/src/logstashui/SNMP/templates/Credentials.html @@ -36,7 +36,7 @@

    Get Started with SNMP Credentials

    • + class="nav-item {% if highlight_snmp_devices %}nav-highlight{% endif %} {% if '/SNMP/Devices' in request.path %}active{% endif %}"> Logstash UI
    • + class="nav-item {% if '/SNMP/Networks' in request.path %}active{% endif %}"> Logstash UI
    • + class="nav-item {% if '/SNMP/Profiles' in request.path %}active{% endif %}"> Logstash UI
    • + class="nav-item {% if '/SNMP/Credentials' in request.path %}active{% endif %}"> Logstash UI
    • -
      - +
    • + +
    • + + + + + Documentation + +
    • +
    • - + + d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> @@ -163,7 +197,8 @@

      Logstash UI

      -
      + +
      LogstashUI v{% app_version %} @@ -197,7 +232,7 @@

      Logstash UI

      {% endif %} -
      + + - + + + + + +
      {% csrf_token %} +
      - @@ -59,7 +67,7 @@

      Confirm * @param {boolean} isAlert - If true, hides Cancel button (for alerts vs confirmations) * @returns {Promise} - Resolves with true if confirmed, false if cancelled */ - show: function(message, title = 'Confirm Action', okText = 'OK', codeMessage = null, isAlert = false) { + show: function(message, title = 'Confirm Action', okText = 'OK', codeMessage = null, isAlert = false, cancelText = 'Cancel') { return new Promise((resolve, reject) => { const modal = document.getElementById('confirmationModal'); const titleEl = document.getElementById('confirmationModalTitle'); @@ -67,16 +75,17 @@

      Confirm const inputEl = document.getElementById('confirmationModalInput'); const codeEl = document.getElementById('confirmationModalCode'); const okBtn = document.getElementById('confirmationModalOkBtn'); - const cancelBtn = modal.querySelector('button[onclick="ConfirmationModal.cancel()"]'); - + const cancelBtn = document.getElementById('confirmationModalCancelBtn'); + // Set content titleEl.textContent = title; messageEl.textContent = message; okBtn.textContent = okText; - - // Hide input field for regular confirmations - inputEl.classList.add('hidden'); - + cancelBtn.textContent = cancelText; + + // Hide input wrapper for regular confirmations + document.getElementById('confirmationModalInputWrapper').classList.add('hidden'); + // Handle code message if (codeMessage) { codeEl.textContent = codeMessage; @@ -84,7 +93,7 @@

      Confirm } else { codeEl.classList.add('hidden'); } - + // Show/hide Cancel button based on isAlert if (isAlert) { cancelBtn.classList.add('hidden'); @@ -119,52 +128,58 @@

      Confirm * @param {string} placeholder - Optional placeholder text for input * @returns {Promise} - Resolves with the input value if confirmed, null if cancelled */ - prompt: function(message, defaultValue = '', title = 'Input Required', placeholder = '') { + prompt: function(message, defaultValue = '', title = 'Input Required', placeholder = '', isPassword = false) { return new Promise((resolve, reject) => { const modal = document.getElementById('confirmationModal'); const titleEl = document.getElementById('confirmationModalTitle'); const messageEl = document.getElementById('confirmationModalMessage'); + const inputWrapper = document.getElementById('confirmationModalInputWrapper'); const inputEl = document.getElementById('confirmationModalInput'); + const toggleBtn = document.getElementById('confirmationModalTogglePassword'); const codeEl = document.getElementById('confirmationModalCode'); const okBtn = document.getElementById('confirmationModalOkBtn'); - const cancelBtn = modal.querySelector('button[onclick="ConfirmationModal.cancel()"]'); - + const cancelBtn = document.getElementById('confirmationModalCancelBtn'); + // Set content titleEl.textContent = title; messageEl.textContent = message; okBtn.textContent = 'OK'; - + cancelBtn.textContent = 'Cancel'; + // Show and configure input field - inputEl.classList.remove('hidden'); + inputWrapper.classList.remove('hidden'); inputEl.value = defaultValue; inputEl.placeholder = placeholder; - + inputEl.type = isPassword ? 'password' : 'text'; + if (isPassword) { + toggleBtn.classList.remove('hidden'); + } else { + toggleBtn.classList.add('hidden'); + } + // Hide code block codeEl.classList.add('hidden'); - + // Show Cancel button cancelBtn.classList.remove('hidden'); - + // Set callbacks this.onConfirm = () => { const value = inputEl.value.trim(); this.hide(); resolve(value || null); }; - + this.onCancel = () => { this.hide(); resolve(null); }; - + // Show modal modal.classList.remove('hidden'); - - // Focus input field and select default value - setTimeout(() => { - inputEl.focus(); - inputEl.select(); - }, 100); + + // Focus input field + setTimeout(() => inputEl.focus(), 100); }); }, @@ -174,9 +189,26 @@

      Confirm hide: function() { const modal = document.getElementById('confirmationModal'); modal.classList.add('hidden'); + // Reset input state + const inputEl = document.getElementById('confirmationModalInput'); + const inputWrapper = document.getElementById('confirmationModalInputWrapper'); + const toggleBtn = document.getElementById('confirmationModalTogglePassword'); + inputEl.type = 'text'; + inputWrapper.classList.add('hidden'); + toggleBtn.classList.add('hidden'); this.onConfirm = null; this.onCancel = null; }, + + _togglePasswordVisibility: function() { + const inputEl = document.getElementById('confirmationModalInput'); + const eyeIcon = document.getElementById('confirmationModalEyeIcon'); + const isHidden = inputEl.type === 'password'; + inputEl.type = isHidden ? 'text' : 'password'; + eyeIcon.innerHTML = isHidden + ? '' + : ''; + }, /** * Handle confirm button click diff --git a/LogstashUI/__init__.py b/src/logstashui/Site/templatetags/__init__.py similarity index 100% rename from LogstashUI/__init__.py rename to src/logstashui/Site/templatetags/__init__.py diff --git a/LogstashUI/Site/templatetags/custom_filters.py b/src/logstashui/Site/templatetags/custom_filters.py similarity index 100% rename from LogstashUI/Site/templatetags/custom_filters.py rename to src/logstashui/Site/templatetags/custom_filters.py diff --git a/LogstashUI/theme/__init__.py b/src/logstashui/Site/tests/__init__.py similarity index 100% rename from LogstashUI/theme/__init__.py rename to src/logstashui/Site/tests/__init__.py diff --git a/LogstashUI/Site/tests/test_views.py b/src/logstashui/Site/tests/test_views.py similarity index 99% rename from LogstashUI/Site/tests/test_views.py rename to src/logstashui/Site/tests/test_views.py index f1782ace..e5670b78 100644 --- a/LogstashUI/Site/tests/test_views.py +++ b/src/logstashui/Site/tests/test_views.py @@ -14,7 +14,7 @@ def test_health_check_returns_200(client): url = reverse('health_check') response = client.get(url) assert response.status_code == 200 - assert response.json() == {'status': 'healthy', 'service': 'LogstashUI'} + assert response.json() == {'status': 'healthy', 'service': 'logstashui'} @pytest.mark.django_db diff --git a/LogstashUI/Site/urls.py b/src/logstashui/Site/urls.py similarity index 100% rename from LogstashUI/Site/urls.py rename to src/logstashui/Site/urls.py diff --git a/LogstashUI/Site/views.py b/src/logstashui/Site/views.py similarity index 99% rename from LogstashUI/Site/views.py rename to src/logstashui/Site/views.py index 8df0af47..e9a3c125 100644 --- a/LogstashUI/Site/views.py +++ b/src/logstashui/Site/views.py @@ -23,7 +23,7 @@ def health_check(request): """ return JsonResponse({ 'status': 'healthy', - 'service': 'LogstashUI' + 'service': 'logstashui' }) def Home(request): diff --git a/LogstashUI/PipelineManager/static/js/move_graph.js b/src/logstashui/Utilities/__init__.py similarity index 100% rename from LogstashUI/PipelineManager/static/js/move_graph.js rename to src/logstashui/Utilities/__init__.py diff --git a/LogstashUI/Utilities/apps.py b/src/logstashui/Utilities/apps.py similarity index 100% rename from LogstashUI/Utilities/apps.py rename to src/logstashui/Utilities/apps.py diff --git a/LogstashUI/Utilities/data/grok-patterns.txt b/src/logstashui/Utilities/data/grok-patterns.txt similarity index 100% rename from LogstashUI/Utilities/data/grok-patterns.txt rename to src/logstashui/Utilities/data/grok-patterns.txt diff --git a/src/logstashui/Utilities/migrations/__init__.py b/src/logstashui/Utilities/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/LogstashUI/Utilities/static/js/grok_debugger.js b/src/logstashui/Utilities/static/js/grok_debugger.js similarity index 100% rename from LogstashUI/Utilities/static/js/grok_debugger.js rename to src/logstashui/Utilities/static/js/grok_debugger.js diff --git a/LogstashUI/Utilities/templates/grok_debugger.html b/src/logstashui/Utilities/templates/grok_debugger.html similarity index 100% rename from LogstashUI/Utilities/templates/grok_debugger.html rename to src/logstashui/Utilities/templates/grok_debugger.html diff --git a/LogstashUI/Utilities/tests/__init__.py b/src/logstashui/Utilities/tests/__init__.py similarity index 100% rename from LogstashUI/Utilities/tests/__init__.py rename to src/logstashui/Utilities/tests/__init__.py diff --git a/LogstashUI/Utilities/tests/test_grok_patterns.py b/src/logstashui/Utilities/tests/test_grok_patterns.py similarity index 100% rename from LogstashUI/Utilities/tests/test_grok_patterns.py rename to src/logstashui/Utilities/tests/test_grok_patterns.py diff --git a/LogstashUI/Utilities/tests/test_views.py b/src/logstashui/Utilities/tests/test_views.py similarity index 100% rename from LogstashUI/Utilities/tests/test_views.py rename to src/logstashui/Utilities/tests/test_views.py diff --git a/LogstashUI/Utilities/urls.py b/src/logstashui/Utilities/urls.py similarity index 100% rename from LogstashUI/Utilities/urls.py rename to src/logstashui/Utilities/urls.py diff --git a/LogstashUI/Utilities/views.py b/src/logstashui/Utilities/views.py similarity index 100% rename from LogstashUI/Utilities/views.py rename to src/logstashui/Utilities/views.py diff --git a/src/logstashui/__init__.py b/src/logstashui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/logstashui.example.yml b/src/logstashui/logstashui.example.yml similarity index 82% rename from logstashui.example.yml rename to src/logstashui/logstashui.example.yml index 4630015b..e10f0fdc 100644 --- a/logstashui.example.yml +++ b/src/logstashui/logstashui.example.yml @@ -1,3 +1,8 @@ +# WARNING: Enabling no_auth disables all authentication. +# Only use in sandbox/development environments. Never enable in production. +no_auth: + enabled: false # true | false + simulation: # embedded - Runs in a local container (slower) # host - Runs on the host machine (much faster, requires Logstash to be installed and not running. If you have pipelines or configuration it will overwrite / remove it) diff --git a/LogstashUI/manage.py b/src/logstashui/manage.py similarity index 100% rename from LogstashUI/manage.py rename to src/logstashui/manage.py diff --git a/src/logstashui/theme/__init__.py b/src/logstashui/theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/LogstashUI/theme/apps.py b/src/logstashui/theme/apps.py similarity index 100% rename from LogstashUI/theme/apps.py rename to src/logstashui/theme/apps.py diff --git a/LogstashUI/theme/package-lock.json b/src/logstashui/theme/package-lock.json similarity index 100% rename from LogstashUI/theme/package-lock.json rename to src/logstashui/theme/package-lock.json diff --git a/src/logstashui/theme/static/images/favicon.ico b/src/logstashui/theme/static/images/favicon.ico new file mode 100644 index 00000000..d40699ab Binary files /dev/null and b/src/logstashui/theme/static/images/favicon.ico differ diff --git a/LogstashUI/theme/static_src/.gitignore b/src/logstashui/theme/static_src/.gitignore similarity index 100% rename from LogstashUI/theme/static_src/.gitignore rename to src/logstashui/theme/static_src/.gitignore diff --git a/LogstashUI/theme/static_src/package-lock.json b/src/logstashui/theme/static_src/package-lock.json similarity index 79% rename from LogstashUI/theme/static_src/package-lock.json rename to src/logstashui/theme/static_src/package-lock.json index 7ff57c74..4f50da92 100644 --- a/LogstashUI/theme/static_src/package-lock.json +++ b/src/logstashui/theme/static_src/package-lock.json @@ -16,7 +16,7 @@ "postcss-cli": "^11.0.1", "postcss-nested": "^7.0.2", "postcss-simple-vars": "^7.0.1", - "rimraf": "^6.0.1", + "rimraf": "^5.0.5", "tailwindcss": "^4.1.11" } }, @@ -32,27 +32,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -70,18 +49,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -127,53 +94,58 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@tailwindcss/node": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", - "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.5.1", - "lightningcss": "1.30.1", - "magic-string": "^0.30.18", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.13" + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", - "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "dev": true, - "hasInstallScript": true, - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-x64": "4.1.13", - "@tailwindcss/oxide-freebsd-x64": "4.1.13", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-x64-musl": "4.1.13", - "@tailwindcss/oxide-wasm32-wasi": "4.1.13", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", - "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "cpu": [ "arm64" ], @@ -183,13 +155,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", - "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "cpu": [ "arm64" ], @@ -199,13 +171,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", - "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "cpu": [ "x64" ], @@ -215,13 +187,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", - "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "cpu": [ "x64" ], @@ -231,13 +203,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", - "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "cpu": [ "arm" ], @@ -247,13 +219,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", - "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "cpu": [ "arm64" ], @@ -263,13 +235,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", - "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "cpu": [ "arm64" ], @@ -279,13 +251,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", - "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "cpu": [ "x64" ], @@ -295,13 +267,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", - "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "cpu": [ "x64" ], @@ -311,13 +283,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", - "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -332,21 +304,21 @@ "dev": true, "optional": true, "dependencies": { - "@emnapi/core": "^1.4.5", - "@emnapi/runtime": "^1.4.5", - "@emnapi/wasi-threads": "^1.0.4", - "@napi-rs/wasm-runtime": "^0.2.12", - "@tybys/wasm-util": "^0.10.0", - "tslib": "^2.8.0" + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", - "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ "arm64" ], @@ -356,13 +328,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", - "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -372,20 +344,20 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", - "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.13", - "@tailwindcss/oxide": "4.1.13", - "postcss": "^8.4.41", - "tailwindcss": "4.1.13" + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" } }, "node_modules/ansi-regex": { @@ -425,6 +397,12 @@ "node": ">= 8" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -437,6 +415,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -473,15 +460,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "engines": { - "node": ">=18" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -632,9 +610,9 @@ } }, "node_modules/daisyui": { - "version": "5.1.25", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.1.25.tgz", - "integrity": "sha512-LYOGVIzTCCucEFkKmdj0fxbHHPZ83fpkYD7jXYF3/7UwrUu68TtXkIdGtEXadzeqUT361hCe6cj5tBB/7mvszw==", + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", "dev": true, "funding": { "url": "https://github.com/saadeghi/daisyui?sponsor=1" @@ -650,9 +628,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "engines": { "node": ">=8" @@ -671,13 +649,13 @@ "dev": true }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -721,9 +699,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "dependencies": { "graceful-fs": "^4.2.0", @@ -758,24 +736,22 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -856,24 +832,24 @@ "dev": true }, "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jiti": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", - "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "bin": { "jiti": "lib/jiti-cli.mjs" @@ -892,9 +868,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "dependencies": { "detect-libc": "^2.0.3" @@ -907,22 +883,43 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -940,9 +937,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -960,9 +957,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -980,9 +977,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -1000,9 +997,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -1020,9 +1017,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -1040,9 +1037,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -1060,9 +1057,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -1080,9 +1077,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -1100,9 +1097,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -1132,59 +1129,44 @@ } }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "dev": true, - "engines": { - "node": "20 || >=22" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1228,16 +1210,16 @@ } }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1250,9 +1232,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "engines": { "node": ">=8.6" @@ -1271,9 +1253,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -1289,7 +1271,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1418,9 +1399,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -1486,20 +1467,16 @@ } }, "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "dev": true, "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" + "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -1618,12 +1595,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -1655,15 +1632,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", - "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "dev": true }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "dev": true, "engines": { "node": ">=6" @@ -1673,22 +1650,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", - "dev": true, - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/thenby": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.3.4.tgz", @@ -1729,11 +1690,10 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -1883,25 +1843,19 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "engines": { - "node": ">=18" - } - }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/LogstashUI/theme/static_src/package.json b/src/logstashui/theme/static_src/package.json similarity index 61% rename from LogstashUI/theme/static_src/package.json rename to src/logstashui/theme/static_src/package.json index 15b18294..1a64d1b4 100644 --- a/LogstashUI/theme/static_src/package.json +++ b/src/logstashui/theme/static_src/package.json @@ -5,9 +5,9 @@ "scripts": { "start": "npm run dev", "build": "npm run build:clean && npm run build:tailwind", - "build:clean": "rimraf ../static/css/dist", - "build:tailwind": "cross-env NODE_ENV=production postcss ./src/styles.css -o ../static/css/dist/styles.css --minify", - "dev": "cross-env NODE_ENV=development postcss ./src/styles.css -o ../static/css/dist/styles.css --watch" + "build:clean": "npx rimraf ../static/css/dist", + "build:tailwind": "npx cross-env NODE_ENV=production postcss ./src/styles.css -o ../static/css/dist/styles.css --minify", + "dev": "npx cross-env NODE_ENV=development postcss ./src/styles.css -o ../static/css/dist/styles.css --watch" }, "keywords": [], "author": "", @@ -20,7 +20,7 @@ "postcss-cli": "^11.0.1", "postcss-nested": "^7.0.2", "postcss-simple-vars": "^7.0.1", - "rimraf": "^6.0.1", + "rimraf": "^5.0.5", "tailwindcss": "^4.1.11" } } diff --git a/LogstashUI/theme/static_src/postcss.config.js b/src/logstashui/theme/static_src/postcss.config.js similarity index 100% rename from LogstashUI/theme/static_src/postcss.config.js rename to src/logstashui/theme/static_src/postcss.config.js diff --git a/LogstashUI/theme/static_src/src/styles.css b/src/logstashui/theme/static_src/src/styles.css similarity index 98% rename from LogstashUI/theme/static_src/src/styles.css rename to src/logstashui/theme/static_src/src/styles.css index cbd42f7d..05e45c0e 100644 --- a/LogstashUI/theme/static_src/src/styles.css +++ b/src/logstashui/theme/static_src/src/styles.css @@ -1,8 +1,6 @@ @import "tailwindcss"; @plugin "daisyui"; -@tailwind utilities; - /* Force dark mode by overriding DaisyUI's default light theme */ @layer base { :root { diff --git a/LogstashUI/theme/static_src/tailwind.config.js b/src/logstashui/theme/static_src/tailwind.config.js similarity index 100% rename from LogstashUI/theme/static_src/tailwind.config.js rename to src/logstashui/theme/static_src/tailwind.config.js diff --git a/LogstashUI/theme/templates/base.html b/src/logstashui/theme/templates/base.html similarity index 100% rename from LogstashUI/theme/templates/base.html rename to src/logstashui/theme/templates/base.html diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..96e16c2e --- /dev/null +++ b/uv.lock @@ -0,0 +1,1086 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "concurrent-log-handler" +version = "0.9.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "portalocker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/ba185acc438cff6b58cd8f8dec27e7f4fcabf6968a1facbb6d0cacbde7fe/concurrent_log_handler-0.9.29.tar.gz", hash = "sha256:bc37a76d3f384cbf4a98f693ebd770543edc0f4cd5c6ab6bc70e9e1d7d582265", size = 42114, upload-time = "2026-02-22T18:18:25.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f3/3e3188fdb3e53c6343fd1c7de41c55d4db626f07db3877eae77b28d58bd2/concurrent_log_handler-0.9.29-py3-none-any.whl", hash = "sha256:0d6c077fbaef2dae49a25975dcf72a602fe0a6a4ce80a3b7c37696d37e10459a", size = 32052, upload-time = "2026-02-22T18:18:24.558Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "django" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + +[[package]] +name = "django-browser-reload" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/ef/ab407c1a2f14e13c75a6419a2d124954aa0364f62580ad95a3d31dc6c73d/django_browser_reload-1.21.0.tar.gz", hash = "sha256:3335ad3d107eb657f623d1a8e680dfbcab8a83ae1f94df1895e069dddf5604ba", size = 15800, upload-time = "2025-09-22T17:00:35.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/61/1b4a8c589652859995bcab87682286443eb9fdf2d7fd584975b9ffc1db33/django_browser_reload-1.21.0-py3-none-any.whl", hash = "sha256:0b2a86ab460774fa9bb142a121c70e75a72f18109f51a4f6de409cd633d3a70d", size = 12852, upload-time = "2025-09-22T17:00:33.479Z" }, +] + +[[package]] +name = "django-htmx" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/f2/8c3e28a5eed8e5226835c762892bfef74eda7e8629c65b49c186098eb303/django_htmx-1.27.0.tar.gz", hash = "sha256:036e5da801bfdf5f1ca815f21592cfb9f004a898f330c842f15e55c70e301a75", size = 65362, upload-time = "2025-11-28T23:18:55.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/ac/25d28489dc43224e260f4ebee7565f7ef1efe12af0f284a89500c19f75e2/django_htmx-1.27.0-py3-none-any.whl", hash = "sha256:13e1e13b87d39b57f95aae6e4987cb3df056d0b1373a41f4a94504a00298ffd8", size = 62126, upload-time = "2025-11-28T23:18:53.57Z" }, +] + +[[package]] +name = "django-login-required-middleware" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/51/edb065f2f24c46d5c17633080b91b3817d1b890d4f0924c36d338e9e125f/django-login-required-middleware-0.9.0.tar.gz", hash = "sha256:847ae9a69fd7a07618ed53192b3c06946af70a0caf6d0f4eb40a8f37593cd970", size = 4452, upload-time = "2022-09-09T16:57:09.141Z" } + +[[package]] +name = "django-tailwind" +version = "4.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "pytailwindcss" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/21/86fda52a8d0f8d2f31d32982ee4d9cc4f29c868dbeb52412e430d083d126/django_tailwind-4.4.2.tar.gz", hash = "sha256:b3a3eb2d22cbb8c17565898fb68ccedcf806542b04fe4107bdfec7035d582819", size = 13965, upload-time = "2025-12-05T18:23:41.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ed/85113d22ab4268600542152bc4b5512abb1204552b90e04848ebc496aa5c/django_tailwind-4.4.2-py3-none-any.whl", hash = "sha256:0e4a2836cb36e8952700457d049fadb8743583017cef80fa3a374f8597c289f4", size = 23358, upload-time = "2025-12-05T18:23:39.462Z" }, +] + +[[package]] +name = "elastic-transport" +version = "9.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "sniffio" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/0a/a92140b666afdcb9862a16e4d80873b3c887c1b7e3f17e945fc3460edf1b/elastic_transport-9.2.1.tar.gz", hash = "sha256:97d9abd638ba8aa90faa4ca1bf1a18bde0fe2088fbc8757f2eb7b299f205773d", size = 77403, upload-time = "2025-12-23T11:54:12.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e6/a42b600ae8b808371f740381f6c32050cad93f870d36cc697b8b7006bf7c/elastic_transport-9.2.1-py3-none-any.whl", hash = "sha256:39e1a25e486af34ce7aa1bc9005d1c736f1b6fb04c9b64ea0604ded5a61fc1d4", size = 65327, upload-time = "2025-12-23T11:54:11.681Z" }, +] + +[[package]] +name = "elasticsearch" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "elastic-transport" }, + { name = "python-dateutil" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/15/283459c9299d412ffa2aaab69b082857631c519233f5491d6c567e3320ca/elasticsearch-9.3.0.tar.gz", hash = "sha256:f76e149c0a22d5ccbba58bdc30c9f51cf894231b359ef4fd7e839b558b59f856", size = 893538, upload-time = "2026-02-03T20:26:38.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/37/3a196f8918743f2104cb66b1f56218079ecac6e128c061de7df7f4faef02/elasticsearch-9.3.0-py3-none-any.whl", hash = "sha256:67bd2bb4f0800f58c2847d29cd57d6e7bf5bc273483b4f17421f93e75ba09f39", size = 979405, upload-time = "2026-02-03T20:26:34.552Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "gevent" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/27/1062fa31333dc3428a1f5f33cd6598b0552165ba679ca3ba116de42c9e8e/gevent-26.4.0.tar.gz", hash = "sha256:288d03addfccf0d1c67268358b6759b04392bf3bc35d26f3d9a45c82899c292d", size = 6242440, upload-time = "2026-04-09T12:08:19.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/16/131d3874f50974b355c90a061a12d3fe2292cde0f875a1fa3d8b224f1251/gevent-26.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:318a0a73f664113e8d86d0cb0e328e7650e2d7d9c2e045418ab6fb1285831ad3", size = 2928699, upload-time = "2026-04-08T21:25:36.215Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8b/199e59b303adaff7f7365def9ab569c7ecd863363c974548bce3ddc2c89d/gevent-26.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ce7aa033a3f68beb6732d1450a80c1af29e63e0c2d01abad7918cf2507f72fa6", size = 1783821, upload-time = "2026-04-08T22:23:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/b8249c9bd3f386191311c3a9bec4068e192a3f9df2fad92a71a15265ba15/gevent-26.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:a1b897c952baefd72232efaeb3bdb1ca2fa7ae94cbfe68ac21201b03e843190a", size = 1879424, upload-time = "2026-04-08T22:27:10.561Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/59216985c1f2c11f2f28bbc88e583588ad44cdde823c530ad4e307be6612/gevent-26.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:7eef2ea508ce41795e20587a5fc868ae4919543097c81a40fbdfd65bc479f54f", size = 1830575, upload-time = "2026-04-08T22:34:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a9/2d67d2b0aa0ca9d7bb7fe73c3bbb97b3695cb15c338a6ea7734f58da9add/gevent-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f7e12fdd28cc9f39a463d8df5172d698c64a8ed385a21d98e7092fd8308a139a", size = 2113898, upload-time = "2026-04-08T21:54:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/457d58d9b3e7da17c8456d841c37a32af8d231a1d71237ad201b19129317/gevent-26.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d48e3ee13d7678c24c22f19d441ad6bc220a79f23662d03ff36fae0d62efdb59", size = 1795890, upload-time = "2026-04-08T22:26:53.252Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cc/cbe78f2626643b20275aaa41cd2cc45ba75056e3665bde36bc190af3cae0/gevent-26.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c58c8e034f94329be4dc0979fba3301005a433dbab42cea0b2c33fd736946872", size = 2139791, upload-time = "2026-04-08T22:00:02.375Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/7875e08b06a95f4577b71708ec470d029fadf873a66eb813a2861d79dfb5/gevent-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c737e6ac6ce1398df0e3f41c58d982e397c993cbe73ac05b7edbe39e128c9cb", size = 1680530, upload-time = "2026-04-08T23:15:38.714Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/51809d98bb00846d7756a0b82625024f9302145f3d024846b43f05efeddb/gevent-26.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1fe581d41c63cd1d8b12c69561ce53a48ad0d8763b254740d7bfea997335a38c", size = 2951507, upload-time = "2026-04-08T21:25:25.809Z" }, + { url = "https://files.pythonhosted.org/packages/d6/86/89325a62a4e8cc1934e155b383b66491ed21d1e774b13d5054d51fa0ac81/gevent-26.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c503b0c0a681e795255a13e5bb4e41615c3b020c1db93b8dfa04cfeb8f19d5a9", size = 1786029, upload-time = "2026-04-08T22:23:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/04d112844aa992da583cbd280f17a4ba097da338dab347efd0aa5e235645/gevent-26.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:684256c29e3e5d4d0c4d06b772d00574d0dc859dfbb2fd13d318c512b16e1f89", size = 1881326, upload-time = "2026-04-08T22:27:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/a1/33/71900c5ba442f5df89456b6d9fdaa43da2ae7cdd937d8c5667b49323ceb4/gevent-26.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:73eafd06b158d511f1ec6e5902a45e0ae3b48e745f35e9df97d25f809f537d88", size = 1833123, upload-time = "2026-04-08T22:34:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/d0/af/7df19c92e56842921f34787e1168c7afc52a23b0d1253bba99344809a935/gevent-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a18e543c830a1c07a2efeb33786a57ccac360af70cb42bbaf5a6f5f7ca49300", size = 2114330, upload-time = "2026-04-08T21:54:16.547Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0e/202694960f8d4dda68fd2a73bbcb8251e2d5308339924310ff1fff31bf7c/gevent-26.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:74f1e3a460c43aefcb4ff9ef91aac15abc0b42e5233771e1956574d14ba9cac6", size = 1798427, upload-time = "2026-04-08T22:26:54.462Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/2d056b2a4e3ef1f65f94002725572d1e99163ff79231dbb68ad529e7cb9d/gevent-26.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:954258873ae0bcc97fb41e48db25284fb73454bfefe27db8ceb89225da5502fb", size = 2140100, upload-time = "2026-04-08T22:00:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/1a7f64aa2476c2b44abaecca919a6561bda85234f99fc7ac3c66bcb93050/gevent-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a9a64c064457c1afaf93ee2815fe0f38be6ecbb92806a6a712f12afc3e26cf5", size = 1680206, upload-time = "2026-04-08T23:01:56.636Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f3/64638a941988f09aa1816e2674eb1efb215b6fa64a97edef6e25177b0845/gevent-26.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7ab0f183a6fd2369eef619832eef14f1f2f69c605163c3f2dc41deb799af4a71", size = 2967206, upload-time = "2026-04-08T21:25:44.73Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/a86be65a51d3ebb92c82a70adc9c5c32b1a9d9579120d0be1db7cf534ce0/gevent-26.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7e5906860e632bf965e1966c57e6bfc19dcb79dc262f04fdb0a9d7c12147bf69", size = 1792916, upload-time = "2026-04-08T22:23:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/40/92/18fdb4b28f20129395f1c041773adee99e7fc2bcfff216df93bfb80787d5/gevent-26.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:297a361071dc6708115d4544859321e93b02a6cd5823ba02c0a909530a519d45", size = 1886617, upload-time = "2026-04-08T22:27:13.716Z" }, + { url = "https://files.pythonhosted.org/packages/af/c9/d02222ecf79d10c8a0c2755661485395b58c4bfffaafd88bcc230ce392de/gevent-26.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:7e74f59e5c9011afa2a9cb7106bb9a59f2a1f74c3d7b272c1b852eb0bc0b8f90", size = 1837660, upload-time = "2026-04-08T22:34:40.823Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/9376d125fa4f7b0f269925d0d622eda0ff8f8dfc8d0c097a096c511fc738/gevent-26.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:45d6010a6a981f5a2b3411c4e38fbe305a1b46e4b12db3b4914775927dea7ba4", size = 2119342, upload-time = "2026-04-08T21:54:17.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/1fe2817daca8e97c365fd739dd4057f71cce26ef600fb8465deb8060c83c/gevent-26.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc38137ba2f43794c488615aafa2eefd0cc142f484a8274d4c827ed7a031a1e2", size = 1805672, upload-time = "2026-04-08T22:26:55.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cc/ccbcbd56e7e85482291fbb90a317f5febf630ec4174a91506f4167ba0912/gevent-26.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:29a225d2d4da37e20c7a246754a64442d0e43e4534b8cc764f89530bb22a4237", size = 2145594, upload-time = "2026-04-08T22:00:05.275Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b9/7dd37b6001d16f692b1bfb6e68cad642beb38b34a753c29bbff312f46e4b/gevent-26.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1c08bc9bb6bd79732a26710a99588b5e9b67b668e165dd609704b876f41baab", size = 1703189, upload-time = "2026-04-08T22:48:31.713Z" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "logstashui" +version = "0.4.0" +source = { virtual = "." } +dependencies = [ + { name = "concurrent-log-handler" }, + { name = "cryptography" }, + { name = "django" }, + { name = "django-browser-reload" }, + { name = "django-htmx" }, + { name = "django-login-required-middleware" }, + { name = "django-tailwind" }, + { name = "elasticsearch" }, + { name = "gevent" }, + { name = "gunicorn" }, + { name = "lark" }, + { name = "markdown" }, + { name = "packaging" }, + { name = "pygrok" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "whitenoise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, +] + +[package.metadata] +requires-dist = [ + { name = "concurrent-log-handler", specifier = ">=0.9.25" }, + { name = "cryptography", specifier = ">=46.0.6" }, + { name = "django", specifier = ">=6.0.3" }, + { name = "django-browser-reload", specifier = ">=1.21.0" }, + { name = "django-htmx", specifier = ">=1.27.0" }, + { name = "django-login-required-middleware", specifier = ">=0.9.0" }, + { name = "django-tailwind", specifier = ">=4.4.2" }, + { name = "elasticsearch", specifier = ">=9.3.0" }, + { name = "gevent", specifier = ">=26.4.0" }, + { name = "gunicorn", specifier = ">=25.3.0" }, + { name = "lark", specifier = ">=1.3.1" }, + { name = "markdown", specifier = ">=3.10.2" }, + { name = "packaging", specifier = ">=26.0" }, + { name = "pygrok", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "requests", specifier = ">=2.33.0" }, + { name = "whitenoise", specifier = ">=6.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-django", specifier = ">=4.10.0" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pygrok" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/a5/963d78c4eda7edb0ea827679dbcf5f77e4d767562b59681bd23ea5913af6/pygrok-1.0.0.tar.gz", hash = "sha256:ae635e3c0ba0eab76aec9d86ae1bab70883e8e71505ec2d6cb8989e66f5810af", size = 18997, upload-time = "2016-09-24T08:32:38.164Z" } + +[[package]] +name = "pytailwindcss" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/90/b83df1eae67f3f22c59ca3f6df67d7a2591bb27157f04a173bd1f164d3e0/pytailwindcss-0.3.0.tar.gz", hash = "sha256:1f71dd64020aacb40608dfe8725ca441c772016c344f757ccbc74b8b6143027d", size = 5527, upload-time = "2025-10-29T19:13:42.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/59/e7b86790c4558cfa0344c424219fe17d371b70273044b9a2d163ce1d3f44/pytailwindcss-0.3.0-py3-none-any.whl", hash = "sha256:a9b770ad3c0a0f40073052bcfe81a060b550fed38af614a472f5aa7bb85fa8aa", size = 7502, upload-time = "2025-10-29T19:13:41.109Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "regex" +version = "2026.3.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/93/5ab3e899c47fa7994e524447135a71cd121685a35c8fe35029005f8b236f/regex-2026.3.32.tar.gz", hash = "sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16", size = 415605, upload-time = "2026-03-28T21:49:22.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/94/69492c45b0e61b027109d8433a5c3d4f7a90709184c057c7cfc60acb1bfa/regex-2026.3.32-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ad8d372587e659940568afd009afeb72be939c769c552c9b28773d0337251391", size = 490572, upload-time = "2026-03-28T21:46:28.031Z" }, + { url = "https://files.pythonhosted.org/packages/92/0a/7dcffeebe0fcac45a1f9caf80712002d3cbd66d7d69d719315ee142b280f/regex-2026.3.32-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f5747501b69299c6b0b047853771e4ed390510bada68cb16da9c9c2078343f7", size = 292078, upload-time = "2026-03-28T21:46:29.789Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ec/988486058ef49eb931476419bae00f164c4ceb44787c45dc7a54b7de0ea4/regex-2026.3.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db976be51375bca900e008941639448d148c655c9545071965d0571ecc04f5d0", size = 289786, upload-time = "2026-03-28T21:46:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cf/1955bb5567bc491bd63068e17f75ab0c9ff5e9d08466beec7e347f5e768d/regex-2026.3.32-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66a5083c3ffe5a5a95f8281ea47a88072d4f24001d562d1d9d28d4cdc005fec5", size = 796431, upload-time = "2026-03-28T21:46:33.101Z" }, + { url = "https://files.pythonhosted.org/packages/27/8a/67fcbca511b792107540181ee0690df6de877bfbcb41b7ecae7028025ca5/regex-2026.3.32-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e83ce8008b48762be296f1401f19afd9ea29f3d035d1974e0cecb74e9afbd1df", size = 865785, upload-time = "2026-03-28T21:46:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/0677bc44f2c28305edcabc11933777b9ad34e9e8ded7ba573d24e4bc3ee7/regex-2026.3.32-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3aa21bad31db904e0b9055e12c8282df62d43169c4a9d2929407060066ebc74", size = 913593, upload-time = "2026-03-28T21:46:36.835Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/661043d1c263b0d9d10c6ff4e9c9745f3df9641c62b51f96a3473638e7ce/regex-2026.3.32-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f54840bea73541652f1170dc63402a5b776fc851ad36a842da9e5163c1f504a0", size = 801512, upload-time = "2026-03-28T21:46:38.587Z" }, + { url = "https://files.pythonhosted.org/packages/ff/27/74c986061380e1811a46cf04cdf9c939db9f8c0e63953eddfe37ffd633ea/regex-2026.3.32-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ffbadc647325dd4e3118269bda93ded1eb5f5b0c3b7ba79a3da9fbd04f248e9", size = 776182, upload-time = "2026-03-28T21:46:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c8/d833397b70cd1bacfcdc0a611f0e2c1f5b91fee8eedd88affcee770cbbb6/regex-2026.3.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66d3126afe7eac41759cd5f0b3b246598086e88e70527c0d68c9e615b81771c4", size = 785837, upload-time = "2026-03-28T21:46:42.926Z" }, + { url = "https://files.pythonhosted.org/packages/e0/53/fa226b72989b5b93db6926fab5478115e085dfcf077e18d2cb386be0fd23/regex-2026.3.32-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f785f44a44702dea89b28bce5bc82552490694ce4e144e21a4f0545e364d2150", size = 860612, upload-time = "2026-03-28T21:46:44.8Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/bdd2fc0c055a1b15702bd4084829bbb6b06095f27990e5bee52b2898ea03/regex-2026.3.32-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b7836aa13721dbdef658aebd11f60d00de633a95726521860fe1f6be75fa225a", size = 765285, upload-time = "2026-03-28T21:46:46.625Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/21f5e2a35a191b27e5a47cccb3914c99e139b49b1342d3f36e64e8cc60f7/regex-2026.3.32-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5336b1506142eb0f23c96fb4a34b37c4fefd4fed2a7042069f3c8058efe17855", size = 851963, upload-time = "2026-03-28T21:46:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/f4/04ed04ebf335a44083695c22772be6a42efa31900415555563acf02cb4de/regex-2026.3.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b56993a7aeb4140c4770f4f7965c9e5af4f024457d06e23c01b0d47501cb18ed", size = 788332, upload-time = "2026-03-28T21:46:50.454Z" }, + { url = "https://files.pythonhosted.org/packages/21/25/5355908f479d0dc13d044f88270cdcabc8723efc12e4c2b19e5a94ff1a96/regex-2026.3.32-cp312-cp312-win32.whl", hash = "sha256:d363660f9ef8c734495598d2f3e527fb41f745c73159dc0d743402f049fb6836", size = 266847, upload-time = "2026-03-28T21:46:52.125Z" }, + { url = "https://files.pythonhosted.org/packages/00/e5/3be71c781a031db5df00735b613895ad5fdbf86c6e3bbea5fbbd7bfb5902/regex-2026.3.32-cp312-cp312-win_amd64.whl", hash = "sha256:c9f261ad3cd97257dc1d9355bfbaa7dd703e06574bffa0fa8fe1e31da915ee38", size = 278034, upload-time = "2026-03-28T21:46:54.096Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/27f1e0b1eea4faa99c66daca34130af20c44fae0237bbc98b87999dbc4a8/regex-2026.3.32-cp312-cp312-win_arm64.whl", hash = "sha256:89e50667e7e8c0e7903e4d644a2764fffe9a3a5d6578f72ab7a7b4205bf204b7", size = 270673, upload-time = "2026-03-28T21:46:56.046Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ba/9c1819f302b42b5fbd4139ead6280e9ec37d19bbe33379df0039b2a57bb4/regex-2026.3.32-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c6d9c6e783b348f719b6118bb3f187b2e138e3112576c9679eb458cc8b2e164b", size = 490394, upload-time = "2026-03-28T21:46:58.112Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/f62b0ce79eb83ca82fffea1736289d29bc24400355968301406789bcebd2/regex-2026.3.32-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f21ae18dfd15752cdd98d03cbd7a3640be826bfd58482a93f730dbd24d7b9fb", size = 291993, upload-time = "2026-03-28T21:47:00.198Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/ba0f8f81f88cd20c0b27acc123561ac5495ea33f800f0b8ebed2038b23eb/regex-2026.3.32-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:844d88509c968dd44b30daeefac72b038b1bf31ac372d5106358ab01d393c48b", size = 289618, upload-time = "2026-03-28T21:47:02.269Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0d/b47a0e68bc511c195ff129c0311a4cd79b954b8676193a9d03a97c623a91/regex-2026.3.32-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fc918cd003ba0d066bf0003deb05a259baaaab4dc9bd4f1207bbbe64224857a", size = 796427, upload-time = "2026-03-28T21:47:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/32b05aa8fde7789ba316533c0f30e87b6b5d38d6d7f8765eadc5aab84671/regex-2026.3.32-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbc458a292aee57d572075f22c035fa32969cdb7987d454e3e34d45a40a0a8b4", size = 865850, upload-time = "2026-03-28T21:47:05.982Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/828d8095501f237b83f630d4069eea8c0e5cb6a204e859cf0b67c223ce12/regex-2026.3.32-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:987cdfcfb97a249abc3601ad53c7de5c370529f1981e4c8c46793e4a1e1bfe8e", size = 913578, upload-time = "2026-03-28T21:47:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f8/acf1eb80f58852e85bd39a6ddfa78ce2243ddc8de8da7582e6ba657da593/regex-2026.3.32-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d88fa37ba5e8a80ca8d956b9ea03805cfa460223ac94b7d4854ee5e30f3173", size = 801536, upload-time = "2026-03-28T21:47:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/9f/05/986cdf8d12693451f5889aaf4ea4f65b2c49b1152ae814fa1fb75439e40b/regex-2026.3.32-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d082be64e51671dd5ee1c208c92da2ddda0f2f20d8ef387e57634f7e97b6aae", size = 776226, upload-time = "2026-03-28T21:47:12.891Z" }, + { url = "https://files.pythonhosted.org/packages/32/02/945a6a2348ca1c6608cb1747275c8affd2ccd957d4885c25218a86377912/regex-2026.3.32-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1d7fa44aece1fa02b8927441614c96520253a5cad6a96994e3a81e060feed55", size = 785933, upload-time = "2026-03-28T21:47:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/53/12/c5bab6cc679ad79a45427a98c4e70809586ac963c5ad54a9217533c4763e/regex-2026.3.32-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d478a2ca902b6ef28ffc9521e5f0f728d036abe35c0b250ee8ae78cfe7c5e44e", size = 860671, upload-time = "2026-03-28T21:47:16.985Z" }, + { url = "https://files.pythonhosted.org/packages/bf/68/8d85f98c2443469facabef62b82b851d369b13f92bec2ca7a3808deaa47b/regex-2026.3.32-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2820d2231885e97aff0fcf230a19ebd5d2b5b8a1ba338c20deb34f16db1c7897", size = 765335, upload-time = "2026-03-28T21:47:18.872Z" }, + { url = "https://files.pythonhosted.org/packages/89/a7/d8a9c270916107a501fca63b748547c6c77e570d19f16a29b557ce734f3d/regex-2026.3.32-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc8ced733d6cd9af5e412f256a32f7c61cd2d7371280a65c689939ac4572499f", size = 851913, upload-time = "2026-03-28T21:47:20.793Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/03d392b26679914ccf21f83d18ad4443232d2f8c3e2c30a962d4e3918d9c/regex-2026.3.32-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847087abe98b3c1ebf1eb49d6ef320dbba75a83ee4f83c94704580f1df007dd4", size = 788447, upload-time = "2026-03-28T21:47:22.628Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/692227d23535a50604333068b39eb262626db780ab1e1b19d83fc66853aa/regex-2026.3.32-cp313-cp313-win32.whl", hash = "sha256:d21a07edddb3e0ca12a8b8712abc8452481c3d3db19ae87fc94e9842d005964b", size = 266834, upload-time = "2026-03-28T21:47:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/13e4e56adc16ba607cffa1fe880f233eb9ded8ab8a8580619683c9e4ce48/regex-2026.3.32-cp313-cp313-win_amd64.whl", hash = "sha256:3c054e39a9f85a3d76c62a1d50c626c5e9306964eaa675c53f61ff7ec1204bbb", size = 277972, upload-time = "2026-03-28T21:47:26.627Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1c/80a86dbb2b416fec003b1801462bdcebbf1d43202ed5acb176e99c1ba369/regex-2026.3.32-cp313-cp313-win_arm64.whl", hash = "sha256:b2e9c2ea2e93223579308263f359eab8837dc340530b860cb59b713651889f14", size = 270649, upload-time = "2026-03-28T21:47:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/e38372da599dc1c39c599907ec535016d110034bd3701ce36554f59767ef/regex-2026.3.32-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5d86e3fb08c94f084a625c8dc2132a79a3a111c8bf6e2bc59351fa61753c2f6e", size = 494495, upload-time = "2026-03-28T21:47:30.642Z" }, + { url = "https://files.pythonhosted.org/packages/5f/27/6e29ece8c9ce01001ece1137fa21c8707529c2305b22828f63623b0eb262/regex-2026.3.32-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b6f366a5ef66a2df4d9e68035cfe9f0eb8473cdfb922c37fac1d169b468607b0", size = 293988, upload-time = "2026-03-28T21:47:32.553Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/8752e18bb87a2fe728b73b0f83c082eb162a470766063f8028759fb26844/regex-2026.3.32-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b8fca73e16c49dd972ce3a88278dfa5b93bf91ddef332a46e9443abe21ca2f7c", size = 292634, upload-time = "2026-03-28T21:47:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/d7729fe294e23e9c7c3871cb69d49059fa7d65fd11e437a2cbea43f6615d/regex-2026.3.32-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b953d9d496d19786f4d46e6ba4b386c6e493e81e40f9c5392332458183b0599d", size = 810532, upload-time = "2026-03-28T21:47:36.839Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/4dae7b000659f611b17b9c1541fba800b0569e4060debc4635ef1b23982c/regex-2026.3.32-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b565f25171e04d4fad950d1fa837133e3af6ea6f509d96166eed745eb0cf63bc", size = 871919, upload-time = "2026-03-28T21:47:39.192Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/aa8ad3977b9399861db3df62b33fe5fef6932ee23a1b9f4f357f58f2094b/regex-2026.3.32-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f28eac18a8733a124444643a66ac96fef2c0ad65f50034e0a043b90333dc677f", size = 916550, upload-time = "2026-03-28T21:47:41.618Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/6379d7f5b59ff0656ba49cf666d5013ecee55e83245275b310b0ffc79143/regex-2026.3.32-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cdd508664430dd51b8888deb6c5b416d8de046b2e11837254378d31febe4a98", size = 814988, upload-time = "2026-03-28T21:47:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/2c/af/2dfddc64074bd9b70e27e170ee9db900542e2870210b489ad4471416ba86/regex-2026.3.32-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c35d097f509cf7e40d20d5bee548d35d6049b36eb9965e8d43e4659923405b9", size = 786337, upload-time = "2026-03-28T21:47:46.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2f/4eb8abd705236402b4fe0e130971634deffb1855e2028bf02a2b7c0e841c/regex-2026.3.32-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:85c9b0c131427470a6423baa0a9330be6fd8c3630cc3ee6fdee03360724cbec5", size = 800029, upload-time = "2026-03-28T21:47:48.356Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/77d9ca2c9df483b51b4b1291c96d79c9ae301077841c4db39bc822f6b4c6/regex-2026.3.32-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e50af656c15e2723eeb7279c0837e07accc594b95ec18b86821a4d44b51b24bf", size = 865843, upload-time = "2026-03-28T21:47:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/306f477a509f4eed699071b1f031d89edd5a2b5fa28c8ede5b2638eaba82/regex-2026.3.32-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4bc32b4dbdb4f9f300cf9f38f8ea2ce9511a068ffaa45ac1373ee7a943f1d810", size = 772473, upload-time = "2026-03-28T21:47:52.771Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f6/54bd83ec46ac037de2beb049afc9dd5d2769c6ecaadf7856254ce610e62a/regex-2026.3.32-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3e5d1802cba785210a4a800e63fcee7a228649a880f3bf7f2aadccb151a834b", size = 856805, upload-time = "2026-03-28T21:47:55.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/ee0e7d14de1fc6582d5782f072db6c61465a38a4142f88e175dda494b536/regex-2026.3.32-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ef250a3f5e93182193f5c927c5e9575b2cb14b80d03e258bc0b89cc5de076b60", size = 801875, upload-time = "2026-03-28T21:47:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/8a/06/0fa9daca59d07b6aabd8e0468d3b86fd578576a157206fbcddbfc2298f7d/regex-2026.3.32-cp313-cp313t-win32.whl", hash = "sha256:9cf7036dfa2370ccc8651521fcbb40391974841119e9982fa312b552929e6c85", size = 269892, upload-time = "2026-03-28T21:47:59.674Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/77f16b5ad9f10ca574f03d84a354b359b0ac33f85054f2f2daafc9f7b807/regex-2026.3.32-cp313-cp313t-win_amd64.whl", hash = "sha256:c940e00e8d3d10932c929d4b8657c2ea47d2560f31874c3e174c0d3488e8b865", size = 281318, upload-time = "2026-03-28T21:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/db4446faaea8d01c8315c9c89c7dc6abbb3305e8e712e9b23936095c4d58/regex-2026.3.32-cp313-cp313t-win_arm64.whl", hash = "sha256:ace48c5e157c1e58b7de633c5e257285ce85e567ac500c833349c363b3df69d4", size = 272366, upload-time = "2026-03-28T21:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/32/68/ff024bf6131b7446a791a636dbbb7fa732d586f33b276d84b3460ea49393/regex-2026.3.32-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166", size = 490430, upload-time = "2026-03-28T21:48:05.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/039d9164817ee298f2a2d0246001afe662241dcbec0eedd1fe03e2a2555e/regex-2026.3.32-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c", size = 291948, upload-time = "2026-03-28T21:48:07.666Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/77f684d90ffe3e99b828d3cabb87a0f1601d2b9decd1333ff345809b1d02/regex-2026.3.32-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc", size = 289786, upload-time = "2026-03-28T21:48:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/bd76069a0304e924682b2efd8683a01617a7e1da9b651af73039d8da76a4/regex-2026.3.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad", size = 796672, upload-time = "2026-03-28T21:48:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/c2d7d9a5671e111a2c16d57e0cb03e1ce35b28a115901590528aa928bb5b/regex-2026.3.32-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4", size = 866556, upload-time = "2026-03-28T21:48:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b9/9921a31931d0bc3416ac30205471e0e2ed60dcbd16fc922bbd69b427322b/regex-2026.3.32-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041", size = 912787, upload-time = "2026-03-28T21:48:16.548Z" }, + { url = "https://files.pythonhosted.org/packages/41/ab/2c1bc8ab99f63cdabdbc7823af8f4cfcd6ddbb2babf01861826c3f1ad44d/regex-2026.3.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929", size = 800879, upload-time = "2026-03-28T21:48:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/0be716eb2c0b2ae3a439e44432534e82b2f81848af64cb21c0473ad8ae46/regex-2026.3.32-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff", size = 776332, upload-time = "2026-03-28T21:48:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/114a61bd25dec7d1070930eaef82aadf9b05961a37629e7cca7bc3fc2257/regex-2026.3.32-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194", size = 786384, upload-time = "2026-03-28T21:48:23.277Z" }, + { url = "https://files.pythonhosted.org/packages/0c/78/be0a6531f8db426e8e60d6356aeef8e9cc3f541655a648c4968b63c87a88/regex-2026.3.32-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9", size = 861381, upload-time = "2026-03-28T21:48:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/45/b1/e5076fbe45b8fb39672584b1b606d512f5bd3a43155be68a95f6b88c1fc5/regex-2026.3.32-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249", size = 765434, upload-time = "2026-03-28T21:48:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/da/fd65d68b897f8b52b1390d20d776fa753582484724a9cb4f4c26de657ae5/regex-2026.3.32-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c", size = 851501, upload-time = "2026-03-28T21:48:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d6/1e9c991c32022a9312e9124cc974961b3a2501338de2cd1cce75a3612d7a/regex-2026.3.32-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298", size = 788076, upload-time = "2026-03-28T21:48:32.025Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5b/b23c72f6d607cbb24ef42acf0c7c2ef4eee1377a9f7ba43b312f889edfbb/regex-2026.3.32-cp314-cp314-win32.whl", hash = "sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61", size = 272255, upload-time = "2026-03-28T21:48:34.355Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ec/32bbcc42366097a8cea2c481e02964be6c6fa5ccfb0fa9581686af0bec5f/regex-2026.3.32-cp314-cp314-win_amd64.whl", hash = "sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d", size = 281160, upload-time = "2026-03-28T21:48:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/89038a028cb68e719fa03ab1ad603649fc199bcda12270d2ac7b471b8f5d/regex-2026.3.32-cp314-cp314-win_arm64.whl", hash = "sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51", size = 273688, upload-time = "2026-03-28T21:48:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/87caccd608837a1fa4f8c7edc48e206103452b9bbc94fc724fa39340e807/regex-2026.3.32-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc", size = 494506, upload-time = "2026-03-28T21:48:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/a922e6b24694d70bdd68fc3fd076950e15b1b418cff9d2cc362b3968d86f/regex-2026.3.32-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0", size = 293986, upload-time = "2026-03-28T21:48:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/60/e4/0cb32203c1aebad0577fcd5b9af1fe764869e617d5234bc6a0ad284299ea/regex-2026.3.32-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1", size = 292677, upload-time = "2026-03-28T21:48:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/5006b70291469d4174dd66ad162802e2f68419c0f2a7952d0c76c1288cfa/regex-2026.3.32-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88", size = 810661, upload-time = "2026-03-28T21:48:48.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/438763a20d22cd1f65f95c8f030dd25df2d80a941068a891d21a5f240456/regex-2026.3.32-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3", size = 872156, upload-time = "2026-03-28T21:48:50.739Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5b/1341287887ac982ed9f5f60125e440513ffe354aa7e3681940495af7c12a/regex-2026.3.32-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b", size = 916749, upload-time = "2026-03-28T21:48:53.57Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/1d2b48b8e94debfffc6fefb84d2a86a178cc208652a1d6493d5f29821c70/regex-2026.3.32-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327", size = 814788, upload-time = "2026-03-28T21:48:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d9/7dacb34c43adaeb954518d851f3e5d3ce495ac00a9d6010e3b4b59917c4a/regex-2026.3.32-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5", size = 786594, upload-time = "2026-03-28T21:48:58.404Z" }, + { url = "https://files.pythonhosted.org/packages/ea/72/28295068c92dbd6d3ce4fd22554345cf504e957cc57dadeda4a64fa86a57/regex-2026.3.32-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745", size = 800167, upload-time = "2026-03-28T21:49:01.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/17/b10745adeca5b8d52da050e7c746137f5d01dabc6dbbe6e8d9d821dc65c1/regex-2026.3.32-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1", size = 865906, upload-time = "2026-03-28T21:49:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/45/9d/1acbcce765044ac0c87f453f4876e0897f7a61c10315262f960184310798/regex-2026.3.32-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c", size = 772642, upload-time = "2026-03-28T21:49:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/24/41/1ef8b4811355ad7b9d7579d3aeca00f18b7bc043ace26c8c609b9287346d/regex-2026.3.32-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27", size = 856927, upload-time = "2026-03-28T21:49:09.373Z" }, + { url = "https://files.pythonhosted.org/packages/97/b1/0dc1d361be80ec1b8b707ada041090181133a7a29d438e432260a4b26f9a/regex-2026.3.32-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac", size = 801910, upload-time = "2026-03-28T21:49:11.818Z" }, + { url = "https://files.pythonhosted.org/packages/b5/db/1a23f767fa250844772a9464306d34e0fafe2c317303b88a1415096b6324/regex-2026.3.32-cp314-cp314t-win32.whl", hash = "sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba", size = 275714, upload-time = "2026-03-28T21:49:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2b/616d31b125ca76079d74d6b1d84ec0860ffdb41c379151135d06e35a8633/regex-2026.3.32-cp314-cp314t-win_amd64.whl", hash = "sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6", size = 285722, upload-time = "2026-03-28T21:49:16.642Z" }, + { url = "https://files.pythonhosted.org/packages/7e/91/043d9a00d6123c5fa22a3dc96b10445ce434a8110e1d5e53efb01f243c8b/regex-2026.3.32-cp314-cp314t-win_arm64.whl", hash = "sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9", size = 275700, upload-time = "2026-03-28T21:49:19.348Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +] + +[[package]] +name = "zope-event" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/04/0b1d92e7d31507c5fbe203d9cc1ae80fb0645688c7af751ea0ec18c2223e/zope_interface-8.3.tar.gz", hash = "sha256:e1a9de7d0b5b5c249a73b91aebf4598ce05e334303af6aa94865893283e9ff10", size = 256822, upload-time = "2026-04-10T06:12:35.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/1e/7ed593f9c3664e560febe1f132fdf73b8bb9a3de6e3448093b0167239c8c/zope_interface-8.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b361b7ce566bc024e55f74eb1e88afc14039d7bd8ea13eeff3b7a8400dc59683", size = 211571, upload-time = "2026-04-10T06:22:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/cf/31/844979b472f30efd2a68480738c9a3be518786b0885137075616607e88c7/zope_interface-8.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5be73ca1304daa3046ee5835f7fa6b3badadf02102b570532dd57cd25dd72d6", size = 211748, upload-time = "2026-04-10T06:22:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b6/71f5c9d8dde7334e1b67306fea5814c67eac92d871bb0dfc664c9f3355f1/zope_interface-8.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:961af756797e36c1e77f7d0dc8ac1322de0c071eaa1a641dbe3b790061968dd9", size = 264718, upload-time = "2026-04-10T06:22:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/94/e3/5eab77fd6795ca37b9ed1aeea5290170018938549322003745bdcd939238/zope_interface-8.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6329f296b70f62043bf2df06eb91b4be040baee32ec4a3e0314f3893fa5c51c", size = 269795, upload-time = "2026-04-10T06:22:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/4bc8807d65833f06335a49beb1786bafcf748cde7472ba14cdb4db463ba8/zope_interface-8.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f420f6c96307ff265981c510782f0ed97475107b78ca9fca0bb04fe36f363eb4", size = 269418, upload-time = "2026-04-10T06:22:23.802Z" }, + { url = "https://files.pythonhosted.org/packages/50/3d/1cfaf770bc6bc64edec3d4c5f17b5dbe600bf93cd2caac5ee0880eb9f9e0/zope_interface-8.3-cp312-cp312-win_amd64.whl", hash = "sha256:ffeae9102aa6ba5bd2f9a547016347bd87c9cf01aea564936c0d165fff0b1242", size = 214390, upload-time = "2026-04-10T06:22:25.735Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/ff205c5463e52ad64cc40be667fdff2b01b9754a385c6b95bac01645fa4f/zope_interface-8.3-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:1aa0e1d72212cedc38b2156bbca08cf24625c057135a7947ef6b19bc732b2772", size = 211889, upload-time = "2026-04-10T06:22:27.612Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/0cc848e22769b1cf4c0cd636ec2e60ea05cfb958423435ea526d5a291fe8/zope_interface-8.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54ab83218a8f6947ba4b6cb1a121f1e1abe2e418b838ccdac71639d0f97e734e", size = 211961, upload-time = "2026-04-10T06:22:29.575Z" }, + { url = "https://files.pythonhosted.org/packages/e3/54/815c9dbb90336c50694b4c7ef7ced06bc389e5597200c77457b557a0221c/zope_interface-8.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:34d6c10fa790005487c471e0e4ab537b0fa9a70e55a96994e51ffeef92205fa4", size = 264409, upload-time = "2026-04-10T06:22:31.426Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/2e5c30adde0e94552d934971fa6eba107449d3d11fa086cfcfeb8ea6354d/zope_interface-8.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93108d5f8dee20177a637438bf4df4c6faf8a317c9d4a8b1d5e78123854e3317", size = 269592, upload-time = "2026-04-10T06:22:33.393Z" }, + { url = "https://files.pythonhosted.org/packages/23/8a/fbb1dceb5c5400b2b27934aa102d29fe4cb06732122e7f409efebeb6e097/zope_interface-8.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f81d90f80b9fbf36602549e2f187861c9d7139837f8c9dd685ce3b933c6360f", size = 269548, upload-time = "2026-04-10T06:22:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/a2/70/abd0bb9cc9b1a9a718f30c81f46a184a2e751dd80cf57db142ffa42730da/zope_interface-8.3-cp313-cp313-win_amd64.whl", hash = "sha256:96106a5f609bb355e1aec6ab0361213c8af0843ca1e1ba9c42eacfbd0910914e", size = 214391, upload-time = "2026-04-10T06:22:36.969Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/95fe0d4d8da09042383c42f239e0106f1019ec86a27ed9f5000e754f6e7a/zope_interface-8.3-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:96f0001b49227d756770fc70ecde49f19332ae98ec98e1bbbf2fd7a87e9d4e45", size = 211979, upload-time = "2026-04-10T06:22:38.628Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b6f694444ea1c911a4ea915f4ef066a95e9d1a58256a30c131ec88c3ae64/zope_interface-8.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3853bfb808084e1b4a3a769b00bd8b58a52b0c4a4fc5c23de26d283cd8beb627", size = 212038, upload-time = "2026-04-10T06:22:40.475Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/237de1fba4f05686bc344eeb035236bd89890679c8211f129f05b5971ccf/zope_interface-8.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:33a13acba79ef693fb64ceb6193ece913d39586f184797f133c1bc549da86851", size = 266041, upload-time = "2026-04-10T06:22:42.093Z" }, + { url = "https://files.pythonhosted.org/packages/58/5f/df85b1ff5626d7f05231e69b7efd38bdc2c82ca363495e0bb112aaf655b3/zope_interface-8.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9f7e4b46741a11a9e1fab8b68710f08dec700e9f1b877cdca02480fbebe4846", size = 269094, upload-time = "2026-04-10T06:22:43.832Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/7ad1ff9c514fe38b176fc1271967c453074eb386a4515bd3b957c485f3a8/zope_interface-8.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ce49d43366e12aeccd14fcaebb3ef110f50f5795e0d4a95383ea057365cedf2", size = 269413, upload-time = "2026-04-10T06:22:45.573Z" }, + { url = "https://files.pythonhosted.org/packages/38/42/3b0b5edee7801e0dd5c42c2c9bb4ec8bec430a6628462eb1315db76a7954/zope_interface-8.3-cp314-cp314-win_amd64.whl", hash = "sha256:301db4049c79a15a3b29d89795e150daf0e9ae701404b112ad6585ea863f6ef5", size = 215170, upload-time = "2026-04-10T06:22:47.115Z" }, +]