From baf1723a50dc0069ef7ac1b3073a3f6694088308 Mon Sep 17 00:00:00 2001 From: Nicolas Koehl Date: Wed, 26 Feb 2025 15:25:39 +0700 Subject: [PATCH] Update README.md --- .env | 26 + .env.example | 22 + CLAUDE_API_INTEGRATION.md | 249 +++++++++ Dockerfile | 19 + QUICK_START.md | 122 +++++ README.md | Bin 28 -> 15966 bytes README_NOMAD_API.md | 116 ++++ USER_GUIDE.md | 135 +++++ .../test_gitea_integration.cpython-313.pyc | Bin 0 -> 4350 bytes __pycache__/test_gitea_repos.cpython-313.pyc | Bin 0 -> 2548 bytes .../test_nomad_connection.cpython-313.pyc | Bin 0 -> 2678 bytes .../test_nomad_namespaces.cpython-313.pyc | Bin 0 -> 4144 bytes app/__init__.py | 2 + app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 181 bytes app/__pycache__/main.cpython-313.pyc | Bin 0 -> 4254 bytes app/main.py | 101 ++++ app/routers/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 163 bytes .../__pycache__/claude.cpython-313.pyc | Bin 0 -> 8082 bytes .../__pycache__/configs.cpython-313.pyc | Bin 0 -> 4591 bytes app/routers/__pycache__/jobs.cpython-313.pyc | Bin 0 -> 15377 bytes app/routers/__pycache__/logs.cpython-313.pyc | Bin 0 -> 11309 bytes .../__pycache__/repositories.cpython-313.pyc | Bin 0 -> 3752 bytes app/routers/claude.py | 230 ++++++++ app/routers/configs.py | 80 +++ app/routers/jobs.py | 396 ++++++++++++++ app/routers/logs.py | 293 ++++++++++ app/routers/repositories.py | 89 +++ app/schemas/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 163 bytes .../__pycache__/claude_api.cpython-313.pyc | Bin 0 -> 4403 bytes .../__pycache__/config.cpython-313.pyc | Bin 0 -> 3221 bytes app/schemas/__pycache__/job.cpython-313.pyc | Bin 0 -> 5082 bytes app/schemas/claude_api.py | 78 +++ app/schemas/config.py | 56 ++ app/schemas/job.py | 80 +++ app/services/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 164 bytes .../config_service.cpython-313.pyc | Bin 0 -> 15847 bytes .../__pycache__/gitea_client.cpython-313.pyc | Bin 0 -> 8734 bytes .../__pycache__/nomad_client.cpython-313.pyc | Bin 0 -> 22909 bytes app/services/config_service.py | 299 +++++++++++ app/services/gitea_client.py | 180 +++++++ app/services/nomad_client.py | 505 ++++++++++++++++++ check_path.py | 33 ++ claude_nomad_tool.json | 71 +++ cleanup_test_jobs.py | 70 +++ configs/example.yaml | 9 + configs/ms-qc-db.yaml | 11 + configs/test-service.yaml | 10 + deploy_nomad_mcp.py | 152 ++++++ deploy_with_claude_api.py | 97 ++++ docker-compose.yml | 14 + job_spec.json | 307 +++++++++++ nomad_job_api_docs.md | 182 +++++++ nomad_mcp_job.nomad | 79 +++ requirements.txt | 9 + run.py | 23 + static/app.js | 355 ++++++++++++ static/index.html | 66 +++ static/styles.css | 244 +++++++++ test_direct_nomad.py | 123 +++++ test_gitea_integration.py | 90 ++++ test_gitea_repos.py | 54 ++ test_job_registration.py | 100 ++++ test_nomad_connection.py | 66 +++ test_nomad_namespaces.py | 86 +++ ...nomad_service.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 14667 bytes tests/test_nomad_service.py | 193 +++++++ 69 files changed, 5525 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 CLAUDE_API_INTEGRATION.md create mode 100644 Dockerfile create mode 100644 QUICK_START.md create mode 100644 README_NOMAD_API.md create mode 100644 USER_GUIDE.md create mode 100644 __pycache__/test_gitea_integration.cpython-313.pyc create mode 100644 __pycache__/test_gitea_repos.cpython-313.pyc create mode 100644 __pycache__/test_nomad_connection.cpython-313.pyc create mode 100644 __pycache__/test_nomad_namespaces.cpython-313.pyc create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-313.pyc create mode 100644 app/__pycache__/main.cpython-313.pyc create mode 100644 app/main.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/__pycache__/__init__.cpython-313.pyc create mode 100644 app/routers/__pycache__/claude.cpython-313.pyc create mode 100644 app/routers/__pycache__/configs.cpython-313.pyc create mode 100644 app/routers/__pycache__/jobs.cpython-313.pyc create mode 100644 app/routers/__pycache__/logs.cpython-313.pyc create mode 100644 app/routers/__pycache__/repositories.cpython-313.pyc create mode 100644 app/routers/claude.py create mode 100644 app/routers/configs.py create mode 100644 app/routers/jobs.py create mode 100644 app/routers/logs.py create mode 100644 app/routers/repositories.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/__pycache__/__init__.cpython-313.pyc create mode 100644 app/schemas/__pycache__/claude_api.cpython-313.pyc create mode 100644 app/schemas/__pycache__/config.cpython-313.pyc create mode 100644 app/schemas/__pycache__/job.cpython-313.pyc create mode 100644 app/schemas/claude_api.py create mode 100644 app/schemas/config.py create mode 100644 app/schemas/job.py create mode 100644 app/services/__init__.py create mode 100644 app/services/__pycache__/__init__.cpython-313.pyc create mode 100644 app/services/__pycache__/config_service.cpython-313.pyc create mode 100644 app/services/__pycache__/gitea_client.cpython-313.pyc create mode 100644 app/services/__pycache__/nomad_client.cpython-313.pyc create mode 100644 app/services/config_service.py create mode 100644 app/services/gitea_client.py create mode 100644 app/services/nomad_client.py create mode 100644 check_path.py create mode 100644 claude_nomad_tool.json create mode 100644 cleanup_test_jobs.py create mode 100644 configs/example.yaml create mode 100644 configs/ms-qc-db.yaml create mode 100644 configs/test-service.yaml create mode 100644 deploy_nomad_mcp.py create mode 100644 deploy_with_claude_api.py create mode 100644 docker-compose.yml create mode 100644 job_spec.json create mode 100644 nomad_job_api_docs.md create mode 100644 nomad_mcp_job.nomad create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/styles.css create mode 100644 test_direct_nomad.py create mode 100644 test_gitea_integration.py create mode 100644 test_gitea_repos.py create mode 100644 test_job_registration.py create mode 100644 test_nomad_connection.py create mode 100644 test_nomad_namespaces.py create mode 100644 tests/__pycache__/test_nomad_service.cpython-313-pytest-8.3.4.pyc create mode 100644 tests/test_nomad_service.py diff --git a/.env b/.env new file mode 100644 index 0000000..85250b5 --- /dev/null +++ b/.env @@ -0,0 +1,26 @@ +# Nomad connection settings +NOMAD_ADDR=http://pjmldk01.ds.meisheng.group:4646 +NOMAD_TOKEN= +NOMAD_SKIP_VERIFY=true +NOMAD_NAMESPACE=development + +# Gitea API configuration +GITEA_API_URL=https://gitea.dev.meisheng.group/api/v1 +GITEA_API_TOKEN=a2de6c0014e6d0108edb94fb7d524777bb75d33a +# Alternative authentication (uncomment if needed) +# GITEA_USERNAME=your-gitea-username +# GITEA_PASSWORD=your-gitea-password +GITEA_VERIFY_SSL=false + +# API settings +PORT=8000 +HOST=0.0.0.0 + +# Configuration directory +CONFIG_DIR=./configs + +# Logging level +LOG_LEVEL=INFO + +# Enable to make development easier +RELOAD=true \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b1f0627 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Nomad connection settings +NOMAD_ADDR=http://localhost:4646 +NOMAD_TOKEN= +NOMAD_SKIP_VERIFY=false + +# Gitea API configuration +GITEA_API_URL=http://gitea.internal.example.com/api/v1 +GITEA_API_TOKEN= +# Alternative authentication (if token is not available) +# GITEA_USERNAME= +# GITEA_PASSWORD= +GITEA_VERIFY_SSL=false + +# API settings +PORT=8000 +HOST=0.0.0.0 + +# Configuration directory +CONFIG_DIR=./configs + +# Optional: Logging level +LOG_LEVEL=INFO \ No newline at end of file diff --git a/CLAUDE_API_INTEGRATION.md b/CLAUDE_API_INTEGRATION.md new file mode 100644 index 0000000..72fefc8 --- /dev/null +++ b/CLAUDE_API_INTEGRATION.md @@ -0,0 +1,249 @@ +# Claude Integration with Nomad MCP + +This document explains how to configure Claude to connect to the Nomad MCP service and manage jobs. + +## Overview + +The Nomad MCP service provides a simplified REST API specifically designed for Claude to interact with Nomad jobs. This API allows Claude to: + +1. List all jobs in a namespace +2. Get the status of a specific job +3. Start, stop, and restart jobs +4. Create new jobs with a simplified specification +5. Retrieve logs from jobs + +## API Endpoints + +The Claude-specific API is available at the `/api/claude` prefix. The following endpoints are available: + +### List Jobs + +``` +GET /api/claude/list-jobs?namespace=development +``` + +Returns a list of all jobs in the specified namespace with their IDs, names, statuses, and types. + +### Manage Jobs + +``` +POST /api/claude/jobs +``` + +Manages existing jobs with operations like status check, stop, and restart. + +Request body: +```json +{ + "job_id": "example-job", + "action": "status|stop|restart", + "namespace": "development", + "purge": false +} +``` + +### Create Jobs + +``` +POST /api/claude/create-job +``` + +Creates a new job with a simplified specification. + +Request body: +```json +{ + "job_id": "example-job", + "name": "Example Job", + "type": "service", + "datacenters": ["jm"], + "namespace": "development", + "docker_image": "nginx:latest", + "count": 1, + "cpu": 100, + "memory": 128, + "ports": [ + { + "Label": "http", + "Value": 0, + "To": 80 + } + ], + "env_vars": { + "ENV_VAR1": "value1", + "ENV_VAR2": "value2" + } +} +``` + +### Get Job Logs + +``` +GET /api/claude/job-logs/{job_id}?namespace=development +``` + +Retrieves logs from the latest allocation of the specified job. + +## Configuring Claude Desktop Application + +To configure Claude to connect to the Nomad MCP service, follow these steps: + +### 1. Set Up API Access + +Claude needs to be configured with the base URL of your Nomad MCP service. This is typically: + +``` +http://your-server-address:8000 +``` + +### 2. Create a Claude Tool Configuration + +In the Claude desktop application, you can create a custom tool configuration that allows Claude to interact with the Nomad MCP API. Here's a sample configuration: + +```json +{ + "tools": [ + { + "name": "nomad_mcp", + "description": "Manage Nomad jobs through the MCP service", + "api_endpoints": [ + { + "name": "list_jobs", + "description": "List all jobs in a namespace", + "method": "GET", + "url": "http://your-server-address:8000/api/claude/list-jobs", + "params": [ + { + "name": "namespace", + "type": "string", + "description": "Nomad namespace", + "required": false, + "default": "development" + } + ] + }, + { + "name": "manage_job", + "description": "Manage a job (status, stop, restart)", + "method": "POST", + "url": "http://your-server-address:8000/api/claude/jobs", + "body": { + "job_id": "string", + "action": "string", + "namespace": "string", + "purge": "boolean" + } + }, + { + "name": "create_job", + "description": "Create a new job", + "method": "POST", + "url": "http://your-server-address:8000/api/claude/create-job", + "body": { + "job_id": "string", + "name": "string", + "type": "string", + "datacenters": "array", + "namespace": "string", + "docker_image": "string", + "count": "integer", + "cpu": "integer", + "memory": "integer", + "ports": "array", + "env_vars": "object" + } + }, + { + "name": "get_job_logs", + "description": "Get logs for a job", + "method": "GET", + "url": "http://your-server-address:8000/api/claude/job-logs/{job_id}", + "params": [ + { + "name": "namespace", + "type": "string", + "description": "Nomad namespace", + "required": false, + "default": "development" + } + ] + } + ] + } + ] +} +``` + +### 3. Import the Tool Configuration + +1. Open the Claude desktop application +2. Go to Settings > Tools +3. Click "Import Tool Configuration" +4. Select the JSON file with the above configuration +5. Click "Save" + +### 4. Test the Connection + +You can test the connection by asking Claude to list all jobs: + +``` +Please list all jobs in the development namespace using the Nomad MCP service. +``` + +Claude should use the configured tool to make an API request to the Nomad MCP service and return the list of jobs. + +## Example Prompts for Claude + +Here are some example prompts you can use with Claude to interact with the Nomad MCP service: + +### List Jobs + +``` +Please list all jobs in the development namespace. +``` + +### Check Job Status + +``` +What is the status of the job "example-job"? +``` + +### Start a New Job + +``` +Please create a new job with the following specifications: +- Job ID: test-nginx +- Docker image: nginx:latest +- Memory: 256MB +- CPU: 200MHz +- Port mapping: HTTP port 80 +``` + +### Stop a Job + +``` +Please stop the job "test-nginx" and purge it from Nomad. +``` + +### Get Job Logs + +``` +Show me the logs for the job "example-job". +``` + +## Troubleshooting + +If Claude is unable to connect to the Nomad MCP service, check the following: + +1. Ensure the Nomad MCP service is running and accessible from Claude's network +2. Verify the base URL in the tool configuration is correct +3. Check that the Nomad MCP service has proper connectivity to the Nomad server +4. Review the logs of the Nomad MCP service for any errors + +## Security Considerations + +The Claude API integration does not include authentication by default. If you need to secure the API: + +1. Add an API key requirement to the FastAPI application +2. Include the API key in the Claude tool configuration +3. Consider using HTTPS for all communications between Claude and the Nomad MCP service \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24956fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Copy requirements first for better layer caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create configs directory +RUN mkdir -p configs + +# Expose the API port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..c207998 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,122 @@ +# Nomad MCP - Quick Start Guide + +This guide will help you quickly set up and start using the Nomad MCP service for managing Nomad jobs. + +## 1. Installation + +### Clone the Repository + +```bash +git clone https://github.com/your-org/nomad-mcp.git +cd nomad-mcp +``` + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +## 2. Configuration + +### Set Up Environment Variables + +Create a `.env` file in the project root: + +``` +# Nomad connection settings +NOMAD_ADDR=http://your-nomad-server:4646 +NOMAD_TOKEN=your-nomad-token +NOMAD_NAMESPACE=development +NOMAD_SKIP_VERIFY=true + +# API settings +PORT=8000 +HOST=0.0.0.0 + +# Logging level +LOG_LEVEL=INFO +``` + +Replace `your-nomad-server` and `your-nomad-token` with your actual Nomad server address and token. + +## 3. Start the Service + +```bash +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +The service will be available at `http://localhost:8000`. + +## 4. Access the Web UI + +Open your browser and navigate to: + +``` +http://localhost:8000 +``` + +You should see the Nomad Job Manager UI with a list of jobs in your default namespace. + +## 5. Basic Operations + +### View Jobs + +1. Select a namespace from the dropdown in the header +2. Browse the list of jobs with their statuses + +### Manage a Job + +1. Click the "View" button next to a job to see its details +2. Use the "Restart" button to restart a job +3. Use the "Stop" button to stop a job + +### View Logs + +1. Select a job to view its details +2. Scroll down to the "Logs" section +3. Switch between stdout and stderr using the tabs + +## 6. API Usage + +### List Jobs + +```bash +curl http://localhost:8000/api/claude/list-jobs?namespace=development +``` + +### Get Job Status + +```bash +curl -X POST http://localhost:8000/api/claude/jobs \ + -H "Content-Type: application/json" \ + -d '{"job_id": "example-job", "action": "status", "namespace": "development"}' +``` + +### Stop a Job + +```bash +curl -X POST http://localhost:8000/api/claude/jobs \ + -H "Content-Type: application/json" \ + -d '{"job_id": "example-job", "action": "stop", "namespace": "development", "purge": false}' +``` + +## 7. Claude AI Integration + +To set up Claude AI integration: + +1. Configure Claude with the provided `claude_nomad_tool.json` file +2. Update the URLs in the configuration to point to your Nomad MCP service +3. Use natural language to ask Claude to manage your Nomad jobs + +Example prompt for Claude: +``` +Please list all jobs in the development namespace using the Nomad MCP service. +``` + +## Next Steps + +- Read the full [README.md](README.md) for detailed information +- Check out the [User Guide](USER_GUIDE.md) for the web UI +- Explore the [Claude API Integration Documentation](CLAUDE_API_INTEGRATION.md) for AI integration +- Review the API documentation at `http://localhost:8000/docs` \ No newline at end of file diff --git a/README.md b/README.md index e9fa580f0ea0f10828af81fbcb0f6bdccadf036e..2c0e375203d7b0e66309843f8f7d36eeb2fd656c 100644 GIT binary patch literal 15966 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 literal 28 icmezWPnki1A&()SA(tVMA%!6xNGCHCFz_;PF#rH@Uo-?k(E2xV`mm;#+n%i zGSW(UXr;7TsVuz^q)G&}D(PGHW#OsnR_aT;4})wLI?HZXDq4v**dVpiR%-t<&e#)5 zHY3?{F8}}i-~XL|=A2m%1iT2Y?|$>gOlJV0f0B-MIW`*WKZ3@uPz=c^M#UT>luVg- zhwOm6bHw>JEz_9H(Ic+6-Le~_8F~-u*>Hy$R}+#wGfd1Ky?_wMAT;B#&u1JNM~6qd z=AGsh6)Q2ugd|Sq#Uyd!vVyvRrL;L!f=p%wUQ}7{Gs1MdgED+siAyC?lFEzoq-&a; zHLa2x*_byU{5#>do^lgqE>YHGjOy8FV|2;V4^7e5eC56e#T=Hch~lVyV=8qB+3}o~ z4d7Lx(6&)vP`ai-*H!))}YDneIg>?y!2C2YODc zm)f~Wg(}N`lL}tjC|MEnvZ_v`ZF>1CmT`It>ipF!=8pwVQlcyFDl_nJVi2q#OibF4 zt1nesSn;ybK@_VwVq3YmD};s)BP6H;D*9C5@NV@(mK=!tuP^+90GG$T;0&hn_-#r+nV7>Ea~6^5AbMA9YN_~Gt#~km z&Q~jr2vQtSfl#y3f4^V(NyiveC#ihJAy&Vh=R}ijEhClT!<6eSfWB{3~z z@-m--O%}7T_u;gVj-ZwU2^(Xscv3Dn?`n99)ae7AwLP$aMLGQo)u zTAe0h=d7ycqLE03oRwH|RsxGG%U|Q>aK0XjYJX_NhRawB>9Rt4$-ez<9>|Km{>eaX^ul!YXeTwMu6{I{e7kqR<^HH zS2;^q?f*Z|s^r`YYXnOL)yj6%7_OWQi)c6`#c*d}P@FfM%7S9h{53(9$$8S)K`J(3 zuS~<4vs{ulf~1Ie(~K0G;XTh~^SntF5LMX-fq4aGAwifL^{)(;#N;9V z`;s66zkpe22`EYv)mQ4O(iI>OEOo3ZwfZ?uQLajI%J7%wOQ&zczbONn^o%MZ2mw|M zCn4>UNbMOEZ+O#!ypgrR5L+{RmDw_xGi9pOWu*pm!mnIL|JmqF9Ex0-5wA$IS$1%0 zvU6-;I5speJ~)$TeF;7+#hNVRmusD__GCCZGrVi_C!8=qoa3o-n?jGe2Xl@A%LM_My6) z9XC4kU7dwmc5&nhxL>?+QE!P9>be%kU+I41+xh$8C%u~?2aC)Q;DlR#ae8qS8ZNKy zJy@gFA-P`4h6E{=ZVt2cd5JY-HjWdhgTzf_jrD%7{@J3CgJ_27vj zbMlKYUh041-*LTvIefd}R>R7{LL;j;L{{asz4zKbZoeNb96qOa59+l;pZkYjI8j~e zOU8^DDbihE`x+kk@B<%SX<&XZjyA#~(9Co_pvW?#1DCKdNb5az1y^ zRo#!9+8#CaJZS1!OBR}bAi>XESFZERr&jVSnbiwxskQTZ?OUJwPrvYhgMYc>BFu@2wsy>^`jTI#QXKSQ)-Mc4us@v#|Gv`kQ@v?U|zg ztclUKvT*lYlX<u7Wc7A16uZ=wNcNP3y-#vGN#ecp%fV$qHlxC>xzZ~8_5_SCbP}68<@NZG? z=mFPfbkpcQ*Jt}&&^DMvf;s~~8zd+tEQS+Cm`-K}({vQXvrH3f0`}wp3(f%c^;B5Rm&HUutb0?ykWH^eV{{iCW B2>Sp4 literal 0 HcmV?d00001 diff --git a/__pycache__/test_gitea_repos.cpython-313.pyc b/__pycache__/test_gitea_repos.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d02624aeaa05446bdc6862dc8f8a1d2e0144db17 GIT binary patch literal 2548 zcmb7G-A~(A6u(ZK#5f->m7Qp=zc-vtV56S5%@$qJ4MUL8BSc3@bV z#KS7FY0}u2O4V*|O_hvyF#Yitj!5ob$Wq zo^$fM-U)|UfS+e~e=0Nw0QiFl&g0pg?8tcX6-WaF(p1_rMj^@`J;;Mc@0fR#Ml>Wk zddxTKM}B+m8w-pwh=CyMKM6W_p|Hl^3{YK`O$WNB0LU=_Wb54ef~Vj~GR6&dQq**) z2}mmGP*PT}nS^TBwu%QoofoeC*bhDwE<)%`K;OZzWUiYMba zAcBAk_5Vb0$k}81urqUz6mQ1U2rgd$K%p~qs?pK!k7?$0(7Btx|J2u|Sx2wzg9r{9fj2avMJYE8$?(dX6ttt26%fzpB*ictB79$yX+f5yByo zQrIPmltUDYu-7ILIIALf)y)NOySV@xsbxjiIF7Tz!y}V}y?pQ05k8Z;V!Szskczrs z%__u1qo_I*RAp8wlo6&ONr4>Z2skUrBKMp$)bl)kVb1W$Qc=>4wh6U{b8tvyo0FhO zc7UoNlo3YaET7G1*iS7;iWMj;*A={C1lS?l!-&=5rMpyRsP#cU4xouFlEa zaO�@xhVw<-v*Jtf1z_tfCh2^-&Z`SsgbhU)|nD;!2AaUCc=eI)fnzA!_ruYnH$k zU1zeHP5Paeeh_QAIr90)s#=M1OXJUi@hv87Mg}&S!7V2EDf2P2QvXR1n?+y!qG##K zHlDyojU5|}9S^)_V~5$;y>#WzV3X~1VUxL7>l8`ciB`hLmxi7kKDtaljwNnReKxgP zSVNUq`=c1Q5#!bu9@JH0eaq})CbGdaKVnX7FelbdKV({8_(1)!mn`tH%a^}QuO6+$ zTFhvx+1mYM`sX7*Og|X^C2q2*O*;L1{P^9_t>O)iQp;Vi+zU^(&G--YHMB243ohGd>eS$p-1w${{MIW z??1o)bM}jiS%3JNt3e`4R+x1Oi0BGDS#)(OM)6YRM^bl9DKh z*QhD$q*bySed`oGStJ!fut+t4o-7nvp{szzEZPD^{ci&hW&l`Z%3dVmKO#iV7#Jp&^|}NW7q+p2GkPaMBtd;UZZpjmCy*5`FuC!t57_++u*} z$@U>w$oUA&BB^If0|;1hvH}Q#)~rCZ9+>gSoD9OFa~QM8GtU-;N10>`7FmP93A*xo zK~M`8>^be6?*+odfiB<#iP_?uY@y9-Sxj0*3$C0*Fu$7<1eh)phoGetoah8VAWfRK zAURu-(=D_CHfIMyu^%AgD9Ch~_)m8OUDW)z+5bQJ)B62WRh_ZaA;TPj6ZaQuRve&N`C?57 zPFt|og4TRA9)BtteFnp-0~l84P!R$?XZZ5xY>w0EhRhk7JW*5FVF}cO1y3$oia@v* z?M9nq4=t4CR)hJ7e)>;%nLnxy#w1CU`e9)T@-h@e1?R08zqBfGQOFC>kCG9L@o~=o1P6jzIg(9l zXb+U}GN~@C^h4Flp5ilmhvV@~Zu9$9dR$yZ*-xd7nSPamupR1_lcTDoAL?{K2`j6z z>R6H6-;1*sr>V*1~)|d@jyq z&G)mwOX^m@hBij?=-p`-H;0wYCAxGkDK&Y>!0JyA1Ha4I10! zZcL4*`nI|?=aDqi@w?ZzVS89wu`zPr<+*a<@`X+1llN2fU03t{8n|iOahE0ShJ2@1 z?oYd4#}jO->t^qb-mSjv#%`^lM=O6L?e4`3Ose!|#f^%s@~`VO*rAnorrlk5-jNF2 zoWC)@b$0u3pVsuIR^Fd>AAR61+jAk){`LU34|5qyHYY2!`XS9ToOWP#nx`@CXnI&t zy-8veDjB#we|0`JckP|zX|4RVd+v6`t+FQTytnQ$$Nzb3_wECW)oT!a_1lr##5dzx zeVVg7&Gh8Y;)?yU{l8r+68l$%=sx{ z6v7b@3bnC%Q3yS0BB7AvM4FNtRXUw{<4E}BBH<^A?h7Xp{%pL;{%lCac}~du1V$)f zR3W^=g+jN`dk3r5VpV2fMU1Y-xnoib8sZ!!51?w7BnV=UvJlih0|@pZU>||nN5J<8 nbnelBaC}(&Cv}AI?nVhfIX2X7()WO3J{sR80aYb6BY^(_GkQZC literal 0 HcmV?d00001 diff --git a/__pycache__/test_nomad_namespaces.cpython-313.pyc b/__pycache__/test_nomad_namespaces.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5744ae2a0e2418cbc4820c6ab4e043da0d296b0a GIT binary patch literal 4144 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 literal 0 HcmV?d00001 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..df85467 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +# Import version +__version__ = "0.1.0" \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0b68971292564fc0faceb7f24be175d717f1155 GIT binary patch literal 181 zcmey&%ge<81pKr2rE37`#~=<2FhUuhIe?6*48aUV4C#!TOjWD~dWL!iewvK8xZ~r? zQj3Z+^Yh~4S2BDCslR3AY!wq)3>1yY%g)cp(Q^+9(Dilo4EAvKbB{^RPf3l*%g;?r ziO)?gh)FCch>4HS%*!l^kJl@xyv1RYo1apelWJGQ1~dv}e=*2;AD9^#86WV8HgXrS G0J#A6i7nv( literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0ec8b1b88ffc30078a3c3f298e4909492c1c0b5 GIT binary patch literal 4254 zcmbtXO>7&-6`tiTxg?jQL{Sp;V_R#3W zqXn(b6O7%*U1_ElNrDuRf>LOV#og)d)L3JzL#5qP(=a17TVG0r$6)<1_Ovsi(WZl` zGmV}@Ni9+Y^jgOthTo$X+EuSjYX1Sfrd{=-QpXSIHSem|DRupTUU*l%ZfVaBy;q%e zH(F`0v=2t~jJYJStG>5bglJ4OMmdzUKh49=^g_)0K%-YWU`4J|>OXZAA^9alPa>D> z#l7i1*imolf}`1JLI0pMaKdGC>U-Xw?oGYX80AnfdQgg``(ehRF-}BzF3-gdJ*VOr zOZeAi!yLbSkpwSXxpH~(ovczaRb3uNzUrfI*R%ehQWHx+F$<|fmM0+S*g}A87 zR7Wgf-PE&sVaYubeSx|_OQ;aNDYf)QNutCGe3|)zFBq$dO`r8>;a$eO8 z61KZptmI%qRW2CLh@xW7>$!S&UXgQPXvsG$m(*eE2lmnJauH7nD|6~Qgfr#5Bd2l3 z6#SL~*7#b)u|Qd2i?uWMI6(P=J=EF-40!2qVCsT7Pds)TV{kXfvhda)9;lzeyG`H# z_92l1Dl-D`8Fy)DADXQ}5UQtw#>_CO+F`9*0V3tz*|TW}8a0>1&bU%^uJ1e&H-em_ zzpLCWc^tXK$6Yf>^3sVU-vE5Uzp!JlJ{LE`rCJaqOBouC@Ca0jWL=V zeBIT8)VyQq^VAa+h`rHBlY+?08sZAmMgV`jTSoN z3_5`Jqgm$)L_CrK#58u+x-O~R6y5&sJd%I5$zjc~&I}=R%;nH zNkNSrXQzK>w{y%KdWG#lbIkwXSL<$b{?~q4QU89wUS{W*SahlVf|U$nc3#Qe6afuO zx~iFR!rRGli{_&NNVs4SuVF)qL41&Ynvyk@9DtmKeT09rh6?e)?zN)1JT8; z^drKDxGe-+>b8-`wqKifP6n9Goe}}bEUKnV+zQ4zo_ZVR2zdaG%mOYD zcb-D2H600Qx|y+kS^x}M>b783+;z5WJ&nOU3-2*_w`tj7r~C|xpbLN`dRRhMdNHTq zrdmXwK>aWkLP!8$$h)jSvi4#Rg6{OMD2Tb@BZPMXMu9;T$;pXR=^5Y|Y3-&yUx=Si zT|P23c~QDBIek8z)pJVPicqGQEv4mBDP5FREnd1sd}jc>m3K<`Ea|ZqlCiccex{&j z0b$R^>jSRCOhy-co}*>7?w@@!di}wV?~Im(J*(a?g+b{4)8Ih-L2Elhd<&Yh%Rvn5 zfi1GG3F4#ol})lh5?|xa^Z*j)VOMC{5!Ns;Tlbj6wp|*4A!K;>a9hw1B;6 z@5et~2~>pWrf>lMw_3%`)}!Utqif;y)}!mKr&lJoy80{K1DoB)%iYJp$kEWs)cv7y zpno<0BoMC#P`GowYpBY)zAgB=9eIVae|9bS=tg<;N1LNp%cEDn7`?vXpWWnV%lvGm z_Xv11U-tHF2_5TP$3MOvXHcMR8@a(NZ|I}I?ZA5U=x>fa{K>CRJ{(+cJNM}4T|4s8v2W1a8uNx-eN;)=)2P?GliwzKd4$4S4=a@nTrdfn3Dv)T6A1~JH$9Pu9 z2BznfL>wtS)M|E)){~g05|<98BTXk&%M*`XSz>yJ5rH~}^#v2K!m5g_(#I-xtjfkJ zW=JO;i_&Kx_B>}3A7d}kurIV(WEs@Vw}zqyOkuV1nf{|zg_X~<~kG8s&NQ$hZg zN+cxba+$^|Nl4YQ1t@VdwyzK?%V+#DXjpkj+(mr>@|s_OT2#jxVc~Ki9>5f+qVXlm zFka9w{UNvwFR~Nbd#0%877EH)JP$mI(#9|0TV)xB`3pK$M#r9^qtDQxr^xpdg`S~( ze?#McLgUZS$!F-L7aqg}ejcns&#~4U^#Md zefYIM#Mdugv&7dM;*xFw?os zB3Gle4~ px39KuG!1SDvE_3WPWUkSLGn&$_1eQLj}B~b=c_#8x^N!0=->_p~b01 z#W8u=`58HS?m+>%zOJ6Z9Kh*@{VwLp_2nK4Cb z?@)29wm~)$6{sUi(%Tg>ne1m0} zA2Bi`vlo~kOW2@^n1USPf;{1a0udx^GhHwTEyNPE5^K;#Y(bHTw4S?QCw6G(E;xt- zp8N%8&_!IdO}J1KbQ5>5medA4#3M0bwu2ccPD{LD`}6^0G393m6RsbC72!o1!rtj4 zdb#p0o5LR2a@-_a!|r1y+2&`S=1X#YdyEl_W9;Et7~|M6rU}Lv0xBkwonc$p5)Oo| z$4mm$;}7Qz^Bs)rGFEwvm21KQkm*L5PqT&~(!7V%+De&K<$1yZDwCJJyJWU3a6NUu z#x?c`nr-~z5 z$D?VD8&BTQ_{ZaFTC+Y$2SgH6J*?)KN-aiWvO=DTN0lBq^hh4N1jeyQ9-JU6szVIQ!yo>S%@wyT0CXk4C8>` z58?OlKF=`w7zr%0m6$oXt{%XC*HD9uCcRyEMdg*jP zcXI8Mtn4`qQ-{pI_0GL?=ZW9a(kx(UUt3>>eM)H6O=XQ4rI5ww@wN0<_a*wP2mKXw zG8-`D%MEg4b=v?NGReN}%r+&tUUy7Ibs zCK+2w#gl1iK1C!QKBaU@T8tzk3yMUMSV9DquE*0?rHG`)7nc%%xJpcdQ3{!lM3p{K zoVglTB|vvw*40QdmQYk2D5aJZLeW{367g$_q^2VzeFz?@r9%>6vi=GKuqC~$O3|xI z^qSfyYG%D+Y6n8&7mT>^up4ea!f(2Sr-n}93t(lFfg+vEIya?>6GVxmm6`aWLJ(fo zS&dsz((qs-DoT*F$0kO?Aypx2IC(8~HPLr&`r_dy&P>YZ&rF>QN5N-=ljxV`7Nbky z$kI}nP?x8MqtrX~E#1(pClZmxE3wF^)~xGq4iwOLB9V$l66z=v^wutzPDKQLo>^`G zN!O3N)*Ks=dmY0Mp^BMVH)-581>7Z3o`J>a%hk*>RgPv^PF_ofit2*To*Kpx*Uxvs;Zfx%UnE0P?MQbVX5;^vJNvF5zj>elZPX6%SFf6${YCTOL)ajK2bUUU7%? z4M>qFu5d(x{o3_|o zTQUN>S^|62%(`$*NTjXy<57?v>{o+tWgUTL)D%f8F@i@zeAvZ`yQd=%MKv3& zkLVK6{4`sc;g}Ptg#}p8Al7qgn)KKRx<1XR8w;EtvKWGAuGuSRbyS{~=F!)%b6udi zak~dVR^b2x6EfmW_=Bp#rs`UIZ)uHCWiT>v2{PKk;?G?#eg|cF&f* z8U700hqt+^f$O8vU&@%8pS$&3 zwzY39w$cALLq8kZ9DeI}^8HU{k6z6BzI{K?TId;gAaG5d2XXY$U&_v<^h>U(qby=&**oLZlHYyW$P-#(nJ@6Fa<%$(h|2&r=B zY~NVc_gFR*-U?lTzqg;rUVS>}yH@bEKCqi{PBYBu{=%F$^3F%@H+F6{9?CTyTKmqM zm)9@n8&71;gSMRhEk{Sr(Xl%8lhGfK-g6vj%HEppKmj8qfMp z?U?`5I>sxlO=L%ccZdFY^!?Fn@VnXJ*_`Ytge?GFFCS}2iJz) z991_=Wsd?a!auJfE#UbI*GAqZ+AD*HrNOUmJaO6dr|!mOqQGw!c1q^HR31 zyHMwYExX14X3RG6f42pY`MLTOSk?>dWFPzD#J=-p=FWaB-#IXT0$zUAi7&tEI%R{G z_xkYVy(5iKzH1gId%3&rarb05_rAo!%lqvDG{4_1V7Zrr@{0XPlp^$D{7%oqJUdVy zSuN$|Zvlq-~VJFc4Y z4ttUx=Ye<7=PusiSo!0@^8D z744Neg`!8$mrXs`lh72wJ_Wex9-+CyA$Yg!E$~<9?%V1f&UFtP4#D5C)zqJB>d!X~ zWL$t*;0tOG{cC-;-7+Hk)t4CCv@1b8-C3|Rw49&IE(Vs zYJfqIq4t>RBsyyFbX-k&UsMofq&j$*VFU#aiJ}LA!)56051^p_J=}EIXcR)MK_okR z?^)Xb7ThgcHQl+I?tIOG3=i*tmQ2lu-p0&C!4c4jPobgdwS`v~ZpQv(vEU2bw%oF; znqRLe_*-u8zqNn$K;GY5S>4hNpwa32A0S#a4>+dIx9wrvZCPup&TLGugc7ysePd#S z{ps1Y;cWe}d)9%ET!A$%I~e?@{=47*$HBV?vd83{YbI-*0rrC9K35TB0VJFN5@V-$ z<{b{p@9^S;$i3qlnh>}tQ%=dv~oh?xiqzaINGeZiO0|4P= zE8-0%56JUl5%hPb4C z+zw=jK6e3C-AYhAr=%q`G@68=e5C@C^ahezr0}IjjfiT+7#{%&aScqgPdvNQGL@=m zM2X9QRG8d>4$@Q)Pw*V5A(Mkp(s-1=!_6Ure8UtA!DJsnWfIhyo>9@!1k+|(YcT?! z!^ysw5&0#K(K8}9+i!Q>>RNTIMeg|@A(&yCA#HG{SsOBq!o-<~dQFCWh@xs7z#%4z zVvvlXPl&}=8Uf8o96WiftX157kBMACQBk-7M3xkxIm_yV&s?*mVS+gd+o&S)J-Dp| zbijszl5LqU2s^scQ{@qm40byH|fN=RcOY08@R`Ed52@>b0%z!Cd!XzI!;| zemvhi@`~-hYJ3G>OJ)+r;`)Yi4MUsOyO;6}Pi5={h|G7^KrcvGG&C1lx(ZDlg{DU~ z8aAxi#^d+>T?Ky^WFzWb51gV~+-5{%xjxErEfw+i(T%~41G_`-`;Am))8RLw8|^=x zU%QlT9KmhXt->3Z%?-Qa0A z_pZo7`CYpJ_3yd`EZ1{T?rGI5^AR}ur8q%cr38T>%o8)-(2rN$&1O-iSouLS!2R+sk z@Q=OagfdE2pb6{}AO-H*9LusFGpGKQIrTAfD9;@Fka2y;*#DC``Z04k$6Wq*o9B-L z&zlQ2_m<87FE$8c^0vjLAxSe=pm60(+m_Hg!PfwtM+l>$!!d;Cv!a~ zpZ90k(GR)~uAR(x9e=>Iup#!Q`YCBR gY@W;yOg>=7Sn;O1jgK{u01p%}&OR_fkt*!}0INsX%K!iX literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/configs.cpython-313.pyc b/app/routers/__pycache__/configs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..930f0737aca3e3a525124f61a23377aa1d104cd0 GIT binary patch literal 4591 zcmdT{O>7&-6`t8$k&;%VBTC#Ow+6+yuBsvsH z%upg*?m?@-lCznKdgUN{7g# zy$x%L%6n$5(U$qcjw!ou)fII`o9kdfbx#Rueb26Z+gl_WqRq$~m4=AgsLE>7lrTY6 zPqckPP`y3mu5_1HZ0Gx?8CC{PrVNv_;sW*4!Vuc5#M5ad{#iVg zj^9aZN`jlb!s6(o9`FmM*IJ6{mK$bM8l+;@qW+X@qkeOGCK!zXhaSy*nq5i%wDq9KZ<}ybFL4;^a*Q!o!y|Kv2szA+*#$O0E(;?_Anb4H3zw;ly$+9 zjs_{L11LM-kQV&V4P*}h!zFpR7%t044e994Hiw|Io4)!7aDnz_c$u=EkLOo(2u)Hm zWjkt+Tk$Hd-N4GdUVy>7c|DKY)jSb0kD6*8m7b!iFhwUZIpMK!5(~Xn&I42#?el7s z0vrekms`7+NUtO{zht51N$@(cQ;l<(QW6TxPgy@G-;3QK%o!3yQpbwegWX6;9@&sL zN6(c;&pkV~84Q(zAtU^;q24rL5R8`PTZVMYPOPz0G+|p0S_rA&bqyx(g4Yc?k8QW+ zQTNAX(5ikVo>8=WP*|g)*9?u&HgfqOAsHbe?4JxyBZ-jU4MLI-s;l5( zM9>8Vibf_1xmhVMRZztU@se>tm8JxZ^6p(%82liWNmhf04QJPz06PknzxorPRv5XK ztzliru7LPLmhF)UDu8|;2$`;=mWLzZsa1-+Et?{AFNUoE5)d_;Zo7M%E_)!ts$!>{ z*}dEBVs}k;?jwNO1VRPT6*I)Ab%id#amwxmeSa&^_TmKOJZSZlrEFUTs zzLQTqZ#(e2tFP-wThE(%B6k;OjME<(A195EG~?t#sd3Se7I(JsI{N6uw2S_w4M@Q= zb~k%x>^_iKD*3>4S%d$pIQQ)zXYfRsy%1oSb?i8h!lC~WW6STL!tm0?#(+Q&xhUQ{ z88%G4DtfKe(F1vQ7MM`--NLq1mIsPwzLWj?ge_wHDr!V-87FU-8e@hO+hN#93Uy=Y zROVA`cy-$H9W9wai<}1&?+f^Ce*?rS$B2Lp0G}jy5ux+gA8H<_DEGS%VNZ5Ws(rtU zyWhQ7%&>!S8ehwE;H`Go#p3r#P=cgPTkrh6xYF_FXVqFRn{l z^`vVMm|xe4Uf0?OZyNdi%;~t3hBUdOyFl@y>9*+(TJVmoWY7-#s#aBb7+xOIpK*-* zGQ!nYL%YDT%NDrU5&1!a2Wx(*=6)MsBVYonIa|yg%jCV#p+*kgdmwNXh5ShHXz?qi zdm#>YuUv|uN4!NcJUtlVV7l_FIoO?HKWF%#m^I+CbXSKz5xsC34%jzqJz(G2>^Z|; zX8JfsVo6($I}8kO$gBy82U%R!ZQ4qYlemm`X;akmj9)5E2~NH|PaIRst>pPd09!mB zu>26^EtwZM&kpt;DrrN~j-&&KS^o?853-Wh&a*j~VyWpr0o@WQr7y|3GCB8>43)^x zKZ*P=GWn9smdNZ64Zh#^uDM>c_J29B=KY?!f7A1@XMLt96>pc6iM1YsPQDfd>faWL zaApffkX}MP=b9~MIKzv|Ixj{m<03UV~1f zuG_R2-3Hd%6nWpL*4lq>fa_uUB(Vj&Vft+o%7nQLHz`alk-mq0>#G~>8;NhnH2o-6eD=8H2fjj7I>0C+XA@X6siqWb~BoelOA(s*ZEj44wrl*FIw6khl}l{ zmLsp-E%eOumbNVs-rqMhZ`Hwj%SB}G8~lQscWw!|HjI0NQp3|%rv2RRjd4VQJ2HR~ I3h+eu4;psWfdBvi literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/jobs.cpython-313.pyc b/app/routers/__pycache__/jobs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..786ca606df7d0dc97e98d6ad899040e8305efb79 GIT binary patch literal 15377 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 literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/logs.cpython-313.pyc b/app/routers/__pycache__/logs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c478cec8602bcb4cfd3c38d3403ab29437932623 GIT binary patch literal 11309 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 literal 0 HcmV?d00001 diff --git a/app/routers/claude.py b/app/routers/claude.py new file mode 100644 index 0000000..1a03b73 --- /dev/null +++ b/app/routers/claude.py @@ -0,0 +1,230 @@ +from fastapi import APIRouter, HTTPException, Body, Query, Depends +from typing import Dict, Any, List, Optional +import logging +import json + +from app.services.nomad_client import NomadService +from app.schemas.claude_api import ClaudeJobRequest, ClaudeJobSpecification, ClaudeJobResponse + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.post("/jobs", response_model=ClaudeJobResponse) +async def manage_job(request: ClaudeJobRequest): + """ + Endpoint for Claude to manage Nomad jobs with a simplified interface. + + This endpoint handles job operations like start, stop, restart, and status checks. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + if request.namespace: + nomad_service.namespace = request.namespace + + # Handle different actions + if request.action.lower() == "status": + # Get job status + job = nomad_service.get_job(request.job_id) + + # Get allocations for more detailed status + allocations = nomad_service.get_allocations(request.job_id) + latest_alloc = None + if allocations: + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + + return ClaudeJobResponse( + success=True, + job_id=request.job_id, + status=job.get("Status", "unknown"), + message=f"Job {request.job_id} is {job.get('Status', 'unknown')}", + details={ + "job": job, + "latest_allocation": latest_alloc + } + ) + + elif request.action.lower() == "stop": + # Stop the job + result = nomad_service.stop_job(request.job_id, purge=request.purge) + + return ClaudeJobResponse( + success=True, + job_id=request.job_id, + status="stopped", + message=f"Job {request.job_id} has been stopped" + (" and purged" if request.purge else ""), + details=result + ) + + elif request.action.lower() == "restart": + # Get the current job specification + job_spec = nomad_service.get_job(request.job_id) + + # Stop the job + nomad_service.stop_job(request.job_id) + + # Start the job with the original specification + result = nomad_service.start_job(job_spec) + + return ClaudeJobResponse( + success=True, + job_id=request.job_id, + status="restarted", + message=f"Job {request.job_id} has been restarted", + details=result + ) + + else: + # Unknown action + raise HTTPException(status_code=400, detail=f"Unknown action: {request.action}") + + except Exception as e: + logger.error(f"Error managing job {request.job_id}: {str(e)}") + return ClaudeJobResponse( + success=False, + job_id=request.job_id, + status="error", + message=f"Error: {str(e)}", + details=None + ) + +@router.post("/create-job", response_model=ClaudeJobResponse) +async def create_job(job_spec: ClaudeJobSpecification): + """ + Endpoint for Claude to create a new Nomad job with a simplified interface. + + This endpoint allows creating a job with minimal configuration. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + if job_spec.namespace: + nomad_service.namespace = job_spec.namespace + + # Convert the simplified job spec to Nomad format + nomad_job_spec = job_spec.to_nomad_job_spec() + + # Start the job + result = nomad_service.start_job(nomad_job_spec) + + return ClaudeJobResponse( + success=True, + job_id=job_spec.job_id, + status="started", + message=f"Job {job_spec.job_id} has been created and started", + details=result + ) + + except Exception as e: + logger.error(f"Error creating job {job_spec.job_id}: {str(e)}") + return ClaudeJobResponse( + success=False, + job_id=job_spec.job_id, + status="error", + message=f"Error: {str(e)}", + details=None + ) + +@router.get("/list-jobs", response_model=List[Dict[str, Any]]) +async def list_jobs(namespace: str = Query("development")): + """ + List all jobs in the specified namespace. + + Returns a simplified list of jobs with their IDs and statuses. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + nomad_service.namespace = namespace + + # Get all jobs + jobs = nomad_service.list_jobs() + + # Return a simplified list + simplified_jobs = [] + for job in jobs: + simplified_jobs.append({ + "id": job.get("ID"), + "name": job.get("Name"), + "status": job.get("Status"), + "type": job.get("Type"), + "namespace": namespace + }) + + return simplified_jobs + + except Exception as e: + logger.error(f"Error listing jobs: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error listing jobs: {str(e)}") + +@router.get("/job-logs/{job_id}", response_model=Dict[str, Any]) +async def get_job_logs(job_id: str, namespace: str = Query("development")): + """ + Get logs for a job. + + Returns logs from the latest allocation of the job. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + nomad_service.namespace = namespace + + # Get allocations for the job + allocations = nomad_service.get_allocations(job_id) + if not allocations: + return { + "success": False, + "job_id": job_id, + "message": f"No allocations found for job {job_id}", + "logs": None + } + + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + alloc_id = latest_alloc.get("ID") + + # Get the task name from the allocation + task_name = None + if "TaskStates" in latest_alloc: + task_states = latest_alloc["TaskStates"] + if task_states: + task_name = next(iter(task_states.keys())) + + if not task_name: + task_name = "app" # Default task name + + # Get logs for the allocation + stdout_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stdout") + stderr_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stderr") + + return { + "success": True, + "job_id": job_id, + "allocation_id": alloc_id, + "task_name": task_name, + "message": f"Retrieved logs for job {job_id}", + "logs": { + "stdout": stdout_logs, + "stderr": stderr_logs + } + } + + except Exception as e: + logger.error(f"Error getting logs for job {job_id}: {str(e)}") + return { + "success": False, + "job_id": job_id, + "message": f"Error getting logs: {str(e)}", + "logs": None + } \ No newline at end of file diff --git a/app/routers/configs.py b/app/routers/configs.py new file mode 100644 index 0000000..ee03de1 --- /dev/null +++ b/app/routers/configs.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, HTTPException, Body, Path +from typing import List, Dict, Any +import json + +from app.services.config_service import ConfigService +from app.schemas.config import ConfigCreate, ConfigUpdate, ConfigResponse + +router = APIRouter() +config_service = ConfigService() + +@router.get("/", response_model=List[ConfigResponse]) +async def list_configs(): + """List all available configurations.""" + return config_service.list_configs() + +@router.get("/{name}", response_model=ConfigResponse) +async def get_config(name: str = Path(..., description="Configuration name")): + """Get a specific configuration by name.""" + return config_service.get_config(name) + +@router.post("/", response_model=ConfigResponse, status_code=201) +async def create_config(config_data: ConfigCreate): + """Create a new configuration.""" + return config_service.create_config(config_data.name, config_data.dict(exclude={"name"})) + +@router.put("/{name}", response_model=ConfigResponse) +async def update_config(name: str, config_data: ConfigUpdate): + """Update an existing configuration.""" + return config_service.update_config(name, config_data.dict(exclude_unset=True)) + +@router.delete("/{name}", response_model=Dict[str, Any]) +async def delete_config(name: str = Path(..., description="Configuration name")): + """Delete a configuration.""" + return config_service.delete_config(name) + +@router.get("/repository/{repository}") +async def get_config_by_repository(repository: str): + """Find configuration by repository.""" + configs = config_service.list_configs() + + for config in configs: + if config.get("repository") == repository: + return config + + raise HTTPException(status_code=404, detail=f"No configuration found for repository: {repository}") + +@router.get("/job/{job_id}") +async def get_config_by_job(job_id: str): + """Find configuration by job ID.""" + configs = config_service.list_configs() + + for config in configs: + if config.get("job_id") == job_id: + return config + + raise HTTPException(status_code=404, detail=f"No configuration found for job_id: {job_id}") + +@router.post("/link") +async def link_repository_to_job( + repository: str = Body(..., embed=True), + job_id: str = Body(..., embed=True), + name: str = Body(None, embed=True) +): + """Link a repository to a job.""" + # Generate a name if not provided + if not name: + name = f"{job_id.lower().replace('/', '_').replace(' ', '_')}" + + # Create the config + config = { + "repository": repository, + "job_id": job_id, + } + + return config_service.create_config(name, config) + +@router.post("/unlink/{name}") +async def unlink_repository_from_job(name: str): + """Unlink a repository from a job by deleting the configuration.""" + return config_service.delete_config(name) \ No newline at end of file diff --git a/app/routers/jobs.py b/app/routers/jobs.py new file mode 100644 index 0000000..5dffca6 --- /dev/null +++ b/app/routers/jobs.py @@ -0,0 +1,396 @@ +from fastapi import APIRouter, Depends, HTTPException, Body, Query +from typing import Dict, Any, List, Optional +import json +import logging + +from app.services.nomad_client import NomadService +from app.services.config_service import ConfigService +from app.schemas.job import JobResponse, JobOperation, JobSpecification + +router = APIRouter() +nomad_service = NomadService() +config_service = ConfigService() + +# Configure logging +logger = logging.getLogger(__name__) + +@router.get("/", response_model=List[JobResponse]) +async def list_jobs(): + """List all jobs.""" + jobs = nomad_service.list_jobs() + # Enhance job responses with repository information if available + for job in jobs: + job_id = job.get("ID") + if job_id: + repository = config_service.get_repository_from_job(job_id) + if repository: + job["repository"] = repository + return jobs + +@router.get("/{job_id}", response_model=JobResponse) +async def get_job(job_id: str): + """Get a job by ID.""" + job = nomad_service.get_job(job_id) + # Add repository information if available + repository = config_service.get_repository_from_job(job_id) + if repository: + job["repository"] = repository + return job + +@router.post("/", response_model=JobOperation) +async def start_job(job_spec: JobSpecification = Body(...)): + """Start a Nomad job with the provided specification.""" + return nomad_service.start_job(job_spec.dict()) + +@router.delete("/{job_id}", response_model=JobOperation) +async def stop_job(job_id: str, purge: bool = Query(False)): + """Stop a job by ID.""" + return nomad_service.stop_job(job_id, purge) + +@router.get("/{job_id}/allocations") +async def get_job_allocations(job_id: str): + """Get all allocations for a job.""" + return nomad_service.get_allocations(job_id) + +@router.get("/{job_id}/latest-allocation") +async def get_latest_allocation(job_id: str): + """Get the latest allocation for a job.""" + allocations = nomad_service.get_allocations(job_id) + if not allocations: + raise HTTPException(status_code=404, detail=f"No allocations found for job {job_id}") + + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + + return sorted_allocations[0] + +@router.get("/{job_id}/status") +async def get_job_status(job_id: str, namespace: str = Query(None, description="Nomad namespace")): + """Get the current status of a job, including deployment and latest allocation.""" + try: + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + custom_nomad.namespace = namespace + logger.info(f"Getting job status for {job_id} in namespace {namespace}") + else: + logger.info(f"Getting job status for {job_id} in default namespace (development)") + + job = custom_nomad.get_job(job_id) + status = { + "job_id": job_id, + "namespace": namespace or custom_nomad.namespace, + "status": job.get("Status", "unknown"), + "stable": job.get("Stable", False), + "submitted_at": job.get("SubmitTime", 0), + } + + # Get the latest deployment if any + try: + deployment = custom_nomad.get_deployment_status(job_id) + if deployment: + status["deployment"] = { + "id": deployment.get("ID"), + "status": deployment.get("Status"), + "description": deployment.get("StatusDescription"), + } + except Exception as e: + logger.warning(f"Failed to get deployment for job {job_id}: {str(e)}") + pass # Deployment info is optional + + # Get the latest allocation if any + try: + allocations = custom_nomad.get_allocations(job_id) + if allocations: + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + status["latest_allocation"] = { + "id": latest_alloc.get("ID"), + "status": latest_alloc.get("ClientStatus"), + "description": latest_alloc.get("ClientDescription", ""), + "created_at": latest_alloc.get("CreateTime", 0), + } + except Exception as e: + logger.warning(f"Failed to get allocations for job {job_id}: {str(e)}") + pass # Allocation info is optional + + return status + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get job status: {str(e)}") + +@router.get("/{job_id}/specification") +async def get_job_specification(job_id: str, namespace: str = Query(None, description="Nomad namespace"), raw: bool = Query(False)): + """Get the job specification for a job.""" + try: + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + custom_nomad.namespace = namespace + logger.info(f"Getting job specification for {job_id} in namespace {namespace}") + else: + logger.info(f"Getting job specification for {job_id} in default namespace (development)") + + job = custom_nomad.get_job(job_id) + + if raw: + return job + + # Extract just the job specification part if present + if "JobID" in job: + job_spec = { + "id": job.get("ID"), + "name": job.get("Name"), + "type": job.get("Type"), + "status": job.get("Status"), + "datacenters": job.get("Datacenters", []), + "namespace": job.get("Namespace"), + "task_groups": job.get("TaskGroups", []), + "meta": job.get("Meta", {}), + } + return job_spec + + return job + except Exception as e: + raise HTTPException(status_code=404, detail=f"Failed to get job specification: {str(e)}") + +@router.post("/{job_id}/restart") +async def restart_job(job_id: str): + """Restart a job by stopping it and starting it again.""" + try: + # Get the current job specification + job_spec = nomad_service.get_job(job_id) + + # Stop the job + nomad_service.stop_job(job_id) + + # Start the job with the original specification + result = nomad_service.start_job(job_spec) + + return { + "job_id": job_id, + "status": "restarted", + "eval_id": result.get("eval_id"), + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to restart job: {str(e)}") + +@router.get("/by-repository/{repository}") +async def get_job_by_repository(repository: str): + """Get job information by repository URL or name.""" + job_info = config_service.get_job_from_repository(repository) + if not job_info: + raise HTTPException(status_code=404, detail=f"No job found for repository: {repository}") + + job_id = job_info.get("job_id") + namespace = job_info.get("namespace") + + # Get the job using the specific namespace if provided + try: + if namespace: + # Override the default namespace with the specific one + custom_nomad = NomadService() + custom_nomad.namespace = namespace + job = custom_nomad.get_job(job_id) + else: + # Use the default namespace settings + job = nomad_service.get_job(job_id) + + # Add repository information + job["repository"] = repository + return job + except Exception as e: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}, Error: {str(e)}") + +@router.post("/by-repository/{repository}/start") +async def start_job_by_repository(repository: str): + """Start a job by its associated repository.""" + logger = logging.getLogger(__name__) + + job_info = config_service.get_job_from_repository(repository) + if not job_info: + raise HTTPException(status_code=404, detail=f"No job found for repository: {repository}") + + job_id = job_info.get("job_id") + namespace = job_info.get("namespace") + + logger.info(f"Starting job for repository {repository}, job_id: {job_id}, namespace: {namespace}") + + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + logger.info(f"Setting custom_nomad.namespace to {namespace}") + custom_nomad.namespace = namespace + + # Log the current namespace being used + logger.info(f"Nomad client namespace: {custom_nomad.namespace}") + + try: + # Get the job specification from an existing job + job_spec = custom_nomad.get_job(job_id) + + # Log the job specification + logger.info(f"Retrieved job specification for {job_id} from existing job") + + # Ensure namespace is set in job spec + if isinstance(job_spec, dict): + # Ensure namespace is explicitly set + if namespace: + logger.info(f"Setting namespace in job spec to {namespace}") + job_spec["Namespace"] = namespace + + # Log the keys in the job specification + logger.info(f"Job spec keys: {job_spec.keys()}") + + # Start the job with the retrieved specification + result = custom_nomad.start_job(job_spec) + + return { + "job_id": job_id, + "repository": repository, + "status": "started", + "eval_id": result.get("eval_id"), + "namespace": namespace + } + except HTTPException as e: + # If job not found, try to get spec from config + if e.status_code == 404: + logger.info(f"Job {job_id} not found, attempting to get specification from config") + + # Try to get job spec from repository config + job_spec = config_service.get_job_spec_from_repository(repository) + + if not job_spec: + logger.warning(f"No job specification found for repository {repository}, creating a default one") + + # Create a simple default job spec if none exists + job_spec = { + "ID": job_id, + "Name": job_id, + "Type": "service", + "Datacenters": ["jm"], # Default datacenter + "TaskGroups": [ + { + "Name": "app", + "Count": 1, + "Tasks": [ + { + "Name": job_id.split('-')[0], # Use first part of job ID as task name + "Driver": "docker", + "Config": { + "image": f"registry.dev.meisheng.group/{repository}:latest", + "force_pull": True, + "ports": ["http"] + }, + "Resources": { + "CPU": 500, + "MemoryMB": 512 + } + } + ], + "Networks": [ + { + "DynamicPorts": [ + { + "Label": "http", + "Value": 0, + "To": 8000 + } + ] + } + ] + } + ], + "Meta": { + "repository": repository + } + } + + # Set the namespace explicitly in the job spec + if namespace: + logger.info(f"Setting namespace in default job spec to {namespace}") + job_spec["Namespace"] = namespace + + logger.info(f"Starting job {job_id} with specification") + + # Log the job specification structure + if isinstance(job_spec, dict): + logger.info(f"Job spec keys: {job_spec.keys()}") + if "Namespace" in job_spec: + logger.info(f"Job spec namespace: {job_spec['Namespace']}") + + # Start the job with the specification + result = custom_nomad.start_job(job_spec) + + return { + "job_id": job_id, + "repository": repository, + "status": "started", + "eval_id": result.get("eval_id"), + "namespace": namespace + } + +@router.post("/by-repository/{repository}/stop") +async def stop_job_by_repository(repository: str, purge: bool = Query(False)): + """Stop a job by its associated repository.""" + job_info = config_service.get_job_from_repository(repository) + if not job_info: + raise HTTPException(status_code=404, detail=f"No job found for repository: {repository}") + + job_id = job_info.get("job_id") + namespace = job_info.get("namespace") + + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + custom_nomad.namespace = namespace + + # Stop the job + result = custom_nomad.stop_job(job_id, purge) + + return { + "job_id": job_id, + "repository": repository, + "status": "stopped", + "eval_id": result.get("eval_id"), + "namespace": namespace + } + +@router.post("/by-repository/{repository}/restart") +async def restart_job_by_repository(repository: str): + """Restart a job by its associated repository.""" + job_info = config_service.get_job_from_repository(repository) + if not job_info: + raise HTTPException(status_code=404, detail=f"No job found for repository: {repository}") + + job_id = job_info.get("job_id") + namespace = job_info.get("namespace") + + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + custom_nomad.namespace = namespace + + # Get the job specification + job_spec = custom_nomad.get_job(job_id) + + # Stop the job first + custom_nomad.stop_job(job_id) + + # Start the job with the original specification + result = custom_nomad.start_job(job_spec) + + return { + "job_id": job_id, + "repository": repository, + "status": "restarted", + "eval_id": result.get("eval_id"), + "namespace": namespace + } \ No newline at end of file diff --git a/app/routers/logs.py b/app/routers/logs.py new file mode 100644 index 0000000..e14a094 --- /dev/null +++ b/app/routers/logs.py @@ -0,0 +1,293 @@ +from fastapi import APIRouter, HTTPException, Query +from typing import List, Dict, Any, Optional +import logging + +from app.services.nomad_client import NomadService +from app.services.config_service import ConfigService + +# Configure logging +logger = logging.getLogger(__name__) + +router = APIRouter() +nomad_service = NomadService() +config_service = ConfigService() + +# More specific routes first +@router.get("/repository/{repository}") +async def get_repository_logs( + repository: str, + log_type: str = Query("stderr", description="Log type: stdout or stderr"), + limit: int = Query(1, description="Number of allocations to return logs for"), + plain_text: bool = Query(False, description="Return plain text logs instead of JSON") +): + """Get logs for a repository's associated job.""" + # Get the job info for the repository + job_info = config_service.get_job_from_repository(repository) + if not job_info: + raise HTTPException(status_code=404, detail=f"No job found for repository: {repository}") + + job_id = job_info.get("job_id") + namespace = job_info.get("namespace") + + logger.info(f"Getting logs for job {job_id} in namespace {namespace}") + + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + custom_nomad.namespace = namespace + + # Get allocations for the job + allocations = custom_nomad.get_allocations(job_id) + if not allocations: + raise HTTPException(status_code=404, detail=f"No allocations found for job {job_id}") + + logger.info(f"Found {len(allocations)} allocations for job {job_id}") + + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + + # Limit the number of allocations + allocations_to_check = sorted_allocations[:limit] + + # Also get the job info to determine task names + job = custom_nomad.get_job(job_id) + + # Collect logs for each allocation and task + result = [] + error_messages = [] + + for alloc in allocations_to_check: + # Use the full UUID of the allocation + alloc_id = alloc.get("ID") + if not alloc_id: + logger.warning(f"Allocation ID not found in allocation data") + error_messages.append("Allocation ID not found in allocation data") + continue + + logger.info(f"Processing allocation {alloc_id} for job {job_id}") + + # Get task name from the allocation's TaskStates + task_states = alloc.get("TaskStates", {}) + if not task_states: + logger.warning(f"No task states found in allocation {alloc_id}") + error_messages.append(f"No task states found in allocation {alloc_id}") + + for task_name, task_state in task_states.items(): + try: + logger.info(f"Retrieving logs for allocation {alloc_id}, task {task_name}") + + logs = custom_nomad.get_allocation_logs(alloc_id, task_name, log_type) + + # Check if logs is an error message + if logs and isinstance(logs, str): + if logs.startswith("Error:") or logs.startswith("No "): + logger.warning(f"Error retrieving logs for {task_name}: {logs}") + error_messages.append(logs) + continue + + # Only add if we got some logs + if logs: + result.append({ + "alloc_id": alloc_id, + "task": task_name, + "type": log_type, + "create_time": alloc.get("CreateTime"), + "logs": logs + }) + logger.info(f"Successfully retrieved logs for {task_name}") + else: + error_msg = f"No logs found for {task_name}" + logger.warning(error_msg) + error_messages.append(error_msg) + except Exception as e: + # Log but continue to try other tasks + error_msg = f"Failed to get logs for {alloc_id}/{task_name}: {str(e)}" + logger.error(error_msg) + error_messages.append(error_msg) + + # Return as plain text if requested + if plain_text: + if not result: + if error_messages: + return f"No logs found for this job. Errors: {'; '.join(error_messages)}" + return "No logs found for this job" + return "\n\n".join([f"=== {r.get('task')} ===\n{r.get('logs')}" for r in result]) + + # Otherwise return as JSON + return { + "job_id": job_id, + "repository": repository, + "namespace": namespace, + "allocation_logs": result, + "errors": error_messages if error_messages else None + } + +@router.get("/job/{job_id}") +async def get_job_logs( + job_id: str, + namespace: str = Query(None, description="Nomad namespace"), + log_type: str = Query("stderr", description="Log type: stdout or stderr"), + limit: int = Query(1, description="Number of allocations to return logs for"), + plain_text: bool = Query(False, description="Return plain text logs instead of JSON") +): + """Get logs for the most recent allocations of a job.""" + # Create a custom service with the specific namespace if provided + custom_nomad = NomadService() + if namespace: + custom_nomad.namespace = namespace + logger.info(f"Getting logs for job {job_id} in namespace {namespace}") + else: + logger.info(f"Getting logs for job {job_id} in default namespace") + + # Get all allocations for the job + allocations = custom_nomad.get_allocations(job_id) + if not allocations: + raise HTTPException(status_code=404, detail=f"No allocations found for job {job_id}") + + logger.info(f"Found {len(allocations)} allocations for job {job_id}") + + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + + # Limit the number of allocations + allocations_to_check = sorted_allocations[:limit] + + # Collect logs for each allocation and task + result = [] + for alloc in allocations_to_check: + alloc_id = alloc.get("ID") + if not alloc_id: + logger.warning(f"Allocation ID not found in allocation data") + continue + + logger.info(f"Processing allocation {alloc_id} for job {job_id}") + + # Get task names from the allocation's TaskStates + task_states = alloc.get("TaskStates", {}) + for task_name, task_state in task_states.items(): + try: + logger.info(f"Retrieving logs for allocation {alloc_id}, task {task_name}") + + logs = custom_nomad.get_allocation_logs(alloc_id, task_name, log_type) + # Only add if we got some logs and not an error message + if logs and not logs.startswith("No") and not logs.startswith("Error"): + result.append({ + "alloc_id": alloc_id, + "task": task_name, + "type": log_type, + "create_time": alloc.get("CreateTime"), + "logs": logs + }) + logger.info(f"Successfully retrieved logs for {task_name}") + else: + logger.warning(f"No logs found for {task_name}: {logs}") + except Exception as e: + # Log but continue to try other tasks + logger.error(f"Failed to get logs for {alloc_id}/{task_name}: {str(e)}") + + # Return as plain text if requested + if plain_text: + if not result: + return "No logs found for this job" + return "\n\n".join([f"=== {r.get('task')} ===\n{r.get('logs')}" for r in result]) + + # Otherwise return as JSON + return { + "job_id": job_id, + "namespace": namespace, + "allocation_logs": result + } + +@router.get("/latest/{job_id}") +async def get_latest_allocation_logs( + job_id: str, + log_type: str = Query("stderr", description="Log type: stdout or stderr"), + plain_text: bool = Query(False, description="Return plain text logs instead of JSON") +): + """Get logs from the latest allocation of a job.""" + # Get all allocations for the job + allocations = nomad_service.get_allocations(job_id) + if not allocations: + raise HTTPException(status_code=404, detail=f"No allocations found for job {job_id}") + + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + + # Get the latest allocation + latest_alloc = sorted_allocations[0] + alloc_id = latest_alloc.get("ID") + + # Get task group and task information + job = nomad_service.get_job(job_id) + task_groups = job.get("TaskGroups", []) + + # Collect logs for each task in the latest allocation + result = [] + for task_group in task_groups: + tasks = task_group.get("Tasks", []) + for task in tasks: + task_name = task.get("Name") + try: + logs = nomad_service.get_allocation_logs(alloc_id, task_name, log_type) + result.append({ + "alloc_id": alloc_id, + "task": task_name, + "type": log_type, + "create_time": latest_alloc.get("CreateTime"), + "logs": logs + }) + except Exception as e: + # Skip if logs cannot be retrieved for this task + pass + + # Return as plain text if requested + if plain_text: + return "\n\n".join([f"=== {r['task']} ===\n{r['logs']}" for r in result]) + + # Otherwise return as JSON + return { + "job_id": job_id, + "latest_allocation": alloc_id, + "task_logs": result + } + +@router.get("/build/{job_id}") +async def get_build_logs(job_id: str, plain_text: bool = Query(False)): + """Get build logs for a job (usually stderr logs from the latest allocation).""" + # This is a convenience endpoint that returns stderr logs from the latest allocation + return await get_latest_allocation_logs(job_id, "stderr", plain_text) + +# Generic allocation logs route last +@router.get("/allocation/{alloc_id}/{task}") +async def get_allocation_logs( + alloc_id: str, + task: str, + log_type: str = Query("stderr", description="Log type: stdout or stderr"), + plain_text: bool = Query(False, description="Return plain text logs instead of JSON") +): + """Get logs for a specific allocation and task.""" + # Validate log_type + if log_type not in ["stdout", "stderr"]: + raise HTTPException(status_code=400, detail="Log type must be stdout or stderr") + + # Get logs from Nomad + logs = nomad_service.get_allocation_logs(alloc_id, task, log_type) + + # Return as plain text if requested + if plain_text: + return logs + + # Otherwise return as JSON + return {"alloc_id": alloc_id, "task": task, "type": log_type, "logs": logs} \ No newline at end of file diff --git a/app/routers/repositories.py b/app/routers/repositories.py new file mode 100644 index 0000000..816ff5a --- /dev/null +++ b/app/routers/repositories.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, HTTPException, Query +from typing import List, Dict, Any, Optional + +from app.services.gitea_client import GiteaClient +from app.services.config_service import ConfigService + +router = APIRouter() +gitea_client = GiteaClient() +config_service = ConfigService() + +@router.get("/") +async def list_repositories(limit: int = Query(100, description="Maximum number of repositories to return")): + """ + List all available repositories from Gitea. + + If Gitea integration is not configured, returns an empty list. + """ + repositories = gitea_client.list_repositories(limit) + + # Enhance with linked job information + for repo in repositories: + # Create a URL from clone_url + repo_url = repo.get("clone_url") + if repo_url: + # Check if repository is linked to a job + configs = config_service.list_configs() + for config in configs: + if config.get("repository") == repo_url: + repo["linked_job"] = config.get("job_id") + repo["config_name"] = config.get("name") + break + + return repositories + +@router.get("/{repository}") +async def get_repository_info(repository: str): + """ + Get information about a specific repository. + + The repository parameter can be a repository URL or a repository alias. + If it's a repository URL, we'll get the info directly from Gitea. + If it's a repository alias, we'll get the info from the configuration and then from Gitea. + """ + # First check if it's a repository URL + repo_info = gitea_client.get_repository_info(repository) + + if repo_info: + # Check if repository is linked to a job + configs = config_service.list_configs() + for config in configs: + if config.get("repository") == repository: + repo_info["linked_job"] = config.get("job_id") + repo_info["config_name"] = config.get("name") + repo_info["config"] = config + break + + return repo_info + else: + # Check if it's a repository alias in our configs + config = config_service.get_config_by_repository(repository) + if config: + repo_url = config.get("repository") + repo_info = gitea_client.get_repository_info(repo_url) + + if repo_info: + repo_info["linked_job"] = config.get("job_id") + repo_info["config_name"] = config.get("name") + repo_info["config"] = config + return repo_info + + raise HTTPException(status_code=404, detail=f"Repository not found: {repository}") + +@router.get("/{repository}/branches") +async def get_repository_branches(repository: str): + """ + Get branches for a specific repository. + + The repository parameter can be a repository URL or a repository alias. + """ + # If it's a repository alias, get the actual URL + config = config_service.get_config_by_repository(repository) + if config: + repository = config.get("repository") + + branches = gitea_client.get_repository_branches(repository) + if not branches: + raise HTTPException(status_code=404, detail=f"No branches found for repository: {repository}") + + return branches \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..911fe69 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +# Import schemas \ No newline at end of file diff --git a/app/schemas/__pycache__/__init__.cpython-313.pyc b/app/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6588d33b3f30beab03d6b1c58cd782bf575a9606 GIT binary patch literal 163 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 literal 0 HcmV?d00001 diff --git a/app/schemas/__pycache__/config.cpython-313.pyc b/app/schemas/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f216bb40d7a48c0e4e2647c69f022587d7a67d8e GIT binary patch literal 3221 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 JG4P-#{|5qgjTHa@ literal 0 HcmV?d00001 diff --git a/app/schemas/claude_api.py b/app/schemas/claude_api.py new file mode 100644 index 0000000..74050e1 --- /dev/null +++ b/app/schemas/claude_api.py @@ -0,0 +1,78 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, List, Optional, Union + + +class ClaudeJobRequest(BaseModel): + """Request model for Claude to start or manage a job""" + job_id: str = Field(..., description="The ID of the job to manage") + action: str = Field(..., description="Action to perform: start, stop, restart, status") + namespace: Optional[str] = Field("development", description="Nomad namespace") + purge: Optional[bool] = Field(False, description="Whether to purge the job when stopping") + + +class ClaudeJobSpecification(BaseModel): + """Simplified job specification for Claude to create a new job""" + job_id: str = Field(..., description="The ID for the new job") + name: Optional[str] = Field(None, description="Name of the job (defaults to job_id)") + type: str = Field("service", description="Job type: service, batch, or system") + datacenters: List[str] = Field(["jm"], description="List of datacenters") + namespace: str = Field("development", description="Nomad namespace") + docker_image: str = Field(..., description="Docker image to run") + count: int = Field(1, description="Number of instances to run") + cpu: int = Field(100, description="CPU resources in MHz") + memory: int = Field(128, description="Memory in MB") + ports: Optional[List[Dict[str, Any]]] = Field(None, description="Port mappings") + env_vars: Optional[Dict[str, str]] = Field(None, description="Environment variables") + + def to_nomad_job_spec(self) -> Dict[str, Any]: + """Convert to Nomad job specification format""" + # Create a task with the specified Docker image + task = { + "Name": "app", + "Driver": "docker", + "Config": { + "image": self.docker_image, + }, + "Resources": { + "CPU": self.cpu, + "MemoryMB": self.memory + } + } + + # Add environment variables if specified + if self.env_vars: + task["Env"] = self.env_vars + + # Create network configuration + network = {} + if self.ports: + network["DynamicPorts"] = self.ports + task["Config"]["ports"] = [port["Label"] for port in self.ports] + + # Create the full job specification + job_spec = { + "ID": self.job_id, + "Name": self.name or self.job_id, + "Type": self.type, + "Datacenters": self.datacenters, + "Namespace": self.namespace, + "TaskGroups": [ + { + "Name": "app", + "Count": self.count, + "Tasks": [task], + "Networks": [network] if network else [] + } + ] + } + + return job_spec + + +class ClaudeJobResponse(BaseModel): + """Response model for Claude job operations""" + success: bool = Field(..., description="Whether the operation was successful") + job_id: str = Field(..., description="The ID of the job") + status: str = Field(..., description="Current status of the job") + message: str = Field(..., description="Human-readable message about the operation") + details: Optional[Dict[str, Any]] = Field(None, description="Additional details about the job") \ No newline at end of file diff --git a/app/schemas/config.py b/app/schemas/config.py new file mode 100644 index 0000000..36a8506 --- /dev/null +++ b/app/schemas/config.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, Optional + + +class ConfigBase(BaseModel): + """Base class for configuration schemas.""" + repository: str = Field(..., description="Repository URL or identifier") + job_id: str = Field(..., description="Nomad job ID") + description: Optional[str] = Field(None, description="Description of this configuration") + repository_alias: Optional[str] = Field(None, description="Short name or alias for the repository") + + # Additional metadata can be stored in the meta field + meta: Optional[Dict[str, Any]] = Field(None, description="Additional metadata") + + +class ConfigCreate(ConfigBase): + """Schema for creating a new configuration.""" + name: str = Field(..., description="Configuration name (used as the file name)") + + +class ConfigUpdate(BaseModel): + """Schema for updating an existing configuration.""" + repository: Optional[str] = Field(None, description="Repository URL or identifier") + job_id: Optional[str] = Field(None, description="Nomad job ID") + description: Optional[str] = Field(None, description="Description of this configuration") + repository_alias: Optional[str] = Field(None, description="Short name or alias for the repository") + meta: Optional[Dict[str, Any]] = Field(None, description="Additional metadata") + + +class ConfigResponse(ConfigBase): + """Schema for configuration response.""" + name: str = Field(..., description="Configuration name") + repository_info: Optional[Dict[str, Any]] = Field(None, description="Repository information from Gitea if available") + + class Config: + schema_extra = { + "example": { + "name": "my-web-app", + "repository": "http://gitea.internal.example.com/username/repo-name", + "repository_alias": "web-app", + "job_id": "web-app", + "description": "Web application running in Nomad", + "meta": { + "owner": "devops-team", + "environment": "production" + }, + "repository_info": { + "description": "A web application", + "default_branch": "main", + "stars": 5, + "forks": 2, + "owner": "username", + "html_url": "http://gitea.internal.example.com/username/repo-name" + } + } + } \ No newline at end of file diff --git a/app/schemas/job.py b/app/schemas/job.py new file mode 100644 index 0000000..ef0407e --- /dev/null +++ b/app/schemas/job.py @@ -0,0 +1,80 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, List, Optional + + +class JobSpecification(BaseModel): + """ + Nomad job specification. This is a simplified schema as the actual + Nomad job spec is quite complex and varies by job type. + """ + id: Optional[str] = Field(None, description="Job ID") + ID: Optional[str] = Field(None, description="Job ID (Nomad format)") + name: Optional[str] = Field(None, description="Job name") + Name: Optional[str] = Field(None, description="Job name (Nomad format)") + type: Optional[str] = Field(None, description="Job type (service, batch, system)") + Type: Optional[str] = Field(None, description="Job type (Nomad format)") + datacenters: Optional[List[str]] = Field(None, description="List of datacenters") + Datacenters: Optional[List[str]] = Field(None, description="List of datacenters (Nomad format)") + task_groups: Optional[List[Dict[str, Any]]] = Field(None, description="Task groups") + TaskGroups: Optional[List[Dict[str, Any]]] = Field(None, description="Task groups (Nomad format)") + meta: Optional[Dict[str, str]] = Field(None, description="Job metadata") + Meta: Optional[Dict[str, str]] = Field(None, description="Job metadata (Nomad format)") + + # Allow additional fields (to handle the complete Nomad job spec) + class Config: + extra = "allow" + + +class JobOperation(BaseModel): + """Response after a job operation (start, stop, etc.)""" + job_id: str = Field(..., description="The ID of the job") + eval_id: Optional[str] = Field(None, description="The evaluation ID") + status: str = Field(..., description="The status of the operation") + warnings: Optional[str] = Field(None, description="Any warnings from Nomad") + + +class JobResponse(BaseModel): + """ + Job response schema. This is a simplified version as the actual + Nomad job response is quite complex and varies by job type. + """ + ID: str = Field(..., description="Job ID") + Name: str = Field(..., description="Job name") + Status: str = Field(..., description="Job status") + Type: str = Field(..., description="Job type") + repository: Optional[str] = Field(None, description="Associated repository if any") + + # Allow additional fields (to handle the complete Nomad job response) + class Config: + extra = "allow" + + +class TaskGroup(BaseModel): + """Task group schema.""" + Name: str + Count: int + Tasks: List[Dict[str, Any]] + + class Config: + extra = "allow" + + +class Task(BaseModel): + """Task schema.""" + Name: str + Driver: str + Config: Dict[str, Any] + + class Config: + extra = "allow" + + +class Allocation(BaseModel): + """Allocation schema.""" + ID: str + JobID: str + TaskGroup: str + ClientStatus: str + + class Config: + extra = "allow" \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..5d17f52 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Import services \ No newline at end of file diff --git a/app/services/__pycache__/__init__.cpython-313.pyc b/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ea0473a9d956c10feb524f21550deddb2cac7cc GIT binary patch literal 164 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 EZ(BxljsO4v literal 0 HcmV?d00001 diff --git a/app/services/__pycache__/gitea_client.cpython-313.pyc b/app/services/__pycache__/gitea_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa644c16c5edca78ee98fe992f9416d1f0b91e8d GIT binary patch literal 8734 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 Ug0=Z0({K5hfp!0e!I(<^AKdUrp8x;= literal 0 HcmV?d00001 diff --git a/app/services/__pycache__/nomad_client.cpython-313.pyc b/app/services/__pycache__/nomad_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..81b60a592ec93482d8048fd6c93b25011427ce2d GIT binary patch literal 22909 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 List[Dict[str, Any]]: + """List all available configurations.""" + configs = [] + try: + for file_path in self.config_dir.glob("*.yaml"): + with open(file_path, "r") as f: + config = yaml.safe_load(f) + config["name"] = file_path.stem + configs.append(config) + return configs + except Exception as e: + logger.error(f"Failed to list configurations: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to list configurations: {str(e)}") + + def get_config(self, name: str) -> Dict[str, Any]: + """Get a specific configuration by name.""" + file_path = self.config_dir / f"{name}.yaml" + try: + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"Configuration not found: {name}") + + with open(file_path, "r") as f: + config = yaml.safe_load(f) + config["name"] = name + + # Enrich with repository information if available + if repository := config.get("repository"): + repo_info = self.gitea_client.get_repository_info(repository) + if repo_info: + config["repository_info"] = { + "description": repo_info.get("description"), + "default_branch": repo_info.get("default_branch"), + "stars": repo_info.get("stars_count"), + "forks": repo_info.get("forks_count"), + "owner": repo_info.get("owner", {}).get("login"), + "html_url": repo_info.get("html_url"), + } + + return config + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to read configuration {name}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to read configuration: {str(e)}") + + def create_config(self, name: str, config: Dict[str, Any]) -> Dict[str, Any]: + """Create a new configuration.""" + file_path = self.config_dir / f"{name}.yaml" + try: + if file_path.exists(): + raise HTTPException(status_code=409, detail=f"Configuration already exists: {name}") + + # Validate required fields + required_fields = ["repository", "job_id"] + for field in required_fields: + if field not in config: + raise HTTPException(status_code=400, detail=f"Missing required field: {field}") + + # Validate repository exists if Gitea integration is configured + if not self.gitea_client.check_repository_exists(config["repository"]): + raise HTTPException(status_code=400, detail=f"Repository not found: {config['repository']}") + + # Add name to the config + config["name"] = name + + # Get repository alias if not provided + if "repository_alias" not in config: + try: + owner, repo = self.gitea_client.parse_repo_url(config["repository"]) + config["repository_alias"] = repo + except: + # Use job_id as fallback + config["repository_alias"] = config["job_id"] + + # Write config to file + with open(file_path, "w") as f: + yaml.dump(config, f, default_flow_style=False) + + return config + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create configuration {name}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to create configuration: {str(e)}") + + def update_config(self, name: str, config: Dict[str, Any]) -> Dict[str, Any]: + """Update an existing configuration.""" + file_path = self.config_dir / f"{name}.yaml" + try: + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"Configuration not found: {name}") + + # Read existing config + with open(file_path, "r") as f: + existing_config = yaml.safe_load(f) + + # Update with new values + for key, value in config.items(): + existing_config[key] = value + + # Validate repository exists if changed and Gitea integration is configured + if "repository" in config and config["repository"] != existing_config.get("repository"): + if not self.gitea_client.check_repository_exists(config["repository"]): + raise HTTPException(status_code=400, detail=f"Repository not found: {config['repository']}") + + # Validate required fields + required_fields = ["repository", "job_id"] + for field in required_fields: + if field not in existing_config: + raise HTTPException(status_code=400, detail=f"Missing required field: {field}") + + # Add name to the config + existing_config["name"] = name + + # Update repository alias if repository changed + if "repository" in config and "repository_alias" not in config: + try: + owner, repo = self.gitea_client.parse_repo_url(existing_config["repository"]) + existing_config["repository_alias"] = repo + except: + pass + + # Write config to file + with open(file_path, "w") as f: + yaml.dump(existing_config, f, default_flow_style=False) + + return existing_config + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update configuration {name}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}") + + def delete_config(self, name: str) -> Dict[str, Any]: + """Delete a configuration.""" + file_path = self.config_dir / f"{name}.yaml" + try: + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"Configuration not found: {name}") + + # Get the config before deleting + with open(file_path, "r") as f: + config = yaml.safe_load(f) + config["name"] = name + + # Delete the file + file_path.unlink() + + return {"name": name, "status": "deleted"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete configuration {name}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to delete configuration: {str(e)}") + + def get_job_from_repository(self, repository: str) -> Optional[Dict[str, str]]: + """Find job_id and namespace associated with a repository.""" + try: + for config in self.list_configs(): + if config.get("repository") == repository or config.get("repository_alias") == repository: + return { + "job_id": config.get("job_id"), + "namespace": config.get("namespace") + } + return None + except Exception as e: + logger.error(f"Failed to find job for repository {repository}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to find job for repository: {str(e)}") + + def get_repository_from_job(self, job_id: str) -> Optional[str]: + """Find repository associated with a job_id.""" + try: + for config in self.list_configs(): + if config.get("job_id") == job_id: + return config.get("repository") + return None + except Exception as e: + logger.error(f"Failed to find repository for job {job_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to find repository for job: {str(e)}") + + def get_config_by_repository(self, repository: str) -> Optional[Dict[str, Any]]: + """Find configuration by repository URL or alias.""" + try: + for config in self.list_configs(): + if config.get("repository") == repository or config.get("repository_alias") == repository: + return self.get_config(config.get("name")) + return None + except Exception as e: + logger.error(f"Failed to find config for repository {repository}: {str(e)}") + return None + + def get_job_spec_from_repository(self, repository: str) -> Optional[Dict[str, Any]]: + """Get job specification from repository config and template.""" + try: + # Get the repository configuration + config = self.get_config_by_repository(repository) + if not config: + logger.error(f"No configuration found for repository: {repository}") + return None + + # Check if the job template is specified + job_template = config.get("job_template") + if not job_template: + logger.error(f"No job template specified for repository: {repository}") + return None + + # Read the job template file + template_path = Path(self.config_dir) / "templates" / f"{job_template}.json" + if not template_path.exists(): + logger.error(f"Job template not found: {job_template}") + return None + + try: + with open(template_path, "r") as f: + job_spec = json.load(f) + except Exception as e: + logger.error(f"Failed to read job template {job_template}: {str(e)}") + return None + + # Apply configuration parameters to the template + job_spec["ID"] = config.get("job_id") + job_spec["Name"] = config.get("job_id") + + # Apply other customizations from config + if env_vars := config.get("environment_variables"): + for task_group in job_spec.get("TaskGroups", []): + for task in task_group.get("Tasks", []): + if "Env" not in task: + task["Env"] = {} + task["Env"].update(env_vars) + + if meta := config.get("metadata"): + job_spec["Meta"] = meta + + # Add repository info to the metadata + if "Meta" not in job_spec: + job_spec["Meta"] = {} + job_spec["Meta"]["repository"] = repository + + # Override specific job parameters if specified in config + if job_params := config.get("job_parameters"): + for param_key, param_value in job_params.items(): + # Handle nested parameters with dot notation (e.g., "TaskGroups.0.Tasks.0.Config.image") + if "." in param_key: + parts = param_key.split(".") + current = job_spec + for part in parts[:-1]: + # Handle array indices + if part.isdigit() and isinstance(current, list): + current = current[int(part)] + elif part in current: + current = current[part] + else: + break + else: + # Only set the value if we successfully navigated the path + current[parts[-1]] = param_value + else: + # Direct parameter + job_spec[param_key] = param_value + + logger.info(f"Generated job specification for repository {repository} using template {job_template}") + return job_spec + + except Exception as e: + logger.error(f"Failed to get job specification for repository {repository}: {str(e)}") + return None \ No newline at end of file diff --git a/app/services/gitea_client.py b/app/services/gitea_client.py new file mode 100644 index 0000000..66b5d19 --- /dev/null +++ b/app/services/gitea_client.py @@ -0,0 +1,180 @@ +import os +import logging +import requests +from typing import Dict, Any, List, Optional, Tuple +from urllib.parse import urlparse +from fastapi import HTTPException + +# Configure logging +logger = logging.getLogger(__name__) + +class GiteaClient: + """Client for interacting with Gitea API.""" + + def __init__(self): + """Initialize Gitea client with configuration from environment variables.""" + self.api_base_url = os.getenv("GITEA_API_URL", "").rstrip("/") + self.token = os.getenv("GITEA_API_TOKEN") + self.username = os.getenv("GITEA_USERNAME") + self.verify_ssl = os.getenv("GITEA_VERIFY_SSL", "true").lower() == "true" + + if not self.api_base_url: + logger.warning("GITEA_API_URL is not configured. Gitea integration will not work.") + + if not self.token and (self.username and os.getenv("GITEA_PASSWORD")): + self.token = self._get_token_from_credentials() + + def _get_token_from_credentials(self) -> Optional[str]: + """Get a token using username and password if provided.""" + try: + response = requests.post( + f"{self.api_base_url}/users/{self.username}/tokens", + auth=(self.username, os.getenv("GITEA_PASSWORD", "")), + json={ + "name": "nomad-mcp-service", + "scopes": ["repo", "read:org"] + }, + verify=self.verify_ssl + ) + + if response.status_code == 201: + return response.json().get("sha1") + else: + logger.error(f"Failed to get Gitea token: {response.text}") + return None + except Exception as e: + logger.error(f"Failed to get Gitea token: {str(e)}") + return None + + def _get_headers(self) -> Dict[str, str]: + """Get request headers with authentication.""" + headers = { + "Content-Type": "application/json", + "Accept": "application/json" + } + + if self.token: + headers["Authorization"] = f"token {self.token}" + + return headers + + def parse_repo_url(self, repo_url: str) -> Tuple[str, str]: + """ + Parse a Gitea repository URL to extract owner and repo name. + + Examples: + - http://gitea.internal.example.com/username/repo-name -> (username, repo-name) + - https://gitea.example.com/org/project -> (org, project) + """ + try: + # Parse the URL + parsed_url = urlparse(repo_url) + + # Get the path and remove leading/trailing slashes + path = parsed_url.path.strip("/") + + # Split the path + parts = path.split("/") + + if len(parts) < 2: + raise ValueError(f"Invalid repository URL: {repo_url}") + + # Extract owner and repo + owner = parts[0] + repo = parts[1] + + return owner, repo + except Exception as e: + logger.error(f"Failed to parse repository URL: {repo_url}, error: {str(e)}") + raise ValueError(f"Invalid repository URL: {repo_url}") + + def check_repository_exists(self, repo_url: str) -> bool: + """Check if a repository exists in Gitea.""" + if not self.api_base_url: + # No Gitea integration configured, assume repository exists + return True + + try: + owner, repo = self.parse_repo_url(repo_url) + + response = requests.get( + f"{self.api_base_url}/repos/{owner}/{repo}", + headers=self._get_headers(), + verify=self.verify_ssl + ) + + return response.status_code == 200 + except Exception as e: + logger.error(f"Failed to check repository: {repo_url}, error: {str(e)}") + return False + + def get_repository_info(self, repo_url: str) -> Optional[Dict[str, Any]]: + """Get repository information from Gitea.""" + if not self.api_base_url: + # No Gitea integration configured + return None + + try: + owner, repo = self.parse_repo_url(repo_url) + + response = requests.get( + f"{self.api_base_url}/repos/{owner}/{repo}", + headers=self._get_headers(), + verify=self.verify_ssl + ) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"Failed to get repository info: {response.text}") + return None + except Exception as e: + logger.error(f"Failed to get repository info: {repo_url}, error: {str(e)}") + return None + + def list_repositories(self, limit: int = 100) -> List[Dict[str, Any]]: + """List available repositories from Gitea.""" + if not self.api_base_url: + # No Gitea integration configured + return [] + + try: + response = requests.get( + f"{self.api_base_url}/user/repos", + headers=self._get_headers(), + params={"limit": limit}, + verify=self.verify_ssl + ) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"Failed to list repositories: {response.text}") + return [] + except Exception as e: + logger.error(f"Failed to list repositories: {str(e)}") + return [] + + def get_repository_branches(self, repo_url: str) -> List[Dict[str, Any]]: + """Get branches for a repository.""" + if not self.api_base_url: + # No Gitea integration configured + return [] + + try: + owner, repo = self.parse_repo_url(repo_url) + + response = requests.get( + f"{self.api_base_url}/repos/{owner}/{repo}/branches", + headers=self._get_headers(), + verify=self.verify_ssl + ) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"Failed to get repository branches: {response.text}") + return [] + except Exception as e: + logger.error(f"Failed to get repository branches: {repo_url}, error: {str(e)}") + return [] \ No newline at end of file diff --git a/app/services/nomad_client.py b/app/services/nomad_client.py new file mode 100644 index 0000000..4f79505 --- /dev/null +++ b/app/services/nomad_client.py @@ -0,0 +1,505 @@ +import os +import logging +import nomad +from fastapi import HTTPException +from typing import Dict, Any, Optional, List +from dotenv import load_dotenv +import time + +# Load environment variables +load_dotenv() + +# Configure logging +logger = logging.getLogger(__name__) + +def get_nomad_client(): + """ + Create and return a Nomad client using environment variables. + """ + try: + nomad_addr = os.getenv("NOMAD_ADDR", "http://localhost:4646").rstrip('/') + nomad_token = os.getenv("NOMAD_TOKEN") + # Use "development" as the default namespace since all jobs are likely to be in this namespace + nomad_namespace = os.getenv("NOMAD_NAMESPACE", "development") + + # Ensure namespace is never "*" (wildcard) + if nomad_namespace == "*": + nomad_namespace = "development" + logger.info("Replaced wildcard namespace '*' with 'development'") + + # Extract host and port from the address + host_with_port = nomad_addr.replace("http://", "").replace("https://", "") + host = host_with_port.split(":")[0] + + # Safely extract port + port_part = host_with_port.split(":")[-1] if ":" in host_with_port else "4646" + port = int(port_part.split('/')[0]) # Remove any path components + + logger.info(f"Creating Nomad client with host={host}, port={port}, namespace={nomad_namespace}") + + return nomad.Nomad( + host=host, + port=port, + secure=nomad_addr.startswith("https"), + token=nomad_token, + timeout=10, + namespace=nomad_namespace, # Query across development namespace by default + verify=False if os.getenv("NOMAD_SKIP_VERIFY", "false").lower() == "true" else True + ) + except Exception as e: + logger.error(f"Failed to create Nomad client: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to connect to Nomad: {str(e)}") + +class NomadService: + """Service for interacting with Nomad API.""" + + def __init__(self): + self.client = get_nomad_client() + self.namespace = os.getenv("NOMAD_NAMESPACE", "development") # Use "development" namespace as default + + def get_job(self, job_id: str, max_retries: int = 3, retry_delay: int = 2) -> Dict[str, Any]: + """ + Get a job by ID with retry logic. + + Args: + job_id: The ID of the job to retrieve + max_retries: Maximum number of retry attempts (default: 3) + retry_delay: Delay between retries in seconds (default: 2) + + Returns: + Dict containing job details + """ + last_exception = None + + # Try multiple times to get the job + for attempt in range(max_retries): + try: + # Get the Nomad address from the client + nomad_addr = f"http://{self.client.host}:{self.client.port}" + + # Build the URL for the job endpoint + url = f"{nomad_addr}/v1/job/{job_id}" + + # Set up headers + headers = {} + if hasattr(self.client, 'token') and self.client.token: + headers["X-Nomad-Token"] = self.client.token + + # Set up params with the correct namespace + params = {"namespace": self.namespace} + + # Make the request directly + import requests + response = requests.get( + url=url, + headers=headers, + params=params, + verify=False if os.getenv("NOMAD_SKIP_VERIFY", "false").lower() == "true" else True + ) + + # Check if the request was successful + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + # If not the last attempt, log and retry + if attempt < max_retries - 1: + logger.warning(f"Job {job_id} not found on attempt {attempt+1}/{max_retries}, retrying in {retry_delay}s...") + time.sleep(retry_delay) + continue + else: + raise ValueError(f"Job not found after {max_retries} attempts: {job_id}") + else: + raise ValueError(f"Failed to get job: {response.text}") + + except Exception as e: + last_exception = e + # If not the last attempt, log and retry + if attempt < max_retries - 1: + logger.warning(f"Error getting job {job_id} on attempt {attempt+1}/{max_retries}: {str(e)}, retrying in {retry_delay}s...") + time.sleep(retry_delay) + continue + else: + logger.error(f"Failed to get job {job_id} after {max_retries} attempts: {str(e)}") + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + + # If we get here, all retries failed + logger.error(f"Failed to get job {job_id} after {max_retries} attempts") + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + + def list_jobs(self) -> List[Dict[str, Any]]: + """List all jobs.""" + try: + # Get the Nomad address from the client + nomad_addr = f"http://{self.client.host}:{self.client.port}" + + # Build the URL for the jobs endpoint + url = f"{nomad_addr}/v1/jobs" + + # Set up headers + headers = {} + if hasattr(self.client, 'token') and self.client.token: + headers["X-Nomad-Token"] = self.client.token + + # Set up params with the correct namespace + params = {"namespace": self.namespace} + + # Make the request directly + import requests + response = requests.get( + url=url, + headers=headers, + params=params, + verify=False if os.getenv("NOMAD_SKIP_VERIFY", "false").lower() == "true" else True + ) + + # Check if the request was successful + if response.status_code == 200: + return response.json() + else: + raise ValueError(f"Failed to list jobs: {response.text}") + except Exception as e: + logger.error(f"Failed to list jobs: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to list jobs: {str(e)}") + + def start_job(self, job_spec: Dict[str, Any]) -> Dict[str, Any]: + """ + Start a job using the provided specification. + + Args: + job_spec: The job specification to submit. Can be a raw job spec or wrapped in a "Job" key. + + Returns: + Dict containing job_id, eval_id, status, and any warnings + """ + try: + # Extract job ID from specification + job_id = None + if "Job" in job_spec: + job_id = job_spec["Job"].get("ID") or job_spec["Job"].get("id") + else: + job_id = job_spec.get("ID") or job_spec.get("id") + + if not job_id: + raise ValueError("Job ID is required in the job specification") + + logger.info(f"Processing job start request for job ID: {job_id}") + + # Determine the namespace to use, with clear priorities: + # 1. Explicitly provided in the job spec (highest priority) + # 2. Service instance namespace + # 3. Fallback to "development" + namespace = self.namespace + + # Normalize the job structure to ensure it has a "Job" wrapper + normalized_job_spec = {} + if "Job" in job_spec: + normalized_job_spec = job_spec + # Check if namespace is specified in the job spec + if "Namespace" in job_spec["Job"]: + namespace = job_spec["Job"]["Namespace"] + logger.info(f"Using namespace from job spec: {namespace}") + else: + # Check if namespace is specified in the job spec + if "Namespace" in job_spec: + namespace = job_spec["Namespace"] + logger.info(f"Using namespace from job spec: {namespace}") + + # Wrap the job spec in a "Job" key + normalized_job_spec = {"Job": job_spec} + + # Replace wildcard namespaces with the default + if namespace == "*": + namespace = "development" + logger.info(f"Replaced wildcard namespace with default: {namespace}") + + # Always explicitly set the namespace in the job spec + normalized_job_spec["Job"]["Namespace"] = namespace + + logger.info(f"Submitting job {job_id} to namespace {namespace}") + logger.info(f"Job specification structure: {list(normalized_job_spec.keys())}") + logger.info(f"Job keys: {list(normalized_job_spec['Job'].keys())}") + + # Submit the job - pass the job_id and job spec directly + # The namespace is already set in the job spec + response = self.client.job.register_job(job_id, normalized_job_spec) + + logger.info(f"Job registration response: {response}") + + return { + "job_id": job_id, + "eval_id": response.get("EvalID"), + "status": "started", + "warnings": response.get("Warnings"), + "namespace": namespace + } + except Exception as e: + logger.error(f"Failed to start job: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to start job: {str(e)}") + + def stop_job(self, job_id: str, purge: bool = False) -> Dict[str, Any]: + """ + Stop a job by ID. + + Args: + job_id: The ID of the job to stop + purge: If true, the job will be purged from Nomad's state entirely + + Returns: + Dict containing job_id, eval_id, and status + """ + try: + logger.info(f"Stopping job {job_id} in namespace {self.namespace} (purge={purge})") + + # Get the Nomad address from the client + nomad_addr = f"http://{self.client.host}:{self.client.port}" + + # Build the URL for the job endpoint + url = f"{nomad_addr}/v1/job/{job_id}" + + # Set up headers + headers = {} + if hasattr(self.client, 'token') and self.client.token: + headers["X-Nomad-Token"] = self.client.token + + # Set up params with the correct namespace and purge option + params = { + "namespace": self.namespace, + "purge": str(purge).lower() + } + + # Make the request directly + import requests + response = requests.delete( + url=url, + headers=headers, + params=params, + verify=False if os.getenv("NOMAD_SKIP_VERIFY", "false").lower() == "true" else True + ) + + # Check if the request was successful + if response.status_code == 200: + response_data = response.json() + logger.info(f"Job stop response: {response_data}") + + return { + "job_id": job_id, + "eval_id": response_data.get("EvalID"), + "status": "stopped", + "namespace": self.namespace + } + else: + raise ValueError(f"Failed to stop job: {response.text}") + + except Exception as e: + logger.error(f"Failed to stop job {job_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to stop job: {str(e)}") + + def get_allocations(self, job_id: str) -> List[Dict[str, Any]]: + """Get all allocations for a job.""" + try: + # Get the Nomad address from the client + nomad_addr = f"http://{self.client.host}:{self.client.port}" + + # Build the URL for the job allocations endpoint + url = f"{nomad_addr}/v1/job/{job_id}/allocations" + + # Set up headers + headers = {} + if hasattr(self.client, 'token') and self.client.token: + headers["X-Nomad-Token"] = self.client.token + + # Set up params with the correct namespace + params = {"namespace": self.namespace} + + # Make the request directly + import requests + response = requests.get( + url=url, + headers=headers, + params=params, + verify=False if os.getenv("NOMAD_SKIP_VERIFY", "false").lower() == "true" else True + ) + + # Check if the request was successful + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + logger.warning(f"No allocations found for job {job_id}") + return [] + else: + raise ValueError(f"Failed to get allocations: {response.text}") + except Exception as e: + logger.error(f"Failed to get allocations for job {job_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get allocations: {str(e)}") + + def get_allocation_logs(self, alloc_id: str, task: str, log_type: str = "stderr") -> str: + """Get logs for a specific allocation and task.""" + try: + # More detailed debugging to understand what's happening + logger.info(f"Getting logs for allocation {alloc_id}, task {task}, type {log_type}") + + if alloc_id == "repository": + logger.error("Invalid allocation ID 'repository' detected") + return f"Error: Invalid allocation ID 'repository'" + + # Verify the allocation ID is a valid UUID (must be 36 characters) + if not alloc_id or len(alloc_id) != 36: + logger.error(f"Invalid allocation ID format: {alloc_id} (length: {len(alloc_id) if alloc_id else 0})") + return f"Error: Invalid allocation ID format - must be 36 character UUID" + + # Get allocation info to verify it exists + try: + allocation = self.client.allocation.get_allocation(alloc_id) + if not allocation: + logger.warning(f"Allocation {alloc_id} not found") + return f"Allocation {alloc_id} not found" + except Exception as e: + logger.error(f"Error checking allocation: {str(e)}") + return f"Error checking allocation: {str(e)}" + + # Try multiple approaches to get logs + log_content = None + error_messages = [] + + # Approach 1: Standard API + try: + logger.info(f"Attempting to get logs using standard API") + logs = self.client.allocation.logs.get_logs( + alloc_id, + task, + log_type, + plain=True + ) + + if logs: + if isinstance(logs, dict) and logs.get("Data"): + log_content = logs.get("Data") + logger.info(f"Successfully retrieved logs using standard API") + elif isinstance(logs, str): + log_content = logs + logger.info(f"Successfully retrieved logs as string") + else: + error_messages.append(f"Unexpected log format: {type(logs)}") + logger.warning(f"Unexpected log format: {type(logs)}") + else: + error_messages.append("No logs returned from standard API") + logger.warning("No logs returned from standard API") + except Exception as e: + error_str = str(e) + error_messages.append(f"Standard API error: {error_str}") + logger.warning(f"Standard API failed: {error_str}") + + # Approach 2: Try raw HTTP if the standard API didn't work + if not log_content: + try: + import requests + + # Get the Nomad address from environment or use default + nomad_addr = os.getenv("NOMAD_ADDR", "http://localhost:4646").rstrip('/') + nomad_token = os.getenv("NOMAD_TOKEN") + + # Construct the URL for logs + logs_url = f"{nomad_addr}/v1/client/fs/logs/{alloc_id}" + + # Setup headers + headers = {} + if nomad_token: + headers["X-Nomad-Token"] = nomad_token + + # Setup query parameters + params = { + "task": task, + "type": log_type, + "plain": "true" + } + + if self.namespace and self.namespace != "*": + params["namespace"] = self.namespace + + logger.info(f"Attempting to get logs using direct HTTP request to: {logs_url}") + response = requests.get(logs_url, headers=headers, params=params, verify=False) + + if response.status_code == 200: + log_content = response.text + logger.info(f"Successfully retrieved logs using direct HTTP request") + else: + error_messages.append(f"HTTP request failed with status {response.status_code}: {response.text}") + logger.warning(f"HTTP request failed: {response.status_code} - {response.text}") + except ImportError: + error_messages.append("Requests library not available for fallback HTTP request") + logger.warning("Requests library not available for fallback HTTP request") + except Exception as e: + error_str = str(e) + error_messages.append(f"HTTP request error: {error_str}") + logger.warning(f"HTTP request failed: {error_str}") + + # Approach 3: Direct system call as a last resort + if not log_content: + try: + import subprocess + + # Get the Nomad command-line client path + nomad_cmd = "nomad" # Default, assumes nomad is in PATH + + # Build the command + cmd_parts = [ + nomad_cmd, + "alloc", "logs", + "-verbose", + ] + + # Add namespace if specified + if self.namespace and self.namespace != "*": + cmd_parts.extend(["-namespace", self.namespace]) + + # Add allocation and task info + cmd_parts.extend(["-job", alloc_id, task]) + + # Use stderr or stdout + if log_type == "stderr": + cmd_parts.append("-stderr") + else: + cmd_parts.append("-stdout") + + logger.info(f"Attempting to get logs using command: {' '.join(cmd_parts)}") + process = subprocess.run(cmd_parts, capture_output=True, text=True) + + if process.returncode == 0: + log_content = process.stdout + logger.info(f"Successfully retrieved logs using command-line client") + else: + error_messages.append(f"Command-line client failed: {process.stderr}") + logger.warning(f"Command-line client failed: {process.stderr}") + except Exception as e: + error_str = str(e) + error_messages.append(f"Command-line client error: {error_str}") + logger.warning(f"Command-line client failed: {error_str}") + + # Return the logs if we got them, otherwise return error + if log_content: + return log_content + else: + error_msg = "; ".join(error_messages) + logger.error(f"Failed to get logs after multiple attempts: {error_msg}") + return f"Error retrieving {log_type} logs: {error_msg}" + + except Exception as e: + error_str = str(e) + logger.error(f"Failed to get logs for allocation {alloc_id}, task {task}: {error_str}") + raise HTTPException(status_code=500, detail=f"Failed to get logs: {error_str}") + + def get_deployment_status(self, job_id: str) -> Dict[str, Any]: + """Get the deployment status for a job.""" + try: + return self.client.job.get_deployment(job_id, namespace=self.namespace) + except Exception as e: + logger.error(f"Failed to get deployment status for job {job_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get deployment status: {str(e)}") + + def get_job_evaluations(self, job_id: str) -> List[Dict[str, Any]]: + """Get evaluations for a job.""" + try: + return self.client.job.get_evaluations(job_id, namespace=self.namespace) + except Exception as e: + logger.error(f"Failed to get evaluations for job {job_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get evaluations: {str(e)}") \ No newline at end of file diff --git a/check_path.py b/check_path.py new file mode 100644 index 0000000..e9267f5 --- /dev/null +++ b/check_path.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +""" +Script to check Python path and help diagnose import issues. +""" + +import sys +import os + +def main(): + print("Current working directory:", os.getcwd()) + print("\nPython path:") + for path in sys.path: + print(f" - {path}") + + print("\nChecking for app directory:") + if os.path.exists("app"): + print("✅ 'app' directory exists in current working directory") + print("Contents of app directory:") + for item in os.listdir("app"): + print(f" - {item}") + else: + print("❌ 'app' directory does not exist in current working directory") + + print("\nChecking for app module:") + try: + import app + print("✅ 'app' module can be imported") + print(f"app module location: {app.__file__}") + except ImportError as e: + print(f"❌ Cannot import 'app' module: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/claude_nomad_tool.json b/claude_nomad_tool.json new file mode 100644 index 0000000..b6ac1ee --- /dev/null +++ b/claude_nomad_tool.json @@ -0,0 +1,71 @@ +{ + "tools": [ + { + "name": "nomad_mcp", + "description": "Manage Nomad jobs through the MCP service", + "api_endpoints": [ + { + "name": "list_jobs", + "description": "List all jobs in a namespace", + "method": "GET", + "url": "http://127.0.0.1:8000/api/claude/list-jobs", + "params": [ + { + "name": "namespace", + "type": "string", + "description": "Nomad namespace", + "required": false, + "default": "development" + } + ] + }, + { + "name": "manage_job", + "description": "Manage a job (status, stop, restart)", + "method": "POST", + "url": "http://127.0.0.1:8000/api/claude/jobs", + "body": { + "job_id": "string", + "action": "string", + "namespace": "string", + "purge": "boolean" + } + }, + { + "name": "create_job", + "description": "Create a new job", + "method": "POST", + "url": "http://127.0.0.1:8000/api/claude/create-job", + "body": { + "job_id": "string", + "name": "string", + "type": "string", + "datacenters": "array", + "namespace": "string", + "docker_image": "string", + "count": "integer", + "cpu": "integer", + "memory": "integer", + "ports": "array", + "env_vars": "object" + } + }, + { + "name": "get_job_logs", + "description": "Get logs for a job", + "method": "GET", + "url": "http://127.0.0.1:8000/api/claude/job-logs/{job_id}", + "params": [ + { + "name": "namespace", + "type": "string", + "description": "Nomad namespace", + "required": false, + "default": "development" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/cleanup_test_jobs.py b/cleanup_test_jobs.py new file mode 100644 index 0000000..61231bc --- /dev/null +++ b/cleanup_test_jobs.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +""" +Script to clean up test jobs from Nomad. +""" + +import os +import sys +from dotenv import load_dotenv +from app.services.nomad_client import NomadService + +# Load environment variables from .env file +load_dotenv() + +def main(): + print("Cleaning up test jobs from Nomad...") + + # Check if NOMAD_ADDR is configured + nomad_addr = os.getenv("NOMAD_ADDR") + if not nomad_addr: + print("Error: NOMAD_ADDR is not configured in .env file.") + sys.exit(1) + + print(f"Connecting to Nomad at: {nomad_addr}") + + try: + # Initialize the Nomad service + nomad_service = NomadService() + + # List all jobs + print("\nListing all jobs...") + jobs = nomad_service.list_jobs() + print(f"Found {len(jobs)} jobs") + + # Filter for test jobs (starting with "test-") + test_jobs = [job for job in jobs if job.get('ID', '').startswith('test-')] + print(f"Found {len(test_jobs)} test jobs:") + + # Print each test job's ID and status + for job in test_jobs: + print(f" - {job.get('ID')}: {job.get('Status')}") + + # Confirm before proceeding + if test_jobs: + print("\nDo you want to stop and purge all these test jobs? (y/n)") + response = input().strip().lower() + + if response == 'y': + print("\nStopping and purging test jobs...") + + for job in test_jobs: + job_id = job.get('ID') + try: + print(f"Stopping and purging job: {job_id}...") + stop_response = nomad_service.stop_job(job_id, purge=True) + print(f" - Success: {stop_response}") + except Exception as e: + print(f" - Error stopping job {job_id}: {str(e)}") + + print("\nCleanup completed.") + else: + print("\nCleanup cancelled.") + else: + print("\nNo test jobs found to clean up.") + + except Exception as e: + print(f"Error during cleanup: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/configs/example.yaml b/configs/example.yaml new file mode 100644 index 0000000..6758394 --- /dev/null +++ b/configs/example.yaml @@ -0,0 +1,9 @@ +repository: https://github.com/example/my-service +job_id: my-service +description: Example service managed by MCP +meta: + owner: ai-team + environment: development + tags: + - api + - example \ No newline at end of file diff --git a/configs/ms-qc-db.yaml b/configs/ms-qc-db.yaml new file mode 100644 index 0000000..f0f9d5c --- /dev/null +++ b/configs/ms-qc-db.yaml @@ -0,0 +1,11 @@ +repository: https://gitea.dev.meisheng.group/Mei_Sheng_Textiles/MS_QC_DB +repository_alias: ms-qc-db +job_id: ms-qc-db-dev +namespace: development +description: MS QC Database application for quality control tracking +meta: + owner: ms-team + environment: development + tags: + - database + - qc \ No newline at end of file diff --git a/configs/test-service.yaml b/configs/test-service.yaml new file mode 100644 index 0000000..0a3d9e7 --- /dev/null +++ b/configs/test-service.yaml @@ -0,0 +1,10 @@ +repository: http://gitea.internal/username/test-service +repository_alias: test-service +job_id: test-service +description: Test service managed by MCP for Gitea integration +meta: + owner: ai-team + environment: development + tags: + - test + - api \ No newline at end of file diff --git a/deploy_nomad_mcp.py b/deploy_nomad_mcp.py new file mode 100644 index 0000000..6c0eebe --- /dev/null +++ b/deploy_nomad_mcp.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +""" +Script to deploy the Nomad MCP service using our own Nomad client. +""" + +import os +import sys +import json +from dotenv import load_dotenv +from app.services.nomad_client import NomadService + +# Load environment variables from .env file +load_dotenv() + +def read_job_spec(file_path): + """Read the Nomad job specification from a file.""" + try: + with open(file_path, 'r') as f: + content = f.read() + + # Convert HCL to JSON (simplified approach) + # In a real scenario, you might want to use a proper HCL parser + # This is a very basic approach that assumes the job spec is valid + job_id = "nomad-mcp" + + # Create a basic job structure + job_spec = { + "ID": job_id, + "Name": job_id, + "Type": "service", + "Datacenters": ["jm"], + "Namespace": "development", + "TaskGroups": [ + { + "Name": "app", + "Count": 1, + "Networks": [ + { + "DynamicPorts": [ + { + "Label": "http", + "To": 8000 + } + ] + } + ], + "Tasks": [ + { + "Name": "nomad-mcp", + "Driver": "docker", + "Config": { + "image": "registry.dev.meisheng.group/nomad_mcp:20250226", + "ports": ["http"], + "command": "python", + "args": ["-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + }, + "Env": { + "NOMAD_ADDR": "http://pjmldk01.ds.meisheng.group:4646", + "NOMAD_NAMESPACE": "development", + "NOMAD_SKIP_VERIFY": "true", + "PORT": "8000", + "HOST": "0.0.0.0", + "LOG_LEVEL": "INFO", + "RELOAD": "true" + }, + "Resources": { + "CPU": 200, + "MemoryMB": 256 + }, + "Services": [ + { + "Name": "nomad-mcp", + "PortLabel": "http", + "Tags": [ + "traefik.enable=true", + "traefik.http.routers.nomad-mcp.entryPoints=https", + "traefik.http.routers.nomad-mcp.rule=Host(`nomad_mcp.dev.meisheng.group`)", + "traefik.http.routers.nomad-mcp.middlewares=proxyheaders@consulcatalog" + ], + "Checks": [ + { + "Type": "http", + "Path": "/api/health", + "Interval": 10000000000, + "Timeout": 2000000000, + "CheckRestart": { + "Limit": 3, + "Grace": 60000000000 + } + } + ] + } + ] + } + ] + } + ], + "Update": { + "MaxParallel": 1, + "MinHealthyTime": 30000000000, + "HealthyDeadline": 300000000000, + "AutoRevert": True + } + } + + return job_spec + except Exception as e: + print(f"Error reading job specification: {str(e)}") + sys.exit(1) + +def main(): + print("Deploying Nomad MCP service using our own Nomad client...") + + # Check if NOMAD_ADDR is configured + nomad_addr = os.getenv("NOMAD_ADDR") + if not nomad_addr: + print("Error: NOMAD_ADDR is not configured in .env file.") + sys.exit(1) + + print(f"Connecting to Nomad at: {nomad_addr}") + + try: + # Initialize the Nomad service + nomad_service = NomadService() + + # Read the job specification + job_spec = read_job_spec("nomad_mcp_job.nomad") + print("Job specification loaded successfully.") + + # Start the job + print("Registering and starting the nomad-mcp job...") + response = nomad_service.start_job(job_spec) + + print("\nJob registration response:") + print(json.dumps(response, indent=2)) + + if response.get("status") == "started": + print("\n✅ Nomad MCP service deployed successfully!") + print(f"Job ID: {response.get('job_id')}") + print(f"Evaluation ID: {response.get('eval_id')}") + print("\nThe service will be available at: https://nomad_mcp.dev.meisheng.group") + else: + print("\n❌ Failed to deploy Nomad MCP service.") + print(f"Status: {response.get('status')}") + print(f"Message: {response.get('message', 'Unknown error')}") + + except Exception as e: + print(f"Error deploying Nomad MCP service: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/deploy_with_claude_api.py b/deploy_with_claude_api.py new file mode 100644 index 0000000..1b186d4 --- /dev/null +++ b/deploy_with_claude_api.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +""" +Script to deploy the Nomad MCP service using the Claude API. +""" + +import os +import sys +import json +import requests +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +def main(): + print("Deploying Nomad MCP service using the Claude API...") + + # Define the API endpoint + api_url = "http://localhost:8000/api/claude/create-job" + + # Create the job specification for the Claude API + job_spec = { + "job_id": "nomad-mcp", + "name": "Nomad MCP Service", + "type": "service", + "datacenters": ["jm"], + "namespace": "development", + "docker_image": "registry.dev.meisheng.group/nomad_mcp:20250226", + "count": 1, + "cpu": 200, + "memory": 256, + "ports": [ + { + "Label": "http", + "Value": 0, + "To": 8000 + } + ], + "env_vars": { + "NOMAD_ADDR": "http://pjmldk01.ds.meisheng.group:4646", + "NOMAD_NAMESPACE": "development", + "NOMAD_SKIP_VERIFY": "true", + "PORT": "8000", + "HOST": "0.0.0.0", + "LOG_LEVEL": "INFO", + "RELOAD": "true" + }, + # Note: The Claude API doesn't directly support command and args, + # so we'll need to add a note about this limitation + } + + try: + # Make the API request + print("Sending request to Claude API...") + response = requests.post( + api_url, + json=job_spec, + headers={"Content-Type": "application/json"} + ) + + # Check if the request was successful + if response.status_code == 200: + result = response.json() + print("\nJob registration response:") + print(json.dumps(result, indent=2)) + + if result.get("success"): + print("\n✅ Nomad MCP service deployed successfully!") + print(f"Job ID: {result.get('job_id')}") + print(f"Status: {result.get('status')}") + print("\nThe service will be available at: https://nomad_mcp.dev.meisheng.group") + + # Add Traefik configuration and command information + print("\nImportant Notes:") + print("1. The Claude API doesn't directly support adding Traefik tags.") + print(" You may need to update the job manually to add the following tags:") + print(" - traefik.enable=true") + print(" - traefik.http.routers.nomad-mcp.entryPoints=https") + print(" - traefik.http.routers.nomad-mcp.rule=Host(`nomad_mcp.dev.meisheng.group`)") + print(" - traefik.http.routers.nomad-mcp.middlewares=proxyheaders@consulcatalog") + print("\n2. The Claude API doesn't directly support specifying command and args.") + print(" You need to update the job manually to add the following:") + print(" - command: python") + print(" - args: [\"-m\", \"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]") + else: + print("\n❌ Failed to deploy Nomad MCP service.") + print(f"Message: {result.get('message', 'Unknown error')}") + else: + print(f"\n❌ API request failed with status code: {response.status_code}") + print(f"Response: {response.text}") + + except Exception as e: + print(f"Error deploying Nomad MCP service: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d1e4c88 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3' + +services: + nomad-mcp: + build: . + ports: + - "8000:8000" + volumes: + - ./configs:/app/configs + env_file: + - .env + environment: + - CONFIG_DIR=/app/configs + restart: unless-stopped \ No newline at end of file diff --git a/job_spec.json b/job_spec.json new file mode 100644 index 0000000..477153d --- /dev/null +++ b/job_spec.json @@ -0,0 +1,307 @@ +{ + "Job": { + "Stop": false, + "Region": "global", + "Namespace": "development", + "ID": "ms-qc-db-dev", + "ParentID": "", + "Name": "ms-qc-db-dev", + "Type": "service", + "Priority": 50, + "AllAtOnce": false, + "Datacenters": [ + "jm" + ], + "NodePool": "default", + "Constraints": null, + "Affinities": null, + "Spreads": null, + "TaskGroups": [ + { + "Name": "app", + "Count": 1, + "Update": { + "Stagger": 30000000000, + "MaxParallel": 1, + "HealthCheck": "checks", + "MinHealthyTime": 10000000000, + "HealthyDeadline": 300000000000, + "ProgressDeadline": 600000000000, + "AutoRevert": false, + "AutoPromote": false, + "Canary": 0 + }, + "Migrate": { + "MaxParallel": 1, + "HealthCheck": "checks", + "MinHealthyTime": 10000000000, + "HealthyDeadline": 300000000000 + }, + "Constraints": [ + { + "LTarget": "${attr.consul.version}", + "RTarget": "\u003e= 1.8.0", + "Operand": "semver" + } + ], + "Scaling": null, + "RestartPolicy": { + "Attempts": 2, + "Interval": 1800000000000, + "Delay": 15000000000, + "Mode": "fail", + "RenderTemplates": false + }, + "Tasks": [ + { + "Name": "ms-qc-db", + "Driver": "docker", + "User": "", + "Config": { + "command": "uvicorn", + "args": [ + "app.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + "--workers", + "2", + "--proxy-headers", + "--forwarded-allow-ips", + "*" + ], + "image": "registry.dev.meisheng.group/ms_qc_db:20250211", + "force_pull": true, + "ports": [ + "http" + ] + }, + "Env": { + "PYTHONPATH": "/local/MS_QC_DB", + "LOG_LEVEL": "INFO", + "USE_SQLITE": "false" + }, + "Services": null, + "Vault": null, + "Consul": null, + "Templates": [ + { + "SourcePath": "", + "DestPath": "secrets/app.env", + "EmbeddedTmpl": "{{with secret \"infrastructure/nomad/msqc\"}}\nDB_USER=\"{{ .Data.data.DB_USER }}\"\nDB_PASSWORD=\"{{ .Data.data.DB_PASSWORD }}\"\nDB_HOST=\"{{ .Data.data.DB_HOST }}\"\nDB_PORT=\"{{ .Data.data.DB_PORT }}\"\nDB_NAME=\"qc_rolls_dev\"\nWEBHOOK_SECRET=\"{{ .Data.data.WEBHOOK_SECRET }}\"\n{{end}}\n", + "ChangeMode": "restart", + "ChangeSignal": "", + "ChangeScript": null, + "Splay": 5000000000, + "Perms": "0644", + "Uid": null, + "Gid": null, + "LeftDelim": "{{", + "RightDelim": "}}", + "Envvars": true, + "VaultGrace": 0, + "Wait": null, + "ErrMissingKey": false + } + ], + "Constraints": null, + "Affinities": null, + "Resources": { + "CPU": 500, + "Cores": 0, + "MemoryMB": 512, + "MemoryMaxMB": 0, + "DiskMB": 0, + "IOPS": 0, + "Networks": null, + "Devices": null, + "NUMA": null + }, + "RestartPolicy": { + "Attempts": 2, + "Interval": 1800000000000, + "Delay": 15000000000, + "Mode": "fail", + "RenderTemplates": false + }, + "DispatchPayload": null, + "Lifecycle": null, + "Meta": null, + "KillTimeout": 5000000000, + "LogConfig": { + "MaxFiles": 10, + "MaxFileSizeMB": 10, + "Disabled": false + }, + "Artifacts": [ + { + "GetterSource": "git::ssh://git@gitea.service.mesh:2222/Mei_Sheng_Textiles/MS_QC_DB.git", + "GetterOptions": { + "sshkey": "LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNENHJwM05hZXA4K2lwVnlOZXNEbEVKckE0Rlg3MXA5VW5BWmxZcEJCNDh6d0FBQUppQ1ZWczhnbFZiClBBQUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDRDRycDNOYWVwOCtpcFZ5TmVzRGxFSnJBNEZYNzFwOVVuQVpsWXBCQjQ4encKQUFBRUNuckxjc1JDeUQyNmRnQ3dqdG5PUnNOK1VzUjdxZ1pqbXZpU2tVNmozalVmaXVuYzFwNm56NktsWEkxNndPVVFtcwpEZ1ZmdlduMVNjQm1WaWtFSGp6UEFBQUFFMjF6WDNGalgyUmlYMlJsY0d4dmVTQnJaWGtCQWc9PQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K", + "ref": "main" + }, + "GetterHeaders": null, + "GetterMode": "any", + "RelativeDest": "local/MS_QC_DB" + } + ], + "Leader": false, + "ShutdownDelay": 0, + "VolumeMounts": null, + "ScalingPolicies": null, + "KillSignal": "", + "Kind": "", + "CSIPluginConfig": null, + "Identity": { + "Name": "default", + "Audience": [ + "nomadproject.io" + ], + "ChangeMode": "", + "ChangeSignal": "", + "Env": false, + "File": false, + "ServiceName": "", + "TTL": 0 + }, + "Identities": null, + "Actions": null + } + ], + "EphemeralDisk": { + "Sticky": false, + "SizeMB": 300, + "Migrate": false + }, + "Meta": null, + "ReschedulePolicy": { + "Attempts": 0, + "Interval": 0, + "Delay": 30000000000, + "DelayFunction": "exponential", + "MaxDelay": 3600000000000, + "Unlimited": true + }, + "Affinities": null, + "Spreads": null, + "Networks": [ + { + "Mode": "", + "Device": "", + "CIDR": "", + "IP": "", + "Hostname": "", + "MBits": 0, + "DNS": null, + "ReservedPorts": null, + "DynamicPorts": [ + { + "Label": "http", + "Value": 0, + "To": 8000, + "HostNetwork": "default" + } + ] + } + ], + "Consul": { + "Namespace": "", + "Cluster": "default", + "Partition": "" + }, + "Services": [ + { + "Name": "${NOMAD_JOB_NAME}", + "TaskName": "", + "PortLabel": "http", + "AddressMode": "auto", + "Address": "", + "EnableTagOverride": false, + "Tags": [ + "traefik.http.routers.${NOMAD_JOB_NAME}.entryPoints=https", + "traefik.http.routers.${NOMAD_JOB_NAME}.rule=Host(`dev_qc.dev.meisheng.group`)", + "traefik.http.routers.${NOMAD_JOB_NAME}.middlewares=proxyheaders@consulcatalog", + "traefik.enable=true" + ], + "CanaryTags": null, + "Checks": [ + { + "Name": "service: \"${NOMAD_JOB_NAME}\" check", + "Type": "http", + "Command": "", + "Args": null, + "Path": "/api/v1/health", + "Protocol": "", + "PortLabel": "http", + "Expose": false, + "AddressMode": "", + "Interval": 10000000000, + "Timeout": 2000000000, + "InitialStatus": "", + "TLSServerName": "", + "TLSSkipVerify": false, + "Method": "", + "Header": null, + "CheckRestart": null, + "GRPCService": "", + "GRPCUseTLS": false, + "TaskName": "", + "SuccessBeforePassing": 0, + "FailuresBeforeCritical": 0, + "FailuresBeforeWarning": 0, + "Body": "", + "OnUpdate": "require_healthy" + } + ], + "Connect": null, + "Meta": null, + "CanaryMeta": null, + "TaggedAddresses": null, + "Namespace": "default", + "OnUpdate": "require_healthy", + "Provider": "consul", + "Cluster": "default", + "Identity": null + } + ], + "Volumes": null, + "ShutdownDelay": null, + "StopAfterClientDisconnect": null, + "MaxClientDisconnect": null, + "PreventRescheduleOnLost": false + } + ], + "Update": { + "Stagger": 30000000000, + "MaxParallel": 1, + "HealthCheck": "", + "MinHealthyTime": 0, + "HealthyDeadline": 0, + "ProgressDeadline": 0, + "AutoRevert": false, + "AutoPromote": false, + "Canary": 0 + }, + "Multiregion": null, + "Periodic": null, + "ParameterizedJob": null, + "Dispatched": false, + "DispatchIdempotencyToken": "", + "Payload": null, + "Meta": null, + "ConsulToken": "", + "ConsulNamespace": "", + "VaultToken": "", + "VaultNamespace": "", + "NomadTokenID": "", + "Status": "dead", + "StatusDescription": "", + "Stable": true, + "Version": 4, + "SubmitTime": 1740554361561458507, + "CreateIndex": 3415698, + "ModifyIndex": 3416318, + "JobModifyIndex": 3416317 + } +} diff --git a/nomad_job_api_docs.md b/nomad_job_api_docs.md new file mode 100644 index 0000000..fae7b61 --- /dev/null +++ b/nomad_job_api_docs.md @@ -0,0 +1,182 @@ +# Nomad Job Management API Documentation + +## Overview + +This document outlines the process for managing jobs (starting, stopping, and monitoring) in Hashicorp Nomad via its HTTP API. These operations are essential for deploying, updating, and terminating workloads in a Nomad cluster. + +## Prerequisites + +- A running Nomad cluster +- Network access to the Nomad API endpoint (default port 4646) +- Proper authentication credentials (if ACLs are enabled) + +## API Basics + +- Base URL: `http://:4646` +- API Version: `v1` +- Content Type: `application/json` + +## Job Lifecycle + +A Nomad job goes through multiple states during its lifecycle: + +1. **Pending**: The job has been submitted but not yet scheduled +2. **Running**: The job is active and its tasks are running +3. **Dead**: The job has been stopped or failed + +## Job Management Operations + +### 1. List Jobs + +List all jobs in a namespace to get an overview of the cluster's workloads. + +``` +GET /v1/jobs?namespace= +``` + +Example PowerShell command: +```powershell +Invoke-RestMethod -Uri "http://nomad-server:4646/v1/jobs?namespace=development" -Method GET +``` + +### 2. Starting a Job + +Starting a job in Nomad involves registering a job specification with the API server. + +``` +POST /v1/jobs +``` + +With a job specification in the request body: + +```json +{ + "Job": { + "ID": "example-job", + "Name": "example-job", + "Namespace": "development", + "Type": "service", + "Datacenters": ["dc1"], + "TaskGroups": [ + { + "Name": "app", + "Count": 1, + "Tasks": [ + { + "Name": "server", + "Driver": "docker", + "Config": { + "image": "nginx:latest" + } + } + ] + } + ] + } +} +``` + +Example PowerShell command: +```powershell +$jobSpec = @{ + Job = @{ + ID = "example-job" + # ... other job properties + } +} | ConvertTo-Json -Depth 20 + +Invoke-RestMethod -Uri "http://nomad-server:4646/v1/jobs" -Method POST -Body $jobSpec -ContentType "application/json" +``` + +To start an existing (stopped) job: +1. Retrieve the job specification with `GET /v1/job/?namespace=` +2. Set `Stop = false` in the job specification +3. Submit the modified spec with `POST /v1/jobs` + +### 3. Stopping a Job + +Stopping a job is simpler and requires a DELETE request: + +``` +DELETE /v1/job/?namespace= +``` + +This marks the job for stopping but preserves its configuration in Nomad. + +Example PowerShell command: +```powershell +Invoke-RestMethod -Uri "http://nomad-server:4646/v1/job/example-job?namespace=development" -Method DELETE +``` + +Optional parameters: +- `purge=true` - Completely removes the job from Nomad's state + +### 4. Reading Job Status + +To check the status of a job: + +``` +GET /v1/job/?namespace= +``` + +This returns detailed information about the job, including: +- Current status (`running`, `pending`, `dead`) +- Task group count and health +- Version information + +Example PowerShell command: +```powershell +Invoke-RestMethod -Uri "http://nomad-server:4646/v1/job/example-job?namespace=development" -Method GET +``` + +### 5. Reading Job Allocations + +To see all allocations (instances) of a job: + +``` +GET /v1/job//allocations?namespace= +``` + +This returns information about where the job is running and in what state. + +Example PowerShell command: +```powershell +Invoke-RestMethod -Uri "http://nomad-server:4646/v1/job/example-job/allocations?namespace=development" -Method GET +``` + +## Common Issues and Troubleshooting + +### Namespace Issues + +Nomad requires specifying the correct namespace when managing jobs. If not specified, operations will default to the "default" namespace, which may not contain your jobs. + +### Job Specification Formatting + +When starting a job, ensure the job specification is properly wrapped in a "Job" object: + +```json +{ + "Job": { + // job details go here + } +} +``` + +### Error Codes + +- **400**: Bad request, often due to malformed job specification +- **403**: Permission denied, check ACL tokens +- **404**: Job not found, verify job ID and namespace +- **500**: Server error, check Nomad server logs + +## Best Practices + +1. Always specify the namespace explicitly in API calls +2. Use the job's existing specification when updating, to avoid losing configuration +3. Log API responses to aid in troubleshooting +4. Implement proper error handling for API failures +5. Consider using official client libraries instead of direct API calls when possible + +## Conclusion + +The Nomad HTTP API provides a robust interface for job lifecycle management. Understanding these API workflows is crucial for building reliable automation and integration with Nomad clusters. \ No newline at end of file diff --git a/nomad_mcp_job.nomad b/nomad_mcp_job.nomad new file mode 100644 index 0000000..749b76b --- /dev/null +++ b/nomad_mcp_job.nomad @@ -0,0 +1,79 @@ +job "nomad-mcp" { + datacenters = ["jm"] + type = "service" + namespace = "development" + + group "app" { + count = 1 + + network { + port "http" { + to = 8000 + } + } + + task "nomad-mcp" { + driver = "docker" + + config { + image = "registry.dev.meisheng.group/nomad_mcp:20250226" + ports = ["http"] + command = "python" + args = ["-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + } + + env { + # Nomad connection settings + NOMAD_ADDR = "http://pjmldk01.ds.meisheng.group:4646" + NOMAD_NAMESPACE = "development" + NOMAD_SKIP_VERIFY = "true" + + # API settings + PORT = "8000" + HOST = "0.0.0.0" + + # Logging level + LOG_LEVEL = "INFO" + + # Enable to make development easier + RELOAD = "true" + } + + resources { + cpu = 200 + memory = 256 + } + + service { + name = "nomad-mcp" + port = "http" + tags = [ + "traefik.enable=true", + "traefik.http.routers.nomad-mcp.entryPoints=https", + "traefik.http.routers.nomad-mcp.rule=Host(`nomad_mcp.dev.meisheng.group`)", + "traefik.http.routers.nomad-mcp.middlewares=proxyheaders@consulcatalog" + ] + + check { + type = "http" + path = "/api/health" + interval = "10s" + timeout = "2s" + + check_restart { + limit = 3 + grace = "60s" + } + } + } + } + } + + # Define update strategy + update { + max_parallel = 1 + min_healthy_time = "30s" + healthy_deadline = "5m" + auto_revert = true + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f2832d7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +python-nomad +pydantic +python-dotenv +httpx +python-multipart +pyyaml +requests \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..63e0503 --- /dev/null +++ b/run.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +import uvicorn +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Configuration from environment +host = os.getenv("HOST", "0.0.0.0") +port = int(os.getenv("PORT", "8000")) +reload = os.getenv("RELOAD", "false").lower() == "true" + +if __name__ == "__main__": + print(f"Starting Nomad MCP service on {host}:{port}") + print(f"API documentation available at http://{host}:{port}/docs") + + uvicorn.run( + "app.main:app", + host=host, + port=port, + reload=reload, + ) \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..becd58b --- /dev/null +++ b/static/app.js @@ -0,0 +1,355 @@ +// API endpoints +const API_BASE_URL = '/api/claude'; +const ENDPOINTS = { + listJobs: `${API_BASE_URL}/list-jobs`, + manageJob: `${API_BASE_URL}/jobs`, + jobLogs: `${API_BASE_URL}/job-logs` +}; + +// DOM elements +const elements = { + namespaceSelector: document.getElementById('namespace-selector'), + refreshBtn: document.getElementById('refresh-btn'), + jobList: document.getElementById('job-list'), + jobTable: document.getElementById('job-table'), + jobDetails: document.getElementById('job-details'), + logContent: document.getElementById('log-content'), + logTabs: document.querySelectorAll('.log-tab'), + loading: document.getElementById('loading'), + errorMessage: document.getElementById('error-message') +}; + +// State +let state = { + jobs: [], + selectedJob: null, + selectedNamespace: 'development', + logs: { + stdout: '', + stderr: '', + currentTab: 'stdout' + } +}; + +// Initialize the app +function init() { + // Set up event listeners + elements.namespaceSelector.addEventListener('change', handleNamespaceChange); + elements.refreshBtn.addEventListener('click', loadJobs); + elements.logTabs.forEach(tab => { + tab.addEventListener('click', () => { + const logType = tab.getAttribute('data-log-type'); + switchLogTab(logType); + }); + }); + + // Load initial jobs + loadJobs(); +} + +// Load jobs from the API +async function loadJobs() { + showLoading(true); + hideError(); + + try { + const namespace = elements.namespaceSelector.value; + const response = await fetch(`${ENDPOINTS.listJobs}?namespace=${namespace}`); + + if (!response.ok) { + throw new Error(`Failed to load jobs: ${response.statusText}`); + } + + const jobs = await response.json(); + state.jobs = jobs; + state.selectedNamespace = namespace; + + renderJobList(); + showLoading(false); + } catch (error) { + console.error('Error loading jobs:', error); + showError(`Failed to load jobs: ${error.message}`); + showLoading(false); + } +} + +// Render the job list +function renderJobList() { + elements.jobList.innerHTML = ''; + + if (state.jobs.length === 0) { + const row = document.createElement('tr'); + row.innerHTML = `No jobs found in the ${state.selectedNamespace} namespace`; + elements.jobList.appendChild(row); + return; + } + + state.jobs.forEach(job => { + const row = document.createElement('tr'); + row.setAttribute('data-job-id', job.id); + row.innerHTML = ` + ${job.id} + ${job.type} + ${job.status} + + + + + + `; + + elements.jobList.appendChild(row); + }); + + // Add event listeners to buttons + document.querySelectorAll('.btn-view').forEach(btn => { + btn.addEventListener('click', () => viewJob(btn.getAttribute('data-job-id'))); + }); + + document.querySelectorAll('.btn-restart').forEach(btn => { + btn.addEventListener('click', () => restartJob(btn.getAttribute('data-job-id'))); + }); + + document.querySelectorAll('.btn-stop').forEach(btn => { + btn.addEventListener('click', () => stopJob(btn.getAttribute('data-job-id'))); + }); +} + +// View job details +async function viewJob(jobId) { + showLoading(true); + + try { + // Get job status + const statusResponse = await fetch(ENDPOINTS.manageJob, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + job_id: jobId, + action: 'status', + namespace: state.selectedNamespace + }) + }); + + if (!statusResponse.ok) { + throw new Error(`Failed to get job status: ${statusResponse.statusText}`); + } + + const jobStatus = await statusResponse.json(); + state.selectedJob = jobStatus; + + // Get job logs + const logsResponse = await fetch(`${ENDPOINTS.jobLogs}/${jobId}?namespace=${state.selectedNamespace}`); + + if (logsResponse.ok) { + const logsData = await logsResponse.json(); + + if (logsData.success) { + state.logs.stdout = logsData.logs.stdout || 'No stdout logs available'; + state.logs.stderr = logsData.logs.stderr || 'No stderr logs available'; + } else { + state.logs.stdout = 'Logs not available'; + state.logs.stderr = 'Logs not available'; + } + } else { + state.logs.stdout = 'Failed to load logs'; + state.logs.stderr = 'Failed to load logs'; + } + + renderJobDetails(); + renderLogs(); + showLoading(false); + + // Highlight the selected job in the table + document.querySelectorAll('#job-list tr').forEach(row => { + row.classList.remove('selected'); + }); + + const selectedRow = document.querySelector(`#job-list tr[data-job-id="${jobId}"]`); + if (selectedRow) { + selectedRow.classList.add('selected'); + } + } catch (error) { + console.error('Error viewing job:', error); + showError(`Failed to view job: ${error.message}`); + showLoading(false); + } +} + +// Restart a job +async function restartJob(jobId) { + if (!confirm(`Are you sure you want to restart job "${jobId}"?`)) { + return; + } + + showLoading(true); + + try { + const response = await fetch(ENDPOINTS.manageJob, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + job_id: jobId, + action: 'restart', + namespace: state.selectedNamespace + }) + }); + + if (!response.ok) { + throw new Error(`Failed to restart job: ${response.statusText}`); + } + + const result = await response.json(); + + if (result.success) { + alert(`Job "${jobId}" has been restarted successfully.`); + loadJobs(); + } else { + throw new Error(result.message); + } + + showLoading(false); + } catch (error) { + console.error('Error restarting job:', error); + showError(`Failed to restart job: ${error.message}`); + showLoading(false); + } +} + +// Stop a job +async function stopJob(jobId) { + const purge = confirm(`Do you want to purge job "${jobId}" after stopping?`); + + if (!confirm(`Are you sure you want to stop job "${jobId}"?`)) { + return; + } + + showLoading(true); + + try { + const response = await fetch(ENDPOINTS.manageJob, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + job_id: jobId, + action: 'stop', + namespace: state.selectedNamespace, + purge: purge + }) + }); + + if (!response.ok) { + throw new Error(`Failed to stop job: ${response.statusText}`); + } + + const result = await response.json(); + + if (result.success) { + alert(`Job "${jobId}" has been stopped${purge ? ' and purged' : ''} successfully.`); + loadJobs(); + } else { + throw new Error(result.message); + } + + showLoading(false); + } catch (error) { + console.error('Error stopping job:', error); + showError(`Failed to stop job: ${error.message}`); + showLoading(false); + } +} + +// Render job details +function renderJobDetails() { + if (!state.selectedJob) { + elements.jobDetails.innerHTML = '

