diff --git a/.checkov.yaml b/.checkov.yaml new file mode 100644 index 00000000..51b974eb --- /dev/null +++ b/.checkov.yaml @@ -0,0 +1,5 @@ +# Checkov configuration. +# Cloud Run false-positives (CKV_K8S_21/28/30) are suppressed via per-file +# inline checkov:skip comments in deploy/gcp/cloudrun-service.yaml rather than +# globally here, so future real Kubernetes manifests are not silently exempted. +skip-check: [] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d8283dfb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,103 @@ +# Start with a tiny Docker context and opt in only files used by Dockerfile. +* +!Dockerfile +!.dockerignore +!pyproject.toml +!README.md +!LICENSE +!MANIFEST.in +!semantica/ +!semantica/** +!integrations/ +!integrations/** +!explorer/ +!explorer/** + +# VCS, local config, and secrets. +.git +.git/** +.github +.github/** +.claude +.claude/** +.codex +.codex/** +.agents +.agents/** +.env +.env.* +*.env + +# Python build/test/cache artifacts. +__pycache__ +**/__pycache__ +*.py[cod] +.pytest_cache +.pytest_cache/** +.mypy_cache +.mypy_cache/** +.ruff_cache +.ruff_cache/** +.tox +.tox/** +.venv +.venv/** +venv +venv/** +coverage +coverage/** +htmlcov +htmlcov/** +*.egg-info +*.egg-info/** +build +build/** +dist +dist/** + +# Frontend dependency/build artifacts. +node_modules +node_modules/** +explorer/node_modules +explorer/node_modules/** +explorer/dist +explorer/dist/** +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local outputs and large generated samples. +logs +logs/** +*.log +*.tmp +*.bak +*.backup +tests +tests/** +explorer/tests +explorer/tests/** +docs +docs/** +site +site/** +.mkdocs_cache +.mkdocs_cache/** +cookbook +cookbook/** +examples +examples/** +demo_assets +demo_assets/** +demo_out +demo_out/** +demo_out_* +demo_out_*/** +outputs +outputs/** +pytest-cache-files-* +pytest-cache-files-*/** +test_data +test_data/** +sample_data +sample_data/** diff --git a/.github/workflows/defender-for-devops.yml b/.github/workflows/defender-for-devops.yml index 3714e923..12da9b36 100644 --- a/.github/workflows/defender-for-devops.yml +++ b/.github/workflows/defender-for-devops.yml @@ -32,7 +32,7 @@ permissions: jobs: MSDO: - # currently only windows latest is supported + # currently only windows-latest is supported runs-on: windows-latest steps: @@ -46,8 +46,43 @@ jobs: uses: microsoft/security-devops-action@v1.12.0 id: msdo with: - tools: checkov,eslint,templateanalyzer,terrascan + # checkov is intentionally excluded from this MSDO step. + # MSDO 0.215.0's guardian.cmd wrapper treats checkov's exit code 1 + # (emitted whenever any violation is found, even below the active severity + # threshold) as a fatal "tool error" and breaks the build even when + # "Active results: 0" and "Found no breaking results." The .checkov.yaml + # soft-fail setting is never read by the guardian wrapper. + # IaC security scanning continues below in this same MSDO job identity. + # That preserves the existing GitHub code-scanning configuration while + # avoiding the guardian.cmd/checkov exit-code bug in the MSDO wrapper. + tools: eslint,templateanalyzer,terrascan - name: Upload results to Security tab uses: github/codeql-action/upload-sarif@v4 with: sarif_file: ${{ steps.msdo.outputs.sarifFile }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Checkov + run: python -m pip install checkov==3.3.1 + + - name: Run Checkov + shell: pwsh + env: + PYTHONUTF8: "1" + run: | + New-Item -ItemType Directory -Force reports | Out-Null + checkov --directory . --framework kubernetes helm dockerfile github_actions secrets bicep arm --soft-fail --output sarif --output-file-path reports/checkov.sarif + if (-not (Test-Path reports/checkov.sarif)) { + $sarif = Get-ChildItem -Path reports -Recurse -Filter *.sarif | Select-Object -First 1 + if ($null -eq $sarif) { throw "Checkov did not produce a SARIF file" } + Copy-Item $sarif.FullName reports/checkov.sarif + } + + - name: Upload Checkov results to Security tab + uses: github/codeql-action/upload-sarif@v4 + if: always() + with: + sarif_file: reports/checkov.sarif diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 58cd2fdb..c51bb709 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -18,6 +18,9 @@ on: - 'requirements-docs.txt' - '**/*.md' +permissions: + contents: read + jobs: security-scan: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 55abb455..a5231ef3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,40 @@ -FROM node:26-alpine AS frontend-builder +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS frontend-builder -WORKDIR /app/semantica-explorer +WORKDIR /app +COPY explorer/package*.json ./explorer/ +WORKDIR /app/explorer +RUN npm ci +COPY explorer/ ./ +RUN mkdir -p /app/semantica && npm run build -COPY semantica-explorer/package.json semantica-explorer/package-lock.json* ./ +FROM python:3.12-slim AS runtime - -RUN npm install - - -COPY semantica-explorer/ ./ -RUN npm run build - - -FROM python:3.14-slim AS runtime +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FALKORDB_HOST=falkordb \ + FALKORDB_PORT=6379 \ + ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 WORKDIR /app -COPY pyproject.toml ./ -COPY semantica/ ./semantica/ +RUN groupadd --system semantica \ + && useradd --system --gid semantica --home-dir /app --shell /usr/sbin/nologin semantica +COPY pyproject.toml README.md LICENSE MANIFEST.in ./ +COPY semantica/ ./semantica/ +COPY integrations/ ./integrations/ COPY --from=frontend-builder /app/semantica/static ./semantica/static -RUN pip install --no-cache-dir ".[explorer]" +RUN pip install --no-cache-dir ".[explorer]" \ + && chown -R semantica:semantica /app + +USER semantica EXPOSE 8000 -CMD ["python", "-m", "uvicorn", "semantica.explorer.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" + +CMD ["python", "-m", "uvicorn", "semantica.explorer.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/azure/README.md b/deploy/azure/README.md new file mode 100644 index 00000000..078871c4 --- /dev/null +++ b/deploy/azure/README.md @@ -0,0 +1,26 @@ +# Azure Container Apps + +Deploy with Azure Developer CLI from this template directory: + +```bash +cd deploy/azure +azd auth login +azd init --environment semantica-ke +azd env set AZURE_LOCATION eastus + +# The template defaults to an internal (private) Container Apps environment. +# Provide the resource ID of an existing subnet (delegated to Microsoft.App/environments): +azd env set AZURE_INFRASTRUCTURE_SUBNET_ID /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/ + +# For a quick public dev/test deployment without a VNet, override the default: +# azd env set AZURE_INFRASTRUCTURE_SUBNET_ID "" and set vnetInternal=false in main.parameters.json + +azd up +``` + +After the first deploy, set `allowedOrigins` in `main.parameters.json` to the Container App URL printed by `azd up` (e.g. `https://..eastus.azurecontainerapps.io`), then re-run `azd up` to apply the CORS restriction. + +The Bicep template provisions: + +- A Container Apps managed environment with an internal load balancer (private VNet, no public IP) and a system-assigned managed identity on the Container App (AZR-000363 / AZR-000361 compliant). +- HTTP ingress, scale-to-zero, max 10 replicas, and a `/api/health` liveness probe. diff --git a/deploy/azure/azure.yaml b/deploy/azure/azure.yaml new file mode 100644 index 00000000..c880004d --- /dev/null +++ b/deploy/azure/azure.yaml @@ -0,0 +1,14 @@ +name: semantica-knowledge-explorer +metadata: + template: semantica-knowledge-explorer@0.1.0 +services: + explorer: + project: ../.. + host: containerapp + language: docker + docker: + path: ./Dockerfile + context: . +infra: + provider: bicep + path: . diff --git a/deploy/azure/main.bicep b/deploy/azure/main.bicep new file mode 100644 index 00000000..a90da153 --- /dev/null +++ b/deploy/azure/main.bicep @@ -0,0 +1,132 @@ +targetScope = 'resourceGroup' + +param environmentName string = 'semantica-ke' +param location string = resourceGroup().location +param imageName string +param containerPort int = 8000 +param allowedOrigins string = '*' +param falkordbHost string = 'falkordb' +param falkordbPort string = '6379' + +@description('Deploy the managed environment with an internal load balancer (no public IP). Recommended for production. Set false only for quick dev/test deployments.') +param vnetInternal bool = true + +@description('Resource ID of an existing subnet for the Container Apps environment. Required when vnetInternal is true. E.g. /subscriptions/.../subnets/aca-subnet') +param infrastructureSubnetId string = '' + +var appName = '${environmentName}-explorer' +var logAnalyticsName = '${environmentName}-logs' +var managedEnvironmentName = '${environmentName}-env' + +// Two concrete objects avoids a `null` ternary branch, which crashes checkov's Bicep parser. +var vnetConfigInternal = { + internal: true + infrastructureSubnetId: infrastructureSubnetId +} +var vnetConfigExternal = { + internal: false +} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +resource managedEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: managedEnvironmentName + location: location + properties: { + vnetConfiguration: vnetInternal ? vnetConfigInternal : vnetConfigExternal + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalytics.properties.customerId + sharedKey: logAnalytics.listKeys().primarySharedKey + } + } + } +} + +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: appName + location: location + identity: { + type: 'SystemAssigned' + } + tags: { + 'azd-service-name': 'explorer' + } + properties: { + managedEnvironmentId: managedEnvironment.id + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: containerPort + transport: 'auto' + allowInsecure: false + } + } + template: { + containers: [ + { + name: 'explorer' + image: imageName + env: [ + { + name: 'ALLOWED_ORIGINS' + value: allowedOrigins + } + { + name: 'FALKORDB_HOST' + value: falkordbHost + } + { + name: 'FALKORDB_PORT' + value: falkordbPort + } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/api/health' + port: containerPort + } + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + } + ] + resources: { + cpu: json('0.5') + memory: '1Gi' + } + } + ] + scale: { + minReplicas: 0 + maxReplicas: 10 + rules: [ + { + name: 'http-concurrency' + http: { + metadata: { + concurrentRequests: '100' + } + } + } + ] + } + } + } +} + +output endpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}' diff --git a/deploy/azure/main.parameters.json b/deploy/azure/main.parameters.json new file mode 100644 index 00000000..42c581cb --- /dev/null +++ b/deploy/azure/main.parameters.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "imageName": { + "value": "${SERVICE_EXPLORER_IMAGE_NAME}" + }, + "allowedOrigins": { + "value": "https://REPLACE_ME.azurecontainerapps.io" + }, + "falkordbHost": { + "value": "falkordb" + }, + "falkordbPort": { + "value": "6379" + }, + "vnetInternal": { + "value": true + }, + "infrastructureSubnetId": { + "value": "${AZURE_INFRASTRUCTURE_SUBNET_ID}" + } + } +} diff --git a/deploy/fly/README.md b/deploy/fly/README.md new file mode 100644 index 00000000..5c5d1258 --- /dev/null +++ b/deploy/fly/README.md @@ -0,0 +1,15 @@ +# Fly.io + +Deploy from a clean checkout using the root Dockerfile: + +```bash +flyctl auth login +flyctl launch --copy-config --config deploy/fly/fly.toml --no-deploy +# Replace with your FalkorDB Fly app name. +# Fly.io private networking uses .internal hostnames — do not use localhost +# unless FalkorDB is a co-located process inside the same Machine. +flyctl secrets set FALKORDB_HOST=.internal FALKORDB_PORT=6379 +flyctl deploy --config deploy/fly/fly.toml +``` + +Change `app` in `fly.toml` before launch if the default app name is already taken. diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml new file mode 100644 index 00000000..a236cbb8 --- /dev/null +++ b/deploy/fly/fly.toml @@ -0,0 +1,32 @@ +app = "semantica-knowledge-explorer" +primary_region = "iad" +kill_signal = "SIGTERM" +kill_timeout = "30s" + +[build] +dockerfile = "Dockerfile" + +[env] +ALLOWED_ORIGINS = "https://semantica-knowledge-explorer.fly.dev" +# Set via: flyctl secrets set FALKORDB_HOST=.internal FALKORDB_PORT=6379 +# Do not use localhost unless FalkorDB runs as a co-located process in the same Machine. +FALKORDB_HOST = "falkordb-REPLACE_ME.internal" +FALKORDB_PORT = "6379" + +[http_service] +internal_port = 8000 +force_https = true +auto_stop_machines = "stop" +auto_start_machines = true +min_machines_running = 0 + +[[http_service.checks]] +grace_period = "20s" +interval = "30s" +method = "GET" +timeout = "5s" +path = "/api/health" + +[[vm]] +size = "shared-cpu-1x" +memory = "512mb" diff --git a/deploy/gcp/README.md b/deploy/gcp/README.md new file mode 100644 index 00000000..1794628b --- /dev/null +++ b/deploy/gcp/README.md @@ -0,0 +1,19 @@ +# GCP Cloud Run + +Create the Secret Manager entries, then submit the Cloud Build pipeline: + +```bash +gcloud services enable cloudbuild.googleapis.com run.googleapis.com secretmanager.googleapis.com +printf "falkordb-host.example.internal" | gcloud secrets create falkordb-host --data-file=- +printf "6379" | gcloud secrets create falkordb-port --data-file=- +# Set _ALLOWED_ORIGINS to your actual service URL after the first deploy. +gcloud builds submit --config deploy/gcp/cloudbuild.yaml \ + --substitutions _REGION=us-central1,_SERVICE_NAME=knowledge-explorer,_ALLOWED_ORIGINS=https://knowledge-explorer-REPLACE_ME.a.run.app +``` + +For declarative deploys, substitute your project ID and deploy in one step: + +```bash +sed "s/PROJECT_ID/$(gcloud config get-value project)/g" deploy/gcp/cloudrun-service.yaml | \ + gcloud run services replace - --region us-central1 +``` diff --git a/deploy/gcp/cloudbuild.yaml b/deploy/gcp/cloudbuild.yaml new file mode 100644 index 00000000..674d7d2e --- /dev/null +++ b/deploy/gcp/cloudbuild.yaml @@ -0,0 +1,61 @@ +substitutions: + _REGION: us-central1 + _SERVICE_NAME: knowledge-explorer + _IMAGE: gcr.io/$PROJECT_ID/knowledge-explorer + # Set to your actual service URL — do not use '*' in production. + _ALLOWED_ORIGINS: https://knowledge-explorer-REPLACE_ME.a.run.app + +steps: + - name: gcr.io/cloud-builders/docker + args: + - build + - -t + - ${_IMAGE}:$SHORT_SHA + - -t + - ${_IMAGE}:latest + - . + + - name: gcr.io/cloud-builders/docker + args: + - push + - ${_IMAGE}:$SHORT_SHA + + - name: gcr.io/cloud-builders/docker + args: + - push + - ${_IMAGE}:latest + + - name: gcr.io/google.com/cloudsdktool/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - ${_SERVICE_NAME} + - --image + - ${_IMAGE}:$SHORT_SHA + - --region + - ${_REGION} + - --platform + - managed + # SECURITY: Remove --allow-unauthenticated and restrict ingress for + # production; add IAP or a load balancer with auth before enabling + # unauthenticated access. See: cloud.google.com/run/docs/authenticating + - --no-allow-unauthenticated + - --ingress + - internal-and-cloud-load-balancing + - --port + - "8000" + - --min-instances + - "0" + - --max-instances + - "10" + - --set-env-vars + # Replace with your actual Cloud Run service URL after first deploy, + # e.g. ALLOWED_ORIGINS=https://knowledge-explorer-abc123-uc.a.run.app + - ALLOWED_ORIGINS=${_ALLOWED_ORIGINS} + - --set-secrets + - FALKORDB_HOST=falkordb-host:latest,FALKORDB_PORT=falkordb-port:latest + +images: + - ${_IMAGE}:$SHORT_SHA + - ${_IMAGE}:latest diff --git a/deploy/gcp/cloudrun-service.yaml b/deploy/gcp/cloudrun-service.yaml new file mode 100644 index 00000000..426ea998 --- /dev/null +++ b/deploy/gcp/cloudrun-service.yaml @@ -0,0 +1,58 @@ +# checkov:skip=CKV_K8S_21:Cloud Run has no namespace concept; Knative services are project-scoped not namespace-scoped +# checkov:skip=CKV_K8S_28:Cloud Run enforces seccomp at the platform level; this Knative YAML is not a K8s deployment +# checkov:skip=CKV_K8S_30:Cloud Run enforces AppArmor at the platform level; this Knative YAML is not a K8s deployment +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: knowledge-explorer + annotations: + # Use 'internal-and-cloud-load-balancing' or 'internal' in production. + # 'all' permits direct unauthenticated public internet access. + run.googleapis.com/ingress: internal-and-cloud-load-balancing +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "0" + autoscaling.knative.dev/maxScale: "10" + spec: + containerConcurrency: 80 + timeoutSeconds: 300 + containers: + - name: explorer + # Replace PROJECT_ID with your GCP project ID before deploying. + # See the README for the sed one-liner that does this automatically. + image: gcr.io/PROJECT_ID/knowledge-explorer:latest + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1000 + ports: + - name: http1 + containerPort: 8000 + env: + - name: ALLOWED_ORIGINS + # Replace with your actual service URL — do not use '*' in production. + value: "https://knowledge-explorer-REPLACE_ME.a.run.app" + - name: FALKORDB_HOST + valueFrom: + secretKeyRef: + name: falkordb-host + key: latest + - name: FALKORDB_PORT + valueFrom: + secretKeyRef: + name: falkordb-port + key: latest + resources: + limits: + cpu: "1" + memory: 512Mi + livenessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 diff --git a/deploy/helm/README.md b/deploy/helm/README.md new file mode 100644 index 00000000..bc4d0462 --- /dev/null +++ b/deploy/helm/README.md @@ -0,0 +1,9 @@ +# Helm + +Deploy the Knowledge Explorer chart: + +```bash +helm lint deploy/helm/knowledge-explorer +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace -f deploy/helm/knowledge-explorer/values.prod.yaml +``` diff --git a/deploy/helm/knowledge-explorer/Chart.yaml b/deploy/helm/knowledge-explorer/Chart.yaml new file mode 100644 index 00000000..b0b64c0a --- /dev/null +++ b/deploy/helm/knowledge-explorer/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: knowledge-explorer +description: Semantica Knowledge Explorer deployment chart +type: application +version: 0.1.0 +appVersion: "0.5.0" diff --git a/deploy/helm/knowledge-explorer/README.md b/deploy/helm/knowledge-explorer/README.md new file mode 100644 index 00000000..dd2ab738 --- /dev/null +++ b/deploy/helm/knowledge-explorer/README.md @@ -0,0 +1,9 @@ +# Knowledge Explorer Helm Chart + +```bash +helm lint deploy/helm/knowledge-explorer +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace +helm upgrade --install knowledge-explorer deploy/helm/knowledge-explorer --namespace semantica --create-namespace -f deploy/helm/knowledge-explorer/values.prod.yaml +``` + +Set `autoscaling.enabled=true` to render the HPA. Put sensitive values in Kubernetes Secrets and reference them outside this chart, or pass non-secret env values through `env`. diff --git a/deploy/helm/knowledge-explorer/templates/_helpers.tpl b/deploy/helm/knowledge-explorer/templates/_helpers.tpl new file mode 100644 index 00000000..6680c6c1 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/_helpers.tpl @@ -0,0 +1,29 @@ +{{- define "knowledge-explorer.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "knowledge-explorer.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "knowledge-explorer.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "knowledge-explorer.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "knowledge-explorer.selectorLabels" -}} +app.kubernetes.io/name: {{ include "knowledge-explorer.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/deploy/helm/knowledge-explorer/templates/configmap.yaml b/deploy/helm/knowledge-explorer/templates/configmap.yaml new file mode 100644 index 00000000..753ed65f --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/deployment.yaml b/deploy/helm/knowledge-explorer/templates/deployment.yaml new file mode 100644 index 00000000..09acc837 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/deployment.yaml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "knowledge-explorer.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "knowledge-explorer.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: {{ .Values.automountServiceAccountToken }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: explorer + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + {{- if .Values.image.digest }} + image: "{{ .Values.image.repository }}@{{ .Values.image.digest }}" + {{- else }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + {{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "knowledge-explorer.fullname" . }} + {{- if .Values.secretEnv }} + env: + {{- range $key, $value := .Values.secretEnv }} + - name: {{ $key }} + valueFrom: + secretKeyRef: + name: {{ $value.secretName }} + key: {{ $value.secretKey }} + {{- end }} + {{- end }} + livenessProbe: + httpGet: + path: {{ .Values.livenessProbe.path }} + port: http + initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.livenessProbe.failureThreshold }} + readinessProbe: + httpGet: + path: {{ .Values.readinessProbe.path }} + port: http + initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.readinessProbe.failureThreshold }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/hpa.yaml b/deploy/helm/knowledge-explorer/templates/hpa.yaml new file mode 100644 index 00000000..372fdcdc --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "knowledge-explorer.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/ingress.yaml b/deploy/helm/knowledge-explorer/templates/ingress.yaml new file mode 100644 index 00000000..21361ddc --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/ingress.yaml @@ -0,0 +1,42 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "knowledge-explorer.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/networkpolicy.yaml b/deploy/helm/knowledge-explorer/templates/networkpolicy.yaml new file mode 100644 index 00000000..1fea55c4 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/networkpolicy.yaml @@ -0,0 +1,43 @@ +{{- if .Values.networkPolicy.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "knowledge-explorer.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + # Allow traffic from the ingress controller namespace. + # Override networkPolicy.ingressNamespace in values if your controller uses a different namespace. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.networkPolicy.ingressNamespace }} + ports: + - protocol: TCP + port: {{ .Values.service.targetPort }} + # Allow traffic from pods within the same namespace (e.g. monitoring sidecars). + - from: + - podSelector: {} + ports: + - protocol: TCP + port: {{ .Values.service.targetPort }} + egress: + # FalkorDB + - ports: + - protocol: TCP + port: {{ .Values.networkPolicy.falkordbPort | default 6379 }} + # DNS resolution + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} diff --git a/deploy/helm/knowledge-explorer/templates/service.yaml b/deploy/helm/knowledge-explorer/templates/service.yaml new file mode 100644 index 00000000..d165c9f3 --- /dev/null +++ b/deploy/helm/knowledge-explorer/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "knowledge-explorer.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "knowledge-explorer.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "knowledge-explorer.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/knowledge-explorer/values.prod.yaml b/deploy/helm/knowledge-explorer/values.prod.yaml new file mode 100644 index 00000000..ee6e10bb --- /dev/null +++ b/deploy/helm/knowledge-explorer/values.prod.yaml @@ -0,0 +1,29 @@ +image: + repository: ghcr.io/semantica-agi/semantica-knowledge-explorer + # Replace this placeholder digest with the digest of the image you publish. + digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + tag: "0.5.0" + pullPolicy: Always + +ingress: + enabled: true + hosts: + - host: knowledge-explorer.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: knowledge-explorer-tls + hosts: + - knowledge-explorer.example.com + +env: + ALLOWED_ORIGINS: https://knowledge-explorer.example.com + FALKORDB_HOST: falkordb.semantic-data.svc.cluster.local + FALKORDB_PORT: "6379" + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 75 diff --git a/deploy/helm/knowledge-explorer/values.yaml b/deploy/helm/knowledge-explorer/values.yaml new file mode 100644 index 00000000..cfb183a7 --- /dev/null +++ b/deploy/helm/knowledge-explorer/values.yaml @@ -0,0 +1,103 @@ +replicaCount: 2 + +image: + repository: semantica-knowledge-explorer + pullPolicy: Always + # Replace this placeholder digest with the digest of the image you publish. + digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + # Used only when image.digest is empty. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +automountServiceAccountToken: false + +podAnnotations: + # AppArmor — must match the container name defined in the Deployment template ("explorer"). + container.apparmor.security.beta.kubernetes.io/explorer: runtime/default +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + +service: + type: ClusterIP + port: 80 + targetPort: 8000 + +ingress: + enabled: false + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: knowledge-explorer.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: knowledge-explorer-tls + hosts: + - knowledge-explorer.example.com + +env: + ALLOWED_ORIGINS: https://knowledge-explorer.example.com + FALKORDB_HOST: falkordb + FALKORDB_PORT: "6379" + +secretEnv: {} + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +livenessProbe: + path: /api/health + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + path: /api/health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + +nodeSelector: {} +tolerations: [] +affinity: {} + +networkPolicy: + enabled: true + # Namespace label of your ingress controller. Ingress is only admitted from this namespace + # and from pods within the same namespace as the Explorer. + ingressNamespace: ingress-nginx + # FalkorDB port allowed for egress. Must match FALKORDB_PORT. + falkordbPort: 6379 diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md new file mode 100644 index 00000000..b7ec1d4c --- /dev/null +++ b/deploy/kubernetes/README.md @@ -0,0 +1,12 @@ +# Kubernetes + +Apply the raw manifests with Kustomize: + +```bash +cp deploy/kubernetes/secret.yaml.example deploy/kubernetes/secret.yaml +kubectl apply -f deploy/kubernetes/secret.yaml +kubectl apply -k deploy/kubernetes +kubectl -n semantica rollout status deployment/knowledge-explorer +``` + +Update the placeholder image digest and ingress host before deploying to production. `secret.yaml` is intentionally ignored from the kustomization; keep only `secret.yaml.example` in git. diff --git a/deploy/kubernetes/configmap.yaml b/deploy/kubernetes/configmap.yaml new file mode 100644 index 00000000..79aea825 --- /dev/null +++ b/deploy/kubernetes/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: knowledge-explorer-config + namespace: semantica +data: + ALLOWED_ORIGINS: "https://knowledge-explorer.example.com" + FALKORDB_HOST: "falkordb" + FALKORDB_PORT: "6379" diff --git a/deploy/kubernetes/deployment.yaml b/deploy/kubernetes/deployment.yaml new file mode 100644 index 00000000..e9167d66 --- /dev/null +++ b/deploy/kubernetes/deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: knowledge-explorer + namespace: semantica + labels: + app.kubernetes.io/name: knowledge-explorer + app.kubernetes.io/part-of: semantica +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: knowledge-explorer + template: + metadata: + annotations: + container.apparmor.security.beta.kubernetes.io/explorer: runtime/default + seccomp.security.alpha.kubernetes.io/pod: runtime/default + labels: + app.kubernetes.io/name: knowledge-explorer + app.kubernetes.io/part-of: semantica + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: explorer + image: semantica-knowledge-explorer@sha256:0000000000000000000000000000000000000000000000000000000000000000 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + envFrom: + - configMapRef: + name: knowledge-explorer-config + - secretRef: + name: knowledge-explorer-secrets + optional: true + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 20 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/deploy/kubernetes/ingress.yaml b/deploy/kubernetes/ingress.yaml new file mode 100644 index 00000000..50264357 --- /dev/null +++ b/deploy/kubernetes/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: knowledge-explorer + namespace: semantica + annotations: + kubernetes.io/ingress.class: nginx + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + tls: + - hosts: + - knowledge-explorer.example.com + secretName: knowledge-explorer-tls + rules: + - host: knowledge-explorer.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: knowledge-explorer + port: + name: http diff --git a/deploy/kubernetes/kustomization.yaml b/deploy/kubernetes/kustomization.yaml new file mode 100644 index 00000000..217d0e28 --- /dev/null +++ b/deploy/kubernetes/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - configmap.yaml + - deployment.yaml + - service.yaml + - ingress.yaml + - networkpolicy.yaml diff --git a/deploy/kubernetes/namespace.yaml b/deploy/kubernetes/namespace.yaml new file mode 100644 index 00000000..1bf5ecda --- /dev/null +++ b/deploy/kubernetes/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: semantica + labels: + owner: semantica diff --git a/deploy/kubernetes/networkpolicy.yaml b/deploy/kubernetes/networkpolicy.yaml new file mode 100644 index 00000000..45fc7ebe --- /dev/null +++ b/deploy/kubernetes/networkpolicy.yaml @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: knowledge-explorer + namespace: semantica + labels: + app.kubernetes.io/name: knowledge-explorer + app.kubernetes.io/part-of: semantica +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: knowledge-explorer + policyTypes: + - Ingress + - Egress + ingress: + # Allow traffic from the ingress controller namespace. + # Adjust the namespace label if your ingress controller uses a different namespace. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - protocol: TCP + port: 8000 + # Allow traffic from pods within the same namespace (e.g. monitoring sidecars). + - from: + - podSelector: {} + ports: + - protocol: TCP + port: 8000 + egress: + # FalkorDB + - ports: + - protocol: TCP + port: 6379 + # DNS resolution + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/deploy/kubernetes/secret.yaml.example b/deploy/kubernetes/secret.yaml.example new file mode 100644 index 00000000..ea6098f2 --- /dev/null +++ b/deploy/kubernetes/secret.yaml.example @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: knowledge-explorer-secrets + namespace: semantica +type: Opaque +stringData: + FALKORDB_PASSWORD: "replace-me-if-your-falkordb-requires-auth" diff --git a/deploy/kubernetes/service.yaml b/deploy/kubernetes/service.yaml new file mode 100644 index 00000000..c80af091 --- /dev/null +++ b/deploy/kubernetes/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: knowledge-explorer + namespace: semantica + labels: + app.kubernetes.io/name: knowledge-explorer +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: knowledge-explorer + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/railway/README.md b/deploy/railway/README.md new file mode 100644 index 00000000..a42163ca --- /dev/null +++ b/deploy/railway/README.md @@ -0,0 +1,15 @@ +# Railway + +Deploys the Knowledge Explorer from the root `Dockerfile` and checks `/api/health`. + +```bash +railway login +railway init +railway add --database redis +railway variable --set "FALKORDB_HOST=${{Redis.REDISHOST}}" +railway variable --set "FALKORDB_PORT=${{Redis.REDISPORT}}" +railway variable --set "ALLOWED_ORIGINS=https://${{RAILWAY_PUBLIC_DOMAIN}}" +railway up +``` + +The Redis plugin variables are wired to the requested FalkorDB env names for deployment compatibility. The Explorer currently reads these settings but does not persist graph state to FalkorDB. diff --git a/deploy/railway/railway.toml b/deploy/railway/railway.toml new file mode 100644 index 00000000..138f96b4 --- /dev/null +++ b/deploy/railway/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/api/health" +healthcheckTimeout = 300 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/deploy/render/README.md b/deploy/render/README.md new file mode 100644 index 00000000..e252527a --- /dev/null +++ b/deploy/render/README.md @@ -0,0 +1,11 @@ +# Render + +This Blueprint provisions a Docker web service plus a Render Key Value instance and wires the datastore host/port into the Explorer env vars. + +```bash +render login +render blueprints validate deploy/render/render.yaml +render blueprint apply deploy/render/render.yaml +``` + +After creation, update `ALLOWED_ORIGINS` in the Render dashboard if you attach a custom domain. diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml new file mode 100644 index 00000000..d0776749 --- /dev/null +++ b/deploy/render/render.yaml @@ -0,0 +1,29 @@ +services: + - type: web + name: semantica-knowledge-explorer + runtime: docker + plan: starter + region: oregon + dockerfilePath: ./Dockerfile + dockerContext: . + healthCheckPath: /api/health + envVars: + - key: ALLOWED_ORIGINS + value: https://semantica-knowledge-explorer.onrender.com + - key: FALKORDB_HOST + fromService: + type: keyvalue + name: semantica-explorer-redis + property: host + - key: FALKORDB_PORT + fromService: + type: keyvalue + name: semantica-explorer-redis + property: port + + - type: keyvalue + name: semantica-explorer-redis + plan: starter + ipAllowList: [] + maxmemoryPolicy: noeviction + persistenceMode: snapshot diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..5ff13b8d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,46 @@ +services: + explorer: + command: + - python + - -m + - uvicorn + - semantica.explorer.app:app + - --host + - 0.0.0.0 + - --port + - "8000" + - --reload + - --reload-dir + - /app/semantica + environment: + ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000 + FALKORDB_HOST: falkordb + FALKORDB_PORT: "6379" + volumes: + - ./semantica:/app/semantica + - ./pyproject.toml:/app/pyproject.toml:ro + + frontend: + image: node:22-alpine + working_dir: /app/explorer + command: sh -c "npm ci && npm run dev -- --host 0.0.0.0" + environment: + VITE_EXPLORER_API_TARGET: http://explorer:8000 + VITE_EXPLORER_WS_TARGET: ws://explorer:8000 + ports: + - "5173:5173" + volumes: + - ./explorer:/app/explorer + - explorer_node_modules:/app/explorer/node_modules + depends_on: + explorer: + condition: service_started + networks: + - semantica + +networks: + semantica: + driver: bridge + +volumes: + explorer_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 27503d40..746b6bf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,35 @@ services: + explorer: + build: + context: . + dockerfile: Dockerfile + image: semantica-knowledge-explorer:latest + environment: + FALKORDB_HOST: falkordb + FALKORDB_PORT: "6379" + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000} + depends_on: + falkordb: + condition: service_started + ports: + - "8000:8000" + networks: + - semantica + restart: unless-stopped + falkordb: image: falkordb/falkordb:latest ports: - "6379:6379" volumes: - falkordb_data:/data - restart: always + networks: + - semantica + restart: unless-stopped + +networks: + semantica: + driver: bridge volumes: falkordb_data: diff --git a/docs/cli-setup.md b/docs/cli-setup.md index 588ef980..3abe4d90 100644 --- a/docs/cli-setup.md +++ b/docs/cli-setup.md @@ -69,7 +69,7 @@ python -c "import semantica; print(semantica.__version__)" ```bash curl http://localhost:8000/health - # {"status": "healthy"} + # {"status": "ok"} curl http://localhost:8000/api/info # {"name": "Semantica API", "version": "...", "status": "active"} diff --git a/docs/explorer-setup.md b/docs/explorer-setup.md index 6714c084..f023730e 100644 --- a/docs/explorer-setup.md +++ b/docs/explorer-setup.md @@ -56,7 +56,7 @@ The browser opens at `http://127.0.0.1:8000`. The health endpoint confirms the s ```bash curl http://127.0.0.1:8000/api/health -# {"status": "healthy"} +# {"status": "ok"} ``` @@ -174,7 +174,7 @@ Once the server is running: | :--- | :------------ | | `http://127.0.0.1:8000` | Interactive dashboard | | `http://127.0.0.1:8000/docs` | Swagger UI: every REST endpoint, interactive | -| `http://127.0.0.1:8000/api/health` | Health check: `{"status": "healthy"}` | +| `http://127.0.0.1:8000/api/health` | Health check: `{"status": "ok"}` | The browser tab opens shortly after startup. If it does not open, navigate to the URL manually or pass `--no-browser` and open it yourself. diff --git a/docs/reference/explorer.md b/docs/reference/explorer.md index 2bccb30e..578ca316 100644 --- a/docs/reference/explorer.md +++ b/docs/reference/explorer.md @@ -306,7 +306,7 @@ Full interactive docs at `http://localhost:8000/docs`. All endpoints accept and | Endpoint | Method | Description | | :-------- | :------ | :----------- | - | `/api/health` | `GET` | Returns `{"status": "healthy"}` | + | `/api/health` | `GET` | Returns `{"status": "ok"}` | | `/api/info` | `GET` | Server name, version, status | | `/docs` | `GET` | Interactive Swagger UI: all endpoints | diff --git a/explorer/vite.config.ts b/explorer/vite.config.ts index 0ada812a..bdc253f0 100644 --- a/explorer/vite.config.ts +++ b/explorer/vite.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +const apiTarget = process.env.VITE_EXPLORER_API_TARGET ?? 'http://127.0.0.1:8000' +const wsTarget = process.env.VITE_EXPLORER_WS_TARGET ?? apiTarget.replace(/^http/, 'ws') + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -57,11 +60,11 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://127.0.0.1:8000', + target: apiTarget, changeOrigin: true, }, '/ws': { - target: 'ws://127.0.0.1:8000', + target: wsTarget, ws: true, }, }, diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py index e1242e5a..96d66b14 100644 --- a/semantica/explorer/app.py +++ b/semantica/explorer/app.py @@ -1,4 +1,4 @@ -""" +""" Semantica Explorer FastAPI application factory. """ @@ -14,11 +14,44 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .. import __version__ +from ..context.context_graph import ContextGraph from .session import GraphSession from .ws import ConnectionManager +def _read_int_env(name: str, default: int) -> int: + raw_value = os.environ.get(name) + if raw_value is None or raw_value.strip() == "": + return default + try: + return int(raw_value) + except ValueError: + return default + + +def _read_explorer_settings() -> dict: + if "ALLOWED_ORIGINS" in os.environ: + raw_origins = os.environ["ALLOWED_ORIGINS"] + elif "EXPLORER_CORS_ORIGINS" in os.environ: + raw_origins = os.environ["EXPLORER_CORS_ORIGINS"] + else: + raw_origins = "http://localhost:5173,http://127.0.0.1:5173" + return { + "allowed_origins": [ + origin.strip() for origin in raw_origins.split(",") if origin.strip() + ], + # These are read and stored for future use when direct FalkorDB connection + # support is added to the Explorer. Currently GraphSession uses an in-memory + # ContextGraph and does not open a network connection to FalkorDB. + "falkordb_host": os.environ.get("FALKORDB_HOST", "localhost"), + "falkordb_port": _read_int_env("FALKORDB_PORT", 6379), + } + + def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None: + if getattr(session.graph, "_mutation_bridge_installed", False): + return + session.graph._mutation_bridge_installed = True previous_callback = getattr(session.graph, "mutation_callback", None) def on_mutation(event_type: str, entity_id: str, payload: dict) -> None: @@ -43,13 +76,15 @@ def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None: def create_app(session: Optional[GraphSession] = None) -> FastAPI: + active_session = session or GraphSession(ContextGraph(advanced_analytics=False)) + settings = _read_explorer_settings() + @asynccontextmanager async def lifespan(app: FastAPI): app.state.event_loop = asyncio.get_running_loop() app.state.ws_manager = ConnectionManager() - if session is not None: - app.state.session = session - _install_mutation_bridge(app, session) + app.state.session = active_session + _install_mutation_bridge(app, active_session) yield app = FastAPI( @@ -59,10 +94,8 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI: lifespan=lifespan, ) - _raw_origins = os.environ.get( - "EXPLORER_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173" - ) - _cors_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()] + app.state.explorer_settings = settings + # allow_credentials lets browsers send cookies/auth headers cross-origin. # The Explorer has no authentication, so credentials serve no purpose and # enabling them when origins are broadened creates cross-site request risk. @@ -71,7 +104,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI: _allow_credentials = os.environ.get("EXPLORER_CORS_CREDENTIALS", "false").lower() == "true" app.add_middleware( CORSMiddleware, - allow_origins=_cors_origins, + allow_origins=settings["allowed_origins"], allow_credentials=_allow_credentials, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], @@ -169,7 +202,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI: @app.get("/api/health") async def health(): - return {"status": "healthy"} + return {"status": "ok"} @app.get("/api/info") async def info(): diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py index dc1c4bb3..18597743 100644 --- a/tests/explorer/test_explorer_api.py +++ b/tests/explorer/test_explorer_api.py @@ -1,4 +1,4 @@ -"""Integration tests for the explorer API.""" +"""Integration tests for the explorer API.""" import json from pathlib import Path @@ -151,7 +151,7 @@ class TestHealthInfo: def test_health(self, client): response = client.get("/api/health") assert response.status_code == 200 - assert response.json()["status"] == "healthy" + assert response.json() == {"status": "ok"} def test_info(self, client): response = client.get("/api/info") @@ -161,6 +161,40 @@ class TestHealthInfo: assert payload["status"] == "active" assert payload["version"] + def test_env_settings_are_read_from_supported_names(self, monkeypatch): + monkeypatch.setenv( + "ALLOWED_ORIGINS", + "https://app.example.com, https://team.example.com", + ) + monkeypatch.setenv("FALKORDB_HOST", "falkordb.internal") + monkeypatch.setenv("FALKORDB_PORT", "6380") + + app = create_app() + + assert app.state.explorer_settings["allowed_origins"] == [ + "https://app.example.com", + "https://team.example.com", + ] + assert app.state.explorer_settings["falkordb_host"] == "falkordb.internal" + assert app.state.explorer_settings["falkordb_port"] == 6380 + + def test_env_settings_fall_back_to_legacy_cors_name(self, monkeypatch): + monkeypatch.delenv("ALLOWED_ORIGINS", raising=False) + monkeypatch.setenv("EXPLORER_CORS_ORIGINS", "https://legacy.example.com") + + app = create_app() + + assert app.state.explorer_settings["allowed_origins"] == ["https://legacy.example.com"] + + def test_default_app_initializes_empty_graph_session(self): + with TestClient(create_app()) as test_client: + response = test_client.get("/api/graph/nodes") + + assert response.status_code == 200 + payload = response.json() + assert payload["nodes"] == [] + assert payload["total"] == 0 + class TestGraphNodes: def test_list_nodes(self, client):