@synapta/skills 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +11 -4
- package/package.json +3 -4
- package/skills/ATTRIBUTION.md +80 -0
- package/skills/accessibility-audit/SKILL.md +325 -0
- package/skills/accessibility-audit/reference/wcag-checklist.md +103 -0
- package/skills/apns-notifier/SKILL.md +86 -0
- package/skills/approval-policy-enforcer/SKILL.md +66 -0
- package/skills/apps-sdk-builder/LICENSE.txt +201 -0
- package/skills/apps-sdk-builder/SKILL.md +328 -0
- package/skills/apps-sdk-builder/agents/openai.yaml +13 -0
- package/skills/apps-sdk-builder/references/app-archetypes.md +132 -0
- package/skills/apps-sdk-builder/references/apps-sdk-docs-workflow.md +135 -0
- package/skills/apps-sdk-builder/references/interactive-state-sync-patterns.md +113 -0
- package/skills/apps-sdk-builder/references/repo-contract-and-validation.md +93 -0
- package/skills/apps-sdk-builder/references/search-fetch-standard.md +67 -0
- package/skills/apps-sdk-builder/references/upstream-example-workflow.md +79 -0
- package/skills/apps-sdk-builder/references/window-openai-patterns.md +79 -0
- package/skills/apps-sdk-builder/scripts/scaffold_node_ext_apps.mjs +606 -0
- package/skills/architecture-selector/SKILL.md +64 -0
- package/skills/backlog-planner/SKILL.md +68 -0
- package/skills/carplay-entitlement-checker/SKILL.md +82 -0
- package/skills/concept-deepener/SKILL.md +86 -0
- package/skills/concept-discovery/SKILL.md +517 -0
- package/skills/concept-discovery/assets/sample-analysis.json +81 -0
- package/skills/concept-discovery/expected_outputs/sample-enum-dictionary.md +25 -0
- package/skills/concept-discovery/expected_outputs/sample-page-user-list.md +83 -0
- package/skills/concept-discovery/expected_outputs/sample-prd-readme.md +43 -0
- package/skills/concept-discovery/references/framework-patterns.md +228 -0
- package/skills/concept-discovery/references/prd-quality-checklist.md +65 -0
- package/skills/concept-discovery/scripts/codebase_analyzer.py +732 -0
- package/skills/concept-discovery/scripts/prd_scaffolder.py +435 -0
- package/skills/dast-zap/SKILL.md +453 -0
- package/skills/dast-zap/assets/.gitkeep +9 -0
- package/skills/dast-zap/assets/github_action.yml +207 -0
- package/skills/dast-zap/assets/gitlab_ci.yml +226 -0
- package/skills/dast-zap/assets/zap_automation.yaml +196 -0
- package/skills/dast-zap/assets/zap_context.xml +192 -0
- package/skills/dast-zap/references/EXAMPLE.md +40 -0
- package/skills/dast-zap/references/api_testing_guide.md +475 -0
- package/skills/dast-zap/references/authentication_guide.md +431 -0
- package/skills/dast-zap/references/false_positive_handling.md +427 -0
- package/skills/dast-zap/references/owasp_mapping.md +255 -0
- package/skills/dep-sbom-scan/SKILL.md +466 -0
- package/skills/deploy-cloudflare/SKILL.md +930 -0
- package/skills/deploy-docker/SKILL.md +55 -0
- package/skills/deploy-fly/SKILL.md +228 -0
- package/skills/deploy-k8s/SKILL.md +108 -0
- package/skills/deploy-k8s/assets/logo.png +0 -0
- package/skills/deploy-k8s/docs/README.md +29 -0
- package/skills/deploy-k8s/docs/SUMMARY.md +56 -0
- package/skills/deploy-k8s/docs/advanced/token-efficiency.md +61 -0
- package/skills/deploy-k8s/docs/architecture/multi-tenancy.md +96 -0
- package/skills/deploy-k8s/docs/architecture/storage-and-state.md +102 -0
- package/skills/deploy-k8s/docs/architecture/workload-patterns.md +87 -0
- package/skills/deploy-k8s/docs/book.json +16 -0
- package/skills/deploy-k8s/docs/community/changelog.md +34 -0
- package/skills/deploy-k8s/docs/community/contributing.md +67 -0
- package/skills/deploy-k8s/docs/core-concepts/failure-modes.md +153 -0
- package/skills/deploy-k8s/docs/core-concepts/philosophy.md +83 -0
- package/skills/deploy-k8s/docs/core-concepts/workflow.md +124 -0
- package/skills/deploy-k8s/docs/examples/bad-patterns.md +47 -0
- package/skills/deploy-k8s/docs/examples/do-dont-checklist.md +37 -0
- package/skills/deploy-k8s/docs/examples/good-patterns.md +49 -0
- package/skills/deploy-k8s/docs/failure-modes/api-drift.md +104 -0
- package/skills/deploy-k8s/docs/failure-modes/fragile-rollouts.md +99 -0
- package/skills/deploy-k8s/docs/failure-modes/insecure-workload-defaults.md +80 -0
- package/skills/deploy-k8s/docs/failure-modes/network-exposure.md +98 -0
- package/skills/deploy-k8s/docs/failure-modes/privilege-sprawl.md +91 -0
- package/skills/deploy-k8s/docs/failure-modes/resource-starvation.md +85 -0
- package/skills/deploy-k8s/docs/getting-started/installation.md +152 -0
- package/skills/deploy-k8s/docs/getting-started/quick-start.md +115 -0
- package/skills/deploy-k8s/docs/guides/helm-patterns.md +71 -0
- package/skills/deploy-k8s/docs/guides/kustomize-patterns.md +65 -0
- package/skills/deploy-k8s/docs/guides/observability.md +67 -0
- package/skills/deploy-k8s/docs/guides/security-hardening.md +59 -0
- package/skills/deploy-k8s/docs/guides/validation-and-policy.md +66 -0
- package/skills/deploy-k8s/docs/integrations/mcp-integration.md +52 -0
- package/skills/deploy-k8s/docs/package-lock.json +2892 -0
- package/skills/deploy-k8s/docs/package.json +13 -0
- package/skills/deploy-k8s/references/api-drift.md +298 -0
- package/skills/deploy-k8s/references/conditional/aks-patterns.md +70 -0
- package/skills/deploy-k8s/references/conditional/eks-patterns.md +79 -0
- package/skills/deploy-k8s/references/conditional/gitops-controllers.md +71 -0
- package/skills/deploy-k8s/references/conditional/gke-patterns.md +74 -0
- package/skills/deploy-k8s/references/conditional/observability-stacks.md +80 -0
- package/skills/deploy-k8s/references/conditional/openshift-patterns.md +67 -0
- package/skills/deploy-k8s/references/daemonset-operator-patterns.md +155 -0
- package/skills/deploy-k8s/references/deployment-patterns.md +146 -0
- package/skills/deploy-k8s/references/do-dont-patterns.md +87 -0
- package/skills/deploy-k8s/references/examples-bad.md +282 -0
- package/skills/deploy-k8s/references/examples-good.md +440 -0
- package/skills/deploy-k8s/references/fragile-rollouts.md +303 -0
- package/skills/deploy-k8s/references/helm-patterns.md +203 -0
- package/skills/deploy-k8s/references/insecure-workload-defaults.md +300 -0
- package/skills/deploy-k8s/references/job-patterns.md +120 -0
- package/skills/deploy-k8s/references/kustomize-patterns.md +239 -0
- package/skills/deploy-k8s/references/multi-tenancy.md +343 -0
- package/skills/deploy-k8s/references/network-exposure.md +481 -0
- package/skills/deploy-k8s/references/observability.md +302 -0
- package/skills/deploy-k8s/references/privilege-sprawl.md +273 -0
- package/skills/deploy-k8s/references/resource-starvation.md +374 -0
- package/skills/deploy-k8s/references/security-hardening.md +209 -0
- package/skills/deploy-k8s/references/stateful-patterns.md +130 -0
- package/skills/deploy-k8s/references/storage-and-state.md +330 -0
- package/skills/deploy-k8s/references/validation-and-policy.md +242 -0
- package/skills/deploy-railway/SKILL.md +235 -0
- package/skills/deploy-railway/references/analyze-db-mongo.md +84 -0
- package/skills/deploy-railway/references/analyze-db-mysql.md +254 -0
- package/skills/deploy-railway/references/analyze-db-postgres.md +479 -0
- package/skills/deploy-railway/references/analyze-db-redis.md +208 -0
- package/skills/deploy-railway/references/analyze-db.md +344 -0
- package/skills/deploy-railway/references/configure.md +309 -0
- package/skills/deploy-railway/references/deploy.md +195 -0
- package/skills/deploy-railway/references/operate.md +214 -0
- package/skills/deploy-railway/references/request.md +248 -0
- package/skills/deploy-railway/references/setup.md +312 -0
- package/skills/deploy-railway/scripts/analyze-mongo.py +1549 -0
- package/skills/deploy-railway/scripts/analyze-mysql.py +1195 -0
- package/skills/deploy-railway/scripts/analyze-postgres.py +3058 -0
- package/skills/deploy-railway/scripts/analyze-redis.py +1090 -0
- package/skills/deploy-railway/scripts/dal.py +671 -0
- package/skills/deploy-railway/scripts/enable-pg-stats.py +170 -0
- package/skills/deploy-railway/scripts/pg-extensions.py +370 -0
- package/skills/deploy-railway/scripts/railway-api.sh +52 -0
- package/skills/deploy-ssh/SKILL.md +91 -0
- package/skills/deploy-vercel/SKILL.md +304 -0
- package/skills/deploy-vercel/resources/deploy-codex.sh +301 -0
- package/skills/deploy-vercel/resources/deploy.sh +301 -0
- package/skills/docs-runbooks/SKILL.md +399 -0
- package/skills/drive-status-renderer/SKILL.md +62 -0
- package/skills/iac-scan/SKILL.md +680 -0
- package/skills/iac-scan/assets/.gitkeep +9 -0
- package/skills/iac-scan/assets/checkov_config.yaml +94 -0
- package/skills/iac-scan/assets/github_actions.yml +199 -0
- package/skills/iac-scan/assets/gitlab_ci.yml +218 -0
- package/skills/iac-scan/assets/pre_commit_config.yaml +92 -0
- package/skills/iac-scan/references/EXAMPLE.md +40 -0
- package/skills/iac-scan/references/compliance_mapping.md +237 -0
- package/skills/iac-scan/references/custom_policies.md +460 -0
- package/skills/iac-scan/references/suppression_guide.md +431 -0
- package/skills/incident-briefing/SKILL.md +66 -0
- package/skills/incident-triage/SKILL.md +481 -0
- package/{LICENSE → skills/mcp-builder/LICENSE.txt} +15 -14
- package/skills/mcp-builder/SKILL.md +244 -0
- package/skills/mcp-builder/reference/evaluation.md +602 -0
- package/skills/mcp-builder/reference/mcp_best_practices.md +249 -0
- package/skills/mcp-builder/reference/node_mcp_server.md +970 -0
- package/skills/mcp-builder/reference/python_mcp_server.md +719 -0
- package/skills/mcp-builder/scripts/connections.py +151 -0
- package/skills/mcp-builder/scripts/evaluation.py +373 -0
- package/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
- package/skills/mcp-builder/scripts/requirements.txt +2 -0
- package/skills/mobile-pairing/SKILL.md +52 -0
- package/skills/ops-sre/SKILL.md +297 -0
- package/skills/playwright-qa/LICENSE.txt +201 -0
- package/skills/playwright-qa/NOTICE.txt +14 -0
- package/skills/playwright-qa/SKILL.md +156 -0
- package/skills/playwright-qa/agents/openai.yaml +6 -0
- package/skills/playwright-qa/assets/playwright-small.svg +3 -0
- package/skills/playwright-qa/assets/playwright.png +0 -0
- package/skills/playwright-qa/references/cli.md +116 -0
- package/skills/playwright-qa/references/workflows.md +95 -0
- package/skills/playwright-qa/scripts/playwright_cli.sh +25 -0
- package/skills/release-publish/SKILL.md +85 -0
- package/skills/repo-bootstrap/SKILL.md +92 -0
- package/skills/repo-bootstrap/assets/example-workflows/validate-agents.yml +89 -0
- package/skills/repo-bootstrap/assets/root-thin.md +141 -0
- package/skills/repo-bootstrap/assets/root-verbose.md +149 -0
- package/skills/repo-bootstrap/assets/scoped/backend-go.md +107 -0
- package/skills/repo-bootstrap/assets/scoped/backend-php.md +94 -0
- package/skills/repo-bootstrap/assets/scoped/backend-python.md +84 -0
- package/skills/repo-bootstrap/assets/scoped/backend-typescript.md +89 -0
- package/skills/repo-bootstrap/assets/scoped/claude-code-skill.md +101 -0
- package/skills/repo-bootstrap/assets/scoped/cli.md +83 -0
- package/skills/repo-bootstrap/assets/scoped/concourse.md +196 -0
- package/skills/repo-bootstrap/assets/scoped/ddev.md +68 -0
- package/skills/repo-bootstrap/assets/scoped/docker.md +160 -0
- package/skills/repo-bootstrap/assets/scoped/documentation.md +98 -0
- package/skills/repo-bootstrap/assets/scoped/examples.md +96 -0
- package/skills/repo-bootstrap/assets/scoped/frontend-typescript.md +88 -0
- package/skills/repo-bootstrap/assets/scoped/github-actions.md +174 -0
- package/skills/repo-bootstrap/assets/scoped/gitlab-ci.md +174 -0
- package/skills/repo-bootstrap/assets/scoped/oro-bundle.md +209 -0
- package/skills/repo-bootstrap/assets/scoped/oro-project.md +170 -0
- package/skills/repo-bootstrap/assets/scoped/python-modern.md +170 -0
- package/skills/repo-bootstrap/assets/scoped/resources.md +96 -0
- package/skills/repo-bootstrap/assets/scoped/skill-repo.md +139 -0
- package/skills/repo-bootstrap/assets/scoped/symfony.md +168 -0
- package/skills/repo-bootstrap/assets/scoped/testing.md +87 -0
- package/skills/repo-bootstrap/assets/scoped/typo3-docs.md +103 -0
- package/skills/repo-bootstrap/assets/scoped/typo3-extension.md +133 -0
- package/skills/repo-bootstrap/assets/scoped/typo3-project.md +137 -0
- package/skills/repo-bootstrap/assets/scoped/typo3-testing.md +80 -0
- package/skills/repo-bootstrap/checkpoints.yaml +279 -0
- package/skills/repo-bootstrap/evals/evals.json +385 -0
- package/skills/repo-bootstrap/references/ai-contribution-guidelines.md +63 -0
- package/skills/repo-bootstrap/references/ai-tool-compatibility.md +223 -0
- package/skills/repo-bootstrap/references/directory-coverage.md +82 -0
- package/skills/repo-bootstrap/references/examples/coding-agent-cli/AGENTS.md +70 -0
- package/skills/repo-bootstrap/references/examples/coding-agent-cli/go.mod +3 -0
- package/skills/repo-bootstrap/references/examples/coding-agent-cli/scripts-AGENTS.md +389 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/.env.example +13 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/AGENTS.md +91 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/package.json +33 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/pnpm-lock.yaml +3 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/AGENTS.md +91 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/config.ts +28 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/controllers/userController.ts +74 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/index.ts +26 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/middleware/errorHandler.ts +45 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/middleware/requestLogger.ts +18 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/routes/health.ts +18 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/routes/users.ts +13 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/utils/errors.ts +40 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/src/utils/logger.ts +14 -0
- package/skills/repo-bootstrap/references/examples/express-api-ts/tsconfig.json +24 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/.env.example +19 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/AGENTS.md +92 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/pyproject.toml +88 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/AGENTS.md +85 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/__init__.py +3 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/config.py +49 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/main.py +66 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/models/__init__.py +13 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/models/item.py +43 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/models/user.py +40 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/routes/__init__.py +5 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/routes/health.py +20 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/routes/items.py +61 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/routes/users.py +55 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/services/__init__.py +6 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/services/item_service.py +77 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/src/services/user_service.py +69 -0
- package/skills/repo-bootstrap/references/examples/fastapi-app/uv.lock +4 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/.scopes +3 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/AGENTS.md +86 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/admin/package.json +20 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/admin/src/App.tsx +5 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/cmd/api/main.go +7 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/go.mod +2 -0
- package/skills/repo-bootstrap/references/examples/go-api-with-react-admin/main.go +7 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/.scopes +3 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/AGENTS.md +89 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/go.mod +2 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/AGENTS.md +90 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/package.json +17 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/src/App.tsx +1 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/src/Button.tsx +1 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/src/Footer.tsx +1 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/src/Header.tsx +1 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/internal/web/src/Sidebar.tsx +1 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/main.go +7 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/package-lock.json +0 -0
- package/skills/repo-bootstrap/references/examples/go-with-internal-web-tsx/package.json +12 -0
- package/skills/repo-bootstrap/references/examples/ldap-selfservice/AGENTS.md +70 -0
- package/skills/repo-bootstrap/references/examples/ldap-selfservice/go.mod +3 -0
- package/skills/repo-bootstrap/references/examples/ldap-selfservice/internal-AGENTS.md +371 -0
- package/skills/repo-bootstrap/references/examples/ldap-selfservice/internal-web-AGENTS.md +448 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/.scopes +3 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/AGENTS.md +91 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/composer.json +8 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/package.json +15 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/pnpm-lock.yaml +0 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/src/Controller.php +3 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/AGENTS.md +92 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/package.json +26 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/src/App.tsx +3 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/src/Button.tsx +10 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/src/Footer.tsx +9 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/src/Header.tsx +9 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/src/main.tsx +3 -0
- package/skills/repo-bootstrap/references/examples/php-with-frontend/web/tsconfig.json +13 -0
- package/skills/repo-bootstrap/references/examples/pnpm-workspace/AGENTS.md +75 -0
- package/skills/repo-bootstrap/references/examples/pnpm-workspace/package.json +7 -0
- package/skills/repo-bootstrap/references/examples/pnpm-workspace/packages/web/package.json +11 -0
- package/skills/repo-bootstrap/references/examples/pnpm-workspace/packages/web/src/index.ts +11 -0
- package/skills/repo-bootstrap/references/examples/pnpm-workspace/pnpm-lock.yaml +42 -0
- package/skills/repo-bootstrap/references/examples/pnpm-workspace/pnpm-workspace.yaml +2 -0
- package/skills/repo-bootstrap/references/examples/simple-ldap-go/AGENTS.md +70 -0
- package/skills/repo-bootstrap/references/examples/simple-ldap-go/examples-AGENTS.md +45 -0
- package/skills/repo-bootstrap/references/examples/simple-ldap-go/go.mod +3 -0
- package/skills/repo-bootstrap/references/examples/t3x-rte-ckeditor-image/AGENTS.md +70 -0
- package/skills/repo-bootstrap/references/examples/t3x-rte-ckeditor-image/Classes-AGENTS.md +392 -0
- package/skills/repo-bootstrap/references/examples/t3x-rte-ckeditor-image/composer.json +8 -0
- package/skills/repo-bootstrap/references/feedback-memory-schema.md +135 -0
- package/skills/repo-bootstrap/references/git-hooks-setup.md +79 -0
- package/skills/repo-bootstrap/references/output-structure.md +124 -0
- package/skills/repo-bootstrap/references/scripts-guide.md +175 -0
- package/skills/repo-bootstrap/references/verification-guide.md +137 -0
- package/skills/repo-bootstrap/scripts/analyze-git-history.sh +315 -0
- package/skills/repo-bootstrap/scripts/check-freshness.sh +230 -0
- package/skills/repo-bootstrap/scripts/detect-golden-samples.sh +161 -0
- package/skills/repo-bootstrap/scripts/detect-heuristics.sh +93 -0
- package/skills/repo-bootstrap/scripts/detect-project.sh +486 -0
- package/skills/repo-bootstrap/scripts/detect-scopes.sh +330 -0
- package/skills/repo-bootstrap/scripts/detect-utilities.sh +133 -0
- package/skills/repo-bootstrap/scripts/extract-adrs.sh +194 -0
- package/skills/repo-bootstrap/scripts/extract-agent-configs.sh +331 -0
- package/skills/repo-bootstrap/scripts/extract-architecture-rules.sh +522 -0
- package/skills/repo-bootstrap/scripts/extract-ci-commands.sh +385 -0
- package/skills/repo-bootstrap/scripts/extract-ci-rules.sh +384 -0
- package/skills/repo-bootstrap/scripts/extract-commands.sh +358 -0
- package/skills/repo-bootstrap/scripts/extract-documentation.sh +308 -0
- package/skills/repo-bootstrap/scripts/extract-github-rulesets.sh +96 -0
- package/skills/repo-bootstrap/scripts/extract-github-settings.sh +88 -0
- package/skills/repo-bootstrap/scripts/extract-ide-settings.sh +228 -0
- package/skills/repo-bootstrap/scripts/extract-platform-files.sh +290 -0
- package/skills/repo-bootstrap/scripts/extract-quality-configs.sh +442 -0
- package/skills/repo-bootstrap/scripts/generate-agents.sh +2424 -0
- package/skills/repo-bootstrap/scripts/generate-file-map.sh +153 -0
- package/skills/repo-bootstrap/scripts/lib/config-root.sh +211 -0
- package/skills/repo-bootstrap/scripts/lib/summary.sh +244 -0
- package/skills/repo-bootstrap/scripts/lib/template.sh +397 -0
- package/skills/repo-bootstrap/scripts/validate-structure.sh +324 -0
- package/skills/repo-bootstrap/scripts/verify-commands.sh +615 -0
- package/skills/repo-bootstrap/scripts/verify-content.sh +302 -0
- package/skills/schema-api-contracts/SKILL.md +56 -0
- package/skills/secret-hygiene/SKILL.md +511 -0
- package/skills/secret-hygiene/assets/.gitkeep +9 -0
- package/skills/secret-hygiene/assets/config-balanced.toml +81 -0
- package/skills/secret-hygiene/assets/config-custom.toml +178 -0
- package/skills/secret-hygiene/assets/config-strict.toml +48 -0
- package/skills/secret-hygiene/assets/github-action.yml +181 -0
- package/skills/secret-hygiene/assets/gitlab-ci.yml +257 -0
- package/skills/secret-hygiene/assets/precommit-config.yaml +70 -0
- package/skills/secret-hygiene/references/EXAMPLE.md +40 -0
- package/skills/secret-hygiene/references/compliance_mapping.md +538 -0
- package/skills/secret-hygiene/references/detection_rules.md +276 -0
- package/skills/secret-hygiene/references/false_positives.md +598 -0
- package/skills/secret-hygiene/references/remediation_guide.md +530 -0
- package/skills/stack-selector/SKILL.md +56 -0
- package/skills/telegram-control/SKILL.md +110 -0
- package/skills/telegram-control/references/architecture.md +184 -0
- package/skills/telegram-control/references/convex.md +173 -0
- package/skills/telegram-control/references/error_handling.md +212 -0
- package/skills/telegram-control/references/initial_setup.md +165 -0
- package/skills/telegram-control/references/telegram_api.md +156 -0
- package/skills/telegram-control/scripts/cancel_message.ts +53 -0
- package/skills/telegram-control/scripts/list_scheduled.ts +103 -0
- package/skills/telegram-control/scripts/logger.ts +121 -0
- package/skills/telegram-control/scripts/proxy-util.ts +11 -0
- package/skills/telegram-control/scripts/schedule_message.ts +216 -0
- package/skills/telegram-control/scripts/send_message.ts +115 -0
- package/skills/telegram-control/scripts/setup.ts +185 -0
- package/skills/telegram-control/scripts/types.ts +75 -0
- package/skills/telegram-control/scripts/view_history.ts +74 -0
- package/skills/test-strategy/SKILL.md +352 -0
- package/skills/threat-model/SKILL.md +303 -0
- package/skills/threat-model/examples/example-output.md +196 -0
- package/skills/threat-model/template.md +96 -0
- package/skills/ts-lint/SKILL.md +80 -0
- package/skills/ui-flow/SKILL.md +668 -0
- package/skills/voice-command-router/SKILL.md +51 -0
- package/skills/widget-live-activity-sync/SKILL.md +66 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Redis analysis for Railway deployments.
|
|
4
|
+
|
|
5
|
+
Produces a comprehensive report covering:
|
|
6
|
+
- Server overview (version, uptime, clients)
|
|
7
|
+
- Memory usage and fragmentation
|
|
8
|
+
- Throughput and command stats
|
|
9
|
+
- Cache performance (hit/miss ratio)
|
|
10
|
+
- Persistence status
|
|
11
|
+
- Keyspace summary
|
|
12
|
+
- Railway infrastructure metrics (CPU, memory, disk, network)
|
|
13
|
+
- Recent logs
|
|
14
|
+
- Recommendations
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
analyze-redis.py --service <name>
|
|
18
|
+
analyze-redis.py --service <name> --json
|
|
19
|
+
analyze-redis.py --service <name> --step ssh-test
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import re
|
|
28
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Dict, List, Optional, Any, Tuple
|
|
31
|
+
from dataclasses import dataclass, field, asdict
|
|
32
|
+
|
|
33
|
+
import dal
|
|
34
|
+
from dal import (
|
|
35
|
+
LOG_LINES_DEFAULT, ProgressTimer, RailwayContext,
|
|
36
|
+
_init_context, progress, run_railway_command, run_ssh_query,
|
|
37
|
+
get_railway_status, get_deployment_status,
|
|
38
|
+
get_all_metrics_from_api, _analyze_window, _build_metrics_history,
|
|
39
|
+
get_recent_logs,
|
|
40
|
+
_safe_int, _safe_float, _format_uptime,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Data model
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RedisAnalysisResult:
|
|
50
|
+
"""Container for Redis analysis results."""
|
|
51
|
+
service: str
|
|
52
|
+
db_type: str
|
|
53
|
+
timestamp: str
|
|
54
|
+
deployment_status: str = "UNKNOWN"
|
|
55
|
+
|
|
56
|
+
# Redis INFO sections
|
|
57
|
+
overview: Optional[Dict[str, Any]] = None
|
|
58
|
+
memory: Optional[Dict[str, Any]] = None
|
|
59
|
+
throughput: Optional[Dict[str, Any]] = None
|
|
60
|
+
cache: Optional[Dict[str, Any]] = None
|
|
61
|
+
persistence: Optional[Dict[str, Any]] = None
|
|
62
|
+
keyspace: List[Dict[str, Any]] = field(default_factory=list)
|
|
63
|
+
total_keys: int = 0
|
|
64
|
+
command_stats: List[Dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
slowlog_len: Optional[int] = None
|
|
66
|
+
slowlog_entries: List[Dict[str, Any]] = field(default_factory=list)
|
|
67
|
+
big_keys: List[Dict[str, Any]] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
# Railway infrastructure
|
|
70
|
+
metrics_history: Optional[Dict[str, Any]] = None
|
|
71
|
+
recent_logs: List[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
# Status tracking
|
|
74
|
+
collection_status: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
75
|
+
errors: List[str] = field(default_factory=list)
|
|
76
|
+
recommendations: List[Dict[str, str]] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Redis data collection
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def parse_redis_info(raw: str) -> Dict[str, str]:
|
|
84
|
+
"""Parse Redis INFO output into a flat key:value dict.
|
|
85
|
+
|
|
86
|
+
Lines starting with # are section headers and are skipped.
|
|
87
|
+
"""
|
|
88
|
+
info: Dict[str, str] = {}
|
|
89
|
+
for line in raw.splitlines():
|
|
90
|
+
line = line.strip()
|
|
91
|
+
if not line or line.startswith("#"):
|
|
92
|
+
continue
|
|
93
|
+
if ":" in line:
|
|
94
|
+
key, _, value = line.partition(":")
|
|
95
|
+
info[key.strip()] = value.strip()
|
|
96
|
+
return info
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def extract_overview(info: Dict[str, str]) -> Dict[str, Any]:
|
|
100
|
+
"""Extract overview metrics from INFO dict."""
|
|
101
|
+
return {
|
|
102
|
+
"redis_version": info.get("redis_version", "unknown"),
|
|
103
|
+
"uptime_in_seconds": _safe_int(info.get("uptime_in_seconds")),
|
|
104
|
+
"connected_clients": _safe_int(info.get("connected_clients")),
|
|
105
|
+
"blocked_clients": _safe_int(info.get("blocked_clients")),
|
|
106
|
+
"rejected_connections": _safe_int(info.get("rejected_connections")),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_memory(info: Dict[str, str]) -> Dict[str, Any]:
|
|
111
|
+
"""Extract memory metrics from INFO dict."""
|
|
112
|
+
return {
|
|
113
|
+
"used_memory_human": info.get("used_memory_human", "N/A"),
|
|
114
|
+
"used_memory_rss_human": info.get("used_memory_rss_human", "N/A"),
|
|
115
|
+
"used_memory_peak_human": info.get("used_memory_peak_human", "N/A"),
|
|
116
|
+
"mem_fragmentation_ratio": _safe_float(info.get("mem_fragmentation_ratio")),
|
|
117
|
+
"maxmemory": _safe_int(info.get("maxmemory")),
|
|
118
|
+
"maxmemory_human": info.get("maxmemory_human", "N/A"),
|
|
119
|
+
"maxmemory_policy": info.get("maxmemory_policy", "unknown"),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_throughput(info: Dict[str, str]) -> Dict[str, Any]:
|
|
124
|
+
"""Extract throughput metrics from INFO dict."""
|
|
125
|
+
return {
|
|
126
|
+
"instantaneous_ops_per_sec": _safe_int(info.get("instantaneous_ops_per_sec")),
|
|
127
|
+
"total_commands_processed": _safe_int(info.get("total_commands_processed")),
|
|
128
|
+
"total_connections_received": _safe_int(info.get("total_connections_received")),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extract_cache(info: Dict[str, str]) -> Dict[str, Any]:
|
|
133
|
+
"""Extract cache performance metrics from INFO dict."""
|
|
134
|
+
hits = _safe_int(info.get("keyspace_hits"))
|
|
135
|
+
misses = _safe_int(info.get("keyspace_misses"))
|
|
136
|
+
total = hits + misses
|
|
137
|
+
hit_rate = round(hits / total * 100, 2) if total > 0 else 0.0
|
|
138
|
+
return {
|
|
139
|
+
"keyspace_hits": hits,
|
|
140
|
+
"keyspace_misses": misses,
|
|
141
|
+
"hit_rate": hit_rate,
|
|
142
|
+
"expired_keys": _safe_int(info.get("expired_keys")),
|
|
143
|
+
"evicted_keys": _safe_int(info.get("evicted_keys")),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_persistence(info: Dict[str, str]) -> Dict[str, Any]:
|
|
148
|
+
"""Extract persistence metrics from INFO dict."""
|
|
149
|
+
return {
|
|
150
|
+
"rdb_last_save_time": _safe_int(info.get("rdb_last_save_time")),
|
|
151
|
+
"rdb_last_bgsave_status": info.get("rdb_last_bgsave_status", "unknown"),
|
|
152
|
+
"rdb_current_bgsave_time_sec": _safe_int(info.get("rdb_current_bgsave_time_sec")),
|
|
153
|
+
"aof_enabled": info.get("aof_enabled", "0") == "1",
|
|
154
|
+
"aof_last_rewrite_status": info.get("aof_last_rewrite_status", "unknown"),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_keyspace(info: Dict[str, str]) -> Tuple[List[Dict[str, Any]], int]:
|
|
159
|
+
"""Extract keyspace metrics from INFO dict.
|
|
160
|
+
|
|
161
|
+
Keyspace entries look like: db0:keys=1234,expires=567,avg_ttl=12345
|
|
162
|
+
Returns (list of db dicts, total_keys).
|
|
163
|
+
"""
|
|
164
|
+
databases: List[Dict[str, Any]] = []
|
|
165
|
+
total_keys = 0
|
|
166
|
+
for key, value in info.items():
|
|
167
|
+
if not re.match(r'^db\d+$', key):
|
|
168
|
+
continue
|
|
169
|
+
# Parse keys=X,expires=Y,avg_ttl=Z
|
|
170
|
+
parts = {}
|
|
171
|
+
for item in value.split(","):
|
|
172
|
+
k, _, v = item.partition("=")
|
|
173
|
+
parts[k] = v
|
|
174
|
+
keys = _safe_int(parts.get("keys"))
|
|
175
|
+
total_keys += keys
|
|
176
|
+
databases.append({
|
|
177
|
+
"db": key,
|
|
178
|
+
"keys": keys,
|
|
179
|
+
"expires": _safe_int(parts.get("expires")),
|
|
180
|
+
"avg_ttl": _safe_int(parts.get("avg_ttl")),
|
|
181
|
+
})
|
|
182
|
+
return databases, total_keys
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def extract_command_stats(info: Dict[str, str]) -> List[Dict[str, Any]]:
|
|
186
|
+
"""Extract command statistics from INFO dict.
|
|
187
|
+
|
|
188
|
+
Command stat entries look like: cmdstat_GET:calls=123,usec=456,usec_per_call=3.71
|
|
189
|
+
Returns list sorted by calls descending.
|
|
190
|
+
"""
|
|
191
|
+
stats: List[Dict[str, Any]] = []
|
|
192
|
+
for key, value in info.items():
|
|
193
|
+
if not key.startswith("cmdstat_"):
|
|
194
|
+
continue
|
|
195
|
+
cmd_name = key[len("cmdstat_"):]
|
|
196
|
+
parts = {}
|
|
197
|
+
for item in value.split(","):
|
|
198
|
+
k, _, v = item.partition("=")
|
|
199
|
+
parts[k] = v
|
|
200
|
+
stats.append({
|
|
201
|
+
"command": cmd_name,
|
|
202
|
+
"calls": _safe_int(parts.get("calls")),
|
|
203
|
+
"usec": _safe_int(parts.get("usec")),
|
|
204
|
+
"usec_per_call": _safe_float(parts.get("usec_per_call")),
|
|
205
|
+
})
|
|
206
|
+
stats.sort(key=lambda x: x["calls"], reverse=True)
|
|
207
|
+
return stats
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_CLIENT_IP_RE = re.compile(r'^(\[.+\]|(?:\d{1,3}\.){3}\d{1,3}):\d+$')
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_slowlog_get(raw: str) -> List[Dict[str, Any]]:
|
|
214
|
+
"""Parse SLOWLOG GET output into structured entries.
|
|
215
|
+
|
|
216
|
+
redis-cli --raw SLOWLOG GET format (Redis 4.0+):
|
|
217
|
+
<id>
|
|
218
|
+
<timestamp_unix>
|
|
219
|
+
<duration_us>
|
|
220
|
+
<cmd>
|
|
221
|
+
<arg1>
|
|
222
|
+
...
|
|
223
|
+
<argN>
|
|
224
|
+
<client_ip:port>
|
|
225
|
+
[<client_name>] (optional, may be absent)
|
|
226
|
+
|
|
227
|
+
There is no num_args field in the raw output. The client IP line
|
|
228
|
+
(IPv4 or IPv6 with port) marks the end of each entry's arguments.
|
|
229
|
+
"""
|
|
230
|
+
entries: List[Dict[str, Any]] = []
|
|
231
|
+
lines = [l.strip() for l in raw.strip().splitlines() if l.strip()]
|
|
232
|
+
if not lines:
|
|
233
|
+
return entries
|
|
234
|
+
|
|
235
|
+
i = 0
|
|
236
|
+
while i < len(lines):
|
|
237
|
+
try:
|
|
238
|
+
entry_id = int(lines[i])
|
|
239
|
+
except (ValueError, IndexError):
|
|
240
|
+
i += 1
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
if i + 3 >= len(lines):
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
timestamp = int(lines[i + 1])
|
|
248
|
+
duration_us = int(lines[i + 2])
|
|
249
|
+
except (ValueError, IndexError):
|
|
250
|
+
i += 1
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# lines[i+3] is the command name; scan forward for client IP
|
|
254
|
+
cmd_start = i + 3
|
|
255
|
+
client_ip_pos = None
|
|
256
|
+
for k in range(cmd_start, min(cmd_start + 30, len(lines))):
|
|
257
|
+
if _CLIENT_IP_RE.match(lines[k]):
|
|
258
|
+
client_ip_pos = k
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if client_ip_pos is not None:
|
|
262
|
+
cmd_parts = lines[cmd_start:client_ip_pos]
|
|
263
|
+
# Advance past client IP and optional client name
|
|
264
|
+
next_i = client_ip_pos + 1
|
|
265
|
+
if next_i < len(lines):
|
|
266
|
+
try:
|
|
267
|
+
int(lines[next_i])
|
|
268
|
+
except ValueError:
|
|
269
|
+
next_i += 1 # skip client name
|
|
270
|
+
else:
|
|
271
|
+
# No client IP found — take command + first arg only and advance
|
|
272
|
+
cmd_parts = lines[cmd_start:cmd_start + 2]
|
|
273
|
+
next_i = cmd_start + 2
|
|
274
|
+
|
|
275
|
+
command = " ".join(cmd_parts) if cmd_parts else "unknown"
|
|
276
|
+
if len(command) > 120:
|
|
277
|
+
command = command[:117] + "..."
|
|
278
|
+
|
|
279
|
+
entries.append({
|
|
280
|
+
"id": entry_id,
|
|
281
|
+
"timestamp_unix": timestamp,
|
|
282
|
+
"duration_us": duration_us,
|
|
283
|
+
"command": command,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
i = next_i
|
|
287
|
+
|
|
288
|
+
return entries
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_bigkeys(raw: str) -> List[Dict[str, Any]]:
|
|
292
|
+
"""Parse redis-cli --bigkeys output into structured entries.
|
|
293
|
+
|
|
294
|
+
Looks for lines like:
|
|
295
|
+
Biggest string found "cache:render:page/dashboard" has 2145832 bytes
|
|
296
|
+
Biggest hash found "user:sessions" has 14291 fields
|
|
297
|
+
Biggest list found "queue:notifications" has 8402 items
|
|
298
|
+
Biggest set found "tags:all" has 291 members
|
|
299
|
+
Biggest zset found "leaderboard:global" has 10042 members
|
|
300
|
+
Biggest stream found "events:main" has 5012 entries
|
|
301
|
+
"""
|
|
302
|
+
entries: List[Dict[str, Any]] = []
|
|
303
|
+
# Match: Biggest <type> found "<key>" has <count> <unit>
|
|
304
|
+
# Redis 8+ uses double quotes; older versions used single quotes
|
|
305
|
+
pattern = re.compile(
|
|
306
|
+
r'Biggest\s+(\w+)\s+found\s+["\']([^"\']+)["\']\s+has\s+([\d,]+)\s+(\w+)',
|
|
307
|
+
re.IGNORECASE,
|
|
308
|
+
)
|
|
309
|
+
for line in raw.splitlines():
|
|
310
|
+
m = pattern.search(line)
|
|
311
|
+
if m:
|
|
312
|
+
key_type = m.group(1).lower()
|
|
313
|
+
key_name = m.group(2)
|
|
314
|
+
size_str = m.group(3).replace(",", "")
|
|
315
|
+
unit = m.group(4).lower()
|
|
316
|
+
size = _safe_int(size_str)
|
|
317
|
+
|
|
318
|
+
# Format size for display
|
|
319
|
+
if unit == "bytes":
|
|
320
|
+
detail = _format_bytes_human(size)
|
|
321
|
+
else:
|
|
322
|
+
detail = f"{size:,} {unit}"
|
|
323
|
+
|
|
324
|
+
entries.append({
|
|
325
|
+
"type": key_type,
|
|
326
|
+
"key": key_name,
|
|
327
|
+
"size_or_count": size,
|
|
328
|
+
"detail": detail,
|
|
329
|
+
})
|
|
330
|
+
return entries
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Formatting helpers
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def _format_number(n: int) -> str:
|
|
338
|
+
"""Format a large number with K/M/B suffixes."""
|
|
339
|
+
if n >= 1_000_000_000:
|
|
340
|
+
return f"{n / 1_000_000_000:.1f}B"
|
|
341
|
+
if n >= 1_000_000:
|
|
342
|
+
return f"{n / 1_000_000:.1f}M"
|
|
343
|
+
if n >= 1_000:
|
|
344
|
+
return f"{n / 1_000:.1f}K"
|
|
345
|
+
return f"{n:,}"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _format_duration(seconds: int) -> str:
|
|
349
|
+
"""Format a duration in seconds to a human-readable relative string."""
|
|
350
|
+
if seconds <= 0:
|
|
351
|
+
return "N/A"
|
|
352
|
+
if seconds < 60:
|
|
353
|
+
return f"{seconds}s ago"
|
|
354
|
+
if seconds < 3600:
|
|
355
|
+
return f"{seconds // 60}m ago"
|
|
356
|
+
if seconds < 86400:
|
|
357
|
+
return f"{seconds // 3600}h ago"
|
|
358
|
+
return f"{seconds // 86400}d ago"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _format_ttl(ms: int) -> str:
|
|
362
|
+
"""Format average TTL in milliseconds to a human-readable string."""
|
|
363
|
+
if ms <= 0:
|
|
364
|
+
return "none"
|
|
365
|
+
seconds = ms // 1000
|
|
366
|
+
if seconds < 60:
|
|
367
|
+
return f"{seconds}s"
|
|
368
|
+
if seconds < 3600:
|
|
369
|
+
return f"{seconds // 60}m"
|
|
370
|
+
if seconds < 86400:
|
|
371
|
+
return f"{seconds // 3600}h"
|
|
372
|
+
return f"{seconds // 86400}d"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _format_usec(usec: float) -> str:
|
|
376
|
+
"""Format microseconds to a human-readable string."""
|
|
377
|
+
if usec < 1000:
|
|
378
|
+
return f"{usec:.1f}us"
|
|
379
|
+
if usec < 1_000_000:
|
|
380
|
+
return f"{usec / 1000:.1f}ms"
|
|
381
|
+
return f"{usec / 1_000_000:.2f}s"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _format_total_time(usec: int) -> str:
|
|
385
|
+
"""Format total microseconds to a readable time string."""
|
|
386
|
+
seconds = usec / 1_000_000
|
|
387
|
+
if seconds < 1:
|
|
388
|
+
return f"{usec / 1000:.1f}ms"
|
|
389
|
+
if seconds < 60:
|
|
390
|
+
return f"{seconds:.1f}s"
|
|
391
|
+
if seconds < 3600:
|
|
392
|
+
return f"{seconds / 60:.1f}m"
|
|
393
|
+
return f"{seconds / 3600:.1f}h"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _format_bytes_human(nbytes: int) -> str:
|
|
397
|
+
"""Format bytes into human-readable string."""
|
|
398
|
+
if nbytes <= 0:
|
|
399
|
+
return "0"
|
|
400
|
+
for unit in ["B", "K", "M", "G", "T"]:
|
|
401
|
+
if nbytes < 1024:
|
|
402
|
+
return f"{nbytes:.1f}{unit}" if nbytes != int(nbytes) else f"{int(nbytes)}{unit}"
|
|
403
|
+
nbytes /= 1024
|
|
404
|
+
return f"{nbytes:.1f}P"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# Recommendations engine
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def generate_recommendations(result: RedisAnalysisResult) -> List[Dict[str, str]]:
|
|
412
|
+
"""Generate recommendations based on collected metrics."""
|
|
413
|
+
recs: List[Dict[str, str]] = []
|
|
414
|
+
|
|
415
|
+
# Collection failures — surface critical issues when SSH/introspection failed
|
|
416
|
+
if result.collection_status:
|
|
417
|
+
failed = {k: v for k, v in result.collection_status.items() if v.get("status") == "failed"}
|
|
418
|
+
ssh_sources = {"redis_info", "slowlog", "slowlog_entries", "big_keys"}
|
|
419
|
+
ssh_failed = {k: v for k, v in failed.items() if k in ssh_sources}
|
|
420
|
+
if ssh_failed:
|
|
421
|
+
sources = ", ".join(ssh_failed.keys())
|
|
422
|
+
errors = "; ".join(v.get("error", "unknown") for v in ssh_failed.values())
|
|
423
|
+
recs.append({
|
|
424
|
+
"severity": "critical",
|
|
425
|
+
"category": "collection",
|
|
426
|
+
"message": f"SSH introspection failed — unable to collect {sources}. "
|
|
427
|
+
f"Error: {errors}. "
|
|
428
|
+
f"Analysis is incomplete: memory fragmentation, cache hit rate, "
|
|
429
|
+
f"keyspace stats, and persistence health could not be evaluated.",
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
# Memory fragmentation
|
|
433
|
+
if result.memory:
|
|
434
|
+
frag = result.memory.get("mem_fragmentation_ratio", 0)
|
|
435
|
+
if frag > 1.5:
|
|
436
|
+
recs.append({
|
|
437
|
+
"severity": "warning",
|
|
438
|
+
"category": "memory",
|
|
439
|
+
"message": f"High memory fragmentation ({frag:.2f}). Consider restarting Redis to defragment, or enable activedefrag.",
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
# Cache hit rate
|
|
443
|
+
if result.cache:
|
|
444
|
+
hit_rate = result.cache.get("hit_rate", 0)
|
|
445
|
+
if hit_rate < 80 and (result.cache.get("keyspace_hits", 0) + result.cache.get("keyspace_misses", 0)) > 0:
|
|
446
|
+
recs.append({
|
|
447
|
+
"severity": "warning",
|
|
448
|
+
"category": "cache",
|
|
449
|
+
"message": f"Low cache hit rate ({hit_rate:.1f}%). Review key access patterns - many keys may be expired or evicted before use.",
|
|
450
|
+
})
|
|
451
|
+
elif hit_rate < 95 and hit_rate >= 80:
|
|
452
|
+
recs.append({
|
|
453
|
+
"severity": "info",
|
|
454
|
+
"category": "cache",
|
|
455
|
+
"message": f"Cache hit rate at {hit_rate:.1f}% — could be improved. Check if working set fits in memory.",
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
# Evicted keys
|
|
459
|
+
if result.cache:
|
|
460
|
+
evicted = result.cache.get("evicted_keys", 0)
|
|
461
|
+
if evicted > 0:
|
|
462
|
+
recs.append({
|
|
463
|
+
"severity": "warning",
|
|
464
|
+
"category": "memory",
|
|
465
|
+
"message": f"Redis is evicting keys ({_format_number(evicted)} evicted). Increase maxmemory or reduce dataset size.",
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
# Rejected connections
|
|
469
|
+
if result.overview:
|
|
470
|
+
rejected = result.overview.get("rejected_connections", 0)
|
|
471
|
+
if rejected > 0:
|
|
472
|
+
recs.append({
|
|
473
|
+
"severity": "warning",
|
|
474
|
+
"category": "connections",
|
|
475
|
+
"message": f"Connections being rejected ({_format_number(rejected)}). Check maxclients setting.",
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
# Blocked clients
|
|
479
|
+
if result.overview:
|
|
480
|
+
blocked = result.overview.get("blocked_clients", 0)
|
|
481
|
+
if blocked > 0:
|
|
482
|
+
recs.append({
|
|
483
|
+
"severity": "info",
|
|
484
|
+
"category": "connections",
|
|
485
|
+
"message": f"Blocked clients detected ({blocked}). Check for blocking operations (BLPOP, BRPOP, etc.).",
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
# maxmemory not set — on Railway this is expected; autoscaling handles growth
|
|
489
|
+
|
|
490
|
+
# RDB save failure
|
|
491
|
+
if result.persistence:
|
|
492
|
+
rdb_status = result.persistence.get("rdb_last_bgsave_status", "")
|
|
493
|
+
if rdb_status and rdb_status != "ok":
|
|
494
|
+
recs.append({
|
|
495
|
+
"severity": "critical",
|
|
496
|
+
"category": "persistence",
|
|
497
|
+
"message": "Last RDB save failed. Check disk space and permissions.",
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
# Slow log — data-driven when entries are available
|
|
501
|
+
if result.slowlog_entries:
|
|
502
|
+
# Analyze the actual slow commands
|
|
503
|
+
total_entries = len(result.slowlog_entries)
|
|
504
|
+
cmd_counts: Dict[str, int] = {}
|
|
505
|
+
total_duration = 0
|
|
506
|
+
for entry in result.slowlog_entries:
|
|
507
|
+
cmd = entry["command"].split()[0] if entry["command"] else "unknown"
|
|
508
|
+
cmd_counts[cmd] = cmd_counts.get(cmd, 0) + 1
|
|
509
|
+
total_duration += entry["duration_us"]
|
|
510
|
+
top_cmd = max(cmd_counts, key=cmd_counts.get) if cmd_counts else "unknown"
|
|
511
|
+
top_count = cmd_counts.get(top_cmd, 0)
|
|
512
|
+
avg_duration = total_duration / total_entries if total_entries > 0 else 0
|
|
513
|
+
|
|
514
|
+
msg = (f"Slow log contains {result.slowlog_len or total_entries} entries. "
|
|
515
|
+
f"Of the {total_entries} most recent: {top_count} are {top_cmd} commands "
|
|
516
|
+
f"averaging {_format_usec(avg_duration)}.")
|
|
517
|
+
if result.big_keys:
|
|
518
|
+
big_key_types = ", ".join(f"{bk['type']} ({bk['detail']})" for bk in result.big_keys[:3])
|
|
519
|
+
msg += f" Largest keys: {big_key_types} — check if these correlate with slow commands."
|
|
520
|
+
severity = "warning" if (result.slowlog_len or 0) > 100 else "info"
|
|
521
|
+
recs.append({"severity": severity, "category": "performance", "message": msg})
|
|
522
|
+
elif result.slowlog_len is not None and result.slowlog_len > 100:
|
|
523
|
+
recs.append({
|
|
524
|
+
"severity": "warning",
|
|
525
|
+
"category": "performance",
|
|
526
|
+
"message": f"High number of slow log entries ({result.slowlog_len}). Slow log details could not be collected.",
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
# Big keys — standalone recommendation when no slowlog correlation
|
|
530
|
+
if result.big_keys and not result.slowlog_entries:
|
|
531
|
+
big_key_summary = "; ".join(f"{bk['key']} ({bk['type']}: {bk['detail']})" for bk in result.big_keys[:5])
|
|
532
|
+
recs.append({
|
|
533
|
+
"severity": "info",
|
|
534
|
+
"category": "performance",
|
|
535
|
+
"message": f"Largest keys by type: {big_key_summary}. Large keys can cause latency spikes on read/delete operations.",
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
return recs
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Report formatting
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def format_report(result: RedisAnalysisResult) -> str:
|
|
546
|
+
"""Format the analysis result as a markdown report."""
|
|
547
|
+
lines: List[str] = []
|
|
548
|
+
|
|
549
|
+
lines.append(f"# Redis Analysis: {result.service}")
|
|
550
|
+
lines.append(f"Timestamp: {result.timestamp}")
|
|
551
|
+
lines.append(f"Deployment Status: {result.deployment_status}")
|
|
552
|
+
lines.append("")
|
|
553
|
+
|
|
554
|
+
# --- Overview ---
|
|
555
|
+
if result.overview:
|
|
556
|
+
o = result.overview
|
|
557
|
+
lines.append("## Overview")
|
|
558
|
+
lines.append("| Metric | Value |")
|
|
559
|
+
lines.append("|--------|-------|")
|
|
560
|
+
lines.append(f"| Version | {o.get('redis_version', 'N/A')} |")
|
|
561
|
+
lines.append(f"| Uptime | {_format_uptime(o.get('uptime_in_seconds', 0))} |")
|
|
562
|
+
lines.append(f"| Connected Clients | {o.get('connected_clients', 0):,} |")
|
|
563
|
+
lines.append(f"| Blocked Clients | {o.get('blocked_clients', 0):,} |")
|
|
564
|
+
lines.append(f"| Rejected Connections | {o.get('rejected_connections', 0):,} |")
|
|
565
|
+
lines.append(f"| Total Keys | {result.total_keys:,} |")
|
|
566
|
+
lines.append("")
|
|
567
|
+
|
|
568
|
+
# --- Memory ---
|
|
569
|
+
if result.memory:
|
|
570
|
+
m = result.memory
|
|
571
|
+
lines.append("## Memory")
|
|
572
|
+
lines.append("| Metric | Value | Status |")
|
|
573
|
+
lines.append("|--------|-------|--------|")
|
|
574
|
+
|
|
575
|
+
frag = m.get("mem_fragmentation_ratio", 0)
|
|
576
|
+
frag_status = "OK" if 1.0 <= frag <= 1.5 else ("HIGH" if frag > 1.5 else "LOW")
|
|
577
|
+
|
|
578
|
+
lines.append(f"| Used Memory | {m.get('used_memory_human', 'N/A')} | |")
|
|
579
|
+
lines.append(f"| RSS Memory | {m.get('used_memory_rss_human', 'N/A')} | |")
|
|
580
|
+
lines.append(f"| Peak Memory | {m.get('used_memory_peak_human', 'N/A')} | |")
|
|
581
|
+
lines.append(f"| Fragmentation Ratio | {frag:.2f} | {frag_status} |")
|
|
582
|
+
|
|
583
|
+
maxmem = m.get("maxmemory", 0)
|
|
584
|
+
if maxmem > 0:
|
|
585
|
+
lines.append(f"| Max Memory | {m.get('maxmemory_human', _format_bytes_human(maxmem))} | |")
|
|
586
|
+
else:
|
|
587
|
+
lines.append("| Max Memory | Unlimited | |")
|
|
588
|
+
|
|
589
|
+
lines.append(f"| Eviction Policy | {m.get('maxmemory_policy', 'N/A')} | |")
|
|
590
|
+
lines.append("")
|
|
591
|
+
|
|
592
|
+
# --- Throughput ---
|
|
593
|
+
if result.throughput:
|
|
594
|
+
t = result.throughput
|
|
595
|
+
lines.append("## Throughput")
|
|
596
|
+
lines.append("| Metric | Value |")
|
|
597
|
+
lines.append("|--------|-------|")
|
|
598
|
+
lines.append(f"| Ops/sec | {t.get('instantaneous_ops_per_sec', 0):,} |")
|
|
599
|
+
lines.append(f"| Total Commands | {_format_number(t.get('total_commands_processed', 0))} |")
|
|
600
|
+
lines.append(f"| Total Connections | {_format_number(t.get('total_connections_received', 0))} |")
|
|
601
|
+
if result.slowlog_len is not None:
|
|
602
|
+
lines.append(f"| Slow Log Entries | {result.slowlog_len:,} |")
|
|
603
|
+
lines.append("")
|
|
604
|
+
|
|
605
|
+
# --- Cache Performance ---
|
|
606
|
+
if result.cache:
|
|
607
|
+
c = result.cache
|
|
608
|
+
hit_rate = c.get("hit_rate", 0)
|
|
609
|
+
hit_status = "OK" if hit_rate >= 95 else ("WARN" if hit_rate >= 80 else "LOW")
|
|
610
|
+
evicted = c.get("evicted_keys", 0)
|
|
611
|
+
evict_status = "OK" if evicted == 0 else "WARN"
|
|
612
|
+
|
|
613
|
+
lines.append("## Cache Performance")
|
|
614
|
+
lines.append("| Metric | Value | Status |")
|
|
615
|
+
lines.append("|--------|-------|--------|")
|
|
616
|
+
lines.append(f"| Hit Rate | {hit_rate:.1f}% | {hit_status} |")
|
|
617
|
+
lines.append(f"| Hits | {_format_number(c.get('keyspace_hits', 0))} | |")
|
|
618
|
+
lines.append(f"| Misses | {_format_number(c.get('keyspace_misses', 0))} | |")
|
|
619
|
+
lines.append(f"| Expired Keys | {_format_number(c.get('expired_keys', 0))} | |")
|
|
620
|
+
lines.append(f"| Evicted Keys | {_format_number(evicted)} | {evict_status} |")
|
|
621
|
+
lines.append("")
|
|
622
|
+
|
|
623
|
+
# --- Persistence ---
|
|
624
|
+
if result.persistence:
|
|
625
|
+
p = result.persistence
|
|
626
|
+
rdb_status = p.get("rdb_last_bgsave_status", "unknown")
|
|
627
|
+
rdb_status_display = "OK" if rdb_status == "ok" else "FAIL"
|
|
628
|
+
|
|
629
|
+
lines.append("## Persistence")
|
|
630
|
+
lines.append("| Metric | Value | Status |")
|
|
631
|
+
lines.append("|--------|-------|--------|")
|
|
632
|
+
|
|
633
|
+
rdb_last_save = p.get("rdb_last_save_time", 0)
|
|
634
|
+
if rdb_last_save > 0:
|
|
635
|
+
now_epoch = int(datetime.now(timezone.utc).timestamp())
|
|
636
|
+
save_ago = now_epoch - rdb_last_save
|
|
637
|
+
lines.append(f"| RDB Last Save | {_format_duration(save_ago)} | |")
|
|
638
|
+
else:
|
|
639
|
+
lines.append("| RDB Last Save | never | |")
|
|
640
|
+
|
|
641
|
+
lines.append(f"| RDB Status | {rdb_status} | {rdb_status_display} |")
|
|
642
|
+
lines.append(f"| AOF Enabled | {'Yes' if p.get('aof_enabled') else 'No'} | |")
|
|
643
|
+
|
|
644
|
+
if p.get("aof_enabled"):
|
|
645
|
+
aof_status = p.get("aof_last_rewrite_status", "unknown")
|
|
646
|
+
aof_display = "OK" if aof_status == "ok" else aof_status
|
|
647
|
+
lines.append(f"| AOF Rewrite Status | {aof_status} | {aof_display} |")
|
|
648
|
+
|
|
649
|
+
lines.append("")
|
|
650
|
+
|
|
651
|
+
# --- Command Stats ---
|
|
652
|
+
if result.command_stats:
|
|
653
|
+
top_n = result.command_stats[:20]
|
|
654
|
+
lines.append("## Command Stats (top 20)")
|
|
655
|
+
lines.append("| Command | Calls | Avg Latency | Total Time |")
|
|
656
|
+
lines.append("|---------|-------|-------------|------------|")
|
|
657
|
+
for cs in top_n:
|
|
658
|
+
lines.append(
|
|
659
|
+
f"| {cs['command']} "
|
|
660
|
+
f"| {_format_number(cs['calls'])} "
|
|
661
|
+
f"| {_format_usec(cs['usec_per_call'])} "
|
|
662
|
+
f"| {_format_total_time(cs['usec'])} |"
|
|
663
|
+
)
|
|
664
|
+
lines.append("")
|
|
665
|
+
|
|
666
|
+
# --- Slow Log Entries ---
|
|
667
|
+
if result.slowlog_entries:
|
|
668
|
+
lines.append("## Slow Log Entries (recent)")
|
|
669
|
+
lines.append("| # | Timestamp | Duration | Command |")
|
|
670
|
+
lines.append("|---|-----------|----------|---------|")
|
|
671
|
+
now_epoch = int(datetime.now(timezone.utc).timestamp())
|
|
672
|
+
for entry in result.slowlog_entries:
|
|
673
|
+
age = now_epoch - entry["timestamp_unix"]
|
|
674
|
+
lines.append(
|
|
675
|
+
f"| {entry['id']} "
|
|
676
|
+
f"| {_format_duration(age)} "
|
|
677
|
+
f"| {_format_usec(entry['duration_us'])} "
|
|
678
|
+
f"| {entry['command']} |"
|
|
679
|
+
)
|
|
680
|
+
lines.append("")
|
|
681
|
+
|
|
682
|
+
# --- Biggest Keys ---
|
|
683
|
+
if result.big_keys:
|
|
684
|
+
lines.append("## Biggest Keys")
|
|
685
|
+
lines.append("| Type | Key | Size/Count |")
|
|
686
|
+
lines.append("|------|-----|------------|")
|
|
687
|
+
for bk in result.big_keys:
|
|
688
|
+
lines.append(
|
|
689
|
+
f"| {bk['type']} "
|
|
690
|
+
f"| {bk['key']} "
|
|
691
|
+
f"| {bk['detail']} |"
|
|
692
|
+
)
|
|
693
|
+
lines.append("")
|
|
694
|
+
|
|
695
|
+
# --- Keyspace ---
|
|
696
|
+
if result.keyspace:
|
|
697
|
+
lines.append("## Keyspace")
|
|
698
|
+
lines.append("| Database | Keys | Expires | Avg TTL |")
|
|
699
|
+
lines.append("|----------|------|---------|---------|")
|
|
700
|
+
for db in result.keyspace:
|
|
701
|
+
lines.append(
|
|
702
|
+
f"| {db['db']} "
|
|
703
|
+
f"| {db['keys']:,} "
|
|
704
|
+
f"| {db['expires']:,} "
|
|
705
|
+
f"| {_format_ttl(db['avg_ttl'])} |"
|
|
706
|
+
)
|
|
707
|
+
lines.append("")
|
|
708
|
+
|
|
709
|
+
# --- Infrastructure Metrics ---
|
|
710
|
+
if result.metrics_history:
|
|
711
|
+
windows = result.metrics_history.get("windows", {})
|
|
712
|
+
for window_label, window_data in windows.items():
|
|
713
|
+
mh = window_data.get("metrics", {})
|
|
714
|
+
if not mh:
|
|
715
|
+
continue
|
|
716
|
+
lines.append(f"## Infrastructure Metrics ({window_label})")
|
|
717
|
+
lines.append("| Metric | Current | Min | Max | Avg | Trend |")
|
|
718
|
+
lines.append("|--------|---------|-----|-----|-----|-------|")
|
|
719
|
+
for key in ["cpu", "memory", "disk", "network_rx", "network_tx"]:
|
|
720
|
+
if key in mh:
|
|
721
|
+
entry = mh[key]
|
|
722
|
+
trend = entry.get("trend", {})
|
|
723
|
+
trend_str = trend.get("direction", "N/A")
|
|
724
|
+
change = trend.get("change_pct", 0)
|
|
725
|
+
if change != 0:
|
|
726
|
+
trend_str += f" ({change:+.1f}%)"
|
|
727
|
+
lines.append(
|
|
728
|
+
f"| {key.replace('_', ' ').title()} "
|
|
729
|
+
f"| {entry['current']}{entry['unit']} "
|
|
730
|
+
f"| {entry['min']}{entry['unit']} "
|
|
731
|
+
f"| {entry['max']}{entry['unit']} "
|
|
732
|
+
f"| {entry['avg']}{entry['unit']} "
|
|
733
|
+
f"| {trend_str} |"
|
|
734
|
+
)
|
|
735
|
+
lines.append("")
|
|
736
|
+
|
|
737
|
+
# --- Collection Status ---
|
|
738
|
+
if result.collection_status:
|
|
739
|
+
failed = {k: v for k, v in result.collection_status.items() if v.get("status") == "failed"}
|
|
740
|
+
if failed:
|
|
741
|
+
lines.append("## Collection Issues")
|
|
742
|
+
for source, status in failed.items():
|
|
743
|
+
lines.append(f"- **{source}**: {status.get('error', 'unknown error')}")
|
|
744
|
+
lines.append("")
|
|
745
|
+
|
|
746
|
+
# --- Recommendations ---
|
|
747
|
+
if result.recommendations:
|
|
748
|
+
lines.append("## Recommendations")
|
|
749
|
+
for rec in result.recommendations:
|
|
750
|
+
severity = rec.get("severity", "info").upper()
|
|
751
|
+
lines.append(f"- [{severity}] {rec['message']}")
|
|
752
|
+
lines.append("")
|
|
753
|
+
|
|
754
|
+
return "\n".join(lines)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ---------------------------------------------------------------------------
|
|
758
|
+
# Main analysis function
|
|
759
|
+
# ---------------------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
def analyze_redis(service: str, timeout: int = 300, quiet: bool = False,
|
|
762
|
+
skip_logs: bool = False,
|
|
763
|
+
metrics_hours: int = 168,
|
|
764
|
+
project_id: Optional[str] = None,
|
|
765
|
+
environment_id: Optional[str] = None,
|
|
766
|
+
service_id: Optional[str] = None) -> RedisAnalysisResult:
|
|
767
|
+
"""Run complete Redis analysis with maximum data collection.
|
|
768
|
+
|
|
769
|
+
Collects Redis INFO ALL, SLOWLOG LEN, SLOWLOG GET 20, --bigkeys,
|
|
770
|
+
Railway metrics, and logs in parallel where possible.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
skip_logs: Skip log fetching for faster analysis
|
|
774
|
+
metrics_hours: Hours of metrics history to fetch (default: 168, max: 168)
|
|
775
|
+
project_id: Project ID (bypasses railway link config)
|
|
776
|
+
environment_id: Environment ID (bypasses railway link config)
|
|
777
|
+
service_id: Service ID (bypasses railway link config)
|
|
778
|
+
"""
|
|
779
|
+
if not quiet:
|
|
780
|
+
print(f"Analyzing redis database: {service}", file=sys.stderr)
|
|
781
|
+
|
|
782
|
+
result = RedisAnalysisResult(
|
|
783
|
+
service=service,
|
|
784
|
+
db_type="redis",
|
|
785
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# === FAST CONTEXT LOADING ===
|
|
789
|
+
if not quiet:
|
|
790
|
+
print(" [0/5] Getting Railway context...", file=sys.stderr, flush=True)
|
|
791
|
+
dal._progress_timer.start()
|
|
792
|
+
|
|
793
|
+
if environment_id and service_id:
|
|
794
|
+
dal._ctx = RailwayContext(project_id=project_id, environment_id=environment_id, service_id=service_id)
|
|
795
|
+
if not quiet:
|
|
796
|
+
print(f" using explicit IDs (env={environment_id[:8]}..., svc={service_id[:8]}...)", file=sys.stderr, flush=True)
|
|
797
|
+
else:
|
|
798
|
+
railway_status = get_railway_status()
|
|
799
|
+
if railway_status:
|
|
800
|
+
dal._ctx = RailwayContext(
|
|
801
|
+
project_id=railway_status.get("projectId"),
|
|
802
|
+
environment_id=railway_status.get("environmentId"),
|
|
803
|
+
service_id=railway_status.get("serviceId"),
|
|
804
|
+
)
|
|
805
|
+
environment_id = dal._ctx.environment_id
|
|
806
|
+
service_id = dal._ctx.service_id
|
|
807
|
+
|
|
808
|
+
# Get deployment status via API (~1s)
|
|
809
|
+
progress(1, 5, "Fetching deployment status...", quiet)
|
|
810
|
+
result.deployment_status = get_deployment_status(service, service_id=service_id)
|
|
811
|
+
|
|
812
|
+
# === SSH PRE-CHECK WITH RETRY ===
|
|
813
|
+
progress(2, 5, "Testing SSH connectivity...", quiet)
|
|
814
|
+
ssh_available = False
|
|
815
|
+
ssh_stderr = ""
|
|
816
|
+
ssh_attempts = [30, 60, 90]
|
|
817
|
+
for attempt, attempt_timeout in enumerate(ssh_attempts, 1):
|
|
818
|
+
ssh_code, ssh_stdout, ssh_stderr = run_ssh_query(service, "echo ok", timeout=attempt_timeout)
|
|
819
|
+
if ssh_code == 0 and "ok" in ssh_stdout:
|
|
820
|
+
ssh_available = True
|
|
821
|
+
if not quiet:
|
|
822
|
+
for line in ssh_stderr.splitlines():
|
|
823
|
+
if line.startswith("Using SSH key:"):
|
|
824
|
+
print(f" {line}", file=sys.stderr, flush=True)
|
|
825
|
+
break
|
|
826
|
+
break
|
|
827
|
+
if not quiet:
|
|
828
|
+
remaining = len(ssh_attempts) - attempt
|
|
829
|
+
if remaining > 0:
|
|
830
|
+
print(f" SSH attempt {attempt}/{len(ssh_attempts)} failed ({ssh_stderr or 'no response'}), retrying with {ssh_attempts[attempt]}s timeout...", file=sys.stderr, flush=True)
|
|
831
|
+
else:
|
|
832
|
+
print(f" SSH attempt {attempt}/{len(ssh_attempts)} failed ({ssh_stderr or 'no response'}), giving up", file=sys.stderr, flush=True)
|
|
833
|
+
|
|
834
|
+
# === PARALLEL EXECUTION ===
|
|
835
|
+
progress(3, 5, "Running analysis (Redis INFO, slowlog, bigkeys, metrics, logs in parallel)...", quiet)
|
|
836
|
+
|
|
837
|
+
def task_redis_info():
|
|
838
|
+
"""Fetch Redis INFO ALL via SSH."""
|
|
839
|
+
if not ssh_available:
|
|
840
|
+
return ("failed", f"SSH not available: {ssh_stderr or 'connection failed'}", "")
|
|
841
|
+
command = 'timeout 30s redis-cli -h localhost -p 6379 -a "$REDISPASSWORD" --no-auth-warning --raw INFO ALL'
|
|
842
|
+
code, stdout, stderr = run_ssh_query(service, command, timeout=45)
|
|
843
|
+
if code == 0 and stdout.strip():
|
|
844
|
+
return ("ok", "", stdout)
|
|
845
|
+
return ("failed", stderr or "empty response", stdout)
|
|
846
|
+
|
|
847
|
+
def task_slowlog():
|
|
848
|
+
"""Fetch Redis SLOWLOG LEN via SSH."""
|
|
849
|
+
if not ssh_available:
|
|
850
|
+
return ("failed", f"SSH not available: {ssh_stderr or 'connection failed'}", "")
|
|
851
|
+
command = 'timeout 30s redis-cli -h localhost -p 6379 -a "$REDISPASSWORD" --no-auth-warning --raw SLOWLOG LEN'
|
|
852
|
+
code, stdout, stderr = run_ssh_query(service, command, timeout=45)
|
|
853
|
+
if code == 0 and stdout.strip():
|
|
854
|
+
return ("ok", "", stdout.strip())
|
|
855
|
+
return ("failed", stderr or "empty response", "")
|
|
856
|
+
|
|
857
|
+
def task_slowlog_get():
|
|
858
|
+
"""Fetch Redis SLOWLOG GET 20 via SSH for actual slow query details."""
|
|
859
|
+
if not ssh_available:
|
|
860
|
+
return ("failed", f"SSH not available: {ssh_stderr or 'connection failed'}", "")
|
|
861
|
+
command = 'timeout 30s redis-cli -h localhost -p 6379 -a "$REDISPASSWORD" --no-auth-warning --raw SLOWLOG GET 20'
|
|
862
|
+
code, stdout, stderr = run_ssh_query(service, command, timeout=45)
|
|
863
|
+
if code == 0 and stdout.strip():
|
|
864
|
+
return ("ok", "", stdout.strip())
|
|
865
|
+
return ("failed", stderr or "empty response", "")
|
|
866
|
+
|
|
867
|
+
def task_bigkeys():
|
|
868
|
+
"""Fetch redis-cli --bigkeys via SSH (SCAN-based, may take longer)."""
|
|
869
|
+
if not ssh_available:
|
|
870
|
+
return ("failed", f"SSH not available: {ssh_stderr or 'connection failed'}", "")
|
|
871
|
+
command = 'timeout 60s redis-cli -h localhost -p 6379 -a "$REDISPASSWORD" --no-auth-warning --bigkeys'
|
|
872
|
+
code, stdout, stderr = run_ssh_query(service, command, timeout=75)
|
|
873
|
+
if code == 0 and stdout.strip():
|
|
874
|
+
return ("ok", "", stdout.strip())
|
|
875
|
+
return ("failed", stderr or "empty response", "")
|
|
876
|
+
|
|
877
|
+
def task_metrics():
|
|
878
|
+
"""Fetch all metrics (disk, CPU, memory) in one API call."""
|
|
879
|
+
if environment_id and service_id:
|
|
880
|
+
return get_all_metrics_from_api(environment_id, service_id, hours=metrics_hours)
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
def task_logs():
|
|
884
|
+
"""Fetch recent logs via API (~3s)."""
|
|
885
|
+
if skip_logs:
|
|
886
|
+
return []
|
|
887
|
+
return get_recent_logs(service, lines=LOG_LINES_DEFAULT,
|
|
888
|
+
environment_id=environment_id,
|
|
889
|
+
service_id=service_id)
|
|
890
|
+
|
|
891
|
+
with ThreadPoolExecutor(max_workers=6) as executor:
|
|
892
|
+
future_info = executor.submit(task_redis_info)
|
|
893
|
+
future_slowlog = executor.submit(task_slowlog)
|
|
894
|
+
future_slowlog_get = executor.submit(task_slowlog_get)
|
|
895
|
+
future_bigkeys = executor.submit(task_bigkeys)
|
|
896
|
+
future_metrics = executor.submit(task_metrics)
|
|
897
|
+
future_logs = executor.submit(task_logs)
|
|
898
|
+
|
|
899
|
+
# Collect results
|
|
900
|
+
info_result = future_info.result()
|
|
901
|
+
slowlog_result = future_slowlog.result()
|
|
902
|
+
slowlog_get_result = future_slowlog_get.result()
|
|
903
|
+
bigkeys_result = future_bigkeys.result()
|
|
904
|
+
metrics_result = future_metrics.result()
|
|
905
|
+
logs_result = future_logs.result()
|
|
906
|
+
|
|
907
|
+
# === PROCESS RESULTS ===
|
|
908
|
+
progress(4, 5, "Processing results...", quiet)
|
|
909
|
+
|
|
910
|
+
# Redis INFO ALL
|
|
911
|
+
info_status, info_error, info_raw = info_result
|
|
912
|
+
if info_status == "ok" and info_raw:
|
|
913
|
+
result.collection_status["redis_info"] = {"status": "ok"}
|
|
914
|
+
info = parse_redis_info(info_raw)
|
|
915
|
+
|
|
916
|
+
result.overview = extract_overview(info)
|
|
917
|
+
result.memory = extract_memory(info)
|
|
918
|
+
result.throughput = extract_throughput(info)
|
|
919
|
+
result.cache = extract_cache(info)
|
|
920
|
+
result.persistence = extract_persistence(info)
|
|
921
|
+
result.keyspace, result.total_keys = extract_keyspace(info)
|
|
922
|
+
result.command_stats = extract_command_stats(info)
|
|
923
|
+
else:
|
|
924
|
+
result.collection_status["redis_info"] = {"status": "failed", "error": info_error}
|
|
925
|
+
result.errors.append(f"Redis INFO failed: {info_error}")
|
|
926
|
+
|
|
927
|
+
# SLOWLOG LEN
|
|
928
|
+
sl_status, sl_error, sl_raw = slowlog_result
|
|
929
|
+
if sl_status == "ok" and sl_raw:
|
|
930
|
+
result.collection_status["slowlog"] = {"status": "ok"}
|
|
931
|
+
result.slowlog_len = _safe_int(sl_raw)
|
|
932
|
+
else:
|
|
933
|
+
result.collection_status["slowlog"] = {"status": "failed", "error": sl_error}
|
|
934
|
+
|
|
935
|
+
# SLOWLOG GET 20
|
|
936
|
+
slg_status, slg_error, slg_raw = slowlog_get_result
|
|
937
|
+
if slg_status == "ok" and slg_raw:
|
|
938
|
+
result.collection_status["slowlog_entries"] = {"status": "ok"}
|
|
939
|
+
result.slowlog_entries = parse_slowlog_get(slg_raw)
|
|
940
|
+
else:
|
|
941
|
+
result.collection_status["slowlog_entries"] = {"status": "failed", "error": slg_error}
|
|
942
|
+
|
|
943
|
+
# Big keys
|
|
944
|
+
bk_status, bk_error, bk_raw = bigkeys_result
|
|
945
|
+
if bk_status == "ok" and bk_raw:
|
|
946
|
+
result.collection_status["big_keys"] = {"status": "ok"}
|
|
947
|
+
result.big_keys = parse_bigkeys(bk_raw)
|
|
948
|
+
else:
|
|
949
|
+
result.collection_status["big_keys"] = {"status": "failed", "error": bk_error}
|
|
950
|
+
|
|
951
|
+
# Metrics
|
|
952
|
+
if metrics_result:
|
|
953
|
+
result.collection_status["metrics"] = {"status": "ok"}
|
|
954
|
+
result.metrics_history = metrics_result.get("metrics_history")
|
|
955
|
+
else:
|
|
956
|
+
result.collection_status["metrics"] = {"status": "failed", "error": "no metrics returned"}
|
|
957
|
+
|
|
958
|
+
# Logs
|
|
959
|
+
if logs_result:
|
|
960
|
+
result.collection_status["logs"] = {"status": "ok", "lines": len(logs_result)}
|
|
961
|
+
result.recent_logs = logs_result
|
|
962
|
+
else:
|
|
963
|
+
result.collection_status["logs"] = {"status": "failed", "error": "no logs returned"}
|
|
964
|
+
|
|
965
|
+
# === RECOMMENDATIONS ===
|
|
966
|
+
progress(5, 5, "Generating recommendations...", quiet)
|
|
967
|
+
result.recommendations = generate_recommendations(result)
|
|
968
|
+
|
|
969
|
+
if not quiet:
|
|
970
|
+
elapsed = dal._progress_timer.step_elapsed()
|
|
971
|
+
if elapsed:
|
|
972
|
+
print(f" done{elapsed}", file=sys.stderr, flush=True)
|
|
973
|
+
print(f" Analysis complete{dal._progress_timer.total_elapsed()}", file=sys.stderr, flush=True)
|
|
974
|
+
|
|
975
|
+
return result
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
# ---------------------------------------------------------------------------
|
|
979
|
+
# Single-step debugging
|
|
980
|
+
# ---------------------------------------------------------------------------
|
|
981
|
+
|
|
982
|
+
def run_single_step(args) -> int:
|
|
983
|
+
"""Run a single collection step for debugging."""
|
|
984
|
+
service = args.service
|
|
985
|
+
_init_context(args)
|
|
986
|
+
environment_id = dal._ctx.environment_id
|
|
987
|
+
service_id = dal._ctx.service_id
|
|
988
|
+
|
|
989
|
+
if args.step == "ssh-test":
|
|
990
|
+
print(f"Testing SSH to service: {service}", file=sys.stderr)
|
|
991
|
+
code, stdout, stderr = run_ssh_query(service, "echo ok", timeout=45)
|
|
992
|
+
print(f"Exit code: {code}")
|
|
993
|
+
print(f"Stdout: {stdout.strip()}")
|
|
994
|
+
if stderr:
|
|
995
|
+
print(f"Stderr: {stderr.strip()}")
|
|
996
|
+
return 0 if (code == 0 and "ok" in stdout) else 1
|
|
997
|
+
|
|
998
|
+
elif args.step == "query":
|
|
999
|
+
print(f"Running Redis INFO ALL on: {service}", file=sys.stderr)
|
|
1000
|
+
command = 'timeout 30s redis-cli -h localhost -p 6379 -a "$REDISPASSWORD" --no-auth-warning --raw INFO ALL'
|
|
1001
|
+
code, stdout, stderr = run_ssh_query(service, command, timeout=45)
|
|
1002
|
+
print(f"Exit code: {code}")
|
|
1003
|
+
if code == 0 and stdout:
|
|
1004
|
+
info = parse_redis_info(stdout)
|
|
1005
|
+
print(json.dumps(info, indent=2))
|
|
1006
|
+
else:
|
|
1007
|
+
print(f"Error: {stderr or stdout}")
|
|
1008
|
+
return code
|
|
1009
|
+
|
|
1010
|
+
elif args.step == "logs":
|
|
1011
|
+
print(f"Fetching logs for: {service}", file=sys.stderr)
|
|
1012
|
+
logs = get_recent_logs(service, lines=LOG_LINES_DEFAULT,
|
|
1013
|
+
environment_id=environment_id,
|
|
1014
|
+
service_id=service_id)
|
|
1015
|
+
print(f"Lines fetched: {len(logs)}")
|
|
1016
|
+
for line in logs:
|
|
1017
|
+
print(line)
|
|
1018
|
+
return 0
|
|
1019
|
+
|
|
1020
|
+
elif args.step == "metrics":
|
|
1021
|
+
print(f"Fetching metrics for: {service}", file=sys.stderr)
|
|
1022
|
+
if environment_id and service_id:
|
|
1023
|
+
metrics = get_all_metrics_from_api(environment_id, service_id)
|
|
1024
|
+
if metrics:
|
|
1025
|
+
print(json.dumps(metrics, indent=2))
|
|
1026
|
+
else:
|
|
1027
|
+
print("No metrics returned", file=sys.stderr)
|
|
1028
|
+
return 1
|
|
1029
|
+
else:
|
|
1030
|
+
print("No environment_id or service_id available", file=sys.stderr)
|
|
1031
|
+
return 1
|
|
1032
|
+
return 0
|
|
1033
|
+
|
|
1034
|
+
else:
|
|
1035
|
+
print(f"Unknown step: {args.step}", file=sys.stderr)
|
|
1036
|
+
return 1
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
# ---------------------------------------------------------------------------
|
|
1040
|
+
# CLI entry point
|
|
1041
|
+
# ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
def main():
|
|
1044
|
+
parser = argparse.ArgumentParser(
|
|
1045
|
+
description="Redis analysis for Railway services.",
|
|
1046
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
parser.add_argument("--service", required=True, help="Service name")
|
|
1050
|
+
parser.add_argument("--json", action="store_true",
|
|
1051
|
+
help="Output as JSON")
|
|
1052
|
+
parser.add_argument("--timeout", type=int, default=300,
|
|
1053
|
+
help="Timeout in seconds for analysis (default: 300)")
|
|
1054
|
+
parser.add_argument("--quiet", "-q", action="store_true",
|
|
1055
|
+
help="Suppress progress messages")
|
|
1056
|
+
parser.add_argument("--skip-logs", action="store_true",
|
|
1057
|
+
help="Skip log fetching for faster analysis")
|
|
1058
|
+
parser.add_argument("--metrics-hours", type=int, default=168,
|
|
1059
|
+
help="Hours of metrics history to fetch (default: 168, max: 168)")
|
|
1060
|
+
parser.add_argument("--step", choices=["ssh-test", "query", "logs", "metrics"],
|
|
1061
|
+
help="Run a single collection step for debugging")
|
|
1062
|
+
parser.add_argument("--project-id", help="Project ID (bypasses railway link)")
|
|
1063
|
+
parser.add_argument("--environment-id", help="Environment ID (bypasses railway link)")
|
|
1064
|
+
parser.add_argument("--service-id", help="Service ID (bypasses railway link)")
|
|
1065
|
+
|
|
1066
|
+
args = parser.parse_args()
|
|
1067
|
+
|
|
1068
|
+
# Single-step debugging mode
|
|
1069
|
+
if args.step:
|
|
1070
|
+
return run_single_step(args)
|
|
1071
|
+
|
|
1072
|
+
# Run analysis
|
|
1073
|
+
result = analyze_redis(args.service, timeout=args.timeout, quiet=args.quiet,
|
|
1074
|
+
skip_logs=args.skip_logs,
|
|
1075
|
+
metrics_hours=min(args.metrics_hours, 168),
|
|
1076
|
+
project_id=args.project_id,
|
|
1077
|
+
environment_id=args.environment_id,
|
|
1078
|
+
service_id=args.service_id)
|
|
1079
|
+
|
|
1080
|
+
# Output
|
|
1081
|
+
if args.json:
|
|
1082
|
+
print(json.dumps(asdict(result), indent=2))
|
|
1083
|
+
else:
|
|
1084
|
+
print(format_report(result))
|
|
1085
|
+
|
|
1086
|
+
return 0
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
if __name__ == "__main__":
|
|
1090
|
+
sys.exit(main())
|