Select a job to view details

'; + return; + } + + const job = state.selectedJob; + const details = job.details?.job || {}; + const allocation = job.details?.latest_allocation || {}; + + let detailsHtml = ` +

${job.job_id}

+

Status: ${job.status}

+ `; + + if (details.Type) { + detailsHtml += `

Type: ${details.Type}

`; + } + + if (details.Namespace) { + detailsHtml += `

Namespace: ${details.Namespace}

`; + } + + if (details.Datacenters) { + detailsHtml += `

Datacenters: ${details.Datacenters.join(', ')}

`; + } + + if (allocation.ID) { + detailsHtml += ` +

Latest Allocation

+

ID: ${allocation.ID}

+

Status: ${allocation.ClientStatus || 'Unknown'}

+ `; + + if (allocation.ClientDescription) { + detailsHtml += `

Description: ${allocation.ClientDescription}

`; + } + } + + elements.jobDetails.innerHTML = detailsHtml; +} + +// Render logs +function renderLogs() { + elements.logContent.textContent = state.logs[state.logs.currentTab]; +} + +// Switch log tab +function switchLogTab(logType) { + state.logs.currentTab = logType; + + // Update active tab + elements.logTabs.forEach(tab => { + if (tab.getAttribute('data-log-type') === logType) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + renderLogs(); +} + +// Handle namespace change +function handleNamespaceChange() { + loadJobs(); +} + +// Show/hide loading indicator +function showLoading(show) { + elements.loading.style.display = show ? 'block' : 'none'; + elements.jobTable.style.display = show ? 'none' : 'table'; +} + +// Show error message +function showError(message) { + elements.errorMessage.textContent = message; + elements.errorMessage.style.display = 'block'; +} + +// Hide error message +function hideError() { + elements.errorMessage.style.display = 'none'; +} + +// Initialize the app when the DOM is loaded +document.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7521c29 --- /dev/null +++ b/static/index.html @@ -0,0 +1,66 @@ + + + + + + Nomad Job Manager + + + +
+
+

Nomad Job Manager

+
+ + +
+
+ +
+
+

Jobs

+
Loading jobs...
+
+ + + + + + + + + + + + +
Job IDTypeStatusActions
+
+ +
+

Job Details

+
+

Select a job to view details

+
+
+

Logs

+
+ + +
+
Select a job to view logs
+
+
+
+ +
+

Nomad MCP Service - Claude Integration

+
+
+ + + + \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..f852318 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,244 @@ +/* Base styles */ +:root { + --primary-color: #1976d2; + --secondary-color: #424242; + --success-color: #4caf50; + --danger-color: #f44336; + --warning-color: #ff9800; + --light-gray: #f5f5f5; + --border-color: #e0e0e0; + --text-color: #333; + --text-light: #666; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: #f9f9f9; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.controls { + display: flex; + gap: 10px; +} + +/* Buttons */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-warning { + background-color: var(--warning-color); + color: white; +} + +.btn:hover { + opacity: 0.9; +} + +/* Form elements */ +select { + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: white; +} + +/* Main content */ +main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +/* Job list */ +.job-list-container { + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 20px; +} + +.job-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +.job-table th, +.job-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.job-table th { + background-color: var(--light-gray); + font-weight: 600; +} + +.job-table tr:hover { + background-color: var(--light-gray); +} + +.job-actions { + display: flex; + gap: 5px; +} + +/* Job details */ +.job-details-container { + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 20px; +} + +.job-details { + margin-bottom: 20px; +} + +.job-details h3 { + margin-top: 15px; + margin-bottom: 5px; + color: var(--secondary-color); +} + +.job-details p { + margin-bottom: 10px; +} + +.job-details .label { + font-weight: 600; + color: var(--text-light); +} + +/* Logs */ +.job-logs { + margin-top: 20px; +} + +.log-tabs { + display: flex; + margin-bottom: 10px; +} + +.log-tab { + padding: 8px 16px; + background-color: var(--light-gray); + border: 1px solid var(--border-color); + border-bottom: none; + cursor: pointer; +} + +.log-tab.active { + background-color: white; + border-bottom: 2px solid var(--primary-color); +} + +.log-content { + background-color: #282c34; + color: #abb2bf; + padding: 15px; + border-radius: 4px; + overflow: auto; + max-height: 300px; + font-family: 'Courier New', Courier, monospace; + white-space: pre-wrap; +} + +/* Status indicators */ +.status { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; +} + +.status-running { + background-color: rgba(76, 175, 80, 0.2); + color: #2e7d32; +} + +.status-pending { + background-color: rgba(255, 152, 0, 0.2); + color: #ef6c00; +} + +.status-dead { + background-color: rgba(244, 67, 54, 0.2); + color: #c62828; +} + +/* Loading and error states */ +.loading { + padding: 20px; + text-align: center; + color: var(--text-light); +} + +.error-message { + padding: 10px; + background-color: rgba(244, 67, 54, 0.1); + color: var(--danger-color); + border-radius: 4px; + margin: 10px 0; + display: none; +} + +.select-job-message { + color: var(--text-light); + font-style: italic; +} + +/* Footer */ +footer { + margin-top: 40px; + text-align: center; + color: var(--text-light); + font-size: 0.9em; +} \ No newline at end of file diff --git a/test_direct_nomad.py b/test_direct_nomad.py new file mode 100644 index 0000000..d89813c --- /dev/null +++ b/test_direct_nomad.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +""" +Test script to directly use the Nomad client library. +""" + +import os +import sys +import uuid +import nomad +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +def get_test_job_spec(job_id): + """Create a simple test job specification.""" + return { + "Job": { + "ID": job_id, + "Name": job_id, + "Type": "service", + "Datacenters": ["jm"], + "Namespace": "development", + "Priority": 50, + "TaskGroups": [ + { + "Name": "app", + "Count": 1, + "Tasks": [ + { + "Name": "nginx", + "Driver": "docker", + "Config": { + "image": "nginx:latest", + "ports": ["http"], + }, + "Resources": { + "CPU": 100, + "MemoryMB": 128 + } + } + ], + "Networks": [ + { + "DynamicPorts": [ + { + "Label": "http", + "Value": 0, + "To": 80 + } + ] + } + ] + } + ] + } + } + +def main(): + print("Testing direct Nomad client...") + + # Check if NOMAD_ADDR is configured + nomad_addr = os.getenv("NOMAD_ADDR") + if not nomad_addr: + print("Error: NOMAD_ADDR is not configured in .env file.") + sys.exit(1) + + print(f"Connecting to Nomad at: {nomad_addr}") + + try: + # Extract host and port from the address + host_with_port = nomad_addr.replace("http://", "").replace("https://", "") + host = host_with_port.split(":")[0] + + # Safely extract port + port_part = host_with_port.split(":")[-1] if ":" in host_with_port else "4646" + port = int(port_part.split('/')[0]) # Remove any path components + + # Initialize the Nomad client + client = nomad.Nomad( + host=host, + port=port, + secure=nomad_addr.startswith("https"), + timeout=10, + namespace="development", # Set namespace explicitly + verify=False + ) + + # Create a unique job ID for testing + job_id = f"test-job-{uuid.uuid4().hex[:8]}" + print(f"Created test job ID: {job_id}") + + # Create job specification + job_spec = get_test_job_spec(job_id) + print("Created job specification with explicit namespace: development") + + # Start the job + print(f"Attempting to start job {job_id}...") + + # Print the job spec for debugging + print(f"Job spec structure: {list(job_spec.keys())}") + print(f"Job keys: {list(job_spec['Job'].keys())}") + + # Register the job + response = client.job.register_job(job_id, job_spec) + + print(f"Job registration response: {response}") + print(f"Job {job_id} started successfully!") + + # Clean up - stop the job + print(f"Stopping job {job_id}...") + stop_response = client.job.deregister_job(job_id, purge=True) + print(f"Job stop response: {stop_response}") + print(f"Job {job_id} stopped and purged successfully!") + + print("\nDirect Nomad client test completed successfully.") + + except Exception as e: + print(f"Error during direct Nomad client test: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_gitea_integration.py b/test_gitea_integration.py new file mode 100644 index 0000000..406293e --- /dev/null +++ b/test_gitea_integration.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +""" +Test script to verify Gitea integration with Nomad MCP. +This script tests the basic functionality of the Gitea client. +""" + +import os +import sys +from dotenv import load_dotenv +from app.services.gitea_client import GiteaClient + +# Load environment variables from .env file +load_dotenv() + +def main(): + print("Testing Gitea integration with Nomad MCP...") + + # Check if Gitea API URL is configured + gitea_api_url = os.getenv("GITEA_API_URL") + if not gitea_api_url: + print("Error: GITEA_API_URL is not configured in .env file.") + print("Please configure the Gitea API URL and try again.") + sys.exit(1) + + # Check if authentication is configured + gitea_token = os.getenv("GITEA_API_TOKEN") + gitea_username = os.getenv("GITEA_USERNAME") + gitea_password = os.getenv("GITEA_PASSWORD") + + if not gitea_token and not (gitea_username and gitea_password): + print("Warning: No authentication configured for Gitea API.") + print("You might not be able to access protected repositories.") + + # Initialize the Gitea client + gitea_client = GiteaClient() + + # Test listing repositories + print("\nTesting repository listing...") + repositories = gitea_client.list_repositories(limit=5) + + if not repositories: + print("No repositories found or error occurred.") + else: + print(f"Found {len(repositories)} repositories:") + for repo in repositories: + print(f" - {repo.get('full_name')}: {repo.get('html_url')}") + + # Test parsing repository URLs + print("\nTesting repository URL parsing...") + test_urls = [ + f"{gitea_api_url.replace('/api/v1', '')}/username/repo-name", + "http://gitea.internal.example.com/org/project", + "https://gitea.example.com/user/repository", + ] + + for url in test_urls: + try: + owner, repo = gitea_client.parse_repo_url(url) + print(f" {url} -> Owner: {owner}, Repo: {repo}") + except ValueError as e: + print(f" {url} -> Error: {str(e)}") + + # If we have repositories, test getting repository info for the first one + if repositories: + print("\nTesting repository info retrieval...") + first_repo = repositories[0] + repo_url = first_repo.get("html_url") + + repo_info = gitea_client.get_repository_info(repo_url) + if repo_info: + print(f"Repository info for {repo_url}:") + print(f" Name: {repo_info.get('name')}") + print(f" Description: {repo_info.get('description')}") + print(f" Default branch: {repo_info.get('default_branch')}") + print(f" Stars: {repo_info.get('stars_count')}") + print(f" Forks: {repo_info.get('forks_count')}") + + # Test getting branches + branches = gitea_client.get_repository_branches(repo_url) + if branches: + print(f" Branches: {', '.join([b.get('name') for b in branches])}") + else: + print(" No branches found or error occurred.") + else: + print(f"Error retrieving repository info for {repo_url}") + + print("\nGitea integration test completed.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_gitea_repos.py b/test_gitea_repos.py new file mode 100644 index 0000000..238bfee --- /dev/null +++ b/test_gitea_repos.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +""" +Test script to list all accessible Gitea repositories grouped by owner. +This will show both personal and organization repositories. +""" + +import os +import sys +from collections import defaultdict +from dotenv import load_dotenv +from app.services.gitea_client import GiteaClient + +# Load environment variables from .env file +load_dotenv() + +def main(): + print("Testing Gitea Repository Access for Personal and Organization Accounts...") + + # Check if Gitea API URL is configured + gitea_api_url = os.getenv("GITEA_API_URL") + if not gitea_api_url: + print("Error: GITEA_API_URL is not configured in .env file.") + sys.exit(1) + + # Initialize the Gitea client + gitea_client = GiteaClient() + + # Get all repositories (increase limit if you have many) + repositories = gitea_client.list_repositories(limit=100) + + if not repositories: + print("No repositories found or error occurred.") + sys.exit(1) + + # Group repositories by owner + owners = defaultdict(list) + for repo in repositories: + owner_name = repo.get('owner', {}).get('login', 'unknown') + owners[owner_name].append(repo) + + # Display repositories grouped by owner + print(f"\nFound {len(repositories)} repositories across {len(owners)} owners:") + + for owner, repos in owners.items(): + print(f"\n== {owner} ({len(repos)} repositories) ==") + for repo in repos: + print(f" - {repo.get('name')}: {repo.get('html_url')}") + print(f" Description: {repo.get('description') or 'No description'}") + print(f" Default branch: {repo.get('default_branch')}") + + print("\nTest completed successfully.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_job_registration.py b/test_job_registration.py new file mode 100644 index 0000000..0b6ad6e --- /dev/null +++ b/test_job_registration.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +""" +Test script to verify job registration with explicit namespace. +""" + +import os +import sys +import uuid +from dotenv import load_dotenv +from app.services.nomad_client import NomadService + +# Load environment variables from .env file +load_dotenv() + +def get_test_job_spec(job_id): + """Create a simple test job specification.""" + return { + "ID": job_id, + "Name": job_id, + "Type": "service", + "Datacenters": ["jm"], + "Namespace": "development", + "Priority": 50, + "TaskGroups": [ + { + "Name": "app", + "Count": 1, + "Tasks": [ + { + "Name": "nginx", + "Driver": "docker", + "Config": { + "image": "nginx:latest", + "ports": ["http"], + }, + "Resources": { + "CPU": 100, + "MemoryMB": 128 + } + } + ], + "Networks": [ + { + "DynamicPorts": [ + { + "Label": "http", + "Value": 0, + "To": 80 + } + ] + } + ] + } + ] + } + +def main(): + print("Testing Nomad job registration...") + + # Check if NOMAD_ADDR is configured + nomad_addr = os.getenv("NOMAD_ADDR") + if not nomad_addr: + print("Error: NOMAD_ADDR is not configured in .env file.") + sys.exit(1) + + print(f"Connecting to Nomad at: {nomad_addr}") + + try: + # Initialize the Nomad service + nomad_service = NomadService() + + # Create a unique job ID for testing + job_id = f"test-job-{uuid.uuid4().hex[:8]}" + print(f"Created test job ID: {job_id}") + + # Create job specification + job_spec = get_test_job_spec(job_id) + print("Created job specification with explicit namespace: development") + + # Start the job + print(f"Attempting to start job {job_id}...") + start_response = nomad_service.start_job(job_spec) + + print(f"Job start response: {start_response}") + print(f"Job {job_id} started successfully!") + + # Clean up - stop the job + print(f"Stopping job {job_id}...") + stop_response = nomad_service.stop_job(job_id, purge=True) + print(f"Job stop response: {stop_response}") + print(f"Job {job_id} stopped and purged successfully!") + + print("\nNomad job registration test completed successfully.") + + except Exception as e: + print(f"Error during job registration test: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_nomad_connection.py b/test_nomad_connection.py new file mode 100644 index 0000000..a07b116 --- /dev/null +++ b/test_nomad_connection.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +""" +Test script to verify Nomad connection and check for specific jobs. +""" + +import os +import sys +from dotenv import load_dotenv +from pprint import pprint +from app.services.nomad_client import NomadService + +# Load environment variables from .env file +load_dotenv() + +def main(): + print("Testing Nomad connection...") + + # Check if NOMAD_ADDR is configured + nomad_addr = os.getenv("NOMAD_ADDR") + if not nomad_addr: + print("Error: NOMAD_ADDR is not configured in .env file.") + sys.exit(1) + + print(f"Connecting to Nomad at: {nomad_addr}") + + try: + # Initialize the Nomad service + nomad_service = NomadService() + + # List all jobs + print("\nListing all jobs...") + jobs = nomad_service.list_jobs() + print(f"Found {len(jobs)} jobs:") + + # Print each job's ID and status + for job in jobs: + print(f" - {job.get('ID')}: {job.get('Status')}") + + # Look for specific job + job_id = "ms-qc-db-dev" + print(f"\nLooking for job '{job_id}'...") + + job_found = False + for job in jobs: + if job.get('ID') == job_id: + job_found = True + print(f"Found job '{job_id}'!") + print(f" Status: {job.get('Status')}") + print(f" Type: {job.get('Type')}") + print(f" Priority: {job.get('Priority')}") + break + + if not job_found: + print(f"Job '{job_id}' not found in the list of jobs.") + print("Available jobs:") + for job in jobs: + print(f" - {job.get('ID')}") + + print("\nNomad connection test completed successfully.") + + except Exception as e: + print(f"Error connecting to Nomad: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_nomad_namespaces.py b/test_nomad_namespaces.py new file mode 100644 index 0000000..d93988b --- /dev/null +++ b/test_nomad_namespaces.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +""" +Test script to identify the exact namespace of the ms-qc-db-dev job. +""" + +import os +import sys +from dotenv import load_dotenv +import nomad +from pprint import pprint + +# Load environment variables from .env file +load_dotenv() + +def get_nomad_client(): + """Create a direct nomad client without going through our service layer.""" + nomad_addr = os.getenv("NOMAD_ADDR", "http://localhost:4646").rstrip('/') + host_with_port = nomad_addr.replace("http://", "").replace("https://", "") + host = host_with_port.split(":")[0] + + # Safely extract port + port_part = host_with_port.split(":")[-1] if ":" in host_with_port else "4646" + port = int(port_part.split('/')[0]) + + return nomad.Nomad( + host=host, + port=port, + timeout=10, + namespace="*", # Try with explicit wildcard + verify=False + ) + +def main(): + print(f"Creating Nomad client...") + client = get_nomad_client() + + print(f"\n=== Testing with namespace='*' ===") + try: + # List all jobs with namespace '*' + jobs = client.jobs.get_jobs(namespace="*") + print(f"Found {len(jobs)} jobs using namespace='*'") + + # Look for our specific job and show its namespace + found = False + for job in jobs: + if job.get('ID') == 'ms-qc-db-dev': + found = True + print(f"\nFound job 'ms-qc-db-dev' in namespace: {job.get('Namespace', 'unknown')}") + print(f"Job status: {job.get('Status')}") + print(f"Job type: {job.get('Type')}") + print(f"Job priority: {job.get('Priority')}") + break + + if not found: + print(f"\nJob 'ms-qc-db-dev' not found with namespace='*'") + except Exception as e: + print(f"Error with namespace='*': {str(e)}") + + # Try listing all available namespaces + print(f"\n=== Listing available namespaces ===") + try: + namespaces = client.namespaces.get_namespaces() + print(f"Found {len(namespaces)} namespaces:") + for ns in namespaces: + print(f" - {ns.get('Name')}") + + # Try finding the job in each namespace specifically + print(f"\n=== Searching for job in each namespace ===") + for ns in namespaces: + ns_name = ns.get('Name') + try: + job = client.job.get_job('ms-qc-db-dev', namespace=ns_name) + print(f"Found job in namespace '{ns_name}'!") + print(f" Status: {job.get('Status')}") + print(f" Type: {job.get('Type')}") + break + except Exception: + print(f"Not found in namespace '{ns_name}'") + + except Exception as e: + print(f"Error listing namespaces: {str(e)}") + + print("\nTest completed.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/__pycache__/test_nomad_service.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_nomad_service.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca8cd1549a6b6c52264a0d5879f3e7922dfaf1fe GIT binary patch literal 14667 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 oFa;)Sy