Compare commits
1 Commits
1f4c3d35ef
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bd26c8894f |
@@ -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
|
||||
|
||||
|
||||
Executable
+449
@@ -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 "$@"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+208
@@ -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())
|
||||
Reference in New Issue
Block a user