From 625da46b3e041ac0e77bc4ba390cce7994c5b660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:16:57 +0100 Subject: [PATCH 01/18] feat: fixing deployment --- .env.example | 16 +++ README.md | 40 ++++-- docker-compose.yml | 111 +++++++++++++-- frontend/Dockerfile | 21 +-- nginx/local/Dockerfile | 4 + nginx/local/nginx.conf | 115 ++++++++++++++++ nginx/production/Dockerfile | 4 + nginx/production/nginx.conf | 127 ++++++++++++++++++ nginx/ssl/.gitignore | 2 + .../src/openbinding_gateway/main.py | 15 ++- 10 files changed, 423 insertions(+), 32 deletions(-) create mode 100644 .env.example create mode 100644 nginx/local/Dockerfile create mode 100644 nginx/local/nginx.conf create mode 100644 nginx/production/Dockerfile create mode 100644 nginx/production/nginx.conf create mode 100644 nginx/ssl/.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3043a01 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Docker Compose profile selector: dev | prod +COMPOSE_PROFILES=dev + +# Frontend API base URL used at build time +VITE_API_BASE_URL_DEV=http://localhost:8000 +VITE_API_BASE_URL_PROD=/api + +# Backend CORS policies +CORS_ALLOW_ORIGINS_DEV=http://localhost:80,http://localhost:5173,http://127.0.0.1:80,http://127.0.0.1:5173 +CORS_ALLOW_ORIGINS_PROD=https://openbinding.score.us.es,https://openbinding.us.es + +# TLS certificates directory for production nginx +# This directory must contain: +# - fullchain.pem +# - privkey.pem +NGINX_SSL_DIR=./nginx/ssl diff --git a/README.md b/README.md index 2e4e3e3..6b57042 100644 --- a/README.md +++ b/README.md @@ -102,19 +102,35 @@ Example payloads that follow these schemas live in `examples/`. ### Installation & Running -1. **Start the Stack**: +1. **Configure environment variables**: ```bash - docker compose up --build + cp .env.example .env ``` - The services will be available at: - * **Frontend**: [http://localhost:80](http://localhost:80) - * **Gateway API**: [http://localhost:8000/docs](http://localhost:8000/docs) - * **MiniZinc Engine**: Port 3000 (Internal) - * **Random Search Engine**: Port 8081 (Internal) - * **Many-Heuristic Engine**: Port 8082 (Internal) +2. **Start development stack**: + ```bash + COMPOSE_PROFILES=dev docker compose up --build + ``` + + Development services: + * **Nginx (local)**: [http://localhost:80](http://localhost:80) + * **Frontend dev server**: [http://localhost:5173](http://localhost:5173) + * **Gateway API docs**: [http://localhost:8000/docs](http://localhost:8000/docs) + +3. **Start production stack**: + ```bash + COMPOSE_PROFILES=prod docker compose up --build -d + ``` + + Production notes: + * **Nginx** listens on ports **80/443**. + * Configure DNS for `openbinding.score.us.es` and `openbinding.us.es`. + * Place TLS files in `nginx/ssl/` (or override `NGINX_SSL_DIR`) with names: + - `fullchain.pem` + - `privkey.pem` + * Gateway is exposed only internally behind Nginx. -2. **Stop the Stack**: +4. **Stop the Stack**: ```bash docker compose down ``` @@ -182,13 +198,13 @@ To run the complete test suite in the Docker environment: ```bash # 1. Ensure stack is running -docker compose up -d +COMPOSE_PROFILES=dev docker compose up -d # 2. Run all tests -docker compose exec gateway test +docker compose exec gateway-dev test # 3. Run specific test file -docker compose exec gateway test tests/test_analysis.py -v +docker compose exec gateway-dev test tests/test_analysis.py -v ``` ### Running Tests (Local) diff --git a/docker-compose.yml b/docker-compose.yml index 8ac869f..3f62aa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,39 @@ services: build: context: ./openbinding-gateway dockerfile: Dockerfile + profiles: ["prod"] + volumes: + - ./schemas:/app/schemas + - ./openbinding-gateway:/app + - ./examples/generated_instances:/app/examples/generated_instances + environment: + - APP_ENV=prod + - GENERAL_SCHEMA_PATH=/app/schemas/general/schema.json + - SCHEMAS_DIR=/app/schemas + - PYTHONPATH=/app/src + - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS_PROD:-https://openbinding.score.us.es,https://openbinding.us.es} + - ENGINE_MINIZINC_URL=http://engine-minizinc:3000 + - ENGINE_RANDOM_SEARCH_URL=http://engine-random-search:8080 + - ENGINE_MANY_HEURISTIC_URL=http://engine-many-heuristic:8080 + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s + depends_on: + engine-minizinc: + condition: service_healthy + engine-random-search: + condition: service_healthy + engine-many-heuristic: + condition: service_healthy + + gateway-dev: + build: + context: ./openbinding-gateway + dockerfile: Dockerfile + profiles: ["dev"] ports: - "8000:8000" volumes: @@ -10,9 +43,11 @@ services: - ./openbinding-gateway:/app - ./examples/generated_instances:/app/examples/generated_instances environment: + - APP_ENV=dev - GENERAL_SCHEMA_PATH=/app/schemas/general/schema.json - SCHEMAS_DIR=/app/schemas - PYTHONPATH=/app/src + - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS_DEV:-http://localhost:80,http://localhost:5173,http://127.0.0.1:80,http://127.0.0.1:5173} - ENGINE_MINIZINC_URL=http://engine-minizinc:3000 - ENGINE_RANDOM_SEARCH_URL=http://engine-random-search:8080 - ENGINE_MANY_HEURISTIC_URL=http://engine-many-heuristic:8080 @@ -34,8 +69,7 @@ services: build: context: ./engines/minizinc-csp dockerfile: Dockerfile - ports: - - "3000:3000" + profiles: ["dev", "prod"] healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"] interval: 10s @@ -47,8 +81,7 @@ services: build: context: ./engines/random-search dockerfile: Dockerfile - ports: - - "8081:8080" + profiles: ["dev", "prod"] healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] interval: 10s @@ -60,8 +93,7 @@ services: build: context: ./engines/many-heuristic dockerfile: Dockerfile - ports: - - "8082:8080" + profiles: ["dev", "prod"] healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] interval: 10s @@ -69,12 +101,34 @@ services: retries: 5 start_period: 30s - frontend: + frontend-dev: build: context: ./frontend dockerfile: Dockerfile + target: dev + args: + - VITE_API_BASE_URL=${VITE_API_BASE_URL_DEV:-http://localhost:8000} + profiles: ["dev"] ports: - - "80:80" + - "5173:5173" + depends_on: + gateway-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:5173/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + frontend-prod: + build: + context: ./frontend + dockerfile: Dockerfile + target: prod-static + args: + - VITE_API_BASE_URL=${VITE_API_BASE_URL_PROD:-/api} + profiles: ["prod"] volumes: - ./examples:/usr/share/nginx/html/examples:ro depends_on: @@ -86,3 +140,44 @@ services: timeout: 5s retries: 3 start_period: 10s + + nginx-dev: + build: + context: ./nginx/local + dockerfile: Dockerfile + profiles: ["dev"] + ports: + - "80:80" + depends_on: + gateway-dev: + condition: service_healthy + frontend-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + nginx: + build: + context: ./nginx/production + dockerfile: Dockerfile + profiles: ["prod"] + ports: + - "80:80" + - "443:443" + volumes: + - ${NGINX_SSL_DIR:-./nginx/ssl}:/etc/nginx/ssl:ro + depends_on: + gateway: + condition: service_healthy + frontend-prod: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 58a40ed..5f095b1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,5 @@ -FROM node:20-slim as builder +FROM node:20-slim AS base -# Enable pnpm ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable @@ -12,17 +11,19 @@ RUN pnpm install --frozen-lockfile COPY . . -# Set default API URL for Docker deployment (can be overridden) -ENV VITE_API_BASE_URL=http://localhost:8000 +FROM base AS dev +ARG VITE_API_BASE_URL=http://localhost:8000 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +EXPOSE 5173 +CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] +FROM base AS build +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} RUN pnpm run build -FROM nginx:alpine -COPY --from=builder /app/dist /usr/share/nginx/html +FROM nginx:1.27-alpine AS prod-static COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Create examples directory and make it available to be mounted -RUN mkdir -p /usr/share/nginx/html/examples - +COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/local/Dockerfile b/nginx/local/Dockerfile new file mode 100644 index 0000000..24c08c2 --- /dev/null +++ b/nginx/local/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27.3 + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/nginx/local/nginx.conf b/nginx/local/nginx.conf new file mode 100644 index 0000000..a880795 --- /dev/null +++ b/nginx/local/nginx.conf @@ -0,0 +1,115 @@ +server { + + listen 80; + server_name localhost; + client_max_body_size 100M; + + large_client_header_buffers 8 64k; # Ajusta según tus necesidades + client_header_buffer_size 64k; # Ajusta el tamaño del buffer + + # location /static/assets/ { + # alias /usr/src/app/server/assets; + # } + + # location /api/media/ { + # alias /usr/src/app/flatter-backend/media/; + # } + + # location /admin { + # include fastcgi_params; + # proxy_pass http://flatter-backend:8000; + # proxy_redirect off; + + # proxy_connect_timeout 500; + # proxy_read_timeout 500; + + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # } + + location /api { + include fastcgi_params; + proxy_pass http://gateway-dev:8000; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + # location /static/ { + # include fastcgi_params; + # proxy_pass http://server:8080; + # proxy_redirect off; + + # proxy_connect_timeout 500; + # proxy_read_timeout 500; + + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # } + + location /static/ { + include fastcgi_params; + proxy_pass http://gateway-dev:8000; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + location /avatars/ { + include fastcgi_params; + proxy_pass http://gateway-dev:8000; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + location / { + include fastcgi_params; + proxy_pass http://frontend-dev:5173; + proxy_redirect off; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + # location /api/graphql/ { + # proxy_pass http://flatter-backend:8000; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # } +} diff --git a/nginx/production/Dockerfile b/nginx/production/Dockerfile new file mode 100644 index 0000000..24c08c2 --- /dev/null +++ b/nginx/production/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27.3 + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf new file mode 100644 index 0000000..2a5e1d4 --- /dev/null +++ b/nginx/production/nginx.conf @@ -0,0 +1,127 @@ +server { + + listen 443 ssl; + server_name openbinding.score.us.es openbinding.us.es; + client_max_body_size 100M; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + large_client_header_buffers 8 64k; # Ajusta según tus necesidades + client_header_buffer_size 64k; # Ajusta el tamaño del buffer + + location /api { + include fastcgi_params; + proxy_pass http://gateway:8000; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + # location /harvey-api/events { + # # 1. Reescritura: Transforma "/harvey-api/events" en "/events" + # rewrite ^/harvey-api/(.*)$ /$1 break; + + # # 2. Proxy Pass: Se envía a la raíz del servicio, pero con la URI modificada arriba + # proxy_pass http://harvey:8086; + # proxy_redirect off; + + # # 3. CRUCIAL PARA SSE: Desactivar el buffering + # # Si no pones esto, Nginx esperará a llenar un bloque de datos antes de enviarlo, + # # rompiendo el tiempo real. + # proxy_buffering off; + # proxy_cache off; + + # # 4. Conexión Persistente + # proxy_http_version 1.1; + # proxy_set_header Connection ""; # Asegura que se use keep-alive + + # # 5. Timeouts + # # Nota: Si el stream está inactivo (sin enviar datos) por más de 500s, + # # Nginx cortará la conexión. Asegúrate que tu backend envíe "heartbeats" o pings. + # proxy_connect_timeout 500; + # proxy_read_timeout 500; + + # # 6. Cabeceras estándar + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # } + + # location /harvey-api/ { + # include fastcgi_params; + # proxy_pass http://harvey:8086/; + # proxy_redirect off; + + # proxy_connect_timeout 500; + # proxy_read_timeout 500; + + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # } + + location /static/ { + include fastcgi_params; + proxy_pass http://gateway:8000; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + location /avatars/ { + include fastcgi_params; + proxy_pass http://gateway:8000; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } + + location / { + include fastcgi_params; + proxy_pass http://frontend-prod:80; + proxy_redirect off; + + proxy_connect_timeout 500; + proxy_read_timeout 500; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + } +} + +server { + listen 80; + server_name openbinding.score.us.es openbinding.us.es; + large_client_header_buffers 8 64k; # Ajusta según tus necesidades + client_header_buffer_size 64k; # Ajusta el tamaño del buffer + + location / { + return 301 https://$host$request_uri; + } +} \ No newline at end of file diff --git a/nginx/ssl/.gitignore b/nginx/ssl/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/nginx/ssl/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/openbinding-gateway/src/openbinding_gateway/main.py b/openbinding-gateway/src/openbinding_gateway/main.py index 179e9c6..216b3ad 100644 --- a/openbinding-gateway/src/openbinding_gateway/main.py +++ b/openbinding-gateway/src/openbinding_gateway/main.py @@ -114,10 +114,21 @@ async def lifespan(app: FastAPI): from fastapi.middleware.cors import CORSMiddleware + +def _parse_csv_env(value: str) -> List[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +cors_origins = _parse_csv_env(os.getenv("CORS_ALLOW_ORIGINS", "*")) +cors_allow_credentials = os.getenv("CORS_ALLOW_CREDENTIALS", "true").lower() == "true" + +if "*" in cors_origins: + cors_allow_credentials = False + app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=cors_origins, + allow_credentials=cors_allow_credentials, allow_methods=["*"], allow_headers=["*"], ) From 028babb5883fd2bb684d827b9952ff0c4e836d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:17:12 +0100 Subject: [PATCH 02/18] feat: fixing deployment --- .env.example | 8 ++++- docker-compose.yml | 35 ++++++++++++++++---- frontend/Dockerfile | 2 +- frontend/src/pages/Playground/Playground.tsx | 2 +- nginx/local/nginx.conf | 5 +++ 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 3043a01..f4804a3 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ VITE_API_BASE_URL_DEV=http://localhost:8000 VITE_API_BASE_URL_PROD=/api # Backend CORS policies -CORS_ALLOW_ORIGINS_DEV=http://localhost:80,http://localhost:5173,http://127.0.0.1:80,http://127.0.0.1:5173 +CORS_ALLOW_ORIGINS_DEV=http://localhost,http://localhost:80,http://localhost:5173,http://127.0.0.1,http://127.0.0.1:80,http://127.0.0.1:5173 CORS_ALLOW_ORIGINS_PROD=https://openbinding.score.us.es,https://openbinding.us.es # TLS certificates directory for production nginx @@ -14,3 +14,9 @@ CORS_ALLOW_ORIGINS_PROD=https://openbinding.score.us.es,https://openbinding.us.e # - fullchain.pem # - privkey.pem NGINX_SSL_DIR=./nginx/ssl + +# Optional corporate proxy (primarily used during Docker build) +# Leave empty if you are not behind a proxy. +HTTP_PROXY= +HTTPS_PROXY= +NO_PROXY=localhost,127.0.0.1,gateway,gateway-dev,engine-minizinc,engine-random-search,engine-many-heuristic,frontend-dev,frontend-prod,nginx,nginx-dev diff --git a/docker-compose.yml b/docker-compose.yml index 3f62aa3..75d9571 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,15 @@ +x-proxy-build-args: &proxy-build-args + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,gateway,gateway-dev,engine-minizinc,engine-random-search,engine-many-heuristic,frontend-dev,frontend-prod,nginx,nginx-dev} + services: gateway: build: context: ./openbinding-gateway dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["prod"] volumes: - ./schemas:/app/schemas @@ -35,6 +42,8 @@ services: build: context: ./openbinding-gateway dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["dev"] ports: - "8000:8000" @@ -47,7 +56,7 @@ services: - GENERAL_SCHEMA_PATH=/app/schemas/general/schema.json - SCHEMAS_DIR=/app/schemas - PYTHONPATH=/app/src - - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS_DEV:-http://localhost:80,http://localhost:5173,http://127.0.0.1:80,http://127.0.0.1:5173} + - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS_DEV:-http://localhost,http://localhost:80,http://localhost:5173,http://127.0.0.1,http://127.0.0.1:80,http://127.0.0.1:5173} - ENGINE_MINIZINC_URL=http://engine-minizinc:3000 - ENGINE_RANDOM_SEARCH_URL=http://engine-random-search:8080 - ENGINE_MANY_HEURISTIC_URL=http://engine-many-heuristic:8080 @@ -69,6 +78,8 @@ services: build: context: ./engines/minizinc-csp dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["dev", "prod"] healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"] @@ -81,6 +92,8 @@ services: build: context: ./engines/random-search dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["dev", "prod"] healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] @@ -93,6 +106,8 @@ services: build: context: ./engines/many-heuristic dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["dev", "prod"] healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] @@ -107,7 +122,8 @@ services: dockerfile: Dockerfile target: dev args: - - VITE_API_BASE_URL=${VITE_API_BASE_URL_DEV:-http://localhost:8000} + <<: *proxy-build-args + VITE_API_BASE_URL: ${VITE_API_BASE_URL_DEV:-http://localhost:8000} profiles: ["dev"] ports: - "5173:5173" @@ -115,7 +131,7 @@ services: gateway-dev: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:5173/"] + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:5173/', (r) => process.exit(r.statusCode >= 200 && r.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))"] interval: 30s timeout: 5s retries: 3 @@ -127,7 +143,8 @@ services: dockerfile: Dockerfile target: prod-static args: - - VITE_API_BASE_URL=${VITE_API_BASE_URL_PROD:-/api} + <<: *proxy-build-args + VITE_API_BASE_URL: ${VITE_API_BASE_URL_PROD:-/api} profiles: ["prod"] volumes: - ./examples:/usr/share/nginx/html/examples:ro @@ -145,16 +162,20 @@ services: build: context: ./nginx/local dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["dev"] ports: - "80:80" + volumes: + - ./examples:/usr/share/nginx/html/examples:ro depends_on: gateway-dev: condition: service_healthy frontend-dev: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost/"] + test: ["CMD", "nginx", "-t"] interval: 30s timeout: 5s retries: 3 @@ -164,6 +185,8 @@ services: build: context: ./nginx/production dockerfile: Dockerfile + args: + <<: *proxy-build-args profiles: ["prod"] ports: - "80:80" @@ -176,7 +199,7 @@ services: frontend-prod: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost/"] + test: ["CMD", "nginx", "-t"] interval: 30s timeout: 5s retries: 3 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5f095b1..caba842 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,7 +15,7 @@ FROM base AS dev ARG VITE_API_BASE_URL=http://localhost:8000 ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} EXPOSE 5173 -CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] +CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "5173"] FROM base AS build ARG VITE_API_BASE_URL=/api diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 203db62..32c5179 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -40,7 +40,7 @@ const AVAILABLE_EXAMPLES = { 'demo/02_parallel.json', 'demo/03_xor_choice.json', 'demo/04_conflict.json', - 'demo/05_mono_obj_various.json', + 'demo/05_single_obj_various.json', 'demo/06_loops.json', 'demo/07_soft_constraints.json', 'demo/08_dependencies.json', diff --git a/nginx/local/nginx.conf b/nginx/local/nginx.conf index a880795..0aa2295 100644 --- a/nginx/local/nginx.conf +++ b/nginx/local/nginx.conf @@ -85,6 +85,11 @@ server { } + location /examples/ { + alias /usr/share/nginx/html/examples/; + try_files $uri =404; + } + location / { include fastcgi_params; proxy_pass http://frontend-dev:5173; From c7fc8bef03ea3055363649b58bbc5826d3501c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:28:06 +0100 Subject: [PATCH 03/18] feat: fixing deployment --- engines/many-heuristic/Dockerfile | 28 ++++++++++++++++++++++++++++ engines/minizinc-csp/Dockerfile | 14 ++++++++++++++ engines/random-search/Dockerfile | 28 ++++++++++++++++++++++++++++ frontend/Dockerfile | 28 ++++++++++++++++++++++++++++ nginx/local/Dockerfile | 14 ++++++++++++++ nginx/production/Dockerfile | 14 ++++++++++++++ openbinding-gateway/Dockerfile | 14 ++++++++++++++ 7 files changed, 140 insertions(+) diff --git a/engines/many-heuristic/Dockerfile b/engines/many-heuristic/Dockerfile index 2ea4324..49c4e4e 100644 --- a/engines/many-heuristic/Dockerfile +++ b/engines/many-heuristic/Dockerfile @@ -1,4 +1,18 @@ FROM maven:3.8-openjdk-8 as builder +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + WORKDIR /app COPY pom.xml . COPY src ./src @@ -6,6 +20,20 @@ COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:8-jre +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/ManyHeuristicQoSawareCWSBinding-0.0.1-SNAPSHOT.jar app.jar diff --git a/engines/minizinc-csp/Dockerfile b/engines/minizinc-csp/Dockerfile index d223608..cb0d0bf 100644 --- a/engines/minizinc-csp/Dockerfile +++ b/engines/minizinc-csp/Dockerfile @@ -1,5 +1,19 @@ FROM node:20-slim +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + # Enable pnpm ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/engines/random-search/Dockerfile b/engines/random-search/Dockerfile index d025bf2..0899841 100644 --- a/engines/random-search/Dockerfile +++ b/engines/random-search/Dockerfile @@ -1,4 +1,18 @@ FROM maven:3.8-openjdk-8 as builder +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + WORKDIR /app COPY pom.xml . COPY src ./src @@ -6,6 +20,20 @@ COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:8-jre +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/RandomSearchQoSawareCWSBinding-0.0.1-SNAPSHOT.jar app.jar diff --git a/frontend/Dockerfile b/frontend/Dockerfile index caba842..9708d43 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,19 @@ FROM node:20-slim AS base +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable @@ -23,6 +37,20 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} RUN pnpm run build FROM nginx:1.27-alpine AS prod-static +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 diff --git a/nginx/local/Dockerfile b/nginx/local/Dockerfile index 24c08c2..9369117 100644 --- a/nginx/local/Dockerfile +++ b/nginx/local/Dockerfile @@ -1,4 +1,18 @@ FROM nginx:1.27.3 +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + RUN rm /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/nginx/production/Dockerfile b/nginx/production/Dockerfile index 24c08c2..9369117 100644 --- a/nginx/production/Dockerfile +++ b/nginx/production/Dockerfile @@ -1,4 +1,18 @@ FROM nginx:1.27.3 +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + RUN rm /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/openbinding-gateway/Dockerfile b/openbinding-gateway/Dockerfile index dd71c0c..b906111 100644 --- a/openbinding-gateway/Dockerfile +++ b/openbinding-gateway/Dockerfile @@ -1,4 +1,18 @@ FROM python:3.11-slim +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$HTTP_PROXY \ + https_proxy=$HTTPS_PROXY \ + no_proxy=$NO_PROXY + COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* From 8a5b31dc4f0691ab4ea7c55718f34e449ade417f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:35:27 +0100 Subject: [PATCH 04/18] feat: fixing deployment --- engines/many-heuristic/Dockerfile | 7 ------- engines/minizinc-csp/Dockerfile | 4 +--- engines/random-search/Dockerfile | 6 ------ frontend/Dockerfile | 6 ------ nginx/local/Dockerfile | 3 --- nginx/production/Dockerfile | 3 --- openbinding-gateway/Dockerfile | 3 --- 7 files changed, 1 insertion(+), 31 deletions(-) diff --git a/engines/many-heuristic/Dockerfile b/engines/many-heuristic/Dockerfile index 49c4e4e..5ca65f2 100644 --- a/engines/many-heuristic/Dockerfile +++ b/engines/many-heuristic/Dockerfile @@ -2,10 +2,6 @@ FROM maven:3.8-openjdk-8 as builder ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy - ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ NO_PROXY=$NO_PROXY \ @@ -23,9 +19,6 @@ FROM eclipse-temurin:8-jre ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ diff --git a/engines/minizinc-csp/Dockerfile b/engines/minizinc-csp/Dockerfile index cb0d0bf..d40b1f6 100644 --- a/engines/minizinc-csp/Dockerfile +++ b/engines/minizinc-csp/Dockerfile @@ -3,9 +3,7 @@ FROM node:20-slim ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy + ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ diff --git a/engines/random-search/Dockerfile b/engines/random-search/Dockerfile index 0899841..a2323da 100644 --- a/engines/random-search/Dockerfile +++ b/engines/random-search/Dockerfile @@ -2,9 +2,6 @@ FROM maven:3.8-openjdk-8 as builder ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ @@ -23,9 +20,6 @@ FROM eclipse-temurin:8-jre ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9708d43..1031198 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,9 +3,6 @@ FROM node:20-slim AS base ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ @@ -40,9 +37,6 @@ FROM nginx:1.27-alpine AS prod-static ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ diff --git a/nginx/local/Dockerfile b/nginx/local/Dockerfile index 9369117..c727257 100644 --- a/nginx/local/Dockerfile +++ b/nginx/local/Dockerfile @@ -3,9 +3,6 @@ FROM nginx:1.27.3 ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ diff --git a/nginx/production/Dockerfile b/nginx/production/Dockerfile index 9369117..c727257 100644 --- a/nginx/production/Dockerfile +++ b/nginx/production/Dockerfile @@ -3,9 +3,6 @@ FROM nginx:1.27.3 ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ diff --git a/openbinding-gateway/Dockerfile b/openbinding-gateway/Dockerfile index b906111..b8dcaf7 100644 --- a/openbinding-gateway/Dockerfile +++ b/openbinding-gateway/Dockerfile @@ -2,9 +2,6 @@ FROM python:3.11-slim ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY -ARG http_proxy -ARG https_proxy -ARG no_proxy ENV HTTP_PROXY=$HTTP_PROXY \ HTTPS_PROXY=$HTTPS_PROXY \ From 0b69d5ba6c746587660bed45e9565e3569231e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:42:35 +0100 Subject: [PATCH 05/18] feat: fixing deployment --- docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 75d9571..c2758f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: build: context: ./openbinding-gateway dockerfile: Dockerfile + network: host args: <<: *proxy-build-args profiles: ["prod"] @@ -42,6 +43,7 @@ services: build: context: ./openbinding-gateway dockerfile: Dockerfile + network: host args: <<: *proxy-build-args profiles: ["dev"] @@ -78,6 +80,7 @@ services: build: context: ./engines/minizinc-csp dockerfile: Dockerfile + network: host args: <<: *proxy-build-args profiles: ["dev", "prod"] @@ -92,6 +95,7 @@ services: build: context: ./engines/random-search dockerfile: Dockerfile + network: host args: <<: *proxy-build-args profiles: ["dev", "prod"] @@ -106,6 +110,7 @@ services: build: context: ./engines/many-heuristic dockerfile: Dockerfile + network: host args: <<: *proxy-build-args profiles: ["dev", "prod"] From 57d2e3f6e699723f458817c88d596d5b5c42ab97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:45:01 +0100 Subject: [PATCH 06/18] feat: fixing deployment --- docker-compose.yml | 5 ----- openbinding-gateway/Dockerfile | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c2758f6..75d9571 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ services: build: context: ./openbinding-gateway dockerfile: Dockerfile - network: host args: <<: *proxy-build-args profiles: ["prod"] @@ -43,7 +42,6 @@ services: build: context: ./openbinding-gateway dockerfile: Dockerfile - network: host args: <<: *proxy-build-args profiles: ["dev"] @@ -80,7 +78,6 @@ services: build: context: ./engines/minizinc-csp dockerfile: Dockerfile - network: host args: <<: *proxy-build-args profiles: ["dev", "prod"] @@ -95,7 +92,6 @@ services: build: context: ./engines/random-search dockerfile: Dockerfile - network: host args: <<: *proxy-build-args profiles: ["dev", "prod"] @@ -110,7 +106,6 @@ services: build: context: ./engines/many-heuristic dockerfile: Dockerfile - network: host args: <<: *proxy-build-args profiles: ["dev", "prod"] diff --git a/openbinding-gateway/Dockerfile b/openbinding-gateway/Dockerfile index b8dcaf7..0c3330f 100644 --- a/openbinding-gateway/Dockerfile +++ b/openbinding-gateway/Dockerfile @@ -19,7 +19,7 @@ WORKDIR /app # Install dependencies COPY pyproject.toml . COPY src/ src/ -RUN uv pip install --system ".[test]" +RUN --network=host uv pip install --system ".[test]" # Add test runner script RUN echo '#!/bin/sh\npytest "$@"' > /usr/local/bin/test && \ From ef0d129b1e7deee78c7df658be4c690a3e9bbae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:48:16 +0100 Subject: [PATCH 07/18] feat: fixing deployment --- openbinding-gateway/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openbinding-gateway/Dockerfile b/openbinding-gateway/Dockerfile index 0c3330f..14901f2 100644 --- a/openbinding-gateway/Dockerfile +++ b/openbinding-gateway/Dockerfile @@ -12,14 +12,15 @@ ENV HTTP_PROXY=$HTTP_PROXY \ COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/* +RUN printf 'precedence ::ffff:0:0/96 100\n' >> /etc/gai.conf WORKDIR /app # Install dependencies COPY pyproject.toml . COPY src/ src/ -RUN --network=host uv pip install --system ".[test]" +RUN uv pip install --system ".[test]" # Add test runner script RUN echo '#!/bin/sh\npytest "$@"' > /usr/local/bin/test && \ From b6dc2c13ef739d59dbc13ad264b405bc6f00af25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 19:49:23 +0100 Subject: [PATCH 08/18] feat: fixing deployment --- openbinding-gateway/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/openbinding-gateway/Dockerfile b/openbinding-gateway/Dockerfile index 14901f2..60e546b 100644 --- a/openbinding-gateway/Dockerfile +++ b/openbinding-gateway/Dockerfile @@ -13,7 +13,6 @@ ENV HTTP_PROXY=$HTTP_PROXY \ COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN printf 'precedence ::ffff:0:0/96 100\n' >> /etc/gai.conf WORKDIR /app From 90b887dc1b6a18c18145d31013ed83d63999c67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 20:31:48 +0100 Subject: [PATCH 09/18] feat: fixing deployment --- engines/many-heuristic/Dockerfile | 9 ++++++++- engines/random-search/Dockerfile | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/engines/many-heuristic/Dockerfile b/engines/many-heuristic/Dockerfile index 5ca65f2..339e7c5 100644 --- a/engines/many-heuristic/Dockerfile +++ b/engines/many-heuristic/Dockerfile @@ -13,7 +13,14 @@ WORKDIR /app COPY pom.xml . COPY src ./src # COPY lib ./lib # No lib present or empty -RUN mvn package -DskipTests +RUN mvn -DskipTests \ + -Djava.net.preferIPv4Stack=true -Djava.net.preferIPv6Addresses=false \ + -Dhttp.proxyHost=$(echo "$HTTP_PROXY" | sed -E 's#^https?://([^:/]+).*$#\1#') \ + -Dhttp.proxyPort=$(echo "$HTTP_PROXY" | sed -E 's#^https?://[^:/]+:([0-9]+).*$#\1#') \ + -Dhttps.proxyHost=$(echo "$HTTPS_PROXY" | sed -E 's#^https?://([^:/]+).*$#\1#') \ + -Dhttps.proxyPort=$(echo "$HTTPS_PROXY" | sed -E 's#^https?://[^:/]+:([0-9]+).*$#\1#') \ + -Dhttp.nonProxyHosts="localhost|127.0.0.1|*.int.local|10.*|192.168.*|172.16.*" \ + package FROM eclipse-temurin:8-jre ARG HTTP_PROXY diff --git a/engines/random-search/Dockerfile b/engines/random-search/Dockerfile index a2323da..4f823bc 100644 --- a/engines/random-search/Dockerfile +++ b/engines/random-search/Dockerfile @@ -14,7 +14,14 @@ WORKDIR /app COPY pom.xml . COPY src ./src # COPY lib ./lib # No lib present or empty -RUN mvn package -DskipTests +RUN mvn -DskipTests \ + -Djava.net.preferIPv4Stack=true -Djava.net.preferIPv6Addresses=false \ + -Dhttp.proxyHost=$(echo "$HTTP_PROXY" | sed -E 's#^https?://([^:/]+).*$#\1#') \ + -Dhttp.proxyPort=$(echo "$HTTP_PROXY" | sed -E 's#^https?://[^:/]+:([0-9]+).*$#\1#') \ + -Dhttps.proxyHost=$(echo "$HTTPS_PROXY" | sed -E 's#^https?://([^:/]+).*$#\1#') \ + -Dhttps.proxyPort=$(echo "$HTTPS_PROXY" | sed -E 's#^https?://[^:/]+:([0-9]+).*$#\1#') \ + -Dhttp.nonProxyHosts="localhost|127.0.0.1|*.int.local|10.*|192.168.*|172.16.*" \ + package FROM eclipse-temurin:8-jre ARG HTTP_PROXY From 2a5936c32d2a6c18e6fbd3d399ca9f5e6a8d164d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Mon, 2 Mar 2026 20:43:48 +0100 Subject: [PATCH 10/18] fix: feature direction in minizinc engine --- engines/minizinc-csp/src/dzn_builder.ts | 2 +- .../tests/integration/test_optimality.py | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/engines/minizinc-csp/src/dzn_builder.ts b/engines/minizinc-csp/src/dzn_builder.ts index 2c85415..7fcba29 100644 --- a/engines/minizinc-csp/src/dzn_builder.ts +++ b/engines/minizinc-csp/src/dzn_builder.ts @@ -335,7 +335,7 @@ export class DznBuilder { const qos_weights: number[] = []; for (const feat of features) { let w = Number(weightsObj[feat] || 0.0); - if (featureDirection[feat] === 'MAXIMIZE' && !featureUsesProductSpace[feat]) { + if (featureDirection[feat] === 'MAXIMIZE') { w = -w; } qos_weights.push(w); diff --git a/openbinding-gateway/tests/integration/test_optimality.py b/openbinding-gateway/tests/integration/test_optimality.py index 2682146..b6ead62 100644 --- a/openbinding-gateway/tests/integration/test_optimality.py +++ b/openbinding-gateway/tests/integration/test_optimality.py @@ -179,3 +179,45 @@ def test_loop_composition(gateway_url, wait_for_job, engine): instance["constraints"] = [] run_test(gateway_url, wait_for_job, engine, instance, {"T1": "C1"}, 30) + + +def test_product_maximize_direction(gateway_url, wait_for_job, engine): + """MAXIMIZE + PRODUCT must prefer highest product combination in MiniZinc.""" + if engine != "minizinc-csp": + pytest.skip("Direction/product regression test is specific to MiniZinc objective mapping") + + instance = copy.deepcopy(BASE_INSTANCE) + instance["features"] = [ + { + "id": "reliability", + "name": "Reliability", + "direction": "MAXIMIZE", + "scale": "RATIO", + "unit": "ratio", + "valid_range": {"min": 0.0, "max": 1.0}, + } + ] + instance["aggregation_policies"] = { + "reliability": { + "neutral": 1.0, + "compose": { + "seq": {"fn": "PRODUCT"}, + "xor": {"fn": "SCALED_PRODUCT"}, + "loop": {"fn": "PRODUCT"}, + }, + } + } + instance["objective"] = { + "type": "MONO", + "targets": ["reliability"], + "weights": {"reliability": 1.0}, + } + instance["constraints"] = [] + instance["candidates"] = [ + {"id": "C1", "task_id": "T1", "provider_id": "ProvA", "name": "C1", "features": {"reliability": 0.9}}, + {"id": "C2", "task_id": "T1", "provider_id": "ProvB", "name": "C2", "features": {"reliability": 0.3}}, + {"id": "C3", "task_id": "T2", "provider_id": "ProvA", "name": "C3", "features": {"reliability": 0.8}}, + {"id": "C4", "task_id": "T2", "provider_id": "ProvB", "name": "C4", "features": {"reliability": 0.4}}, + ] + + run_test(gateway_url, wait_for_job, engine, instance, {"T1": "C1", "T2": "C3"}, None) From d64eda9c41d248c3b3dad8e248b669b76b46a1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Tue, 3 Mar 2026 11:39:46 +0100 Subject: [PATCH 11/18] fix: prod deployment --- README.md | 1 + docker-compose.yml | 15 +++++++++------ frontend/Dockerfile | 7 ++----- frontend/nginx.conf | 28 ---------------------------- nginx/local/nginx.conf | 2 +- nginx/production/nginx.conf | 24 +++++++++++++----------- 6 files changed, 26 insertions(+), 51 deletions(-) delete mode 100644 frontend/nginx.conf diff --git a/README.md b/README.md index 6b57042..eb97ba3 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Example payloads that follow these schemas live in `examples/`. Production notes: * **Nginx** listens on ports **80/443**. + * `frontend-prod` generates static assets; only Nginx serves them publicly. * Configure DNS for `openbinding.score.us.es` and `openbinding.us.es`. * Place TLS files in `nginx/ssl/` (or override `NGINX_SSL_DIR`) with names: - `fullchain.pem` diff --git a/docker-compose.yml b/docker-compose.yml index 75d9571..18b2ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,18 +141,16 @@ services: build: context: ./frontend dockerfile: Dockerfile - target: prod-static + target: prod-assets args: <<: *proxy-build-args VITE_API_BASE_URL: ${VITE_API_BASE_URL_PROD:-/api} profiles: ["prod"] volumes: - - ./examples:/usr/share/nginx/html/examples:ro - depends_on: - gateway: - condition: service_healthy + - frontend_dist:/out + command: ["sh", "-c", "rm -rf /out/* && cp -a /dist/. /out/ && tail -f /dev/null"] healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost/"] + test: ["CMD", "test", "-f", "/out/index.html"] interval: 30s timeout: 5s retries: 3 @@ -192,6 +190,8 @@ services: - "80:80" - "443:443" volumes: + - frontend_dist:/usr/share/nginx/html:ro + - ./examples:/usr/share/nginx/html/examples:ro - ${NGINX_SSL_DIR:-./nginx/ssl}:/etc/nginx/ssl:ro depends_on: gateway: @@ -204,3 +204,6 @@ services: timeout: 5s retries: 3 start_period: 10s + +volumes: + frontend_dist: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1031198..a1c622d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -33,7 +33,7 @@ ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} RUN pnpm run build -FROM nginx:1.27-alpine AS prod-static +FROM alpine:3.21 AS prod-assets ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY @@ -45,7 +45,4 @@ ENV HTTP_PROXY=$HTTP_PROXY \ https_proxy=$HTTPS_PROXY \ no_proxy=$NO_PROXY -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +COPY --from=build /app/dist /dist diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index c361672..0000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,28 +0,0 @@ -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } - - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; -} diff --git a/nginx/local/nginx.conf b/nginx/local/nginx.conf index 0aa2295..a076474 100644 --- a/nginx/local/nginx.conf +++ b/nginx/local/nginx.conf @@ -2,7 +2,7 @@ server { listen 80; server_name localhost; - client_max_body_size 100M; + client_max_body_size 512M; large_client_header_buffers 8 64k; # Ajusta según tus necesidades client_header_buffer_size 64k; # Ajusta el tamaño del buffer diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf index 2a5e1d4..475e68e 100644 --- a/nginx/production/nginx.conf +++ b/nginx/production/nginx.conf @@ -2,7 +2,9 @@ server { listen 443 ssl; server_name openbinding.score.us.es openbinding.us.es; - client_max_body_size 100M; + client_max_body_size 512M; + root /usr/share/nginx/html; + index index.html; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; @@ -100,18 +102,18 @@ server { } - location / { - include fastcgi_params; - proxy_pass http://frontend-prod:80; - proxy_redirect off; - - proxy_connect_timeout 500; - proxy_read_timeout 500; + location /examples/ { + alias /usr/share/nginx/html/examples/; + try_files $uri =404; + } - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + location / { + try_files $uri $uri/ /index.html; } } From 40a978528f9dd3bb697ee7248a3761184c664ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Tue, 3 Mar 2026 11:42:51 +0100 Subject: [PATCH 12/18] fix: prod deployment --- docker-compose.yml | 2 +- nginx/production/nginx.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 18b2ac6..c650780 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -191,7 +191,7 @@ services: - "443:443" volumes: - frontend_dist:/usr/share/nginx/html:ro - - ./examples:/usr/share/nginx/html/examples:ro + - ./examples:/opt/openbinding/examples:ro - ${NGINX_SSL_DIR:-./nginx/ssl}:/etc/nginx/ssl:ro depends_on: gateway: diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf index 475e68e..40deaba 100644 --- a/nginx/production/nginx.conf +++ b/nginx/production/nginx.conf @@ -103,7 +103,7 @@ server { } location /examples/ { - alias /usr/share/nginx/html/examples/; + alias /opt/openbinding/examples/; try_files $uri =404; } From c376e78727441d8c140ab6dc942479b16de7371f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Wed, 4 Mar 2026 10:59:54 +0100 Subject: [PATCH 13/18] fix: production deployment --- frontend/index.html | 2 +- nginx/local/nginx.conf | 2 +- nginx/production/nginx.conf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 74edcae..69c8714 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + OpenBinding - QoS-Aware Service Composition diff --git a/nginx/local/nginx.conf b/nginx/local/nginx.conf index a076474..e43fc82 100644 --- a/nginx/local/nginx.conf +++ b/nginx/local/nginx.conf @@ -31,7 +31,7 @@ server { location /api { include fastcgi_params; - proxy_pass http://gateway-dev:8000; + proxy_pass http://gateway-dev:8000/; proxy_redirect off; proxy_connect_timeout 500; diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf index 40deaba..baf1712 100644 --- a/nginx/production/nginx.conf +++ b/nginx/production/nginx.conf @@ -18,7 +18,7 @@ server { location /api { include fastcgi_params; - proxy_pass http://gateway:8000; + proxy_pass http://gateway:8000/; proxy_redirect off; proxy_connect_timeout 500; From 2d1283c9abd858f2ac17df12bc13c7caf0a09898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Wed, 4 Mar 2026 11:36:47 +0100 Subject: [PATCH 14/18] fix: production deployment --- .env.example | 6 +++--- docker-compose.yml | 6 +++--- nginx/local/nginx.conf | 2 +- nginx/production/nginx.conf | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index f4804a3..90f27a3 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,6 @@ NGINX_SSL_DIR=./nginx/ssl # Optional corporate proxy (primarily used during Docker build) # Leave empty if you are not behind a proxy. -HTTP_PROXY= -HTTPS_PROXY= -NO_PROXY=localhost,127.0.0.1,gateway,gateway-dev,engine-minizinc,engine-random-search,engine-many-heuristic,frontend-dev,frontend-prod,nginx,nginx-dev +HTTP_PROXY_ARG= +HTTPS_PROXY_ARG= +NO_PROXY_ARG=localhost,127.0.0.1,gateway,gateway-dev,engine-minizinc,engine-random-search,engine-many-heuristic,frontend-dev,frontend-prod,nginx,nginx-dev diff --git a/docker-compose.yml b/docker-compose.yml index c650780..f4edaae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ x-proxy-build-args: &proxy-build-args - HTTP_PROXY: ${HTTP_PROXY:-} - HTTPS_PROXY: ${HTTPS_PROXY:-} - NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,gateway,gateway-dev,engine-minizinc,engine-random-search,engine-many-heuristic,frontend-dev,frontend-prod,nginx,nginx-dev} + HTTP_PROXY: ${HTTP_PROXY_ARG:-} + HTTPS_PROXY: ${HTTPS_PROXY_ARG:-} + NO_PROXY: ${NO_PROXY_ARG:-localhost,127.0.0.1,gateway,gateway-dev,engine-minizinc,engine-random-search,engine-many-heuristic,frontend-dev,frontend-prod,nginx,nginx-dev} services: gateway: diff --git a/nginx/local/nginx.conf b/nginx/local/nginx.conf index e43fc82..8f69a18 100644 --- a/nginx/local/nginx.conf +++ b/nginx/local/nginx.conf @@ -29,7 +29,7 @@ server { # } - location /api { + location /api/ { include fastcgi_params; proxy_pass http://gateway-dev:8000/; proxy_redirect off; diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf index baf1712..77b4f21 100644 --- a/nginx/production/nginx.conf +++ b/nginx/production/nginx.conf @@ -16,7 +16,7 @@ server { large_client_header_buffers 8 64k; # Ajusta según tus necesidades client_header_buffer_size 64k; # Ajusta el tamaño del buffer - location /api { + location /api/ { include fastcgi_params; proxy_pass http://gateway:8000/; proxy_redirect off; From f8b703d1e161258aaf1bccf96cbf24dc62173d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Wed, 4 Mar 2026 11:56:47 +0100 Subject: [PATCH 15/18] fix: production deployment --- nginx/local/nginx.conf | 11 ++++++----- nginx/production/nginx.conf | 12 ++++++------ openbinding-gateway/src/openbinding_gateway/main.py | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/nginx/local/nginx.conf b/nginx/local/nginx.conf index 8f69a18..a476580 100644 --- a/nginx/local/nginx.conf +++ b/nginx/local/nginx.conf @@ -29,17 +29,18 @@ server { # } - location /api/ { + location /api { include fastcgi_params; - proxy_pass http://gateway-dev:8000/; + proxy_pass http://gateway-dev:8000; proxy_redirect off; proxy_connect_timeout 500; proxy_read_timeout 500; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } diff --git a/nginx/production/nginx.conf b/nginx/production/nginx.conf index 77b4f21..ac3356d 100644 --- a/nginx/production/nginx.conf +++ b/nginx/production/nginx.conf @@ -16,17 +16,18 @@ server { large_client_header_buffers 8 64k; # Ajusta según tus necesidades client_header_buffer_size 64k; # Ajusta el tamaño del buffer - location /api/ { + location /api { include fastcgi_params; - proxy_pass http://gateway:8000/; + proxy_pass http://gateway:8000; proxy_redirect off; proxy_connect_timeout 500; proxy_read_timeout 500; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } @@ -85,7 +86,6 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } location /avatars/ { diff --git a/openbinding-gateway/src/openbinding_gateway/main.py b/openbinding-gateway/src/openbinding_gateway/main.py index 216b3ad..4515786 100644 --- a/openbinding-gateway/src/openbinding_gateway/main.py +++ b/openbinding-gateway/src/openbinding_gateway/main.py @@ -21,7 +21,7 @@ async def lifespan(app: FastAPI): yield -app = FastAPI(title="OpenBinding Gateway", lifespan=lifespan) +app = FastAPI(title="OpenBinding Gateway", lifespan=lifespan, root_path="/api") MAX_SOLVE_BODY_BYTES = 512 * 1024 * 1024 From 2eb0d7bbd89f6c0387e484fdff46ef53170617e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Fri, 6 Mar 2026 13:25:35 +0100 Subject: [PATCH 16/18] feat: new demo instance --- .../isa/qosawarewsbinding/api/Controller.java | 33 +- .../api/ControllerObjectiveValueTest.java | 86 ++++ examples/demo/13_fms.json | 408 ++++++++++++++++++ frontend/src/pages/Playground/Playground.tsx | 3 +- .../src/openbinding_gateway/routing/router.py | 3 + .../validation/engine_plugins/aggregation.py | 325 ++++++++++---- .../engine_plugins/many_heuristic.py | 32 +- .../validation/engine_plugins/minizinc_csp.py | 7 +- openbinding-gateway/tests/test_aggregation.py | 235 ++++++++++ .../tests/test_plugin_transformation.py | 152 +++++++ schemas/general/schema.json | 2 +- .../many-heuristic.schema.json | 2 +- .../specializations/minizinc-csp.schema.json | 2 +- .../specializations/random-search.schema.json | 2 +- 14 files changed, 1188 insertions(+), 104 deletions(-) create mode 100644 engines/many-heuristic/src/test/java/es/us/isa/qosawarewsbinding/api/ControllerObjectiveValueTest.java create mode 100644 examples/demo/13_fms.json create mode 100644 openbinding-gateway/tests/test_aggregation.py diff --git a/engines/many-heuristic/src/main/java/es/us/isa/qosawarewsbinding/api/Controller.java b/engines/many-heuristic/src/main/java/es/us/isa/qosawarewsbinding/api/Controller.java index 43fbd31..3b25dca 100644 --- a/engines/many-heuristic/src/main/java/es/us/isa/qosawarewsbinding/api/Controller.java +++ b/engines/many-heuristic/src/main/java/es/us/isa/qosawarewsbinding/api/Controller.java @@ -30,6 +30,7 @@ public class Controller implements HttpHandler { private static final long MAX_BODY_BYTES = 512L * 1024L * 1024L; private static final String PAYLOAD_TOO_LARGE_MESSAGE = "Request body is too large. Maximum allowed size is " + MAX_BODY_BYTES + " bytes."; + private static final String ERROR_PREFIX = "{\"error\": \""; private final Gson gson = new Gson(); @@ -48,13 +49,8 @@ public void handle(HttpExchange exchange) throws IOException { try { String contentLength = exchange.getRequestHeaders().getFirst("Content-Length"); - if (contentLength != null) { - try { - if (Long.parseLong(contentLength) > MAX_BODY_BYTES) { - throw new PayloadTooLargeException(PAYLOAD_TOO_LARGE_MESSAGE); - } - } catch (NumberFormatException ignored) { - } + if (contentLength != null && isPayloadTooLarge(contentLength)) { + throw new PayloadTooLargeException(PAYLOAD_TOO_LARGE_MESSAGE); } String requestBody = readBodyWithLimit(exchange.getRequestBody(), MAX_BODY_BYTES); @@ -68,13 +64,13 @@ public void handle(HttpExchange exchange) throws IOException { os.write(jsonResp.getBytes()); os.close(); } catch (IllegalArgumentException e) { - String error = "{\"error\": \"" + e.getMessage() + "\"}"; + String error = ERROR_PREFIX + e.getMessage() + "\"}"; exchange.sendResponseHeaders(422, error.length()); OutputStream os = exchange.getResponseBody(); os.write(error.getBytes()); os.close(); } catch (PayloadTooLargeException e) { - String error = "{\"error\": \"" + e.getMessage() + "\"}"; + String error = ERROR_PREFIX + e.getMessage() + "\"}"; exchange.sendResponseHeaders(413, error.length()); OutputStream os = exchange.getResponseBody(); os.write(error.getBytes()); @@ -85,7 +81,7 @@ public void handle(HttpExchange exchange) throws IOException { e.printStackTrace(pw); String stackTrace = sw.toString().replace("\"", "'").replace("\n", "\\n"); - String error = "{\"error\": \"" + e.getMessage() + "\", \"stack\": \"" + stackTrace + "\"}"; + String error = ERROR_PREFIX + e.getMessage() + "\", \"stack\": \"" + stackTrace + "\"}"; exchange.sendResponseHeaders(500, error.length()); OutputStream os = exchange.getResponseBody(); os.write(error.getBytes()); @@ -110,6 +106,19 @@ private String readBodyWithLimit(InputStream inputStream, long maxBytes) throws return new String(output.toByteArray(), StandardCharsets.UTF_8); } + private boolean isPayloadTooLarge(String contentLength) { + try { + return Long.parseLong(contentLength) > MAX_BODY_BYTES; + } catch (NumberFormatException ex) { + return false; + } + } + + static Double computeObjectiveValue(ProblemBuildResult mapped, QoSAwareWSCompositionSolution solution) { + Double objectiveValue = mapped.qosModel.evaluate(solution, mapped.structure); + return objectiveValue != null ? objectiveValue : 0.0; + } + private SolveResponse process(SolveRequest req) { ProblemBuilder builder = new ProblemBuilder(); ProblemBuildResult mapped = builder.build(req); @@ -150,9 +159,7 @@ private SolveResponse process(SolveRequest req) { dto.selection = new HashMap<>(); dto.aggregated_features = new HashMap<>(); - // Calculate objective value? For Many-Obj, it's a vector not a single value. - // keeping objective_value null or 0.0 effectively. - dto.objective_value = 0.0; + dto.objective_value = computeObjectiveValue(mapped, sol); for (QoSProperty p : mapped.propertyMap.values()) { double val = mapped.qosModel.evaluate(sol, p, mapped.structure); diff --git a/engines/many-heuristic/src/test/java/es/us/isa/qosawarewsbinding/api/ControllerObjectiveValueTest.java b/engines/many-heuristic/src/test/java/es/us/isa/qosawarewsbinding/api/ControllerObjectiveValueTest.java new file mode 100644 index 0000000..4c9f385 --- /dev/null +++ b/engines/many-heuristic/src/test/java/es/us/isa/qosawarewsbinding/api/ControllerObjectiveValueTest.java @@ -0,0 +1,86 @@ +package es.us.isa.qosawarewsbinding.api; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.HashMap; + +import org.junit.Test; + +import es.us.isa.qosawarewsbinding.AbstractWebService; +import es.us.isa.qosawarewsbinding.ConcreteWebService; +import es.us.isa.qosawarewsbinding.api.dto.SolveRequest; +import es.us.isa.qosawarewsbinding.api.mapping.ProblemBuildResult; +import es.us.isa.qosawarewsbinding.api.mapping.ProblemBuilder; +import es.us.isa.qosawarewsbinding.solution.vector.QoSAwareWSCompositionVectorSolution; + +public class ControllerObjectiveValueTest { + private static final String COST = "cost"; + private static final String RELIABILITY = "reliability"; + + @Test + public void testComputeObjectiveValueUsesConfiguredWeights() { + SolveRequest request = new SolveRequest(); + request.id = "many-objective-test"; + + request.composition = new SolveRequest.CompositionStructure(); + request.composition.type = "structured"; + request.composition.root = new SolveRequest.Node(); + request.composition.root.id = "root"; + request.composition.root.kind = "TASK"; + request.composition.root.task_id = "T1"; + + request.features = new SolveRequest.QoSModel(); + request.features.properties = new HashMap(); + request.features.weights = new HashMap(); + request.features.aggregation = new HashMap(); + + SolveRequest.QoSPropertyDef cost = new SolveRequest.QoSPropertyDef(); + cost.direction = "minimize"; + cost.min = 0.0; + cost.max = 100.0; + request.features.properties.put(COST, cost); + request.features.weights.put(COST, 0.25); + + SolveRequest.QoSPropertyDef reliability = new SolveRequest.QoSPropertyDef(); + reliability.direction = "maximize"; + reliability.min = 0.0; + reliability.max = 100.0; + request.features.properties.put(RELIABILITY, reliability); + request.features.weights.put(RELIABILITY, 0.75); + + SolveRequest.AggregationPolicy costAggregation = new SolveRequest.AggregationPolicy(); + costAggregation.seq = "sum"; + costAggregation.flow = "sum"; + costAggregation.branch = "sum"; + costAggregation.loop = "sum"; + request.features.aggregation.put(COST, costAggregation); + + SolveRequest.AggregationPolicy reliabilityAggregation = new SolveRequest.AggregationPolicy(); + reliabilityAggregation.seq = "sum"; + reliabilityAggregation.flow = "sum"; + reliabilityAggregation.branch = "sum"; + reliabilityAggregation.loop = "sum"; + request.features.aggregation.put(RELIABILITY, reliabilityAggregation); + + request.market = new HashMap(); + SolveRequest.ServiceCandidates serviceCandidates = new SolveRequest.ServiceCandidates(); + SolveRequest.Service service = new SolveRequest.Service(); + service.id = "cand_t1"; + service.name = "cand_t1"; + service.features = new HashMap(); + service.features.put(COST, 10.0); + service.features.put(RELIABILITY, 90.0); + serviceCandidates.services = Arrays.asList(service); + request.market.put("T1", serviceCandidates); + + ProblemBuildResult mapped = new ProblemBuilder().build(request); + QoSAwareWSCompositionVectorSolution solution = new QoSAwareWSCompositionVectorSolution(mapped.problem); + + AbstractWebService task = mapped.taskMap.get("T1"); + ConcreteWebService selected = mapped.market.get(task).iterator().next(); + solution.setSelectedService(task, selected); + + assertEquals(70.0, Controller.computeObjectiveValue(mapped, solution), 0.0001); + } +} \ No newline at end of file diff --git a/examples/demo/13_fms.json b/examples/demo/13_fms.json new file mode 100644 index 0000000..611d0f5 --- /dev/null +++ b/examples/demo/13_fms.json @@ -0,0 +1,408 @@ +{ + "metadata": { + "id": "fleet_routing_qaco_001", + "name": "Fleet Management System (FMS) QoS-aware Service Composition", + "version": "1.0.0", + "created_at": "2026-03-06T00:00:00Z", + "description": "QoS-aware service composition instance for a fleet management system (FMS).", + "level": "expert" + }, + "features": [ + { + "id": "cost", + "name": "Cost", + "direction": "MINIMIZE", + "unit": "USD/month", + "scale": "RATIO", + "valid_range": { + "min": 0, + "max": 10000 + }, + "description": "Monthly service subscription cost." + }, + { + "id": "execution_time", + "name": "Execution Time", + "direction": "MINIMIZE", + "unit": "ms", + "scale": "RATIO", + "valid_range": { + "min": 0, + "max": 20000 + }, + "description": "Average execution latency per service invocation." + }, + { + "id": "availability", + "name": "Availability", + "direction": "MAXIMIZE", + "unit": "percent", + "scale": "RATIO", + "valid_range": { + "min": 0, + "max": 100 + }, + "description": "Service availability percentage." + }, + { + "id": "reliability", + "name": "Reliability", + "direction": "MAXIMIZE", + "unit": "percent", + "scale": "RATIO", + "valid_range": { + "min": 0, + "max": 100 + }, + "description": "Successful execution percentage." + }, + { + "id": "security", + "name": "Security", + "direction": "MAXIMIZE", + "unit": "score", + "scale": "ORDINAL", + "valid_range": { + "min": 1, + "max": 5 + }, + "description": "Security score on a 1-5 ordinal scale." + } + ], + "providers": [ + { + "id": "google", + "name": "Google" + }, + { + "id": "amazon", + "name": "Amazon" + }, + { + "id": "radius", + "name": "Radius" + }, + { + "id": "samsara", + "name": "Samsara" + }, + { + "id": "here", + "name": "Here" + }, + { + "id": "ibm", + "name": "IBM" + }, + { + "id": "paypal", + "name": "PayPal" + }, + { + "id": "stripe", + "name": "Stripe" + } + ], + "tasks": [ + { + "id": "T1", + "name": "Cloud Deployment", + "description": "Deploy the fleet management platform in the cloud." + }, + { + "id": "T2", + "name": "Telemetry and Tracking", + "description": "Collect vehicle telemetry and tracking information." + }, + { + "id": "T3", + "name": "Distance and Travel Time", + "description": "Compute distances and travel times." + }, + { + "id": "T5", + "name": "Quantum Routing", + "description": "Solve routing optimization using a quantum platform." + }, + { + "id": "T6", + "name": "Billing and Payment", + "description": "Process billing and payment." + } + ], + "candidates": [ + { + "id": "cand_t1_google_cloud", + "task_id": "T1", + "provider_id": "google", + "name": "Google Cloud", + "features": { + "cost": 240, + "execution_time": 220, + "availability": 99.98, + "reliability": 99.8, + "security": 5 + } + }, + { + "id": "cand_t1_aws", + "task_id": "T1", + "provider_id": "amazon", + "name": "AWS", + "features": { + "cost": 220, + "execution_time": 210, + "availability": 99.95, + "reliability": 99.75, + "security": 5 + } + }, + { + "id": "cand_t2_radius_iot_network", + "task_id": "T2", + "provider_id": "radius", + "name": "Radius IoT/Network", + "features": { + "cost": 500, + "execution_time": 420, + "availability": 99.8, + "reliability": 99.3, + "security": 4 + } + }, + { + "id": "cand_t2_samsara_fleet_api", + "task_id": "T2", + "provider_id": "samsara", + "name": "Samsara Fleet API", + "features": { + "cost": 650, + "execution_time": 520, + "availability": 99.85, + "reliability": 99.4, + "security": 4 + } + }, + { + "id": "cand_t3_here_routing_api", + "task_id": "T3", + "provider_id": "here", + "name": "HERE Routing API", + "features": { + "cost": 300, + "execution_time": 380, + "availability": 99.9, + "reliability": 99.5, + "security": 4 + } + }, + { + "id": "cand_t3_distance_matrix_api", + "task_id": "T3", + "provider_id": "google", + "name": "Distance Matrix API", + "features": { + "cost": 900, + "execution_time": 300, + "availability": 99.95, + "reliability": 99.6, + "security": 5 + } + }, + { + "id": "cand_t5_amazon_braket", + "task_id": "T5", + "provider_id": "amazon", + "name": "Amazon Braket", + "features": { + "cost": 1200, + "execution_time": 9000, + "availability": 99.9, + "reliability": 99.0, + "security": 5 + } + }, + { + "id": "cand_t5_ibm_quantum", + "task_id": "T5", + "provider_id": "ibm", + "name": "IBM Quantum", + "features": { + "cost": 1500, + "execution_time": 12000, + "availability": 99.7, + "reliability": 99.7, + "security": 5 + } + }, + { + "id": "cand_t6_paypal", + "task_id": "T6", + "provider_id": "paypal", + "name": "PayPal", + "features": { + "cost": 1200, + "execution_time": 450, + "availability": 99.95, + "reliability": 99.2, + "security": 4 + } + }, + { + "id": "cand_t6_stripe", + "task_id": "T6", + "provider_id": "stripe", + "name": "Stripe", + "features": { + "cost": 950, + "execution_time": 520, + "availability": 99.99, + "reliability": 99.5, + "security": 5 + } + } + ], + "composition": { + "type": "STRUCTURED", + "root": { + "id": "root_seq", + "kind": "SEQ", + "children": [ + { + "id": "node_t1", + "kind": "TASK", + "task_id": "T1" + }, + { + "id": "node_t2", + "kind": "TASK", + "task_id": "T2" + }, + { + "id": "node_t3", + "kind": "TASK", + "task_id": "T3" + }, + { + "id": "node_routing_choice", + "kind": "XOR", + "description": "Choose between classic routing and quantum routing.", + "branches": [ + { + "p": 0.8, + "child": { + "id": "node_classic_routing", + "kind": "ELEMENT", + "description": "Classic routing path executed outside the selectable candidate set." + } + }, + { + "p": 0.2, + "child": { + "id": "node_t5", + "kind": "TASK", + "task_id": "T5" + } + } + ] + }, + { + "id": "node_t6", + "kind": "TASK", + "task_id": "T6" + } + ] + } + }, + "aggregation_policies": { + "cost": { + "neutral": 0, + "compose": { + "seq": { + "fn": "SUM" + }, + "and": { + "fn": "SUM" + }, + "xor": { + "fn": "SCALED_SUM" + } + } + }, + "execution_time": { + "neutral": 0, + "compose": { + "seq": { + "fn": "SUM" + }, + "and": { + "fn": "MAX" + }, + "xor": { + "fn": "SCALED_SUM" + } + } + }, + "availability": { + "neutral": 1, + "compose": { + "seq": { + "fn": "PRODUCT" + }, + "and": { + "fn": "PRODUCT" + }, + "xor": { + "fn": "SCALED_SUM" + } + } + }, + "reliability": { + "neutral": 1, + "compose": { + "seq": { + "fn": "PRODUCT" + }, + "and": { + "fn": "PRODUCT" + }, + "xor": { + "fn": "SCALED_SUM" + } + } + }, + "security": { + "neutral": 5, + "compose": { + "seq": { + "fn": "MIN" + }, + "and": { + "fn": "MIN" + }, + "xor": { + "fn": "SCALED_SUM" + } + } + } + }, + "constraints": [], + "objective": { + "type": "MONO", + "targets": [ + "cost", + "execution_time", + "availability", + "reliability", + "security" + ], + "weights": { + "cost": 0.3, + "execution_time": 0.25, + "availability": 0.15, + "reliability": 0.15, + "security": 0.15 + }, + "weights_sum_to_one": true + } +} \ No newline at end of file diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 32c5179..166d214 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -47,7 +47,8 @@ const AVAILABLE_EXAMPLES = { 'demo/09_mixed.json', 'demo/10_large_scale.json', 'demo/11_multi_obj_negative.json', - 'demo/12_many_obj_pareto.json' + 'demo/12_many_obj_pareto.json', + 'demo/13_fms.json' ], 'Literature Examples': [ 'literature/benatallah.json', diff --git a/openbinding-gateway/src/openbinding_gateway/routing/router.py b/openbinding-gateway/src/openbinding_gateway/routing/router.py index a7fce85..50b62ab 100644 --- a/openbinding-gateway/src/openbinding_gateway/routing/router.py +++ b/openbinding-gateway/src/openbinding_gateway/routing/router.py @@ -6,6 +6,7 @@ from ..models.api import SolveRequest, SolveResponse from ..models.api import JobResponse, JobStatus, Feasibility from ..jobs import JobManager +from ..validation.engine_plugins.aggregation import canonicalize_result_data MAX_ENGINE_PAYLOAD_BYTES = 512 * 1024 * 1024 PAYLOAD_TOO_LARGE_MESSAGE = ( @@ -101,6 +102,7 @@ async def route_solve(self, request: SolveRequest, binding_space: Optional[Any] if "job_id" not in data and sync_selection is not None: # It's a synchronous result result_data = plugin.transform_response(data, request.instance) + result_data = canonicalize_result_data(result_data, request.instance) feasibility = self._compute_feasibility(request.engine_id, result_data) job = JobManager.create_job(request.engine_id, "sync", service_url) @@ -219,6 +221,7 @@ async def get_job_status(self, job_id: str) -> Optional[JobResponse]: plugin = EngineRegistry.get_plugin(job.engine_id) original_request = job.metadata.get("original_request") or {} result_data = plugin.transform_response(data, original_request) + result_data = canonicalize_result_data(result_data, original_request) feasibility = self._compute_feasibility(job.engine_id, result_data) diagnostics = {} diff --git a/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/aggregation.py b/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/aggregation.py index e43636c..3e170ec 100644 --- a/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/aggregation.py +++ b/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/aggregation.py @@ -15,7 +15,14 @@ def build_selected_candidate_by_task( return selected -def _default_for(feature_id: str, features: Dict[str, Any], agg_policies: Dict[str, Any]) -> float: +def _uses_product_space(feature_id: str, agg_policies: Dict[str, Any]) -> bool: + policy = agg_policies.get(feature_id, {}) or {} + compose = policy.get("compose", {}) or {} + fns = [compose.get("seq", {}).get("fn"), compose.get("and", {}).get("fn"), compose.get("xor", {}).get("fn"), compose.get("loop", {}).get("fn")] + return any(str(fn or "").lower() in ("product", "scaled_product") for fn in fns) + + +def _raw_default_for(feature_id: str, features: Dict[str, Any], agg_policies: Dict[str, Any]) -> float: policy = agg_policies.get(feature_id, {}) if "neutral" in policy and isinstance(policy.get("neutral"), (int, float)): return float(policy["neutral"]) @@ -27,6 +34,46 @@ def _default_for(feature_id: str, features: Dict[str, Any], agg_policies: Dict[s return float(vr.get("max", 0.0)) +def _product_ratio_denominator(feature_id: str, features: Dict[str, Any], agg_policies: Dict[str, Any]) -> float: + if not _uses_product_space(feature_id, agg_policies): + return 1.0 + + feat = features.get(feature_id, {}) or {} + scale = str(feat.get("scale") or "").upper() + vr = feat.get("valid_range") or {} + + try: + mx = float(vr.get("max", 1.0)) + except (TypeError, ValueError): + mx = 1.0 + + if scale == "RATIO" and mx > 1.0: + return mx + return 1.0 + + +def _to_composition_value(raw: float, feature_id: str, features: Dict[str, Any], agg_policies: Dict[str, Any]) -> float: + denominator = _product_ratio_denominator(feature_id, features, agg_policies) + if denominator <= 1.0: + return raw + + if 0.0 <= raw <= 1.0: + return raw + return raw / denominator + + +def _from_composition_value(value: float, feature_id: str, features: Dict[str, Any], agg_policies: Dict[str, Any]) -> float: + denominator = _product_ratio_denominator(feature_id, features, agg_policies) + if denominator <= 1.0: + return value + return value * denominator + + +def _default_composition_value(feature_id: str, features: Dict[str, Any], agg_policies: Dict[str, Any]) -> float: + raw = _raw_default_for(feature_id, features, agg_policies) + return _to_composition_value(raw, feature_id, features, agg_policies) + + def _agg_fn(fn: Optional[str], values: List[float], weights: Optional[List[float]] = None) -> float: if not values: return 0.0 @@ -52,6 +99,79 @@ def _agg_fn(fn: Optional[str], values: List[float], weights: Optional[List[float return sum(values) +def _task_value( + node: Dict[str, Any], + feature_id: str, + selected_candidate_by_task: Dict[str, Dict[str, Any]], + features: Dict[str, Any], + agg_policies: Dict[str, Any], +) -> float: + task_id = node.get("task_id") + cand = selected_candidate_by_task.get(task_id) + if cand is None: + return _default_composition_value(feature_id, features, agg_policies) + + raw = float((cand.get("features", {}) or {}).get(feature_id, _raw_default_for(feature_id, features, agg_policies))) + return _to_composition_value(raw, feature_id, features, agg_policies) + + +def _compose_seq_or_and( + kind: str, + node: Dict[str, Any], + feature_id: str, + selected_candidate_by_task: Dict[str, Dict[str, Any]], + features: Dict[str, Any], + agg_policies: Dict[str, Any], +) -> float: + compose = (agg_policies.get(feature_id, {}) or {}).get("compose", {}) or {} + children = node.get("children", []) or [] + values = [_compose_value(c, feature_id, selected_candidate_by_task, features, agg_policies) for c in children] + fn = compose.get("seq" if kind == "SEQ" else "and", {}).get("fn") + return _agg_fn(fn or ("sum" if kind == "SEQ" else "max"), values) + + +def _compose_xor( + node: Dict[str, Any], + feature_id: str, + selected_candidate_by_task: Dict[str, Dict[str, Any]], + features: Dict[str, Any], + agg_policies: Dict[str, Any], +) -> float: + compose = (agg_policies.get(feature_id, {}) or {}).get("compose", {}) or {} + branches = node.get("branches", []) or [] + values = [_compose_value(b.get("child", {}), feature_id, selected_candidate_by_task, features, agg_policies) for b in branches] + probs = [float(b.get("p", 0.0)) for b in branches] + fn = compose.get("xor", {}).get("fn") + if str(fn or "").lower() in ("", "sum", "weighted_sum", "scaled_sum"): + return _agg_fn("weighted_sum", values, probs) + return _agg_fn(fn, values) + + +def _compose_loop( + node: Dict[str, Any], + feature_id: str, + selected_candidate_by_task: Dict[str, Dict[str, Any]], + features: Dict[str, Any], + agg_policies: Dict[str, Any], +) -> float: + compose = (agg_policies.get(feature_id, {}) or {}).get("compose", {}) or {} + body = node.get("body", {}) or {} + body_val = _compose_value(body, feature_id, selected_candidate_by_task, features, agg_policies) + fn = str(compose.get("loop", {}).get("fn") or "sum").lower() + + iterations = node.get("expected_iterations") + if iterations is None: + bounds = node.get("bounds") or {} + iterations = bounds.get("max", 1) + count = float(iterations) + + if "product" in fn: + return float(body_val ** count) + if "sum" in fn or "wsum" in fn or "scale" in fn: + return float(body_val * count) + return body_val + + def _compose_value( node: Dict[str, Any], feature_id: str, @@ -60,49 +180,23 @@ def _compose_value( agg_policies: Dict[str, Any], ) -> float: kind = node.get("kind") - policy = agg_policies.get(feature_id, {}) - compose = policy.get("compose", {}) if kind == "TASK": - task_id = node.get("task_id") - cand = selected_candidate_by_task.get(task_id) - if cand is None: - return _default_for(feature_id, features, agg_policies) - return float((cand.get("features", {}) or {}).get(feature_id, _default_for(feature_id, features, agg_policies))) + return _task_value(node, feature_id, selected_candidate_by_task, features, agg_policies) + + if kind == "ELEMENT": + return _default_composition_value(feature_id, features, agg_policies) if kind in ("SEQ", "AND"): - children = node.get("children", []) or [] - values = [_compose_value(c, feature_id, selected_candidate_by_task, features, agg_policies) for c in children] - fn = compose.get("seq" if kind == "SEQ" else "and", {}).get("fn") - return _agg_fn(fn or ("sum" if kind == "SEQ" else "max"), values) + return _compose_seq_or_and(kind, node, feature_id, selected_candidate_by_task, features, agg_policies) if kind == "XOR": - branches = node.get("branches", []) or [] - values = [_compose_value(b.get("child", {}), feature_id, selected_candidate_by_task, features, agg_policies) for b in branches] - probs = [float(b.get("p", 0.0)) for b in branches] - fn = compose.get("xor", {}).get("fn") - if fn in (None, "sum", "weighted_sum", "scaled_sum", "SCALED_SUM"): - return _agg_fn("weighted_sum", values, probs) - return _agg_fn(fn, values) + return _compose_xor(node, feature_id, selected_candidate_by_task, features, agg_policies) if kind == "LOOP": - body = node.get("body", {}) or {} - body_val = _compose_value(body, feature_id, selected_candidate_by_task, features, agg_policies) - fn = compose.get("loop", {}).get("fn") - iterations = node.get("expected_iterations") - if iterations is None: - bounds = node.get("bounds") or {} - iterations = bounds.get("max", 1) - c = float(iterations) - - fn_lower = (fn or "sum").lower() - if "product" in fn_lower: - return float(body_val ** c) - if "sum" in fn_lower or "wsum" in fn_lower or "scale" in fn_lower: - return float(body_val * c) - return body_val + return _compose_loop(node, feature_id, selected_candidate_by_task, features, agg_policies) - return _default_for(feature_id, features, agg_policies) + return _default_composition_value(feature_id, features, agg_policies) def compute_aggregated_qos( @@ -113,67 +207,146 @@ def compute_aggregated_qos( ) -> Dict[str, float]: aggregated_qos: Dict[str, float] = {} for fid in features.keys(): - aggregated_qos[fid] = _compose_value( + composed = _compose_value( composition_root, fid, selected_candidate_by_task, features, agg_policies, ) + aggregated_qos[fid] = _from_composition_value(composed, fid, features, agg_policies) return aggregated_qos +def _recompute_solution_aggregated_features( + solution: Dict[str, Any], + root: Dict[str, Any], + features: Dict[str, Any], + agg_policies: Dict[str, Any], + candidates_by_id: Dict[str, Dict[str, Any]], +) -> None: + binding = solution.get("binding") + if not isinstance(binding, dict) or not binding or not root or not features: + solution.setdefault("aggregated_features", {}) + return + + selected_candidate_by_task = build_selected_candidate_by_task(binding, candidates_by_id) + solution["aggregated_features"] = compute_aggregated_qos( + root, + features, + selected_candidate_by_task, + agg_policies, + ) + + +def _objective_weights(obj: Dict[str, Any]) -> Dict[str, float]: + objective_type = str(obj.get("type") or "").upper() + weights = {str(fid): float(weight) for fid, weight in (obj.get("weights", {}) or {}).items()} + + if objective_type in {"MONO", "MANY", "MULTI"}: + for target in obj.get("targets", []) or []: + weights.setdefault(str(target), 1.0) + + return weights + + +def _canonicalize_solution_objective_value( + solution: Dict[str, Any], + objective: Dict[str, Any], + features: Dict[str, Any], + agg_policies: Dict[str, Any], +) -> None: + aggregated_qos = solution.get("aggregated_features") + if not isinstance(aggregated_qos, dict): + return + + objective_type = str(objective.get("type") or "").upper() + if objective_type not in {"MONO", "WEIGHTED_SUM", "MANY", "MULTI"}: + return + + if objective_type in {"MONO", "WEIGHTED_SUM"} and solution.get("objective_value") is not None: + return + + normalized_qos = normalize_qos(aggregated_qos, features, agg_policies) + solution["objective_value"] = compute_objective_value(objective, normalized_qos) + + +def canonicalize_result_data(result_data: Dict[str, Any], original_request: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(result_data, dict): + return result_data + + solutions = result_data.get("solutions") + if not isinstance(solutions, list): + return result_data + + root = (original_request.get("composition") or {}).get("root") or {} + features = {feature["id"]: feature for feature in (original_request.get("features") or [])} + agg_policies = (original_request.get("aggregation_policies") or {}) + candidates_by_id = {candidate["id"]: candidate for candidate in (original_request.get("candidates") or [])} + objective = original_request.get("objective") or {} + for solution in solutions: + if not isinstance(solution, dict): + continue + _recompute_solution_aggregated_features(solution, root, features, agg_policies, candidates_by_id) + _canonicalize_solution_objective_value(solution, objective, features, agg_policies) + + return result_data + + def normalize_qos( aggregated_qos: Dict[str, float], features: Dict[str, Any], agg_policies: Dict[str, Any], ) -> Dict[str, float]: - def _normalize_value(feature_id: str, raw: float) -> float: - norm = (agg_policies.get(feature_id, {}) or {}).get("normalize") - if not norm: - return raw - - ntype = norm.get("type") - increasing = norm.get("increasing_is_better") - if increasing is None: - direction = (features.get(feature_id, {}) or {}).get("direction") - increasing = True if direction == "maximize" else False - - if ntype == "minmax": - bounds = norm.get("bounds") or {} - mn = float(bounds.get("min", 0.0)) - mx = float(bounds.get("max", 1.0)) - if mx == mn: - return 0.0 - v = (raw - mn) / (mx - mn) - if v < 0.0: - v = 0.0 - if v > 1.0: - v = 1.0 - return v if increasing else (1.0 - v) - - if ntype == "identity" or ntype is None: - return raw + return {fid: _normalize_value(fid, val, features, agg_policies) for fid, val in aggregated_qos.items()} + + +def _is_increasing(feature_id: str, features: Dict[str, Any], norm: Dict[str, Any]) -> bool: + increasing = norm.get("increasing_is_better") + if increasing is not None: + return bool(increasing) + + direction = str((features.get(feature_id, {}) or {}).get("direction") or "").lower() + return direction == "maximize" + +def _normalize_minmax(raw: float, bounds: Dict[str, Any], increasing: bool) -> float: + mn = float(bounds.get("min", 0.0)) + mx = float(bounds.get("max", 1.0)) + if mx == mn: + return 0.0 + + value = (raw - mn) / (mx - mn) + value = min(1.0, max(0.0, value)) + return value if increasing else (1.0 - value) + + +def _normalize_value( + feature_id: str, + raw: float, + features: Dict[str, Any], + agg_policies: Dict[str, Any], +) -> float: + norm = (agg_policies.get(feature_id, {}) or {}).get("normalize") + if not norm: return raw - return {fid: _normalize_value(fid, val) for fid, val in aggregated_qos.items()} + ntype = norm.get("type") + if ntype == "minmax": + return _normalize_minmax(raw, norm.get("bounds") or {}, _is_increasing(feature_id, features, norm)) + if ntype in ("identity", None): + return raw + return raw def compute_objective_value(obj: Dict[str, Any], normalized_qos: Dict[str, float]) -> float: + objective_type = str(obj.get("type") or "").upper() + if objective_type not in {"MONO", "WEIGHTED_SUM", "MANY", "MULTI"}: + return 0.0 + objective_value = 0.0 - if obj.get("type") in ("MONO", "weighted_sum"): - if obj.get("type") == "MONO": - targets = obj.get("targets", []) - weights = obj.get("weights", {}) - for t in targets: - if t not in weights: - weights[t] = 1.0 - else: - weights = obj.get("weights", {}) or {} - - for fid, w in weights.items(): - val = float(normalized_qos.get(fid, 0.0)) - objective_value += float(w) * val + for fid, weight in _objective_weights(obj).items(): + val = float(normalized_qos.get(fid, 0.0)) + objective_value += float(weight) * val return objective_value diff --git a/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/many_heuristic.py b/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/many_heuristic.py index c0f3e0d..a2e1301 100644 --- a/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/many_heuristic.py +++ b/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/many_heuristic.py @@ -11,6 +11,18 @@ from ...models.api import ValidationViolation class ManyHeuristicEnginePlugin(EngineValidationPlugin): + def _objective_weights(self, instance: Dict[str, Any]) -> Dict[str, float]: + weights: Dict[str, float] = {} + objective = instance.get("objective", {}) or {} + + for feature_id, weight in (objective.get("weights", {}) or {}).items(): + weights[str(feature_id)] = float(weight) + + for target in objective.get("targets", []) or []: + weights.setdefault(str(target), 1.0) + + return weights + async def check_engine_health(self, base_url: str, client: httpx.AsyncClient) -> bool: url = f"{base_url.rstrip('/')}/health" try: @@ -124,11 +136,16 @@ def map_node(node): # QoS Model qos_props = {} - qos_weights = {} + qos_weights = self._objective_weights(instance) for f in instance.get("features", []): - vr = f.get("valid_range") or {} - qos_props[f["id"]] = {"direction": f["direction"].lower(), "min": float(vr.get("min", 0.0)), "max": float(vr.get("max", 1.0))} - qos_weights[f["id"]] = 1.0 + vr = f.get("valid_range") or {} + qos_props[f["id"]] = { + "direction": f["direction"].lower(), + "min": float(vr.get("min", 0.0)), + "max": float(vr.get("max", 1.0)), + } + if f["id"] not in qos_weights: + qos_weights[f["id"]] = 0.0 agg_policies = instance.get("aggregation_policies", {}) qos_aggregation = {} @@ -187,15 +204,20 @@ def transform_response(self, engine_response: Dict[str, Any], original_request: features = {f["id"]: f for f in original_request.get("features", [])} agg_policies = original_request.get("aggregation_policies", {}) root = original_request.get("composition", {}).get("root") + objective = original_request.get("objective", {}) or {} for sol in raw_solutions: sel = sol.get("selection") or {} sel_cand = build_selected_candidate_by_task(sel, candidates) agg_qos = compute_aggregated_qos(root, features, sel_cand, agg_policies) + normalized_qos = normalize_qos(agg_qos, features, agg_policies) + objective_value = sol.get("objective_value") + if objective_value is None: + objective_value = compute_objective_value(objective, normalized_qos) mapped_solutions.append({ "binding": sel, - "aggregated_features": agg_qos, "violations": [], "objective_value": 0.0 + "aggregated_features": agg_qos, "violations": [], "objective_value": objective_value }) return { diff --git a/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/minizinc_csp.py b/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/minizinc_csp.py index 410d97c..3238150 100644 --- a/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/minizinc_csp.py +++ b/openbinding-gateway/src/openbinding_gateway/validation/engine_plugins/minizinc_csp.py @@ -206,11 +206,8 @@ def transform_response(self, engine_response: Dict[str, Any], original_request: selected_candidate_by_task = build_selected_candidate_by_task(selection, candidates_by_id) - if old_sol.get("aggregated_features"): - aggregated_qos = old_sol.get("aggregated_features") - else: - root = (original_request.get("composition", {}) or {}).get("root", {}) - aggregated_qos = compute_aggregated_qos(root, features, selected_candidate_by_task, agg_policies) + root = (original_request.get("composition", {}) or {}).get("root", {}) + aggregated_qos = compute_aggregated_qos(root, features, selected_candidate_by_task, agg_policies) # ----------------------------------------------- diff --git a/openbinding-gateway/tests/test_aggregation.py b/openbinding-gateway/tests/test_aggregation.py new file mode 100644 index 0000000..c0b4f2f --- /dev/null +++ b/openbinding-gateway/tests/test_aggregation.py @@ -0,0 +1,235 @@ +import pytest + +from openbinding_gateway.validation.engine_plugins.aggregation import ( + build_selected_candidate_by_task, + canonicalize_result_data, + compute_aggregated_qos, +) + + +def test_compute_aggregated_qos_uses_neutral_for_element_branch(): + root = { + "id": "root_seq", + "kind": "SEQ", + "children": [ + {"id": "task_t1", "kind": "TASK", "task_id": "T1"}, + { + "id": "routing_choice", + "kind": "XOR", + "branches": [ + { + "p": 0.8, + "child": { + "id": "classic_path", + "kind": "ELEMENT", + }, + }, + { + "p": 0.2, + "child": {"id": "task_t5", "kind": "TASK", "task_id": "T5"}, + }, + ], + }, + ], + } + + features = { + "security": { + "id": "security", + "direction": "MAXIMIZE", + "scale": "ORDINAL", + "valid_range": {"min": 1, "max": 5}, + } + } + agg_policies = { + "security": { + "neutral": 5, + "compose": { + "seq": {"fn": "MIN"}, + "xor": {"fn": "SCALED_SUM"}, + }, + } + } + candidates_by_id = { + "cand_t1": {"id": "cand_t1", "task_id": "T1", "features": {"security": 4}}, + "cand_t5": {"id": "cand_t5", "task_id": "T5", "features": {"security": 5}}, + } + selection = {"T1": "cand_t1", "T5": "cand_t5"} + selected = build_selected_candidate_by_task(selection, candidates_by_id) + + aggregated = compute_aggregated_qos(root, features, selected, agg_policies) + + assert aggregated["security"] == pytest.approx(4.0) + + +def test_compute_aggregated_qos_normalizes_percentage_product_features(): + root = { + "id": "root_seq", + "kind": "SEQ", + "children": [ + {"id": "node_t1", "kind": "TASK", "task_id": "T1"}, + {"id": "node_t2", "kind": "TASK", "task_id": "T2"}, + {"id": "node_t3", "kind": "TASK", "task_id": "T3"}, + { + "id": "routing_choice", + "kind": "XOR", + "branches": [ + {"p": 0.8, "child": {"id": "classic_path", "kind": "ELEMENT"}}, + {"p": 0.2, "child": {"id": "node_t5", "kind": "TASK", "task_id": "T5"}}, + ], + }, + {"id": "node_t6", "kind": "TASK", "task_id": "T6"}, + ], + } + + features = { + "availability": { + "id": "availability", + "direction": "MAXIMIZE", + "scale": "RATIO", + "valid_range": {"min": 0, "max": 100}, + }, + "reliability": { + "id": "reliability", + "direction": "MAXIMIZE", + "scale": "RATIO", + "valid_range": {"min": 0, "max": 100}, + }, + } + agg_policies = { + "availability": { + "neutral": 1, + "compose": { + "seq": {"fn": "PRODUCT"}, + "and": {"fn": "PRODUCT"}, + "xor": {"fn": "SCALED_SUM"}, + }, + }, + "reliability": { + "neutral": 1, + "compose": { + "seq": {"fn": "PRODUCT"}, + "and": {"fn": "PRODUCT"}, + "xor": {"fn": "SCALED_SUM"}, + }, + }, + } + candidates_by_id = { + "cand_t1_aws": { + "id": "cand_t1_aws", + "task_id": "T1", + "features": {"availability": 99.95, "reliability": 99.75}, + }, + "cand_t2_radius": { + "id": "cand_t2_radius", + "task_id": "T2", + "features": {"availability": 99.8, "reliability": 99.3}, + }, + "cand_t3_here": { + "id": "cand_t3_here", + "task_id": "T3", + "features": {"availability": 99.9, "reliability": 99.5}, + }, + "cand_t5_braket": { + "id": "cand_t5_braket", + "task_id": "T5", + "features": {"availability": 99.9, "reliability": 99.0}, + }, + "cand_t6_stripe": { + "id": "cand_t6_stripe", + "task_id": "T6", + "features": {"availability": 99.99, "reliability": 99.5}, + }, + } + selection = { + "T1": "cand_t1_aws", + "T2": "cand_t2_radius", + "T3": "cand_t3_here", + "T5": "cand_t5_braket", + "T6": "cand_t6_stripe", + } + selected = build_selected_candidate_by_task(selection, candidates_by_id) + + aggregated = compute_aggregated_qos(root, features, selected, agg_policies) + + assert aggregated["availability"] == pytest.approx(99.62045678803702) + assert aggregated["reliability"] == pytest.approx(97.8675813761625) + assert 0.0 <= aggregated["availability"] <= 100.0 + assert 0.0 <= aggregated["reliability"] <= 100.0 + + +def test_canonicalize_result_data_recomputes_aggregated_features(): + original_request = { + "features": [ + {"id": "cost", "direction": "MINIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 1000}}, + ], + "candidates": [ + {"id": "cand_t1", "task_id": "T1", "features": {"cost": 10}}, + {"id": "cand_t2", "task_id": "T2", "features": {"cost": 20}}, + ], + "aggregation_policies": { + "cost": {"neutral": 0, "compose": {"seq": {"fn": "SUM"}}}, + }, + "composition": { + "root": { + "id": "root", + "kind": "SEQ", + "children": [ + {"id": "node_t1", "kind": "TASK", "task_id": "T1"}, + {"id": "node_t2", "kind": "TASK", "task_id": "T2"}, + ], + } + }, + "objective": {"type": "MONO"}, + } + result_data = { + "solutions": [ + { + "binding": {"T1": "cand_t1", "T2": "cand_t2"}, + "aggregated_features": {"cost": 999.0}, + "objective_value": 123.0, + "violations": [], + } + ] + } + + canonicalize_result_data(result_data, original_request) + + assert result_data["solutions"][0]["aggregated_features"]["cost"] == pytest.approx(30.0) + assert result_data["solutions"][0]["objective_value"] == pytest.approx(123.0) + + +def test_canonicalize_result_data_computes_many_objective_value(): + original_request = { + "features": [ + {"id": "cost", "direction": "MINIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 1000}}, + {"id": "reliability", "direction": "MAXIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 100}}, + ], + "candidates": [ + {"id": "cand_t1", "task_id": "T1", "features": {"cost": 10, "reliability": 90}}, + ], + "aggregation_policies": { + "cost": {"neutral": 0, "compose": {"seq": {"fn": "SUM"}}, "normalize": {"type": "minmax", "bounds": {"min": 0, "max": 1000}}}, + "reliability": {"neutral": 1, "compose": {"seq": {"fn": "SUM"}}, "normalize": {"type": "minmax", "bounds": {"min": 0, "max": 100}}}, + }, + "composition": { + "root": {"id": "node_t1", "kind": "TASK", "task_id": "T1"} + }, + "objective": {"type": "MANY", "targets": ["cost", "reliability"], "weights": {"cost": 0.25, "reliability": 0.75}}, + } + result_data = { + "solutions": [ + { + "binding": {"T1": "cand_t1"}, + "aggregated_features": {"cost": 999.0, "reliability": 0.0}, + "objective_value": 0.0, + "violations": [], + } + ] + } + + canonicalize_result_data(result_data, original_request) + + assert result_data["solutions"][0]["aggregated_features"]["cost"] == pytest.approx(10.0) + assert result_data["solutions"][0]["aggregated_features"]["reliability"] == pytest.approx(90.0) + assert result_data["solutions"][0]["objective_value"] == pytest.approx(0.9225) \ No newline at end of file diff --git a/openbinding-gateway/tests/test_plugin_transformation.py b/openbinding-gateway/tests/test_plugin_transformation.py index 70f0954..8377ed2 100644 --- a/openbinding-gateway/tests/test_plugin_transformation.py +++ b/openbinding-gateway/tests/test_plugin_transformation.py @@ -93,6 +93,81 @@ def test_minizinc_transform_request_allows_debug_and_solver_options(minizinc_plu _, warnings = minizinc_plugin.transform_request(req, {"debug": True, "solver": "gecode"}) assert warnings == [] + +def test_minizinc_response_recomputes_aggregated_features_in_gateway(minizinc_plugin): + old_sol = { + "solution": { + "feasible": True, + "selection": { + "T1": "cand_t1_aws", + "T2": "cand_t2_radius_iot_network", + "T3": "cand_t3_here_routing_api", + "T5": "cand_t5_amazon_braket", + "T6": "cand_t6_stripe", + }, + "objective_value": -0.006819703777809102, + "aggregated_features": { + "availability": 19.337912048037, + "reliability": 19.3201518851772, + "security": 1, + }, + }, + "provenance": {}, + } + + request = { + "features": [ + {"id": "availability", "direction": "MAXIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 100}}, + {"id": "reliability", "direction": "MAXIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 100}}, + {"id": "security", "direction": "MAXIMIZE", "scale": "ORDINAL", "valid_range": {"min": 1, "max": 5}}, + {"id": "cost", "direction": "MINIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 10000}}, + {"id": "execution_time", "direction": "MINIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 20000}}, + ], + "candidates": [ + {"id": "cand_t1_aws", "task_id": "T1", "features": {"availability": 99.95, "reliability": 99.75, "security": 5, "cost": 220, "execution_time": 210}}, + {"id": "cand_t2_radius_iot_network", "task_id": "T2", "features": {"availability": 99.8, "reliability": 99.3, "security": 4, "cost": 500, "execution_time": 420}}, + {"id": "cand_t3_here_routing_api", "task_id": "T3", "features": {"availability": 99.9, "reliability": 99.5, "security": 4, "cost": 300, "execution_time": 380}}, + {"id": "cand_t5_amazon_braket", "task_id": "T5", "features": {"availability": 99.9, "reliability": 99.0, "security": 5, "cost": 1200, "execution_time": 9000}}, + {"id": "cand_t6_stripe", "task_id": "T6", "features": {"availability": 99.99, "reliability": 99.5, "security": 5, "cost": 950, "execution_time": 520}}, + ], + "aggregation_policies": { + "availability": {"neutral": 1, "compose": {"seq": {"fn": "PRODUCT"}, "and": {"fn": "PRODUCT"}, "xor": {"fn": "SCALED_SUM"}}}, + "reliability": {"neutral": 1, "compose": {"seq": {"fn": "PRODUCT"}, "and": {"fn": "PRODUCT"}, "xor": {"fn": "SCALED_SUM"}}}, + "security": {"neutral": 5, "compose": {"seq": {"fn": "MIN"}, "and": {"fn": "MIN"}, "xor": {"fn": "SCALED_SUM"}}}, + "cost": {"neutral": 0, "compose": {"seq": {"fn": "SUM"}, "and": {"fn": "SUM"}, "xor": {"fn": "SCALED_SUM"}}}, + "execution_time": {"neutral": 0, "compose": {"seq": {"fn": "SUM"}, "and": {"fn": "MAX"}, "xor": {"fn": "SCALED_SUM"}}}, + }, + "composition": { + "root": { + "id": "root_seq", + "kind": "SEQ", + "children": [ + {"id": "node_t1", "kind": "TASK", "task_id": "T1"}, + {"id": "node_t2", "kind": "TASK", "task_id": "T2"}, + {"id": "node_t3", "kind": "TASK", "task_id": "T3"}, + { + "id": "node_routing_choice", + "kind": "XOR", + "branches": [ + {"p": 0.8, "child": {"id": "node_classic_routing", "kind": "ELEMENT"}}, + {"p": 0.2, "child": {"id": "node_t5", "kind": "TASK", "task_id": "T5"}}, + ], + }, + {"id": "node_t6", "kind": "TASK", "task_id": "T6"}, + ], + } + }, + } + + new_sol = minizinc_plugin.transform_response(old_sol, request) + + aggregated = new_sol["solutions"][0]["aggregated_features"] + assert aggregated["security"] == pytest.approx(4.0) + assert aggregated["cost"] == pytest.approx(2210.0) + assert aggregated["execution_time"] == pytest.approx(3330.0) + assert aggregated["availability"] == pytest.approx(99.62045678803702) + assert aggregated["reliability"] == pytest.approx(97.8675813761625) + # --- Random Search Tests --- def test_random_search_request_dependency(random_search_plugin): @@ -206,3 +281,80 @@ def test_many_heuristic_request_dependency(many_heuristic_plugin): assert c["type"] == "DIFFERENT_PROVIDER" assert c["tasks"] == ["t1", "t2"] assert c["hard"] is True + + +def test_many_heuristic_request_uses_objective_weights(many_heuristic_plugin): + instance = { + "objective": { + "type": "MANY", + "targets": ["latency", "availability"], + "weights": {"latency": 0.3, "availability": 0.7}, + }, + "composition": { + "root": { + "kind": "TASK", "id": "t1", "task_id": "t1" + } + }, + "features": [ + {"id": "latency", "direction": "MINIMIZE", "valid_range": {"min": 0, "max": 10}}, + {"id": "availability", "direction": "MAXIMIZE", "valid_range": {"min": 0, "max": 1}}, + {"id": "cost", "direction": "MINIMIZE", "valid_range": {"min": 0, "max": 100}}, + ], + "aggregation_policies": { + "latency": {"compose": {"seq": {"fn": "SUM"}}}, + "availability": {"compose": {"seq": {"fn": "PRODUCT"}}}, + "cost": {"compose": {"seq": {"fn": "SUM"}}}, + }, + "tasks": [{"id": "t1"}], + "candidates": [ + {"id": "c1", "task_id": "t1", "provider_id": "p1", "features": {"latency": 1, "availability": 0.9, "cost": 5}} + ], + "providers": [{"id": "p1"}], + } + + transformed, _ = many_heuristic_plugin.transform_request(instance) + + assert transformed["features"]["weights"]["latency"] == pytest.approx(0.3) + assert transformed["features"]["weights"]["availability"] == pytest.approx(0.7) + assert transformed["features"]["weights"]["cost"] == pytest.approx(0.0) + + +def test_many_heuristic_response_recomputes_missing_objective_value(many_heuristic_plugin): + engine_response = { + "solutions": [ + { + "selection": {"T1": "cand_t1"}, + "aggregated_features": {"cost": 999.0}, + "objective_value": None, + } + ], + "execution_time": 12, + "iterations_count": 3, + } + request = { + "features": [ + {"id": "cost", "direction": "MINIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 1000}}, + {"id": "reliability", "direction": "MAXIMIZE", "scale": "RATIO", "valid_range": {"min": 0, "max": 100}}, + ], + "candidates": [ + {"id": "cand_t1", "task_id": "T1", "features": {"cost": 10, "reliability": 90}}, + ], + "aggregation_policies": { + "cost": {"neutral": 0, "compose": {"seq": {"fn": "SUM"}}, "normalize": {"type": "minmax", "bounds": {"min": 0, "max": 1000}}}, + "reliability": {"neutral": 1, "compose": {"seq": {"fn": "SUM"}}, "normalize": {"type": "minmax", "bounds": {"min": 0, "max": 100}}}, + }, + "composition": { + "root": {"id": "node_t1", "kind": "TASK", "task_id": "T1"} + }, + "objective": { + "type": "MANY", + "targets": ["cost", "reliability"], + "weights": {"cost": 0.25, "reliability": 0.75}, + }, + } + + transformed = many_heuristic_plugin.transform_response(engine_response, request) + + assert transformed["solutions"][0]["aggregated_features"]["cost"] == pytest.approx(10.0) + assert transformed["solutions"][0]["aggregated_features"]["reliability"] == pytest.approx(90.0) + assert transformed["solutions"][0]["objective_value"] == pytest.approx(0.9225) diff --git a/schemas/general/schema.json b/schemas/general/schema.json index 87106dd..bcdda03 100644 --- a/schemas/general/schema.json +++ b/schemas/general/schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "file:///Users/franciscojaviercaverolopez/Workspace/OpenBinding/schemas/general/schema.json", + "$id": "https://openbinding.us.es/api/v1/schemas/general", "title": "Compact QoS-Aware Service Composition", "type": "object", "required": [ diff --git a/schemas/specializations/many-heuristic.schema.json b/schemas/specializations/many-heuristic.schema.json index d2aa2a6..36cf48a 100644 --- a/schemas/specializations/many-heuristic.schema.json +++ b/schemas/specializations/many-heuristic.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "file:///Users/franciscojaviercaverolopez/Workspace/OpenBinding/schemas/specializations/many-heuristic.schema.json", + "$id": "https://openbinding.us.es/api/v1/schemas/many-heuristic", "title": "Many-Heuristic Specialization", "description": "Profile for the Many-Heuristic Java Engine (structured workflows + many-objective + Pareto options).", "type": "object", diff --git a/schemas/specializations/minizinc-csp.schema.json b/schemas/specializations/minizinc-csp.schema.json index 0d3fd52..d9d8b01 100644 --- a/schemas/specializations/minizinc-csp.schema.json +++ b/schemas/specializations/minizinc-csp.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "file:///Users/franciscojaviercaverolopez/Workspace/OpenBinding/schemas/specializations/minizinc-csp.schema.json", + "$id": "https://openbinding.us.es/api/v1/schemas/minizinc-csp", "title": "MiniZinc CSP Specialization", "description": "Restricted profile of the General QoS Schema for the MVP MiniZinc CSP engine.", "type": "object", diff --git a/schemas/specializations/random-search.schema.json b/schemas/specializations/random-search.schema.json index 70fd8a3..9a59c53 100644 --- a/schemas/specializations/random-search.schema.json +++ b/schemas/specializations/random-search.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "file:///Users/franciscojaviercaverolopez/Workspace/OpenBinding/schemas/specializations/random-search.schema.json", + "$id": "https://openbinding.us.es/api/v1/schemas/random-search", "title": "Random-Search Specialization", "description": "Profile for the Random-Search Java Engine (structured workflows + weighted-sum objective + optional global attribute bounds).", "type": "object", From 9946ef3e9ec7e2c830e96aaf11efba93c14e2cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Fri, 6 Mar 2026 13:38:22 +0100 Subject: [PATCH 17/18] fix: ci workflow --- .github/workflows/ci.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a57db4..59c4b59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,25 @@ jobs: sudo apt-get update sudo apt-get install -y docker-compose + - name: Prepare environment + run: | + if [ ! -f .env ]; then + cp .env.example .env + fi + + - name: Validate Docker Compose configuration + run: docker-compose config + - name: Build and start services - run: docker-compose up --build -d + run: docker-compose up --build -d --wait gateway-dev - name: Run Tests - run: docker-compose exec -T gateway test + run: docker-compose exec -T gateway-dev test + + - name: Show service logs on failure + if: failure() + run: docker-compose logs --no-color gateway-dev engine-minizinc engine-random-search engine-many-heuristic - name: Shutdown services if: always() - run: docker-compose down + run: docker-compose down --volumes --remove-orphans From 893e798a499ac7ceed9068c6ae5ba401cfe0365d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20Cavero=20L=C3=B3pez?= Date: Fri, 6 Mar 2026 13:44:00 +0100 Subject: [PATCH 18/18] fix: ci workflow --- .github/workflows/ci.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59c4b59..444d70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,16 +7,13 @@ on: jobs: test: runs-on: ubuntu-latest + env: + COMPOSE_PROFILES: dev steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Docker Compose - run: | - sudo apt-get update - sudo apt-get install -y docker-compose - - name: Prepare environment run: | if [ ! -f .env ]; then @@ -24,18 +21,18 @@ jobs: fi - name: Validate Docker Compose configuration - run: docker-compose config + run: docker compose config - name: Build and start services - run: docker-compose up --build -d --wait gateway-dev + run: docker compose up --build -d --wait gateway-dev - name: Run Tests - run: docker-compose exec -T gateway-dev test + run: docker compose exec -T gateway-dev test - name: Show service logs on failure if: failure() - run: docker-compose logs --no-color gateway-dev engine-minizinc engine-random-search engine-many-heuristic + run: docker compose logs --no-color gateway-dev engine-minizinc engine-random-search engine-many-heuristic - name: Shutdown services if: always() - run: docker-compose down --volumes --remove-orphans + run: docker compose down --volumes --remove-orphans