diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4f0ce29..26b967e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,6 +57,34 @@ publish: tags: - kubernetes +publish-cloudflared: + stage: docker + image: + name: moby/buildkit:rootless + entrypoint: [""] + script: + - | + CLOUDFLARED_IMAGE="$HARBOR_REGISTRY/$HARBOR_PROJECT/cloudflared-tunnel:latest" + IMAGE_TAG="${CI_COMMIT_SHORT_SHA:-$CI_PIPELINE_ID}" + + OUTPUT_FLAGS="--output type=image,name=$CLOUDFLARED_IMAGE,push=true" + OUTPUT_FLAGS="$OUTPUT_FLAGS --output type=image,name=$HARBOR_REGISTRY/$HARBOR_PROJECT/cloudflared-tunnel:$IMAGE_TAG,push=true" + if [ -n "$CI_COMMIT_TAG" ]; then + OUTPUT_FLAGS="$OUTPUT_FLAGS --output type=image,name=$HARBOR_REGISTRY/$HARBOR_PROJECT/cloudflared-tunnel:$CI_COMMIT_TAG,push=true" + fi + + buildctl-daemonless.sh build \ + --frontend dockerfile.v0 \ + --local context=./cloudflare \ + --local dockerfile=./cloudflare/Dockerfile \ + $OUTPUT_FLAGS + + echo "Pushed cloudflared-tunnel to $CLOUDFLARED_IMAGE" + rules: + - if: '$CI_COMMIT_TAG' + tags: + - kubernetes + release: stage: release image: registry.gitlab.com/gitlab-org/cli:latest diff --git a/cloudflare/Dockerfile b/cloudflare/Dockerfile new file mode 100644 index 0000000..9fa93f1 --- /dev/null +++ b/cloudflare/Dockerfile @@ -0,0 +1,6 @@ +# Cloudflared with shell for k8s token injection +# Official image is distroless (no /bin/sh) - we need shell to pass token from secret +FROM harbor.dataknife.net/dockerhub/cloudflare/cloudflared:latest AS cf +FROM harbor.dataknife.net/dockerhub/library/alpine:3.19 +COPY --from=cf /usr/local/bin/cloudflared /usr/local/bin/cloudflared +ENTRYPOINT ["/bin/sh", "-c", "exec cloudflared --no-autoupdate tunnel run --token \"$TUNNEL_TOKEN\""] diff --git a/cloudflare/README.md b/cloudflare/README.md new file mode 100644 index 0000000..7e2252a --- /dev/null +++ b/cloudflare/README.md @@ -0,0 +1,7 @@ +# Cloudflare Tunnel Image + +Builds the cloudflared image for Kubernetes deployment. The official `cloudflare/cloudflared` image is distroless (no shell), so we use Alpine + shell to inject the tunnel token from a k8s secret at runtime. + +**Deployment:** See `../k8s/CLOUDFLARE_TUNNEL.md` + +**GitLab CI:** The `publish-cloudflared` job builds and pushes this image on tags. diff --git a/k8s/CLOUDFLARE_TUNNEL.md b/k8s/CLOUDFLARE_TUNNEL.md new file mode 100644 index 0000000..29d0e15 --- /dev/null +++ b/k8s/CLOUDFLARE_TUNNEL.md @@ -0,0 +1,83 @@ +# Cloudflare Tunnel in Kubernetes + +Expose High Command via Cloudflare Tunnel without port forwarding. The tunnel runs as a pod in the cluster and proxies traffic to internal services. + +## Prerequisites + +- Tunnel created in [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → Networks → Tunnels +- Connector token from the tunnel setup (Docker install step) + +## Deploy + +### 1. Create the secret with your tunnel token + +```bash +kubectl create secret generic cloudflared-tunnel-credentials \ + --from-literal=token='eyJhIjoi...' \ + -n high-command +``` + +Use the token from the Docker command in the Cloudflare dashboard: +`docker run cloudflare/cloudflared:latest tunnel run --token ` + +### 2. Deploy the tunnel and gateway alias + +```bash +kubectl apply -f gateway-tunnel-service.yaml +kubectl apply -f cloudflared-tunnel-deployment.yaml +``` + +### 3. Configure ingress in Cloudflare dashboard + +Route all traffic to the Envoy Gateway — it already handles path routing (`/api`, `/mcp`, `/`) and MCP path rewrite via the HTTPRoute. + +In Zero Trust → Networks → Tunnels → your tunnel → Public Hostname: + +| Public hostname | Path | Service | URL | +|-----------------|------|---------|-----| +| `hc.dataknife.ai` | `/` (or leave empty) | HTTPS | `https://high-command-gateway.high-command.svc.cluster.local:443` | + +**Additional application settings** (expand the section when adding the route): + +- **Origin Server Name**: `hc.dataknife.ai` — The Gateway's listener matches this hostname. Without it, cloudflared sends the internal k8s hostname as SNI and the Gateway resets the connection. +- **No TLS Verify**: Enable this. The Gateway's cert is for `hc.dataknife.ai`, but cloudflared connects to the internal k8s hostname — TLS verification would fail without it. + +## Verify + +```bash +kubectl get pods -n high-command -l app=cloudflared-tunnel +kubectl logs -n high-command -l app=cloudflared-tunnel -f +``` + +## Troubleshooting + +**Connection refused or TLS errors:** + +- Enable **No TLS Verify** in Additional application settings. +- Confirm the Gateway service name is correct (it may change if the Gateway is recreated). + +**API or MCP returns 404:** + +- The Gateway's HTTPRoute handles path routing. Verify the Gateway and HTTPRoute are healthy: `kubectl get gateway,httproute -n high-command`. + +**If the Envoy Gateway service was recreated** (e.g. after a Gateway update), the `high-command-gateway` ExternalName may point to a stale service. Update it: + +```bash +NEW_SVC=$(kubectl get svc -n envoy-gateway-system -o name | grep high-command | cut -d/ -f2) +kubectl patch svc high-command-gateway -n high-command -p "{\"spec\":{\"externalName\":\"${NEW_SVC}.envoy-gateway-system.svc.cluster.local\"}}" +``` + +## Image + +The deployment uses `cloudflare/Dockerfile` (Alpine + cloudflared binary, shell for token injection). GitLab CI builds and pushes on tags. To build manually: + +```bash +docker build -t harbor.dataknife.net/library/cloudflared-tunnel:latest cloudflare/ +docker push harbor.dataknife.net/library/cloudflared-tunnel:latest +``` + +## Files + +- `cloudflared-tunnel-deployment.yaml` - Deployment (token from secret, `--no-autoupdate`) +- `cloudflared-tunnel-secrets-example.yaml` - Secret template (do not commit real token) +- `gateway-tunnel-service.yaml` - Stable alias to Envoy Gateway for tunnel routing diff --git a/k8s/README.md b/k8s/README.md index 7266ea4..7b02a48 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -1,37 +1,47 @@ # Kubernetes Deployment Files -This directory contains Kubernetes manifests for deploying the High Command UI. +This directory contains Kubernetes manifests for deploying the High Command stack (UI, API, MCP, Gateway, Cloudflare Tunnel). -## Files +## Architecture -- `ui-deployment-blue.yaml` - Blue deployment (active version) -- `ui-deployment-green.yaml` - Green deployment (standby version) -- `ui-service.yaml` - Service to route traffic between blue/green -- `ui-pdb.yaml` - Pod Disruption Budget for availability -- `ui-ingress.yaml` - Ingress configuration for external access +Traffic flow: **Cloudflare Tunnel** → **Envoy Gateway** → **HTTPRoute** → UI/API/MCP. No nginx Ingress. + +## UI Files + +- `ui-deployment-blue.yaml`, `ui-deployment-green.yaml` - Blue/green deployments +- `ui-service.yaml` - Service routing +- `ui-pdb.yaml` - Pod Disruption Budget + +## Full Stack Files + +- `api-deployment-blue.yaml`, `api-deployment-green.yaml` - API deployments +- `api-service.yaml`, `api-pdb.yaml` - API service +- `httproute.yaml` - Gateway API HTTPRoute (/api, /claude, /mcp, /) +- `gateway.yaml`, `gatewayclass.yaml`, `gateway-certificate.yaml` - Envoy Gateway +- `gateway-tunnel-service.yaml` - Alias for Cloudflare Tunnel → Gateway +- `cloudflared-tunnel-deployment.yaml` - Cloudflare Tunnel pod +- `mcp-service.yaml`, `mcp-referencegrant.yaml`, `referencegrant.yaml` - MCP routing + +See `CLOUDFLARE_TUNNEL.md` for tunnel setup. ## Secrets **No secrets are stored in these files.** -If you need to use Claude integration, add the API key as a Kubernetes Secret: +**API secrets** (required): `database-url` and optionally `claude-api-key`: ```bash -kubectl create secret generic high-command-ui-secrets \ - --from-literal=vite-claude-api-key='your-api-key-here' \ +kubectl create secret generic high-command-api-secrets \ + --from-literal=database-url='postgresql://user:password@high-command-postgres-rw.high-command.svc.cluster.local:5432/highcommand' \ + --from-literal=claude-api-key='sk-ant-api03-...' \ -n high-command ``` -Then update the deployments to reference the secret: +See `api-secrets-example.yaml` for details. -```yaml -env: -- name: VITE_CLAUDE_API_KEY - valueFrom: - secretKeyRef: - name: high-command-ui-secrets - key: vite-claude-api-key -``` +## Cloudflare + +The `../cloudflare/` folder contains the tunnel Dockerfile. GitLab CI builds the cloudflared-tunnel image from `cloudflare/Dockerfile`. ## Environment Variables diff --git a/k8s/api-deployment-blue.yaml b/k8s/api-deployment-blue.yaml new file mode 100644 index 0000000..b873cc1 --- /dev/null +++ b/k8s/api-deployment-blue.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: high-command-api-blue + namespace: high-command + labels: + app: high-command-api + version: blue +spec: + replicas: 3 + selector: + matchLabels: + app: high-command-api + version: blue + template: + metadata: + labels: + app: high-command-api + version: blue + spec: + containers: + - name: api + image: harbor.dataknife.net/library/high-command-api:v0.11 + imagePullPolicy: Always + env: + - name: MODE + value: "api" + - name: PYTHONUNBUFFERED + value: "1" + - name: LOG_LEVEL + value: "INFO" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: database-url + - name: PORT + value: "5000" + - name: HOST + value: "0.0.0.0" + - name: HELLDIVERS_API_BASE + value: "https://api.helldivers2.dev/api/v1" + - name: HELLDIVERS_API_CLIENT_NAME + value: "High Command" + - name: HELLDIVERS_API_CONTACT + value: "lee@fullmetal.dev" + - name: CLAUDE_API_KEY + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: claude-api-key + optional: true + ports: + - name: http + containerPort: 5000 + protocol: TCP + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 2 + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "1Gi" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - high-command-api + topologyKey: kubernetes.io/hostname diff --git a/k8s/api-deployment-green.yaml b/k8s/api-deployment-green.yaml new file mode 100644 index 0000000..a80ba1c --- /dev/null +++ b/k8s/api-deployment-green.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: high-command-api-green + namespace: high-command + labels: + app: high-command-api + version: green +spec: + replicas: 3 + selector: + matchLabels: + app: high-command-api + version: green + template: + metadata: + labels: + app: high-command-api + version: green + spec: + containers: + - name: api + image: harbor.dataknife.net/library/high-command-api:v0.11 + imagePullPolicy: Always + env: + - name: MODE + value: "api" + - name: PYTHONUNBUFFERED + value: "1" + - name: LOG_LEVEL + value: "INFO" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: database-url + - name: PORT + value: "5000" + - name: HOST + value: "0.0.0.0" + - name: HELLDIVERS_API_BASE + value: "https://api.helldivers2.dev/api/v1" + - name: HELLDIVERS_API_CLIENT_NAME + value: "High Command" + - name: HELLDIVERS_API_CONTACT + value: "lee@fullmetal.dev" + - name: CLAUDE_API_KEY + valueFrom: + secretKeyRef: + name: high-command-api-secrets + key: claude-api-key + optional: true + ports: + - name: http + containerPort: 5000 + protocol: TCP + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 2 + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "1Gi" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - high-command-api + topologyKey: kubernetes.io/hostname diff --git a/k8s/api-pdb.yaml b/k8s/api-pdb.yaml new file mode 100644 index 0000000..f271186 --- /dev/null +++ b/k8s/api-pdb.yaml @@ -0,0 +1,12 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: high-command-api-pdb + namespace: high-command + labels: + app: high-command-api +spec: + minAvailable: 2 + selector: + matchLabels: + app: high-command-api diff --git a/k8s/api-secrets-example.yaml b/k8s/api-secrets-example.yaml new file mode 100644 index 0000000..32d8b47 --- /dev/null +++ b/k8s/api-secrets-example.yaml @@ -0,0 +1,24 @@ +# API secrets - DATABASE_URL required, claude-api-key optional +# Never commit real values. Create with kubectl: +# +# kubectl create secret generic high-command-api-secrets \ +# --from-literal=database-url='postgresql://user:password@high-command-postgres-rw.high-command.svc.cluster.local:5432/highcommand' \ +# --from-literal=claude-api-key='sk-ant-api03-...' \ +# -n high-command +# +# DATABASE_URL: Required. PostgreSQL connection string. +# claude-api-key: Optional. For Claude UI integration via backend proxy. +# +# Restart API pods to pick up the secret: +# kubectl rollout restart deployment/high-command-api-blue -n high-command +# kubectl rollout restart deployment/high-command-api-green -n high-command +# +apiVersion: v1 +kind: Secret +metadata: + name: high-command-api-secrets + namespace: high-command +type: Opaque +stringData: + # database-url: "postgresql://user:password@host:5432/dbname" # REQUIRED + # claude-api-key: "sk-ant-api03-..." # Optional, for Claude UI diff --git a/k8s/api-service.yaml b/k8s/api-service.yaml new file mode 100644 index 0000000..99e2e59 --- /dev/null +++ b/k8s/api-service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: high-command-api + namespace: high-command + labels: + app: high-command-api + annotations: + deployment.kubernetes.io/active-version: "green" +spec: + type: ClusterIP + selector: + app: high-command-api + version: green + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + sessionAffinity: None diff --git a/k8s/cloudflared-tunnel-deployment.yaml b/k8s/cloudflared-tunnel-deployment.yaml new file mode 100644 index 0000000..3b4e22a --- /dev/null +++ b/k8s/cloudflared-tunnel-deployment.yaml @@ -0,0 +1,52 @@ +# Cloudflare Tunnel - translates: +# docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token +# +# Uses wrapper image (cloudflare/cloudflared + alpine shell) for token injection. +# Build: docker build -t harbor.dataknife.net/library/cloudflared-tunnel:latest cloudflare/ +# +# Token from secret (kubectl create secret generic cloudflared-tunnel-credentials +# --from-literal=token='' -n high-command) +# Ingress configured in Cloudflare Zero Trust dashboard +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloudflared-tunnel + namespace: high-command + labels: + app: cloudflared-tunnel +spec: + replicas: 1 + selector: + matchLabels: + app: cloudflared-tunnel + template: + metadata: + labels: + app: cloudflared-tunnel + spec: + imagePullSecrets: + - name: harbor-registry-secret + containers: + - name: cloudflared + image: harbor.dataknife.net/library/cloudflared-tunnel:latest + imagePullPolicy: Always + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: cloudflared-tunnel-credentials + key: token + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "200m" + memory: "128Mi" + securityContext: + runAsNonRoot: true + runAsUser: 65532 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL diff --git a/k8s/cloudflared-tunnel-secrets-example.yaml b/k8s/cloudflared-tunnel-secrets-example.yaml new file mode 100644 index 0000000..4d7830f --- /dev/null +++ b/k8s/cloudflared-tunnel-secrets-example.yaml @@ -0,0 +1,17 @@ +# Cloudflare Tunnel credentials (token-based, from Zero Trust dashboard) +# Create with: kubectl create secret generic cloudflared-tunnel-credentials \ +# --from-literal=token='YOUR_TUNNEL_TOKEN' \ +# -n high-command +# +# Get the token from: Cloudflare Zero Trust → Networks → Tunnels → Create tunnel → Install connector +# The token is the value from: docker run cloudflare/cloudflared:latest tunnel run --token +--- +# Example structure (DO NOT commit real values): +apiVersion: v1 +kind: Secret +metadata: + name: cloudflared-tunnel-credentials + namespace: high-command +type: Opaque +stringData: + token: "REPLACE_WITH_YOUR_TUNNEL_TOKEN" diff --git a/k8s/gateway-certificate.yaml b/k8s/gateway-certificate.yaml new file mode 100644 index 0000000..6af274e --- /dev/null +++ b/k8s/gateway-certificate.yaml @@ -0,0 +1,18 @@ +# Certificate for High Command Gateway TLS (hc.dataknife.ai) +# cert-manager creates this secret and auto-renews via Let's Encrypt DNS-01 +# The Gateway references this secret in certificateRefs +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: high-command-gateway-https-dataknife-ai + namespace: high-command + labels: + app: high-command +spec: + secretName: high-command-gateway-https-dataknife-ai + issuerRef: + name: letsencrypt-dns01 + kind: ClusterIssuer + group: cert-manager.io + dnsNames: + - hc.dataknife.ai diff --git a/k8s/gateway-tunnel-service.yaml b/k8s/gateway-tunnel-service.yaml new file mode 100644 index 0000000..77d0f19 --- /dev/null +++ b/k8s/gateway-tunnel-service.yaml @@ -0,0 +1,18 @@ +# Stable alias for Cloudflare Tunnel to reach the Envoy Gateway. +# The tunnel uses this instead of the auto-generated Envoy service name (which includes a hash). +# If the Gateway is recreated and the Envoy service name changes, update externalName: +# kubectl get svc -n envoy-gateway-system | grep high-command +apiVersion: v1 +kind: Service +metadata: + name: high-command-gateway + namespace: high-command + labels: + app: high-command +spec: + type: ExternalName + externalName: envoy-high-command-high-command-gateway-dfdb1d4f.envoy-gateway-system.svc.cluster.local + ports: + - name: https + port: 443 + protocol: TCP diff --git a/k8s/gateway.yaml b/k8s/gateway.yaml new file mode 100644 index 0000000..0244dab --- /dev/null +++ b/k8s/gateway.yaml @@ -0,0 +1,35 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: high-command-gateway + namespace: high-command + labels: + app: high-command + annotations: + # Kube-VIP load balancer configuration for .ai domain + # IP 192.168.14.184 is assigned for hc.dataknife.ai + # Note: Kube-VIP annotation goes on the LoadBalancer service, not the Gateway + # The service is created by Envoy Gateway and will be annotated separately + # cert-manager: Certificate is managed by gateway-certificate.yaml (explicit Certificate) + # Uses letsencrypt-dns01 ClusterIssuer for auto-renewal + cert-manager.io/cluster-issuer: letsencrypt-dns01 +spec: + gatewayClassName: envoy + listeners: + # HTTPS listener for hc.dataknife.ai (dedicated cert via gateway-certificate.yaml) + # Internal traffic uses port 80, external uses port 443 via WAN port forward + - name: https-dataknife-ai + protocol: HTTPS + port: 443 + hostname: hc.dataknife.ai + tls: + mode: Terminate + # certificateRefs must be present, but cert-manager gateway-shim will manage the Secret + # cert-manager will create/update this Secret automatically based on the annotation + # Secret name pattern: - + # Note: namespace omitted when Secret is in same namespace as Gateway (per cert-manager docs) + certificateRefs: + - name: high-command-gateway-https-dataknife-ai + allowedRoutes: + namespaces: + from: Same diff --git a/k8s/gatewayclass.yaml b/k8s/gatewayclass.yaml new file mode 100644 index 0000000..016605a --- /dev/null +++ b/k8s/gatewayclass.yaml @@ -0,0 +1,6 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: envoy +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller diff --git a/k8s/httproute.yaml b/k8s/httproute.yaml new file mode 100644 index 0000000..edbc1f7 --- /dev/null +++ b/k8s/httproute.yaml @@ -0,0 +1,54 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: high-command-httproute + namespace: high-command + labels: + app: high-command +spec: + parentRefs: + - name: high-command-gateway + sectionName: https-dataknife-ai + hostnames: + - hc.dataknife.ai + rules: + # Route /api and /claude to API service + - matches: + - path: + type: PathPrefix + value: /api + - path: + type: PathPrefix + value: /claude + backendRefs: + - name: high-command-api + port: 80 + weight: 100 + + # Route /mcp to MCP service with path rewrite + # Rewrite /mcp/messages -> /messages to match MCP server expectations + - matches: + - path: + type: PathPrefix + value: /mcp + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: "" + backendRefs: + - name: high-command-mcp + namespace: mcp-servers + port: 8000 + weight: 100 + + # Route / to UI service (catch-all) + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: high-command-ui + port: 80 + weight: 100 diff --git a/k8s/mcp-referencegrant.yaml b/k8s/mcp-referencegrant.yaml new file mode 100644 index 0000000..50c8df6 --- /dev/null +++ b/k8s/mcp-referencegrant.yaml @@ -0,0 +1,14 @@ +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-high-command-mcp-access + namespace: mcp-servers +spec: + from: + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: high-command + to: + - group: "" + kind: Service + name: high-command-mcp diff --git a/k8s/referencegrant.yaml b/k8s/referencegrant.yaml new file mode 100644 index 0000000..fc19137 --- /dev/null +++ b/k8s/referencegrant.yaml @@ -0,0 +1,14 @@ +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-cert-manager-secrets + namespace: cert-manager +spec: + from: + - group: gateway.networking.k8s.io + kind: Gateway + namespace: high-command + to: + - group: "" + kind: Secret + name: wildcard-dataknife-ai-tls diff --git a/k8s/ui-ingress.yaml b/k8s/ui-ingress.yaml deleted file mode 100644 index 428b95b..0000000 --- a/k8s/ui-ingress.yaml +++ /dev/null @@ -1,70 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: high-command-ui - namespace: high-command - labels: - app: high-command-ui - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - # Note: No rewrite-target for /api paths - API expects /api prefix - # rewrite-target only applies to UI root path via path-specific annotations - # Optional: Rate limiting - # nginx.ingress.kubernetes.io/limit-rps: "100" -spec: - ingressClassName: nginx - rules: - - host: hc.dataknife.ai - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: high-command-api - port: - number: 80 - - path: /mcp - pathType: Prefix - backend: - service: - name: high-command-mcp - port: - number: 8000 - - path: / - pathType: Prefix - backend: - service: - name: high-command-ui - port: - number: 80 - - host: hc.dataknife.net - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: high-command-api - port: - number: 80 - - path: /mcp - pathType: Prefix - backend: - service: - name: high-command-mcp - port: - number: 8000 - - path: / - pathType: Prefix - backend: - service: - name: high-command-ui - port: - number: 80 - # Uncomment for TLS (when certificates are ready) - # tls: - # - hosts: - # - hc.dataknife.ai - # - hc.dataknife.net - # secretName: high-command-ui-tls diff --git a/src/services/api.ts b/src/services/api.ts index 7c5d326..9178753 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -3,10 +3,15 @@ import { ClaudeService } from './claude' +interface ApiConfig { + claudeEnabled?: boolean +} + class HighCommandAPI { private baseUrl: string = '/api' // Use local proxy private mcpUrl: string = '/mcp' // Use local proxy private claudeService: ClaudeService + private configCache: ApiConfig | null = null constructor() { console.log('API Server:', this.baseUrl) @@ -14,6 +19,19 @@ class HighCommandAPI { this.claudeService = new ClaudeService() } + private async getConfig(): Promise { + if (this.configCache) return this.configCache + try { + const res = await fetch(`${this.baseUrl}/config`) + if (res.ok) { + this.configCache = await res.json() + } + } catch { + // Ignore - backend may not have /api/config + } + return this.configCache ?? {} + } + private async handleResponse(response: Response) { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -23,14 +41,20 @@ class HighCommandAPI { async executeCommand(prompt: string): Promise { try { - // Use Claude with MCP tools if API key is available + // Use Claude if: (1) we have a local API key, or (2) backend has key in secret const claudeApiKey = import.meta.env.VITE_CLAUDE_API_KEY + const config = await this.getConfig() + const backendHasClaude = config.claudeEnabled === true + if (claudeApiKey) { return await this.claudeService.executeCommand(prompt) } + if (backendHasClaude) { + return await this.claudeService.executeCommand(prompt, { useBackendKey: true }) + } - // Fallback: use keyword matching if no Claude API key - console.warn('No Claude API key found, using basic keyword matching') + // Fallback: use keyword matching if no Claude available + console.warn('No Claude API key found (local or backend), using basic keyword matching') return await this.executeCommandWithKeywordMatching(prompt) } catch (error) { console.error('Command error:', error) diff --git a/src/services/claude.ts b/src/services/claude.ts index 689f86b..0c853f6 100644 --- a/src/services/claude.ts +++ b/src/services/claude.ts @@ -24,7 +24,7 @@ export class ClaudeService { constructor() { this.apiKey = import.meta.env.VITE_CLAUDE_API_KEY || '' if (!this.apiKey) { - console.warn('VITE_CLAUDE_API_KEY not set. Claude integration will not work.') + console.log('VITE_CLAUDE_API_KEY not set. Claude may work if backend has CLAUDE_API_KEY in secret.') } } @@ -106,9 +106,10 @@ export class ClaudeService { } } - async executeCommand(userMessage: string): Promise { - if (!this.apiKey) { - throw new Error('Claude API key not configured. Set VITE_CLAUDE_API_KEY environment variable.') + async executeCommand(userMessage: string, options?: { useBackendKey?: boolean }): Promise { + const useBackendKey = options?.useBackendKey ?? false + if (!this.apiKey && !useBackendKey) { + throw new Error('Claude API key not configured. Set VITE_CLAUDE_API_KEY or configure CLAUDE_API_KEY in the API.') } try { @@ -124,13 +125,16 @@ export class ClaudeService { input_schema: tool.inputSchema })) + const headers: Record = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01' + } + if (this.apiKey) { + headers['x-api-key'] = this.apiKey + } const response = await fetch('/claude/messages', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01' - }, + headers, body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, @@ -221,11 +225,7 @@ Use clear hierarchy with H2 (##) and H3 (###) headers. Always prioritize markdow const followUpResponse = await fetch('/claude/messages', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01' - }, + headers, body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024,