Add production rollout tooling and semantic index ops docs

Capture the production plugin rollout workflow and Qdrant validation steps so operations stay repeatable. Also harden redMCP stdio/schema compatibility to keep diverse MCP clients and validators working.
This commit is contained in:
Jason Thistlethwaite
2026-05-06 22:18:02 -04:00
parent 1f4c3d35ef
commit bd26c8894f
10 changed files with 765 additions and 4 deletions
+6
View File
@@ -79,6 +79,12 @@ Recent validation run for redMCP changes:
concurrent identities/API keys may be needed. Prefer an instance model.
- If systemd is used for redMCP, implement a simple operator script with
`install`, `remove`, and `status` flows.
- Production plugin rollout milestone completed:
- `deploy_redmine_prod_patches.sh` was used to deploy plugin patches on
production.
- Upload bundle used: `dist/redmine-prod-plugin-rollout-20260506T210606Z.tar.gz`.
- User-reported outcome: production deploy completed and appears to be
working.
## Notes to keep in mind
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$SCRIPT_DIR"
REDMINE_ROOT="/usr/share/redmine"
BACKUP_ROOT="/root/redmine-plugin-backups"
REDMINE_URL="${REDMINE_URL:-}"
REDMINE_API_KEY="${REDMINE_API_KEY:-${REDMNINE_API_KEY:-}}"
HELPDESK_ISSUE_ID="${HELPDESK_ISSUE_ID:-}"
NON_HELPDESK_ISSUE_ID="${NON_HELPDESK_ISSUE_ID:-}"
SKIP_CONTACTS=0
SKIP_HELPDESK=0
SKIP_OUTBOX=0
ACTION="deploy"
ROLLBACK_DIR=""
APPLY=0
APPLY_SET=0
PRINT_CHANGE_MAP=0
PLUGINS=(redmine_contacts redmine_contacts_helpdesk redmine_event_outbox)
SELECTED_PLUGINS=()
BACKUP_DIR=""
ROLLBACK_RUNNING=0
usage() {
cat <<'EOF'
Usage:
./deploy_redmine_prod_patches.sh [options]
Defaults to dry-run deploy mode.
Modes:
--apply Execute actions (default is dry-run)
--dry-run Print actions only
--rollback <backup_dir> Restore plugins from a prior backup directory
Core options:
--repo-root <path> Local repo root (default: script directory)
--redmine-root <path> Production Redmine root (default: /usr/share/redmine)
--backup-root <path> Backup root for deploy mode (default: /root/redmine-plugin-backups)
Verification options:
--redmine-url <url> Redmine base URL for API checks
--api-key <key> Redmine API key for API checks
--helpdesk-issue-id <id> Known Helpdesk issue id for include=helpdesk verification
--non-helpdesk-issue-id <id> Known non-Helpdesk issue id for include=helpdesk verification
Selection options:
--skip-contacts
--skip-helpdesk
--skip-outbox
Informational:
--print-change-map Show patch groups and key files, then exit
-h, --help Show this help
Examples:
./deploy_redmine_prod_patches.sh --dry-run
./deploy_redmine_prod_patches.sh --apply --redmine-url https://redmine.example.com --api-key ... --helpdesk-issue-id 39779 --non-helpdesk-issue-id 12345
./deploy_redmine_prod_patches.sh --rollback /root/redmine-plugin-backups/prod-plugin-rollout-20260506T120000Z
EOF
}
log() {
printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
print_change_map() {
cat <<'EOF'
Patch Groups -> Key Files
1) Helpdesk API include patch (semantic-index dependency)
- plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
- plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
- plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
2) Helpdesk search routes/controller in local helpdesk fork
- plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb
- plugins/redmine_contacts_helpdesk/config/routes.rb
- plugins/redmine_contacts_helpdesk/init.rb
3) POP3 compatibility fix in contacts fork
- plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb
4) Event outbox + Helpdesk-related hooks
- plugins/redmine_event_outbox/db/migrate/001_create_event_outbox_events.rb
- plugins/redmine_event_outbox/lib/redmine_event_outbox.rb
- plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/helpdesk_ticket_patch.rb
- plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/journal_message_patch.rb
Operational note:
This script deploys full plugin directories to match the post-import workflow:
- plugins/redmine_contacts/
- plugins/redmine_contacts_helpdesk/
- plugins/redmine_event_outbox/
EOF
}
cmd_string() {
local out=""
local arg
for arg in "$@"; do
out+=" $(printf '%q' "$arg")"
done
printf '%s' "${out# }"
}
run_cmd() {
log "+ $(cmd_string "$@")"
if [[ "$APPLY" -eq 1 ]]; then
"$@"
fi
}
require_path() {
local path="$1"
[[ -e "$path" ]] || die "missing required path: $path"
}
require_dir() {
local path="$1"
[[ -d "$path" ]] || die "missing required directory: $path"
}
build_selected_plugins() {
SELECTED_PLUGINS=()
if [[ "$SKIP_CONTACTS" -eq 0 ]]; then
SELECTED_PLUGINS+=("redmine_contacts")
fi
if [[ "$SKIP_HELPDESK" -eq 0 ]]; then
SELECTED_PLUGINS+=("redmine_contacts_helpdesk")
fi
if [[ "$SKIP_OUTBOX" -eq 0 ]]; then
SELECTED_PLUGINS+=("redmine_event_outbox")
fi
if [[ "${#SELECTED_PLUGINS[@]}" -eq 0 ]]; then
die "nothing selected; remove skip flags or pick at least one plugin"
fi
}
backup_plugin() {
local plugin="$1"
local src="$REDMINE_ROOT/plugins/$plugin"
local dst="$BACKUP_DIR/$plugin"
local absent_marker="$BACKUP_DIR/.${plugin}.absent"
if [[ -d "$src" ]]; then
run_cmd mkdir -p "$dst"
run_cmd rsync -a "$src/" "$dst/"
else
run_cmd mkdir -p "$BACKUP_DIR"
run_cmd touch "$absent_marker"
fi
}
restore_plugin() {
local plugin="$1"
local src="$ROLLBACK_DIR/$plugin"
local dst="$REDMINE_ROOT/plugins/$plugin"
local absent_marker="$ROLLBACK_DIR/.${plugin}.absent"
if [[ -f "$absent_marker" ]]; then
run_cmd rm -rf "$dst"
return
fi
require_dir "$src"
run_cmd mkdir -p "$dst"
run_cmd rsync -a --delete "$src/" "$dst/"
}
deploy_plugin() {
local plugin="$1"
local src="$REPO_ROOT/plugins/$plugin"
local dst="$REDMINE_ROOT/plugins/$plugin"
require_dir "$src"
run_cmd mkdir -p "$dst"
run_cmd rsync -a --delete "$src/" "$dst/"
}
verify_contacts_syntax() {
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb"
}
verify_helpdesk_syntax() {
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb"
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb"
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb"
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb"
}
verify_outbox_syntax() {
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_event_outbox/lib/redmine_event_outbox.rb"
}
migrate_plugins() {
run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec rake redmine:plugins:migrate"
}
restart_passenger() {
run_cmd touch "$REDMINE_ROOT/tmp/restart.txt"
}
verify_outbox_table() {
if [[ "$SKIP_OUTBOX" -eq 1 ]]; then
return
fi
run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec ruby -e \"require './config/environment'; abort('missing event_outbox_events') unless ActiveRecord::Base.connection.table_exists?(:event_outbox_events); puts 'OK event_outbox_events'\""
}
verify_helpdesk_api() {
if [[ "$SKIP_HELPDESK" -eq 1 ]]; then
return
fi
if [[ -z "$REDMINE_URL" || -z "$REDMINE_API_KEY" ]]; then
log "Skipping API verification (set --redmine-url and --api-key to enable)"
return
fi
local tmp1 tmp2
tmp1="$(mktemp)"
tmp2="$(mktemp)"
if [[ -n "$HELPDESK_ISSUE_ID" ]]; then
run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${HELPDESK_ISSUE_ID}.json?include=journals,helpdesk" -o "$tmp1"
run_cmd grep -q '"helpdesk_ticket"' "$tmp1"
else
log "Skipping Helpdesk issue include check (set --helpdesk-issue-id)"
fi
if [[ -n "$NON_HELPDESK_ISSUE_ID" ]]; then
run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${NON_HELPDESK_ISSUE_ID}.json?include=helpdesk" -o "$tmp2"
run_cmd grep -q '"issue"' "$tmp2"
else
log "Skipping non-Helpdesk include check (set --non-helpdesk-issue-id)"
fi
if [[ "$APPLY" -eq 1 ]]; then
rm -f "$tmp1" "$tmp2"
else
log "Temporary API response files (dry-run placeholders): $tmp1 $tmp2"
fi
}
rollback_selected_plugins() {
local plugin
for plugin in "${SELECTED_PLUGINS[@]}"; do
log "Restoring plugin: $plugin"
restore_plugin "$plugin"
done
restart_passenger
}
on_error() {
local line="$1"
local rc="$2"
if [[ "$ROLLBACK_RUNNING" -eq 1 ]]; then
exit "$rc"
fi
log "Failure at line ${line} (exit ${rc})"
if [[ "$ACTION" == "deploy" && "$APPLY" -eq 1 && -n "$BACKUP_DIR" ]]; then
ROLLBACK_RUNNING=1
ROLLBACK_DIR="$BACKUP_DIR"
log "Attempting automatic rollback from: $ROLLBACK_DIR"
rollback_selected_plugins || true
log "Automatic rollback finished"
fi
exit "$rc"
}
parse_args() {
while [[ "$#" -gt 0 ]]; do
case "$1" in
--apply)
APPLY=1
APPLY_SET=1
;;
--dry-run)
APPLY=0
APPLY_SET=1
;;
--rollback)
ACTION="rollback"
shift
[[ "$#" -gt 0 ]] || die "--rollback requires a backup directory"
ROLLBACK_DIR="$1"
;;
--repo-root)
shift
[[ "$#" -gt 0 ]] || die "--repo-root requires a path"
REPO_ROOT="$1"
;;
--redmine-root)
shift
[[ "$#" -gt 0 ]] || die "--redmine-root requires a path"
REDMINE_ROOT="$1"
;;
--backup-root)
shift
[[ "$#" -gt 0 ]] || die "--backup-root requires a path"
BACKUP_ROOT="$1"
;;
--redmine-url)
shift
[[ "$#" -gt 0 ]] || die "--redmine-url requires a value"
REDMINE_URL="$1"
;;
--api-key)
shift
[[ "$#" -gt 0 ]] || die "--api-key requires a value"
REDMINE_API_KEY="$1"
;;
--helpdesk-issue-id)
shift
[[ "$#" -gt 0 ]] || die "--helpdesk-issue-id requires a value"
HELPDESK_ISSUE_ID="$1"
;;
--non-helpdesk-issue-id)
shift
[[ "$#" -gt 0 ]] || die "--non-helpdesk-issue-id requires a value"
NON_HELPDESK_ISSUE_ID="$1"
;;
--skip-contacts)
SKIP_CONTACTS=1
;;
--skip-helpdesk)
SKIP_HELPDESK=1
;;
--skip-outbox)
SKIP_OUTBOX=1
;;
--print-change-map)
PRINT_CHANGE_MAP=1
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
shift
done
}
main() {
parse_args "$@"
if [[ "$PRINT_CHANGE_MAP" -eq 1 ]]; then
print_change_map
exit 0
fi
if [[ "$ACTION" == "rollback" && "$APPLY_SET" -eq 0 ]]; then
APPLY=1
fi
if [[ "$APPLY" -eq 1 && "$EUID" -ne 0 ]]; then
die "--apply and --rollback apply mode require root"
fi
require_dir "$REDMINE_ROOT"
require_dir "$REDMINE_ROOT/plugins"
require_path "$REDMINE_ROOT/tmp"
build_selected_plugins
trap 'on_error ${LINENO} $?' ERR
if [[ "$ACTION" == "rollback" ]]; then
require_dir "$ROLLBACK_DIR"
log "Mode: rollback ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))"
log "Redmine root: $REDMINE_ROOT"
log "Backup source: $ROLLBACK_DIR"
rollback_selected_plugins
trap - ERR
log "Rollback completed"
exit 0
fi
require_dir "$REPO_ROOT"
require_dir "$REPO_ROOT/plugins"
BACKUP_DIR="$BACKUP_ROOT/prod-plugin-rollout-$(date -u +%Y%m%dT%H%M%SZ)"
log "Mode: deploy ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))"
log "Repo root: $REPO_ROOT"
log "Redmine root: $REDMINE_ROOT"
log "Backup dir: $BACKUP_DIR"
log "Selected plugins: ${SELECTED_PLUGINS[*]}"
run_cmd mkdir -p "$BACKUP_DIR"
local plugin
for plugin in "${SELECTED_PLUGINS[@]}"; do
log "Backing up plugin: $plugin"
backup_plugin "$plugin"
done
if [[ "$SKIP_CONTACTS" -eq 0 ]]; then
log "Deploying plugin: redmine_contacts"
deploy_plugin "redmine_contacts"
log "Verifying syntax: redmine_contacts"
verify_contacts_syntax
fi
if [[ "$SKIP_HELPDESK" -eq 0 ]]; then
log "Deploying plugin: redmine_contacts_helpdesk"
deploy_plugin "redmine_contacts_helpdesk"
log "Verifying syntax: redmine_contacts_helpdesk"
verify_helpdesk_syntax
fi
if [[ "$SKIP_OUTBOX" -eq 0 ]]; then
log "Deploying plugin: redmine_event_outbox"
deploy_plugin "redmine_event_outbox"
log "Verifying syntax: redmine_event_outbox"
verify_outbox_syntax
fi
log "Running plugin migrations"
migrate_plugins
log "Restarting Passenger"
restart_passenger
log "Running runtime verifications"
verify_outbox_table
verify_helpdesk_api
trap - ERR
log "Deploy completed successfully"
log "Rollback command: $(cmd_string "$0" --rollback "$BACKUP_DIR")"
}
main "$@"
+25
View File
@@ -32,6 +32,31 @@ environment. Before risky edits, archive the current plugin directories in
- Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes,
then choose the external index target.
## 2026-05-06 - Production Plugin Rollout Via Unified Script
- Touched areas:
- `plugins/redmine_contacts`
- `plugins/redmine_contacts_helpdesk`
- `plugins/redmine_event_outbox`
- `deploy_redmine_prod_patches.sh`
- Purpose:
- Apply the same plugin payload shape used by test-host post-import automation
to production in a controlled, repeatable sequence.
- Keep rollback simple by backing up plugin directories before each apply run.
- Behavior/deployment tooling:
- Added and used `deploy_redmine_prod_patches.sh` with one-command dry-run,
apply, and rollback modes.
- Deployment package assembled as
`dist/redmine-prod-plugin-rollout-20260506T210606Z.tar.gz` for upload and
unpack on production.
- Script-level checks include Ruby syntax checks per plugin, plugin migration,
Passenger restart, and optional API verification for
`include=journals,helpdesk` behavior.
- Production result:
- Production rollout completed and reported working after deploy.
- This confirms the production host now has the local plugin fork updates in
place through the scripted deployment path.
## 2026-04-25 - redMCP Native Search, Filtering, And MCP Operations
- Touched areas:
+18 -1
View File
@@ -53,6 +53,23 @@ docker run -p 6333:6333 -p 6334:6334 \
Before destructive maintenance, create a Qdrant snapshot or preserve the Docker
volume.
## WireGuard Topology
Current WireGuard endpoints:
- production server: `10.11.0.100`
- LAN server: `10.11.0.105`
When Qdrant is hosted on the LAN server, keep it reachable on the WireGuard
address and point production semantic-index traffic at:
```sh
QDRANT_URL=http://10.11.0.105:6333
```
Do not bind production-hosted Qdrant to `10.11.0.105`; that address belongs to
the LAN host.
## Environment
For a production-style install, use:
@@ -68,7 +85,7 @@ target host:
```sh
OPENAI_API_KEY=
QDRANT_URL=http://qdrant-host:6333
QDRANT_URL=http://10.11.0.105:6333
QDRANT_API_KEY=
QDRANT_COLLECTION=redmine_semantic_sample
REDMINE_URL=http://redmine-host
+22
View File
@@ -38,6 +38,28 @@ SEMANTIC_INDEX_OVERLAP_MINUTES=15
Keep `OPENAI_API_KEY`, `QDRANT_URL`, `REDMINE_URL`, and `REDMINE_API_KEY` in the
existing `.env` workflow or in the service manager environment.
Current WireGuard addressing used by this environment:
- production server: `10.11.0.100`
- LAN server: `10.11.0.105`
If Qdrant stays on the LAN server, set:
```sh
QDRANT_URL=http://10.11.0.105:6333
```
Keep Qdrant bound to the WireGuard/LAN path only and protect it with
`QDRANT_API_KEY`.
From the production server, run `./validate_qdrant.py` to verify Qdrant liveness,
readiness, auth, and a minimal create/upsert/read/delete round trip.
```sh
QDRANT_API_KEY=... ./validate_qdrant.py
./validate_qdrant.py --skip-write-test
```
For production-style deployment, use `/opt/semantic-index` for code,
`/etc/semantic-index.env` for service environment, `/var/lib/semantic-index`
for refresh state, and `/var/log/semantic-index` for refresh logs. Systemd
+7
View File
@@ -213,6 +213,13 @@ Redmine credentials from environment variables or `redMCP/.env`.
redMCP/bin/redmcp-server.php
```
The stdio server supports both MCP framing styles used by clients in the wild:
- `Content-Length` framed JSON-RPC messages
- line-delimited JSON messages (one JSON-RPC object per line)
Responses mirror the detected input framing mode for compatibility.
For local testing, run the Streamable HTTP server:
```sh
+2 -2
View File
@@ -254,7 +254,7 @@ final class McpDispatcher
'due_date' => ['type' => 'string'],
'start_date' => ['type' => 'string'],
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
'watcher_user_ids' => ['type' => 'array'],
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
'is_private' => ['type' => ['boolean', 'string', 'integer']],
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
'done_ratio' => ['type' => ['integer', 'string']],
@@ -279,7 +279,7 @@ final class McpDispatcher
'start_date' => ['type' => 'string'],
'private_notes' => ['type' => ['boolean', 'string', 'integer']],
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
'watcher_user_ids' => ['type' => 'array'],
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
'is_private' => ['type' => ['boolean', 'string', 'integer']],
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
'done_ratio' => ['type' => ['integer', 'string']],
+9 -1
View File
@@ -7,6 +7,7 @@ namespace RedMCP;
final class McpStdioServer
{
private McpDispatcher $dispatcher;
private string $wireMode = 'content-length';
public function __construct(McpDispatcher $dispatcher)
{
@@ -36,7 +37,8 @@ final class McpStdioServer
if ($line === '') {
break;
}
if (!str_contains($line, ':')) {
if (!preg_match('/^[A-Za-z0-9-]+\s*:/', $line)) {
$this->wireMode = 'line';
$decoded = json_decode($line, true);
return is_array($decoded) ? $decoded : null;
}
@@ -76,6 +78,12 @@ final class McpStdioServer
return;
}
if ($this->wireMode === 'line') {
fwrite(STDOUT, $body . "\n");
fflush(STDOUT);
return;
}
fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body);
fflush(STDOUT);
}
+19
View File
@@ -0,0 +1,19 @@
## Roadmap 2026-05-06
redMCP is now being used in production, even though the changes to redmine's plugins and the semantic search feature are not yet in production. Since beginning production use, we have learned that the way the MCP server may return certain information wastes valuable tokens and context space for connected agents. This problem doesn't really seem to be an issue with the MCP server itself, but has more to do the contents of tickets themselves.
The issue is, the "description" field of many redmine tickets may contain a lot of extra non-printing characters, unicode, or things of that nature which do not seve any real purpose, and which waste tokens and context.
## Goal
Develop a plan for trimming fetched data to minimize junk information without harming the meaning of the data. This should, in particular, strip needlessly repeating characters from fetched issues or notes.
## Other issues needing addressed
- [ ] MCP server response when client requests projects by human-name needs to be adjusted.
Problem: a client may call list_issues and for the project_id, they may pass "Quality Tracker", which is the human-facing name for that project in redmine. That fails because redmine expects to receive either the numeric project id or the slug (like quality-tracker). This is a mistake that humans can easily make as well.
Proposed solutions:
- [ ] When a "project_id" is passed to commands like list_issues and it contains any spaces or uppercase characters, that likely means the client is triggering this bug. If the subsequent call to redmine results in an empty response or error message, it would be helpful if the MCP server returns an error message that explains this problem.
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Simple Qdrant connectivity and write-path validator.
Run this from the production host to confirm Qdrant is reachable, auth works,
and a minimal create/upsert/read/delete round trip succeeds.
"""
import argparse
import json
import os
import socket
import time
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
DEFAULT_URLS = ("http://127.0.0.1:6333", "http://10.11.0.105:6333")
def main() -> int:
parser = argparse.ArgumentParser(description="Validate Qdrant connectivity and basic operations.")
parser.add_argument(
"--url",
action="append",
help=(
"Qdrant base URL to test. Repeat for multiple endpoints. "
"Defaults to QDRANT_URL if set, otherwise localhost and 10.11.0.105."
),
)
parser.add_argument(
"--api-key",
default=os.getenv("QDRANT_API_KEY", ""),
help="Qdrant API key. Defaults to QDRANT_API_KEY env var.",
)
parser.add_argument(
"--skip-write-test",
action="store_true",
help="Only verify read-only endpoints and auth.",
)
parser.add_argument(
"--timeout",
type=float,
default=5.0,
help="HTTP timeout in seconds (default: 5).",
)
args = parser.parse_args()
urls = normalized_urls(args.url)
api_key = args.api_key.strip()
failures = 0
for url in urls:
print(f"\n== {url} ==")
try:
validate_endpoint(url, api_key=api_key, timeout=args.timeout, skip_write_test=args.skip_write_test)
print(f"[OK] Endpoint validated: {url}")
except ValidationError as exc:
failures += 1
print(f"[FAIL] {url}: {exc}")
print(f"\nSummary: {len(urls) - failures} OK, {failures} FAIL")
return 1 if failures else 0
def normalized_urls(values: Optional[List[str]]) -> List[str]:
if values:
return [v.rstrip("/") for v in values]
env_url = os.getenv("QDRANT_URL", "").strip()
if env_url:
return [env_url.rstrip("/")]
return [u.rstrip("/") for u in DEFAULT_URLS]
def validate_endpoint(base_url: str, api_key: str, timeout: float, skip_write_test: bool) -> None:
headers = {"Content-Type": "application/json"}
if api_key:
headers["api-key"] = api_key
live_text = http_text("GET", f"{base_url}/livez", headers=headers, timeout=timeout)
ensure_health_ok(live_text, "livez")
print("[OK] /livez")
ready_text = http_text("GET", f"{base_url}/readyz", headers=headers, timeout=timeout)
ensure_health_ok(ready_text, "readyz")
print("[OK] /readyz")
collections = http_json("GET", f"{base_url}/collections", headers=headers, timeout=timeout)
ensure_status_ok(collections, "collections")
count = len(collections.get("result", {}).get("collections", []))
print(f"[OK] /collections (count={count})")
if skip_write_test:
print("[OK] Write-path test skipped")
return
collection = temp_collection_name()
created = False
try:
body = {"vectors": {"size": 4, "distance": "Cosine"}}
create_result = http_json(
"PUT",
f"{base_url}/collections/{collection}",
headers=headers,
timeout=timeout,
body=body,
)
ensure_status_ok(create_result, "create collection")
created = True
print(f"[OK] Created temp collection: {collection}")
point = {"id": 1, "vector": [0.1, 0.2, 0.3, 0.4], "payload": {"check": "qdrant-smoke"}}
upsert_result = http_json(
"PUT",
f"{base_url}/collections/{collection}/points?wait=true",
headers=headers,
timeout=timeout,
body={"points": [point]},
)
ensure_status_ok(upsert_result, "upsert point")
print("[OK] Upserted test point")
fetch_result = http_json(
"POST",
f"{base_url}/collections/{collection}/points",
headers=headers,
timeout=timeout,
body={"ids": [1], "with_payload": True, "with_vector": True},
)
ensure_status_ok(fetch_result, "fetch point")
points = fetch_result.get("result", [])
if not points:
raise ValidationError("fetch point returned empty result")
if points[0].get("id") != 1:
raise ValidationError(f"unexpected point id in fetch response: {points[0].get('id')!r}")
print("[OK] Fetched test point")
finally:
if created:
try:
delete_result = http_json(
"DELETE",
f"{base_url}/collections/{collection}?timeout=30",
headers=headers,
timeout=timeout,
)
ensure_status_ok(delete_result, "delete collection")
print(f"[OK] Deleted temp collection: {collection}")
except ValidationError as exc:
print(f"[WARN] Could not delete temp collection {collection}: {exc}")
def temp_collection_name() -> str:
stamp = time.strftime("%Y%m%d%H%M%S")
host = socket.gethostname().replace("_", "-").replace(".", "-")
return f"qdrant_smoke_{host}_{stamp}_{os.getpid()}"
def http_json(method: str, url: str, headers: Dict[str, str], timeout: float, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(url=url, method=method, data=data, headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise ValidationError(f"HTTP {exc.code} for {method} {url}: {detail.strip()}") from exc
except urllib.error.URLError as exc:
raise ValidationError(f"network error for {method} {url}: {exc.reason}") from exc
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise ValidationError(f"non-JSON response for {method} {url}: {payload[:200]!r}") from exc
def http_text(method: str, url: str, headers: Dict[str, str], timeout: float) -> str:
request = urllib.request.Request(url=url, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", errors="replace").strip()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise ValidationError(f"HTTP {exc.code} for {method} {url}: {detail.strip()}") from exc
except urllib.error.URLError as exc:
raise ValidationError(f"network error for {method} {url}: {exc.reason}") from exc
def ensure_status_ok(payload: Dict[str, Any], context: str) -> None:
if payload.get("status") != "ok":
raise ValidationError(f"{context} returned non-ok payload: {payload}")
def ensure_health_ok(payload_text: str, context: str) -> None:
text = payload_text.lower()
if "passed" in text or text == "ok" or "ready" in text:
return
raise ValidationError(f"{context} returned unexpected payload: {payload_text!r}")
class ValidationError(RuntimeError):
pass
if __name__ == "__main__":
raise SystemExit(main())