commit 1a0bbe9dfd88a4005f327a0191b0887f8ea0680e Author: Clemens Hering Date: Sun Mar 1 07:40:49 2026 +0100 Teststand diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7923f3a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.gocache +.gomodcache +server +README.md diff --git a/.gitea/workflows/deploy-kubeviz.yml b/.gitea/workflows/deploy-kubeviz.yml new file mode 100644 index 0000000..c2737d0 --- /dev/null +++ b/.gitea/workflows/deploy-kubeviz.yml @@ -0,0 +1,24 @@ +name: Deploy KubeViz + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: [self-hosted, linux] + env: + IMAGE_REPO: localhost/kubeviz + IMAGE_TAG: prod + SERVICE_NAME: kubeviz.service + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and deploy via Podman + Quadlet service + run: | + chmod +x scripts/deploy-with-podman.sh + scripts/deploy-with-podman.sh + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cf51ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gocache/ +.gomodcache/ +server +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf3d6c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM dhi.io/golang:1.26-dev AS builder +ARG HELM_VERSION=v3.16.4 +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +WORKDIR /src +COPY go.mod . +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/kubeviz ./cmd/server + +FROM alpine:3.20 AS helm +ARG HELM_VERSION=v3.16.4 +ARG TARGETARCH=amd64 +RUN apk add --no-cache ca-certificates wget tar +RUN wget -qO /tmp/helm.tgz "https://get.helm.sh/helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz" +RUN set -eux; \ + tar -xzf /tmp/helm.tgz -C /tmp; \ + cp /tmp/linux-${TARGETARCH}/helm /out-helm; \ + chmod +x /out-helm; \ + rm -rf /tmp/helm.tgz /tmp/linux-${TARGETARCH} + +FROM dhi.io/golang:1.26 +WORKDIR /app +COPY --from=builder /out/kubeviz /app/kubeviz +COPY --from=helm /out-helm /usr/local/bin/helm +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/app/kubeviz"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9402f20 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# KubeViz + +No-Node Kubernetes manifest visualizer built with Go + server-rendered HTML (HTMX + Alpine.js). + +## Features + +- Upload one or more Kubernetes manifest files, or paste YAML/JSON text +- Import manifests directly from a Git repository path +- Render Helm charts directly from Git repositories (`helm template`) and visualize output +- Parse multi-document manifests and `List.items` +- Visual graph with resource relationships and details panel +- Grouping/collapsing by namespace or kind +- Security and validation checks (privileged containers, missing limits/requests, unresolved refs, selector mismatch, duplicates) +- Configurable checks in UI (enable/disable individual rules) +- Manifest diff between base and target states +- Supported first-class resources: + - Deployment, StatefulSet, DaemonSet + - Service, Ingress + - ConfigMap, Secret + - PersistentVolumeClaim + - HorizontalPodAutoscaler +- Generic CRD/custom resource nodes +- Secret redaction (never exposes decoded secret values) +- Session-scoped in-memory dataset storage (no DB) +- SVG and PNG graph export endpoints + +## Run locally + +```bash +go run ./cmd/server +``` + +Open [http://localhost:8080](http://localhost:8080). + +## Run locally with Docker + +Build and run directly: + +```bash +docker build -t kubeviz:local . +docker run --rm -p 8080:8080 \ + -e ADDR=:8080 \ + -e SESSION_TTL=30m \ + -e MAX_UPLOAD_SIZE=5242880 \ + -e COOKIE_SECURE=false \ + -e LOG_LEVEL=info \ + kubeviz:local +``` + +Optional Helm version override during build: + +```bash +docker build --build-arg HELM_VERSION=v3.16.4 -t kubeviz:local . +``` + +Or with Compose: + +```bash +docker compose up --build +``` + +Then open [http://localhost:8080](http://localhost:8080). + +## Environment variables + +- `ADDR` (default `:8080`) +- `SESSION_TTL` (default `30m`) +- `MAX_UPLOAD_SIZE` (bytes, default `5242880`) +- `COOKIE_SECURE` (`true`/`false`, default `true`) +- `GIT_ALLOWED_HOSTS` (CSV allowlist, default `github.com,gitlab.com,bitbucket.org`) +- `LOG_LEVEL` (default `info`) + +## API endpoints + +- `POST /api/manifests/parse` +- `POST /api/git/import` +- `POST /api/helm/render` +- `GET /api/graph` +- `POST /api/diff` +- `GET /api/resources/{id}` +- `GET /api/export/svg` +- `GET /api/export/png` +- `POST /api/session/clear` + +## Test + +```bash +GOCACHE=$(pwd)/.gocache go test ./... +``` + +## Deploy to Kubernetes + +Manifests are in `deploy/k8s/`: + +```bash +kubectl apply -f deploy/k8s/namespace.yaml +kubectl apply -f deploy/k8s/configmap.yaml +kubectl apply -f deploy/k8s/deployment.yaml +kubectl apply -f deploy/k8s/service.yaml +kubectl apply -f deploy/k8s/ingress.yaml +``` + +## Notes + +- The built-in YAML parser is dependency-free and optimized for common Kubernetes manifest structures. +- v1 scope is visualization only (no apply/edit back to cluster). +- Container images use `dhi.io/golang:1.26` (builder and runtime) with a non-root runtime user. +- Runtime image includes `git` and `helm` for Git/Helm import endpoints. + +## Deploy via Gitea (Podman + Quadlet on same VM) + +- Workflow: `.gitea/workflows/deploy-kubeviz.yml` +- Script: `scripts/deploy-with-podman.sh` + +Pipeline flow: +1. Build image locally on server with Podman +2. Tag as `localhost/kubeviz:prod` +3. Restart `kubeviz.service` + +Use this in your Quadlet: + +```ini +Image=localhost/kubeviz:prod +Pull=never +``` diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 0000000..5ffbebe --- /dev/null +++ b/deploy/k8s/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubeviz-config + namespace: kubeviz +data: + ADDR: ":8080" + SESSION_TTL: "30m" + MAX_UPLOAD_SIZE: "5242880" + COOKIE_SECURE: "true" + LOG_LEVEL: "info" + GIT_ALLOWED_HOSTS: "github.com,gitlab.com,bitbucket.org" diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..63b673c --- /dev/null +++ b/deploy/k8s/deployment.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kubeviz + namespace: kubeviz +spec: + replicas: 1 + selector: + matchLabels: + app: kubeviz + template: + metadata: + labels: + app: kubeviz + spec: + automountServiceAccountToken: false + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: kubeviz + image: kubeviz:latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + ports: + - containerPort: 8080 + name: http + envFrom: + - configMapRef: + name: kubeviz-config + volumeMounts: + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + volumes: + - name: tmp + emptyDir: {} diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 0000000..a9dccf9 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kubeviz + namespace: kubeviz + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + tls: + - hosts: + - kubeviz.local + secretName: kubeviz-tls + rules: + - host: kubeviz.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kubeviz + port: + number: 80 diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..0e449ee --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: kubeviz diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml new file mode 100644 index 0000000..dce01f0 --- /dev/null +++ b/deploy/k8s/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: kubeviz + namespace: kubeviz +spec: + selector: + app: kubeviz + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http diff --git a/deploy/quadlet/README.md b/deploy/quadlet/README.md new file mode 100644 index 0000000..027a2a8 --- /dev/null +++ b/deploy/quadlet/README.md @@ -0,0 +1,102 @@ +# Quadlet Templates (AlmaLinux + Podman) + +Files: +- `kubeviz.container`: system-level Quadlet unit template +- `kubeviz-traefik.container`: direct Traefik-label variant (shared Podman network) +- `traefik.network`: optional shared network Quadlet +- `kubeviz.env.example`: optional external environment file + +## 1. Install template + +```bash +sudo mkdir -p /etc/containers/systemd +sudo cp deploy/quadlet/kubeviz.container /etc/containers/systemd/kubeviz.container +``` + +Alternative (Traefik-label mode): + +```bash +sudo cp deploy/quadlet/traefik.network /etc/containers/systemd/traefik.network +sudo cp deploy/quadlet/kubeviz-traefik.container /etc/containers/systemd/kubeviz.container +``` + +Optional env file: + +```bash +sudo mkdir -p /etc/kubeviz +sudo cp deploy/quadlet/kubeviz.env.example /etc/kubeviz/kubeviz.env +# then uncomment EnvironmentFile in kubeviz.container +``` + +## 2. Set real image + +Edit `/etc/containers/systemd/kubeviz.container` and replace: +- `ghcr.io/REPLACE_ME/kubeviz:v0.1.0` + +For Gitea CI/CD without external registry, use a stable local tag: + +```ini +Image=localhost/kubeviz:prod +Pull=never +``` + +## 3. Start service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now kubeviz.service +sudo systemctl status kubeviz.service +sudo journalctl -u kubeviz.service -f +``` + +## 4. Update rollout + +```bash +sudo systemctl restart kubeviz.service +``` + +Because `Pull=always` is set, Podman will pull the latest image for the configured tag on restart. + +## 5. Traefik integration + +Route `kubeviz.valtrix.systems` to `http://127.0.0.1:18080`. +Keep `COOKIE_SECURE=true` in production. + +If you use `kubeviz-traefik.container`, Traefik discovers KubeViz via labels and the shared `traefik` network instead of localhost port mapping. + +## 6. Gitea pipeline (direct deploy on server) + +Workflow template is included at: +- `.gitea/workflows/deploy-kubeviz.yml` +- `scripts/deploy-with-podman.sh` + +The deploy script builds with Podman, tags `localhost/kubeviz:prod`, and restarts `kubeviz.service`. + +Required sudo permissions for the Gitea runner user (example): + +```text +gitea-runner ALL=(root) NOPASSWD:/usr/bin/podman build *,/usr/bin/podman tag *,/usr/bin/systemctl restart kubeviz.service,/usr/bin/systemctl is-active kubeviz.service +``` + +The user must be the one that executes the Gitea Actions runner service (often `gitea-runner`). +Check it with: + +```bash +systemctl cat gitea-runner | grep -E '^User=' +``` + +For BasicAuth labels, use htpasswd hashes (not plain passwords), for example: + +```bash +htpasswd -nB smb +``` + +Then set the generated value in: +- `traefik.http.middlewares.kubeviz-auth.basicauth.users=smb:` + +After updating sudoers: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart gitea-runner +``` diff --git a/deploy/quadlet/kubeviz-traefik.container b/deploy/quadlet/kubeviz-traefik.container new file mode 100644 index 0000000..85201e9 --- /dev/null +++ b/deploy/quadlet/kubeviz-traefik.container @@ -0,0 +1,47 @@ +[Unit] +Description=KubeViz behind Traefik (Podman network) +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=kubeviz +Image=localhost/kubeviz:prod +Pull=always + +# Attach to the same user-defined network as Traefik. +Network=traefik.network + +Environment=TZ=Europe/Berlin +Environment=ADDR=:8080 +Environment=SESSION_TTL=30m +Environment=MAX_UPLOAD_SIZE=5242880 +Environment=COOKIE_SECURE=true +Environment=LOG_LEVEL=info +Environment=GIT_ALLOWED_HOSTS=github.com,gitlab.com,gitea.smb-corp.de + +NoNewPrivileges=true +ReadOnly=true +Tmpfs=/tmp:rw,size=128m,mode=1777 +User=65532 +Group=65532 + +# Traefik labels (Podman provider) +Label=traefik.enable=true +Label=traefik.http.routers.kubeviz.rule=Host(`kubeviz.valtrix.systems`) +Label=traefik.http.routers.kubeviz.entrypoints=websecure +Label=traefik.http.routers.kubeviz.tls=true +Label=traefik.http.routers.kubeviz.tls.certresolver=letsencrypt +Label=traefik.http.routers.kubeviz.middlewares=kubeviz-sec-headers,kubeviz-auth +Label=traefik.http.services.kubeviz.loadbalancer.server.port=8080 +Label=traefik.docker.network=traefik +Label=traefik.http.middlewares.kubeviz-sec-headers.headers.customResponseHeaders.Content-Security-Policy=default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; script-src-elem 'self' 'unsafe-inline'; connect-src 'self' wss: https:; font-src 'self' data:; worker-src 'self' blob:; +Label=traefik.http.middlewares.kubeviz-auth.basicauth.users=smb:REPLACE_WITH_HTPASSWD_HASH + +[Service] +Restart=always +RestartSec=3 +TimeoutStartSec=90 +TimeoutStopSec=20 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/quadlet/kubeviz.container b/deploy/quadlet/kubeviz.container new file mode 100644 index 0000000..0352766 --- /dev/null +++ b/deploy/quadlet/kubeviz.container @@ -0,0 +1,45 @@ +[Unit] +Description=KubeViz (Go Kubernetes manifest visualizer) +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=kubeviz +Image=localhost/kubeviz:prod +Pull=always + +# Bind only on localhost; Traefik handles public ingress. +PublishPort=127.0.0.1:18080:8080 + +# Runtime config +Environment=ADDR=:8080 +Environment=SESSION_TTL=30m +Environment=MAX_UPLOAD_SIZE=5242880 +Environment=COOKIE_SECURE=true +Environment=LOG_LEVEL=info +Environment=GIT_ALLOWED_HOSTS=github.com,gitlab.com,bitbucket.org + +# Optional: keep env values in a separate file +# EnvironmentFile=/etc/kubeviz/kubeviz.env + +# Security hardening +NoNewPrivileges=true +ReadOnly=true +Tmpfs=/tmp:rw,size=128m,mode=1777 +User=65532 +Group=65532 + +# Process / service behavior +HealthCmd=/app/kubeviz --help +HealthInterval=30s +HealthTimeout=5s +HealthRetries=3 + +[Service] +Restart=always +RestartSec=3 +TimeoutStartSec=90 +TimeoutStopSec=20 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/quadlet/kubeviz.env.example b/deploy/quadlet/kubeviz.env.example new file mode 100644 index 0000000..d32b61e --- /dev/null +++ b/deploy/quadlet/kubeviz.env.example @@ -0,0 +1,7 @@ +# Copy to /etc/kubeviz/kubeviz.env and reference via EnvironmentFile in kubeviz.container +ADDR=:8080 +SESSION_TTL=30m +MAX_UPLOAD_SIZE=5242880 +COOKIE_SECURE=true +LOG_LEVEL=info +GIT_ALLOWED_HOSTS=github.com,gitlab.com,bitbucket.org diff --git a/deploy/quadlet/traefik.network b/deploy/quadlet/traefik.network new file mode 100644 index 0000000..1fcf58c --- /dev/null +++ b/deploy/quadlet/traefik.network @@ -0,0 +1,6 @@ +[Unit] +Description=Shared network for Traefik-routed containers + +[Network] +NetworkName=traefik +Driver=bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f094cb3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + kubeviz: + build: + context: . + dockerfile: Dockerfile + image: kubeviz:local + ports: + - "8080:8080" + environment: + ADDR: ":8080" + SESSION_TTL: "30m" + MAX_UPLOAD_SIZE: "5242880" + COOKIE_SECURE: "false" + LOG_LEVEL: "info" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2936bbf --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module kubeviz + +go 1.26.0 diff --git a/internal/analyze/analyze.go b/internal/analyze/analyze.go new file mode 100644 index 0000000..f5fa768 --- /dev/null +++ b/internal/analyze/analyze.go @@ -0,0 +1,334 @@ +package analyze + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "kubeviz/internal/model" + "kubeviz/internal/parser" +) + +const ( + RulePrivilegedContainer = "privileged_container" + RuleRunAsNonRootFalse = "run_as_non_root_false" + RuleMissingReq = "missing_resource_requests" + RuleMissingLimits = "missing_resource_limits" + RuleIngressWildcardHost = "ingress_wildcard_host" + RuleUnresolvedReference = "unresolved_reference" + RuleServiceSelectorMatch = "selector_mismatch" + RuleDuplicateResourceID = "duplicate_resource_id" +) + +var allRules = []string{ + RulePrivilegedContainer, + RuleRunAsNonRootFalse, + RuleMissingReq, + RuleMissingLimits, + RuleIngressWildcardHost, + RuleUnresolvedReference, + RuleServiceSelectorMatch, + RuleDuplicateResourceID, +} + +type Config struct { + enabled map[string]bool +} + +func DefaultConfig() Config { + enabled := map[string]bool{} + for _, rule := range allRules { + enabled[rule] = true + } + return Config{enabled: enabled} +} + +func ConfigFromEnabled(enabledRules map[string]bool) Config { + cfg := Config{enabled: map[string]bool{}} + for _, rule := range allRules { + cfg.enabled[rule] = enabledRules[rule] + } + return cfg +} + +func (c Config) EnabledRules() []string { + out := make([]string, 0, len(allRules)) + for _, rule := range allRules { + if c.enabled[rule] { + out = append(out, rule) + } + } + return out +} + +func (c Config) isEnabled(rule string) bool { + return c.enabled[rule] +} + +func AnalyzeDataset(ds *model.Dataset, cfg Config) ([]model.Finding, map[string]string) { + if ds == nil { + return nil, map[string]string{} + } + + findings := make([]model.Finding, 0) + findings = append(findings, securityFindings(ds, cfg)...) + findings = append(findings, validationFindings(ds, cfg)...) + findings = append(findings, duplicateFindings(ds, cfg)...) + + health := map[string]string{} + for id := range ds.Resources { + health[id] = "healthy" + } + for _, f := range findings { + if f.ResourceID == "" { + continue + } + if f.Severity == "error" || f.Severity == "warning" { + health[f.ResourceID] = "warning" + } + } + + sort.Slice(findings, func(i, j int) bool { + if findings[i].Severity != findings[j].Severity { + return findings[i].Severity > findings[j].Severity + } + if findings[i].Category != findings[j].Category { + return findings[i].Category < findings[j].Category + } + return findings[i].Message < findings[j].Message + }) + + return findings, health +} + +func securityFindings(ds *model.Dataset, cfg Config) []model.Finding { + out := make([]model.Finding, 0) + for _, r := range ds.Resources { + spec := extractPodSpec(r.Raw) + if spec != nil { + containers, ok := spec["containers"].([]any) + if ok { + for _, c := range containers { + cm, ok := c.(map[string]any) + if !ok { + continue + } + name, _ := cm["name"].(string) + securityCtx, _ := cm["securityContext"].(map[string]any) + if securityCtx != nil { + if cfg.isEnabled(RulePrivilegedContainer) { + if priv, ok := securityCtx["privileged"].(bool); ok && priv { + out = append(out, finding("security", RulePrivilegedContainer, "error", r.ID, fmt.Sprintf("container %q runs privileged", name))) + } + } + if cfg.isEnabled(RuleRunAsNonRootFalse) { + if runAsNonRoot, ok := securityCtx["runAsNonRoot"].(bool); ok && !runAsNonRoot { + out = append(out, finding("security", RuleRunAsNonRootFalse, "warning", r.ID, fmt.Sprintf("container %q has runAsNonRoot=false", name))) + } + } + } + resources, _ := cm["resources"].(map[string]any) + if resources == nil { + if cfg.isEnabled(RuleMissingReq) { + out = append(out, finding("security", RuleMissingReq, "warning", r.ID, fmt.Sprintf("container %q has no resources.requests", name))) + } + if cfg.isEnabled(RuleMissingLimits) { + out = append(out, finding("security", RuleMissingLimits, "warning", r.ID, fmt.Sprintf("container %q has no resources.limits", name))) + } + continue + } + if cfg.isEnabled(RuleMissingReq) { + if _, ok := resources["requests"].(map[string]any); !ok { + out = append(out, finding("security", RuleMissingReq, "warning", r.ID, fmt.Sprintf("container %q missing resource requests", name))) + } + } + if cfg.isEnabled(RuleMissingLimits) { + if _, ok := resources["limits"].(map[string]any); !ok { + out = append(out, finding("security", RuleMissingLimits, "warning", r.ID, fmt.Sprintf("container %q missing resource limits", name))) + } + } + } + } + } + + if cfg.isEnabled(RuleIngressWildcardHost) && r.Kind == "Ingress" { + spec, _ := r.Raw["spec"].(map[string]any) + rules, _ := spec["rules"].([]any) + for _, rr := range rules { + rm, ok := rr.(map[string]any) + if !ok { + continue + } + host, _ := rm["host"].(string) + if host == "" || strings.Contains(host, "*") { + out = append(out, finding("security", RuleIngressWildcardHost, "warning", r.ID, "ingress has wildcard or empty host")) + break + } + } + } + } + return out +} + +func validationFindings(ds *model.Dataset, cfg Config) []model.Finding { + out := make([]model.Finding, 0) + + if cfg.isEnabled(RuleUnresolvedReference) { + for _, r := range ds.Resources { + for _, ref := range r.References { + target := resolveReferenceTarget(ds, ref) + if target == "" { + out = append(out, finding("validation", RuleUnresolvedReference, "error", r.ID, fmt.Sprintf("unresolved reference: %s %s", ref.Kind, ref.Name))) + } + } + } + } + + if cfg.isEnabled(RuleServiceSelectorMatch) { + services := make([]*model.Resource, 0) + workloads := make([]*model.Resource, 0) + for _, r := range ds.Resources { + switch r.Kind { + case "Service": + services = append(services, r) + case "Deployment", "StatefulSet", "DaemonSet": + workloads = append(workloads, r) + } + } + for _, svc := range services { + if svc.WorkloadMeta == nil || len(svc.WorkloadMeta.ServiceSelectors) == 0 { + continue + } + matched := false + for _, wl := range workloads { + if wl.Namespace != svc.Namespace || wl.WorkloadMeta == nil { + continue + } + if selectorsMatch(svc.WorkloadMeta.ServiceSelectors, wl.WorkloadMeta.PodTemplateLabels) { + matched = true + break + } + } + if !matched { + out = append(out, finding("validation", RuleServiceSelectorMatch, "warning", svc.ID, "service selector does not match any workload in namespace")) + } + } + } + + return out +} + +func duplicateFindings(ds *model.Dataset, cfg Config) []model.Finding { + if !cfg.isEnabled(RuleDuplicateResourceID) { + return nil + } + out := make([]model.Finding, 0, len(ds.Duplicates)) + for _, id := range ds.Duplicates { + out = append(out, finding("validation", RuleDuplicateResourceID, "warning", id, "duplicate resource id found in input; last document wins")) + } + return out +} + +func DiffDatasets(base, target *model.Dataset) model.DiffResponse { + resp := model.DiffResponse{ + Added: []model.DiffItem{}, + Removed: []model.DiffItem{}, + Changed: []model.DiffItem{}, + } + if base == nil || target == nil { + return resp + } + + for id, t := range target.Resources { + b, ok := base.Resources[id] + if !ok { + resp.Added = append(resp.Added, toDiffItem(t)) + continue + } + if !rawEqual(b.Raw, t.Raw) { + resp.Changed = append(resp.Changed, toDiffItem(t)) + } + } + + for id, b := range base.Resources { + if _, ok := target.Resources[id]; !ok { + resp.Removed = append(resp.Removed, toDiffItem(b)) + } + } + + sort.Slice(resp.Added, func(i, j int) bool { return resp.Added[i].ID < resp.Added[j].ID }) + sort.Slice(resp.Removed, func(i, j int) bool { return resp.Removed[i].ID < resp.Removed[j].ID }) + sort.Slice(resp.Changed, func(i, j int) bool { return resp.Changed[i].ID < resp.Changed[j].ID }) + return resp +} + +func toDiffItem(r *model.Resource) model.DiffItem { + return model.DiffItem{ID: r.ID, Kind: r.Kind, Name: r.Name, Namespace: r.Namespace} +} + +func rawEqual(a, b map[string]any) bool { + aj, err := json.Marshal(a) + if err != nil { + return false + } + bj, err := json.Marshal(b) + if err != nil { + return false + } + return string(aj) == string(bj) +} + +func resolveReferenceTarget(dataset *model.Dataset, ref model.ResourceReference) string { + ns := ref.Namespace + if ns == "" { + ns = "default" + } + candidate := parser.ResourceID(ns, ref.Kind, ref.Name) + if _, ok := dataset.Resources[candidate]; ok { + return candidate + } + candidate = parser.ResourceID("", ref.Kind, ref.Name) + if _, ok := dataset.Resources[candidate]; ok { + return candidate + } + for _, r := range dataset.Resources { + if r.Kind == ref.Kind && r.Name == ref.Name { + return r.ID + } + } + return "" +} + +func finding(category, rule, severity, resourceID, message string) model.Finding { + return model.Finding{ + ID: category + ":" + rule + ":" + severity + ":" + resourceID + ":" + message, + Category: category, + Rule: rule, + Severity: severity, + ResourceID: resourceID, + Message: message, + } +} + +func extractPodSpec(raw map[string]any) map[string]any { + spec, _ := raw["spec"].(map[string]any) + if spec == nil { + return nil + } + if template, ok := spec["template"].(map[string]any); ok { + if tplSpec, ok := template["spec"].(map[string]any); ok { + return tplSpec + } + } + return nil +} + +func selectorsMatch(selector, labels map[string]string) bool { + for k, v := range selector { + if labels[k] != v { + return false + } + } + return true +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2d5bb95 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,96 @@ +package config + +import ( + "log" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Addr string + SessionTTL time.Duration + MaxUploadSize int64 + CookieSecure bool + LogLevel string + GitAllowedHosts []string +} + +func Load() Config { + cfg := Config{ + Addr: envOrDefault("ADDR", ":8080"), + SessionTTL: durationEnvOrDefault("SESSION_TTL", 30*time.Minute), + MaxUploadSize: int64(intEnvOrDefault("MAX_UPLOAD_SIZE", 5*1024*1024)), + CookieSecure: boolEnvOrDefault("COOKIE_SECURE", true), + LogLevel: envOrDefault("LOG_LEVEL", "info"), + GitAllowedHosts: csvEnvOrDefault("GIT_ALLOWED_HOSTS", []string{ + "github.com", + "gitlab.com", + "bitbucket.org", + }), + } + return cfg +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func intEnvOrDefault(key string, fallback int) int { + if value := os.Getenv(key); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil { + log.Printf("invalid integer for %s: %v", key, err) + return fallback + } + return parsed + } + return fallback +} + +func boolEnvOrDefault(key string, fallback bool) bool { + if value := os.Getenv(key); value != "" { + parsed, err := strconv.ParseBool(value) + if err != nil { + log.Printf("invalid boolean for %s: %v", key, err) + return fallback + } + return parsed + } + return fallback +} + +func durationEnvOrDefault(key string, fallback time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + parsed, err := time.ParseDuration(value) + if err != nil { + log.Printf("invalid duration for %s: %v", key, err) + return fallback + } + return parsed + } + return fallback +} + +func csvEnvOrDefault(key string, fallback []string) []string { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + var out []string + for _, part := range strings.Split(raw, ",") { + host := strings.ToLower(strings.TrimSpace(part)) + if host == "" { + continue + } + out = append(out, host) + } + if len(out) == 0 { + return fallback + } + return out +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go new file mode 100644 index 0000000..98f0a98 --- /dev/null +++ b/internal/graph/graph.go @@ -0,0 +1,514 @@ +package graph + +import ( + "fmt" + "sort" + "strings" + + "kubeviz/internal/model" + "kubeviz/internal/parser" +) + +type Filters struct { + Namespace string + Kind string + Query string + FocusID string + Relations map[string]bool + GroupBy string + CollapsedGroups map[string]bool +} + +func BuildGraph(dataset *model.Dataset, filters Filters, healthHints map[string]string) model.GraphResponse { + if dataset == nil { + return model.GraphResponse{Stats: model.GraphStats{Kinds: map[string]int{}}} + } + + nodes := make([]model.GraphNode, 0, len(dataset.Resources)) + edges := make([]model.GraphEdge, 0) + kindStats := map[string]int{} + + for _, r := range dataset.Resources { + health := "unknown" + if h, ok := healthHints[r.ID]; ok { + health = h + } + node := model.GraphNode{ + ID: r.ID, + Kind: r.Kind, + Name: r.Name, + Namespace: r.Namespace, + Labels: r.Labels, + HealthHint: health, + IsSensitive: r.IsSensitive, + } + if !matchesResourceFilters(node, filters) { + continue + } + nodes = append(nodes, node) + kindStats[node.Kind]++ + } + + nodeSet := make(map[string]struct{}, len(nodes)) + for _, n := range nodes { + nodeSet[n.ID] = struct{}{} + } + + for _, r := range dataset.Resources { + if _, ok := nodeSet[r.ID]; !ok { + continue + } + for _, ref := range r.References { + target := resolveReferenceTarget(dataset, ref) + if target == "" { + continue + } + if _, ok := nodeSet[target]; !ok { + continue + } + edges = append(edges, model.GraphEdge{ + Source: r.ID, + Target: target, + RelationType: ref.Relation, + Label: edgeLabelForReference(r, ref), + }) + } + + for _, owner := range r.OwnerRefs { + target := resolveOwner(dataset, r.Namespace, owner) + if target == "" { + continue + } + if _, ok := nodeSet[target]; !ok { + continue + } + edges = append(edges, model.GraphEdge{Source: r.ID, Target: target, RelationType: "owns"}) + } + } + + edges = append(edges, inferServiceEdges(dataset, nodeSet)...) + edges = dedupeEdges(edges) + edges = filterEdges(edges, filters) + + groups := []model.GraphGroup{} + if filters.GroupBy == "namespace" || filters.GroupBy == "kind" { + nodes, edges, groups = applyGrouping(nodes, edges, filters) + kindStats = recomputeKindStats(nodes) + } + + if filters.FocusID != "" { + nodes, edges = applyFocus(nodes, edges, filters.FocusID) + kindStats = recomputeKindStats(nodes) + } + + sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) + sort.Slice(edges, func(i, j int) bool { + if edges[i].Source != edges[j].Source { + return edges[i].Source < edges[j].Source + } + if edges[i].Target != edges[j].Target { + return edges[i].Target < edges[j].Target + } + if edges[i].RelationType != edges[j].RelationType { + return edges[i].RelationType < edges[j].RelationType + } + return edges[i].Label < edges[j].Label + }) + + return model.GraphResponse{ + Nodes: nodes, + Edges: edges, + Groups: groups, + Stats: model.GraphStats{ + TotalNodes: len(nodes), + TotalEdges: len(edges), + Kinds: kindStats, + }, + } +} + +func applyGrouping(nodes []model.GraphNode, edges []model.GraphEdge, filters Filters) ([]model.GraphNode, []model.GraphEdge, []model.GraphGroup) { + groupMembers := map[string][]model.GraphNode{} + for _, n := range nodes { + key := groupKey(n, filters.GroupBy) + groupMembers[key] = append(groupMembers[key], n) + } + + groups := make([]model.GraphGroup, 0, len(groupMembers)) + for key, members := range groupMembers { + groups = append(groups, model.GraphGroup{ + Key: key, + Label: key, + Mode: filters.GroupBy, + Count: len(members), + Collapsed: filters.CollapsedGroups[key], + }) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Key < groups[j].Key }) + + visibleNodes := make(map[string]model.GraphNode) + nodeToVisible := map[string]string{} + + for key, members := range groupMembers { + collapsed := filters.CollapsedGroups[key] + if collapsed { + groupID := groupNodeID(filters.GroupBy, key) + health := "healthy" + sensitive := false + for _, m := range members { + if m.HealthHint == "warning" { + health = "warning" + } + if m.IsSensitive { + sensitive = true + } + nodeToVisible[m.ID] = groupID + } + visibleNodes[groupID] = model.GraphNode{ + ID: groupID, + Kind: strings.Title(filters.GroupBy) + "Group", + Name: key, + Namespace: "", + HealthHint: health, + IsSensitive: sensitive, + IsGroup: true, + GroupBy: filters.GroupBy, + GroupKey: key, + MemberCount: len(members), + } + continue + } + + for _, m := range members { + nodeToVisible[m.ID] = m.ID + visibleNodes[m.ID] = m + } + } + + outNodes := make([]model.GraphNode, 0, len(visibleNodes)) + for _, n := range visibleNodes { + outNodes = append(outNodes, n) + } + + outEdges := make([]model.GraphEdge, 0, len(edges)) + for _, e := range edges { + src := nodeToVisible[e.Source] + tgt := nodeToVisible[e.Target] + if src == "" || tgt == "" || src == tgt { + continue + } + outEdges = append(outEdges, model.GraphEdge{ + Source: src, + Target: tgt, + RelationType: e.RelationType, + Label: e.Label, + }) + } + outEdges = dedupeEdges(outEdges) + + return outNodes, outEdges, groups +} + +func groupKey(node model.GraphNode, mode string) string { + switch mode { + case "namespace": + if node.Namespace == "" { + return "(cluster-scoped)" + } + return node.Namespace + case "kind": + return node.Kind + default: + return "" + } +} + +func groupNodeID(mode, key string) string { + return "group/" + mode + "/" + key +} + +func resolveReferenceTarget(dataset *model.Dataset, ref model.ResourceReference) string { + ns := ref.Namespace + if ns == "" { + ns = "default" + } + candidate := parser.ResourceID(ns, ref.Kind, ref.Name) + if _, ok := dataset.Resources[candidate]; ok { + return candidate + } + candidate = parser.ResourceID("", ref.Kind, ref.Name) + if _, ok := dataset.Resources[candidate]; ok { + return candidate + } + + matches := make([]string, 0) + for _, r := range dataset.Resources { + if r.Kind == ref.Kind && r.Name == ref.Name { + matches = append(matches, r.ID) + } + } + if len(matches) == 1 { + return matches[0] + } + return "" +} + +func resolveOwner(dataset *model.Dataset, namespace string, owner model.OwnerReference) string { + candidate := parser.ResourceID(namespace, owner.Kind, owner.Name) + if _, ok := dataset.Resources[candidate]; ok { + return candidate + } + candidate = parser.ResourceID("", owner.Kind, owner.Name) + if _, ok := dataset.Resources[candidate]; ok { + return candidate + } + return "" +} + +func inferServiceEdges(dataset *model.Dataset, nodeSet map[string]struct{}) []model.GraphEdge { + edges := []model.GraphEdge{} + services := []*model.Resource{} + workloads := []*model.Resource{} + + for _, r := range dataset.Resources { + switch r.Kind { + case "Service": + services = append(services, r) + case "Deployment", "StatefulSet", "DaemonSet": + workloads = append(workloads, r) + } + } + + for _, svc := range services { + if _, ok := nodeSet[svc.ID]; !ok { + continue + } + if svc.WorkloadMeta == nil || len(svc.WorkloadMeta.ServiceSelectors) == 0 { + continue + } + for _, wl := range workloads { + if _, ok := nodeSet[wl.ID]; !ok { + continue + } + if wl.WorkloadMeta == nil || len(wl.WorkloadMeta.PodTemplateLabels) == 0 { + continue + } + if svc.Namespace != wl.Namespace { + continue + } + if selectorsMatch(svc.WorkloadMeta.ServiceSelectors, wl.WorkloadMeta.PodTemplateLabels) { + edges = append(edges, model.GraphEdge{ + Source: svc.ID, + Target: wl.ID, + RelationType: "selects", + Label: servicePortsLabel(svc), + }) + } + } + } + + return edges +} + +func selectorsMatch(selector, labels map[string]string) bool { + for k, v := range selector { + if labels[k] != v { + return false + } + } + return true +} + +func matchesResourceFilters(node model.GraphNode, filters Filters) bool { + if filters.Namespace != "" && node.Namespace != filters.Namespace { + return false + } + if filters.Kind != "" && !strings.EqualFold(node.Kind, filters.Kind) { + return false + } + if filters.Query != "" { + needle := strings.ToLower(filters.Query) + hay := strings.ToLower(node.ID + " " + node.Kind + " " + node.Name + " " + node.Namespace) + if !strings.Contains(hay, needle) { + return false + } + } + return true +} + +func dedupeEdges(edges []model.GraphEdge) []model.GraphEdge { + seen := map[string]struct{}{} + out := make([]model.GraphEdge, 0, len(edges)) + for _, edge := range edges { + key := edge.Source + "|" + edge.Target + "|" + edge.RelationType + "|" + edge.Label + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, edge) + } + return out +} + +func filterEdges(edges []model.GraphEdge, filters Filters) []model.GraphEdge { + if len(filters.Relations) == 0 { + return edges + } + out := make([]model.GraphEdge, 0, len(edges)) + for _, edge := range edges { + if filters.Relations[edge.RelationType] { + out = append(out, edge) + } + } + return out +} + +func applyFocus(nodes []model.GraphNode, edges []model.GraphEdge, focusID string) ([]model.GraphNode, []model.GraphEdge) { + neighbor := map[string]struct{}{focusID: {}} + for _, e := range edges { + if e.Source == focusID { + neighbor[e.Target] = struct{}{} + } + if e.Target == focusID { + neighbor[e.Source] = struct{}{} + } + } + + fNodes := make([]model.GraphNode, 0, len(neighbor)) + for _, n := range nodes { + if _, ok := neighbor[n.ID]; ok { + fNodes = append(fNodes, n) + } + } + + fEdges := make([]model.GraphEdge, 0, len(edges)) + for _, e := range edges { + _, src := neighbor[e.Source] + _, dst := neighbor[e.Target] + if src && dst { + fEdges = append(fEdges, e) + } + } + + return fNodes, fEdges +} + +func recomputeKindStats(nodes []model.GraphNode) map[string]int { + out := map[string]int{} + for _, n := range nodes { + out[n.Kind]++ + } + return out +} + +func edgeLabelForReference(source *model.Resource, ref model.ResourceReference) string { + if source == nil { + return "" + } + + switch source.Kind { + case "Ingress": + if ref.Relation != "routesTo" { + return "" + } + return ingressBackendPortLabel(source, ref.Name) + default: + return "" + } +} + +func ingressBackendPortLabel(ingress *model.Resource, serviceName string) string { + spec, _ := ingress.Raw["spec"].(map[string]any) + if spec == nil { + return "" + } + + if backend, ok := spec["defaultBackend"].(map[string]any); ok { + if name, label := ingressBackendServiceAndPort(backend); name == serviceName { + return label + } + } + + rules, _ := spec["rules"].([]any) + for _, r := range rules { + rule, ok := r.(map[string]any) + if !ok { + continue + } + httpSpec, _ := rule["http"].(map[string]any) + paths, _ := httpSpec["paths"].([]any) + for _, p := range paths { + pathEntry, ok := p.(map[string]any) + if !ok { + continue + } + backend, _ := pathEntry["backend"].(map[string]any) + if name, label := ingressBackendServiceAndPort(backend); name == serviceName { + return label + } + } + } + return "" +} + +func ingressBackendServiceAndPort(backend map[string]any) (string, string) { + service, _ := backend["service"].(map[string]any) + if service == nil { + return "", "" + } + + name, _ := service["name"].(string) + portMap, _ := service["port"].(map[string]any) + if portMap == nil { + return name, "" + } + if number, ok := portMap["number"]; ok { + return name, fmt.Sprintf(":%v", number) + } + if pname, ok := portMap["name"].(string); ok && pname != "" { + return name, ":" + pname + } + return name, "" +} + +func servicePortsLabel(service *model.Resource) string { + spec, _ := service.Raw["spec"].(map[string]any) + if spec == nil { + return "" + } + ports, _ := spec["ports"].([]any) + if len(ports) == 0 { + return "" + } + + parts := make([]string, 0, len(ports)) + for _, p := range ports { + port, ok := p.(map[string]any) + if !ok { + continue + } + protocol, _ := port["protocol"].(string) + if protocol == "" { + protocol = "TCP" + } + external, hasPort := port["port"] + if !hasPort { + continue + } + target, hasTarget := port["targetPort"] + if hasTarget { + parts = append(parts, fmt.Sprintf("%s %v->%v", protocol, external, target)) + continue + } + parts = append(parts, fmt.Sprintf("%s %v", protocol, external)) + } + + switch len(parts) { + case 0: + return "" + case 1: + return parts[0] + default: + return parts[0] + " +" + fmt.Sprintf("%d", len(parts)-1) + } +} diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go new file mode 100644 index 0000000..a5aac48 --- /dev/null +++ b/internal/graph/graph_test.go @@ -0,0 +1,86 @@ +package graph + +import ( + "testing" + "time" + + "kubeviz/internal/model" +) + +func TestBuildGraphIncludesServiceSelectorEdge(t *testing.T) { + ds := &model.Dataset{ + Resources: map[string]*model.Resource{ + "demo/Service/web": { + ID: "demo/Service/web", + Kind: "Service", + Name: "web", + Namespace: "demo", + Raw: map[string]any{ + "spec": map[string]any{ + "ports": []any{ + map[string]any{"port": 80, "targetPort": 8080, "protocol": "TCP"}, + }, + }, + }, + WorkloadMeta: &model.WorkloadMetadata{ + ServiceSelectors: map[string]string{"app": "web"}, + }, + CreatedAt: time.Now(), + }, + "demo/Deployment/web": { + ID: "demo/Deployment/web", + Kind: "Deployment", + Name: "web", + Namespace: "demo", + WorkloadMeta: &model.WorkloadMetadata{ + PodTemplateLabels: map[string]string{"app": "web"}, + }, + CreatedAt: time.Now(), + }, + }, + } + + resp := BuildGraph(ds, Filters{}, map[string]string{}) + if resp.Stats.TotalNodes != 2 { + t.Fatalf("expected 2 nodes, got %d", resp.Stats.TotalNodes) + } + found := false + for _, edge := range resp.Edges { + if edge.Source == "demo/Service/web" && edge.Target == "demo/Deployment/web" && edge.RelationType == "selects" { + if edge.Label == "" { + t.Fatalf("expected selects edge label with port/protocol") + } + found = true + } + } + if !found { + t.Fatalf("expected service selector edge not found") + } +} + +func TestBuildGraphGroupingByNamespace(t *testing.T) { + ds := &model.Dataset{ + Resources: map[string]*model.Resource{ + "demo/Service/web": {ID: "demo/Service/web", Kind: "Service", Name: "web", Namespace: "demo", CreatedAt: time.Now()}, + "demo/Deployment/web": {ID: "demo/Deployment/web", Kind: "Deployment", Name: "web", Namespace: "demo", CreatedAt: time.Now()}, + "other/Deployment/other": {ID: "other/Deployment/other", Kind: "Deployment", Name: "other", Namespace: "other", CreatedAt: time.Now()}, + }, + } + + resp := BuildGraph(ds, Filters{ + GroupBy: "namespace", + CollapsedGroups: map[string]bool{ + "demo": true, + }, + }, map[string]string{}) + + hasDemoGroup := false + for _, n := range resp.Nodes { + if n.IsGroup && n.GroupBy == "namespace" && n.GroupKey == "demo" { + hasDemoGroup = true + } + } + if !hasDemoGroup { + t.Fatalf("expected collapsed namespace group node") + } +} diff --git a/internal/httpserver/assets.go b/internal/httpserver/assets.go new file mode 100644 index 0000000..4002bc2 --- /dev/null +++ b/internal/httpserver/assets.go @@ -0,0 +1,9 @@ +package httpserver + +import "embed" + +//go:embed ui/templates/*.html +var templateFS embed.FS + +//go:embed ui/static/* +var staticFS embed.FS diff --git a/internal/httpserver/export.go b/internal/httpserver/export.go new file mode 100644 index 0000000..0c60244 --- /dev/null +++ b/internal/httpserver/export.go @@ -0,0 +1,217 @@ +package httpserver + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "net/http" + "sort" + "strings" + + "kubeviz/internal/analyze" + "kubeviz/internal/graph" + "kubeviz/internal/model" +) + +type point struct { + X float64 + Y float64 +} + +func (s *Server) handleExportSVG(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + resp, ok := s.exportGraph(w, r) + if !ok { + return + } + svg := renderSVG(resp) + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Content-Disposition", "inline; filename=graph.svg") + _, _ = w.Write([]byte(svg)) +} + +func (s *Server) handleExportPNG(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + resp, ok := s.exportGraph(w, r) + if !ok { + return + } + img := renderPNG(resp) + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Disposition", "inline; filename=graph.png") + _ = png.Encode(w, img) +} + +func (s *Server) exportGraph(w http.ResponseWriter, r *http.Request) (model.GraphResponse, bool) { + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return model.GraphResponse{}, false + } + dataset := s.store.GetDataset(sid) + if dataset == nil { + http.Error(w, "no manifests uploaded", http.StatusNotFound) + return model.GraphResponse{}, false + } + filters := graph.Filters{ + Namespace: r.URL.Query().Get("namespace"), + Kind: r.URL.Query().Get("kind"), + Query: r.URL.Query().Get("q"), + FocusID: r.URL.Query().Get("focus"), + Relations: parseRelations(r.URL.Query().Get("relations")), + GroupBy: r.URL.Query().Get("groupBy"), + CollapsedGroups: parseCSVSet(r.URL.Query().Get("collapsed")), + } + checkCfg := analyze.DefaultConfig() + if raw := r.URL.Query().Get("checkRules"); raw != "" { + checkCfg = analyze.ConfigFromEnabled(parseCSVSet(raw)) + } + _, healthHints := analyze.AnalyzeDataset(dataset, checkCfg) + return graph.BuildGraph(dataset, filters, healthHints), true +} + +func layout(nodes []model.GraphNode) map[string]point { + out := map[string]point{} + sorted := make([]model.GraphNode, len(nodes)) + copy(sorted, nodes) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + + n := len(sorted) + if n == 0 { + return out + } + centerX, centerY := 640.0, 360.0 + radius := 220.0 + if n > 40 { + radius = 280 + } + for i, node := range sorted { + angle := 2 * math.Pi * float64(i) / float64(n) + out[node.ID] = point{ + X: centerX + radius*math.Cos(angle), + Y: centerY + radius*math.Sin(angle), + } + } + return out +} + +func renderSVG(resp model.GraphResponse) string { + coords := layout(resp.Nodes) + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + for _, edge := range resp.Edges { + s, sok := coords[edge.Source] + t, tok := coords[edge.Target] + if !sok || !tok { + continue + } + b.WriteString(fmt.Sprintf(``, s.X, s.Y, t.X, t.Y)) + } + for _, node := range resp.Nodes { + p := coords[node.ID] + fill := "#2563eb" + if node.IsSensitive { + fill = "#dc2626" + } + label := escapeXML(fmt.Sprintf("%s/%s", node.Kind, node.Name)) + b.WriteString(fmt.Sprintf(``, p.X, p.Y, fill)) + b.WriteString(fmt.Sprintf(`%s`, p.X, p.Y+34, label)) + } + b.WriteString(``) + return b.String() +} + +func renderPNG(resp model.GraphResponse) image.Image { + img := image.NewRGBA(image.Rect(0, 0, 1280, 720)) + draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{245, 247, 251, 255}}, image.Point{}, draw.Src) + + coords := layout(resp.Nodes) + + edgeColor := color.RGBA{156, 163, 175, 255} + for _, edge := range resp.Edges { + s, sok := coords[edge.Source] + t, tok := coords[edge.Target] + if !sok || !tok { + continue + } + drawLine(img, int(s.X), int(s.Y), int(t.X), int(t.Y), edgeColor) + } + + for _, node := range resp.Nodes { + p := coords[node.ID] + fill := color.RGBA{37, 99, 235, 255} + if node.IsSensitive { + fill = color.RGBA{220, 38, 38, 255} + } + drawCircle(img, int(p.X), int(p.Y), 20, fill) + } + + return img +} + +func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.Color) { + dx := abs(x1 - x0) + sx := -1 + if x0 < x1 { + sx = 1 + } + dy := -abs(y1 - y0) + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx + dy + for { + if image.Pt(x0, y0).In(img.Bounds()) { + img.Set(x0, y0, c) + } + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +func drawCircle(img *image.RGBA, cx, cy, r int, c color.Color) { + for y := -r; y <= r; y++ { + for x := -r; x <= r; x++ { + if x*x+y*y <= r*r { + px := cx + x + py := cy + y + if image.Pt(px, py).In(img.Bounds()) { + img.Set(px, py, c) + } + } + } + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +func escapeXML(s string) string { + replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return replacer.Replace(s) +} diff --git a/internal/httpserver/handlers.go b/internal/httpserver/handlers.go new file mode 100644 index 0000000..b35573d --- /dev/null +++ b/internal/httpserver/handlers.go @@ -0,0 +1,783 @@ +package httpserver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" + + "kubeviz/internal/analyze" + "kubeviz/internal/graph" + "kubeviz/internal/model" + "kubeviz/internal/parser" +) + +var manifestExts = []string{".yaml", ".yml", ".json"} + +const ( + maxValuesYAMLBytes = 256 * 1024 + maxDiffFieldBytes = 2 * 1024 * 1024 + maxManifestFiles = 2000 + maxManifestBytes = 20 * 1024 * 1024 +) + +var defaultGitAllowedHosts = []string{"github.com", "gitlab.com", "bitbucket.org"} + +type indexData struct { + Title string + Now string +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if _, err := s.sessionID(w, r); err != nil { + http.Error(w, "session initialization failed", http.StatusInternalServerError) + return + } + if err := s.templates.ExecuteTemplate(w, "index.html", indexData{Title: "KubeViz", Now: time.Now().Format(time.RFC3339)}); err != nil { + http.Error(w, "template render error", http.StatusInternalServerError) + } +} + +func (s *Server) handleParseManifests(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadSize) + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + + var ( + body []byte + dataset *model.Dataset + ) + ct := r.Header.Get("Content-Type") + switch { + case strings.HasPrefix(ct, "multipart/form-data"): + if err := r.ParseMultipartForm(s.cfg.MaxUploadSize); err != nil { + http.Error(w, "invalid multipart form", http.StatusBadRequest) + return + } + dataset, err = parseMultipartManifestInput(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + default: + var req struct { + Manifest string `json:"manifest"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + body = []byte(req.Manifest) + } + + if dataset == nil { + if len(strings.TrimSpace(string(body))) == 0 { + http.Error(w, "manifest input is empty", http.StatusBadRequest) + return + } + dataset, err = parser.ParseManifests(body) + if err != nil { + http.Error(w, fmt.Sprintf("parse error: %v", err), http.StatusBadRequest) + return + } + } + s.store.SetDataset(sid, dataset) + + writeJSON(w, http.StatusOK, map[string]any{ + "datasetID": sid, + "summary": dataset.Summary, + }) +} + +type helmRenderRequest struct { + RepoURL string `json:"repoURL"` + Ref string `json:"ref"` + ChartPath string `json:"chartPath"` + ValuesYAML string `json:"valuesYAML"` + ReleaseName string `json:"releaseName"` + Namespace string `json:"namespace"` +} + +type gitImportRequest struct { + RepoURL string `json:"repoURL"` + Ref string `json:"ref"` + Path string `json:"path"` + SourceType string `json:"sourceType"` + ValuesYAML string `json:"valuesYAML"` + ReleaseName string `json:"releaseName"` + Namespace string `json:"namespace"` +} + +func (s *Server) handleHelmRender(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadSize) + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + + var req helmRenderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.RepoURL) == "" { + http.Error(w, "repoURL is required", http.StatusBadRequest) + return + } + if err := validateRepoURL(req.RepoURL, s.cfg.GitAllowedHosts); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(req.ValuesYAML) > maxValuesYAMLBytes { + http.Error(w, "valuesYAML exceeds maximum allowed size", http.StatusBadRequest) + return + } + + rendered, err := renderHelmFromGit(r.Context(), req.RepoURL, req.Ref, req.ChartPath, req.ValuesYAML, req.ReleaseName, req.Namespace) + if err != nil { + writeExecError(w, err) + return + } + + dataset, err := parser.ParseManifests(rendered) + if err != nil { + http.Error(w, fmt.Sprintf("parse error: %v", err), http.StatusBadRequest) + return + } + s.store.SetDataset(sid, dataset) + + writeJSON(w, http.StatusOK, map[string]any{ + "datasetID": sid, + "mode": "helm", + "summary": dataset.Summary, + }) +} + +func (s *Server) handleGitImport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadSize) + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + + var req gitImportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.RepoURL) == "" { + http.Error(w, "repoURL is required", http.StatusBadRequest) + return + } + if err := validateRepoURL(req.RepoURL, s.cfg.GitAllowedHosts); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(req.ValuesYAML) > maxValuesYAMLBytes { + http.Error(w, "valuesYAML exceeds maximum allowed size", http.StatusBadRequest) + return + } + + sourceType := strings.ToLower(strings.TrimSpace(req.SourceType)) + if sourceType == "" { + sourceType = "manifests" + } + + var body []byte + switch sourceType { + case "manifests": + body, err = importManifestsFromGit(r.Context(), req.RepoURL, req.Ref, req.Path) + case "helm": + body, err = renderHelmFromGit(r.Context(), req.RepoURL, req.Ref, req.Path, req.ValuesYAML, req.ReleaseName, req.Namespace) + default: + http.Error(w, "sourceType must be manifests or helm", http.StatusBadRequest) + return + } + if err != nil { + writeExecError(w, err) + return + } + + dataset, err := parser.ParseManifests(body) + if err != nil { + http.Error(w, fmt.Sprintf("parse error: %v", err), http.StatusBadRequest) + return + } + s.store.SetDataset(sid, dataset) + + writeJSON(w, http.StatusOK, map[string]any{ + "datasetID": sid, + "mode": sourceType, + "summary": dataset.Summary, + }) +} + +func collectMultipartManifestInput(r *http.Request) ([]byte, error) { + var combined bytes.Buffer + + appendDoc := func(doc []byte) { + trimmed := bytes.TrimSpace(doc) + if len(trimmed) == 0 { + return + } + if combined.Len() > 0 { + combined.WriteString("\n---\n") + } + combined.Write(trimmed) + combined.WriteByte('\n') + } + + files := r.MultipartForm.File["manifestFile"] + for _, fh := range files { + f, err := fh.Open() + if err != nil { + return nil, fmt.Errorf("failed to open uploaded file %q", fh.Filename) + } + content, err := io.ReadAll(f) + _ = f.Close() + if err != nil { + return nil, fmt.Errorf("failed to read uploaded file %q", fh.Filename) + } + appendDoc(content) + } + + appendDoc([]byte(r.FormValue("manifestText"))) + if combined.Len() == 0 { + return nil, fmt.Errorf("manifest input is empty") + } + + return combined.Bytes(), nil +} + +func parseMultipartManifestInput(r *http.Request) (*model.Dataset, error) { + parts, err := collectMultipartManifestParts(r) + if err != nil { + return nil, err + } + if len(parts) == 0 { + return nil, fmt.Errorf("manifest input is empty") + } + + merged := &model.Dataset{ + Resources: make(map[string]*model.Resource), + CreatedAt: time.Now(), + } + + for idx, part := range parts { + ds, parseErr := parser.ParseManifests(part.Content) + if parseErr != nil { + merged.Summary.Issues = append(merged.Summary.Issues, model.ParseIssue{ + Document: idx + 1, + Message: fmt.Sprintf("%s: %s", part.Source, friendlyParseIssueMessage(part.Source, parseErr.Error(), part.Content)), + }) + continue + } + for _, issue := range ds.Summary.Issues { + issue.Message = fmt.Sprintf("%s: %s", part.Source, friendlyParseIssueMessage(part.Source, issue.Message, part.Content)) + merged.Summary.Issues = append(merged.Summary.Issues, issue) + } + for id, res := range ds.Resources { + if _, exists := merged.Resources[id]; exists { + merged.Duplicates = append(merged.Duplicates, id) + merged.Summary.Issues = append(merged.Summary.Issues, model.ParseIssue{ + Document: idx + 1, + Message: fmt.Sprintf("%s: duplicate resource id %q detected", part.Source, id), + }) + } + merged.Resources[id] = res + } + } + + merged.Summary.Resources = len(merged.Resources) + merged.ModifiedAt = time.Now() + return merged, nil +} + +func friendlyParseIssueMessage(source, raw string, content []byte) string { + msg := strings.TrimSpace(raw) + if msg == "" { + msg = "failed to parse input" + } + if looksLikeHelmSource(source, content) { + return fmt.Sprintf("%s (Helm source detected: upload rendered manifests or use Import From Git/Helm)", msg) + } + return msg +} + +func looksLikeHelmSource(source string, content []byte) bool { + name := strings.ToLower(filepath.Base(strings.TrimSpace(source))) + if name == "chart.yaml" || name == "values.yaml" || name == "values.yml" { + return true + } + text := string(content) + return strings.Contains(text, "{{") && strings.Contains(text, "}}") +} + +type manifestPart struct { + Source string + Content []byte +} + +func collectMultipartManifestParts(r *http.Request) ([]manifestPart, error) { + parts := make([]manifestPart, 0) + for _, files := range r.MultipartForm.File { + for _, fh := range files { + f, err := fh.Open() + if err != nil { + return nil, fmt.Errorf("failed to open uploaded file %q", fh.Filename) + } + content, err := io.ReadAll(f) + _ = f.Close() + if err != nil { + return nil, fmt.Errorf("failed to read uploaded file %q", fh.Filename) + } + trimmed := bytes.TrimSpace(content) + if len(trimmed) == 0 { + continue + } + parts = append(parts, manifestPart{Source: fh.Filename, Content: append(trimmed, '\n')}) + } + } + + if inline := bytes.TrimSpace([]byte(r.FormValue("manifestText"))); len(inline) > 0 { + parts = append(parts, manifestPart{Source: "pasted manifest", Content: append(inline, '\n')}) + } + return parts, nil +} + +func importManifestsFromGit(ctx context.Context, repoURL, ref, manifestPath string) ([]byte, error) { + repoDir, cleanup, err := cloneRepo(ctx, repoURL, ref) + if err != nil { + return nil, err + } + defer cleanup() + + root := repoDir + if manifestPath = strings.TrimSpace(manifestPath); manifestPath != "" { + safe, err := safeJoin(repoDir, manifestPath) + if err != nil { + return nil, err + } + root = safe + } + return collectManifestFiles(root) +} + +func renderHelmFromGit(ctx context.Context, repoURL, ref, chartPath, valuesYAML, releaseName, namespace string) ([]byte, error) { + repoDir, cleanup, err := cloneRepo(ctx, repoURL, ref) + if err != nil { + return nil, err + } + defer cleanup() + + if strings.TrimSpace(chartPath) == "" { + chartPath = "." + } + chartDir, err := safeJoin(repoDir, chartPath) + if err != nil { + return nil, err + } + return runHelmTemplate(ctx, chartDir, valuesYAML, releaseName, namespace) +} + +func cloneRepo(ctx context.Context, repoURL, ref string) (string, func(), error) { + if _, err := exec.LookPath("git"); err != nil { + return "", nil, fmt.Errorf("git executable not found in runtime image") + } + tmpDir, err := os.MkdirTemp("", "kubeviz-git-*") + if err != nil { + return "", nil, fmt.Errorf("failed creating temp directory: %w", err) + } + cleanup := func() { _ = os.RemoveAll(tmpDir) } + + args := []string{"clone", "--quiet", "--depth", "1", "--filter=blob:none", "--no-tags"} + ref = strings.TrimSpace(ref) + if ref != "" { + args = append(args, "--branch", ref, "--single-branch") + } + args = append(args, repoURL, tmpDir) + cloneCmd := exec.CommandContext(ctx, "git", args...) + cloneCmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_ALLOW_PROTOCOL=https", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + ) + if out, err := cloneCmd.CombinedOutput(); err != nil { + cleanup() + log.Printf("git clone failed for %q: %s", repoURL, strings.TrimSpace(string(out))) + return "", nil, fmt.Errorf("git clone failed") + } + + return tmpDir, cleanup, nil +} + +func collectManifestFiles(root string) ([]byte, error) { + info, err := os.Stat(root) + if err != nil { + return nil, fmt.Errorf("path does not exist: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("path %q is not a directory", root) + } + + var files []string + var totalBytes int64 + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + base := strings.ToLower(d.Name()) + if base == ".git" || base == "node_modules" || base == "vendor" { + return filepath.SkipDir + } + return nil + } + if isManifestFile(path) { + if len(files) >= maxManifestFiles { + return fmt.Errorf("too many manifest files (limit: %d)", maxManifestFiles) + } + info, err := d.Info() + if err != nil { + return err + } + totalBytes += info.Size() + if totalBytes > maxManifestBytes { + return fmt.Errorf("manifest file size budget exceeded (limit: %d bytes)", maxManifestBytes) + } + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to collect manifest files: %w", err) + } + if len(files) == 0 { + return nil, fmt.Errorf("no manifest files found in selected path") + } + slices.Sort(files) + + var out bytes.Buffer + appendDoc := func(doc []byte) { + trimmed := bytes.TrimSpace(doc) + if len(trimmed) == 0 { + return + } + if out.Len() > 0 { + out.WriteString("\n---\n") + } + out.Write(trimmed) + out.WriteByte('\n') + } + for _, path := range files { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", path, err) + } + appendDoc(content) + } + if out.Len() == 0 { + return nil, fmt.Errorf("manifest files were empty") + } + return out.Bytes(), nil +} + +func runHelmTemplate(ctx context.Context, chartDir, valuesYAML, releaseName, namespace string) ([]byte, error) { + if _, err := exec.LookPath("helm"); err != nil { + return nil, fmt.Errorf("helm executable not found in runtime image") + } + if releaseName = strings.TrimSpace(releaseName); releaseName == "" { + releaseName = "kubeviz" + } + if namespace = strings.TrimSpace(namespace); namespace == "" { + namespace = "default" + } + + args := []string{"template", releaseName, chartDir, "--namespace", namespace} + var cleanupFiles []string + if trimmed := strings.TrimSpace(valuesYAML); trimmed != "" { + valuesFile, err := os.CreateTemp("", "kubeviz-values-*.yaml") + if err != nil { + return nil, fmt.Errorf("failed creating temp values file: %w", err) + } + if _, err := valuesFile.WriteString(trimmed); err != nil { + _ = valuesFile.Close() + _ = os.Remove(valuesFile.Name()) + return nil, fmt.Errorf("failed writing values file: %w", err) + } + _ = valuesFile.Close() + args = append(args, "--values", valuesFile.Name()) + cleanupFiles = append(cleanupFiles, valuesFile.Name()) + } + defer func() { + for _, f := range cleanupFiles { + _ = os.Remove(f) + } + }() + + cmd := exec.CommandContext(ctx, "helm", args...) + cmd.Env = append(os.Environ(), + "HELM_NO_PLUGINS=1", + "HELM_REGISTRY_CONFIG=/tmp/helm-registry.json", + ) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("helm template failed for chart %q: %s", chartDir, strings.TrimSpace(string(out))) + return nil, fmt.Errorf("helm template failed") + } + trimmed := bytes.TrimSpace(out) + if len(trimmed) == 0 { + return nil, fmt.Errorf("helm template produced no output") + } + return append(trimmed, '\n'), nil +} + +func safeJoin(root, subpath string) (string, error) { + cleanRoot := filepath.Clean(root) + candidate := filepath.Join(cleanRoot, filepath.Clean(subpath)) + rel, err := filepath.Rel(cleanRoot, candidate) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes repository root") + } + return candidate, nil +} + +func validateRepoURL(raw string, allowedHosts []string) error { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("repoURL is invalid") + } + if parsed.Scheme != "https" { + return fmt.Errorf("repoURL must use https") + } + if parsed.Host == "" { + return fmt.Errorf("repoURL host is required") + } + if parsed.User != nil { + return fmt.Errorf("repoURL must not contain credentials") + } + + host := strings.ToLower(parsed.Hostname()) + if len(allowedHosts) == 0 { + allowedHosts = defaultGitAllowedHosts + } + for _, allowed := range allowedHosts { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == "" { + continue + } + if host == allowed || strings.HasSuffix(host, "."+allowed) { + return nil + } + } + return fmt.Errorf("repoURL host is not allowed") +} + +func isManifestFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + for _, allowed := range manifestExts { + if ext == allowed { + return true + } + } + return false +} + +func writeExecError(w http.ResponseWriter, err error) { + if err == nil { + return + } + if errors.Is(err, context.DeadlineExceeded) { + http.Error(w, "operation timed out", http.StatusRequestTimeout) + return + } + log.Printf("external import/render error: %v", err) + msg := err.Error() + switch { + case strings.Contains(msg, "not found in runtime image"): + http.Error(w, msg, http.StatusNotImplemented) + case strings.Contains(msg, "path escapes repository root"): + http.Error(w, msg, http.StatusBadRequest) + case strings.Contains(msg, "repoURL is ") || strings.Contains(msg, "repoURL must"): + http.Error(w, msg, http.StatusBadRequest) + case strings.Contains(msg, "too many manifest files") || strings.Contains(msg, "size budget exceeded"): + http.Error(w, msg, http.StatusBadRequest) + default: + http.Error(w, "external import/render failed", http.StatusBadRequest) + } +} + +func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + dataset := s.store.GetDataset(sid) + checkCfg := analyze.DefaultConfig() + if raw := r.URL.Query().Get("checkRules"); raw != "" { + checkCfg = analyze.ConfigFromEnabled(parseCSVSet(raw)) + } + findings, healthHints := analyze.AnalyzeDataset(dataset, checkCfg) + filters := graph.Filters{ + Namespace: r.URL.Query().Get("namespace"), + Kind: r.URL.Query().Get("kind"), + Query: r.URL.Query().Get("q"), + FocusID: r.URL.Query().Get("focus"), + Relations: parseRelations(r.URL.Query().Get("relations")), + GroupBy: r.URL.Query().Get("groupBy"), + CollapsedGroups: parseCSVSet(r.URL.Query().Get("collapsed")), + } + resp := graph.BuildGraph(dataset, filters, healthHints) + resp.Findings = findings + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleDiff(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadSize) + + var req struct { + BaseManifest string `json:"baseManifest"` + TargetManifest string `json:"targetManifest"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.BaseManifest) == "" || strings.TrimSpace(req.TargetManifest) == "" { + http.Error(w, "baseManifest and targetManifest are required", http.StatusBadRequest) + return + } + if len(req.BaseManifest) > maxDiffFieldBytes || len(req.TargetManifest) > maxDiffFieldBytes { + http.Error(w, "diff payload exceeds maximum allowed size", http.StatusBadRequest) + return + } + + base, err := parser.ParseManifests([]byte(req.BaseManifest)) + if err != nil { + http.Error(w, fmt.Sprintf("base parse error: %v", err), http.StatusBadRequest) + return + } + target, err := parser.ParseManifests([]byte(req.TargetManifest)) + if err != nil { + http.Error(w, fmt.Sprintf("target parse error: %v", err), http.StatusBadRequest) + return + } + + writeJSON(w, http.StatusOK, analyze.DiffDatasets(base, target)) +} + +func (s *Server) handleResource(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + dataset := s.store.GetDataset(sid) + if dataset == nil { + http.Error(w, "no manifests uploaded", http.StatusNotFound) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/resources/") + id = strings.TrimSpace(id) + if id == "" { + http.Error(w, "resource id required", http.StatusBadRequest) + return + } + res, ok := dataset.Resources[id] + if !ok { + http.Error(w, "resource not found", http.StatusNotFound) + return + } + + payload := map[string]any{ + "id": res.ID, + "apiVersion": res.APIVersion, + "kind": res.Kind, + "name": res.Name, + "namespace": res.Namespace, + "labels": res.Labels, + "isSensitive": res.IsSensitive, + "keyNames": res.KeyNames, + "references": res.References, + "ownerRefs": res.OwnerRefs, + "raw": sanitizeRawForResponse(res), + "clusterScoped": res.ClusterScoped, + } + writeJSON(w, http.StatusOK, payload) +} + +func sanitizeRawForResponse(res *model.Resource) map[string]any { + raw := map[string]any{} + for k, v := range res.Raw { + raw[k] = v + } + if res.IsSensitive { + raw["data"] = "" + raw["stringData"] = "" + } + return raw +} + +func (s *Server) handleClearSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + sid, err := s.sessionID(w, r) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + s.store.Clear(sid) + writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) +} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go new file mode 100644 index 0000000..462a5c9 --- /dev/null +++ b/internal/httpserver/server.go @@ -0,0 +1,143 @@ +package httpserver + +import ( + "context" + "embed" + "encoding/json" + "errors" + "html/template" + "io/fs" + "log" + "net/http" + "strings" + "time" + + "kubeviz/internal/config" + "kubeviz/internal/session" +) + +const sessionCookieName = "kubeviz_session" + +type Server struct { + cfg config.Config + store *session.Store + templates *template.Template + mux *http.ServeMux +} + +func New(cfg config.Config) (*Server, error) { + tpls, err := template.ParseFS(templateFS, "ui/templates/*.html") + if err != nil { + return nil, err + } + + s := &Server{ + cfg: cfg, + store: session.NewStore(cfg.SessionTTL), + templates: tpls, + mux: http.NewServeMux(), + } + s.routes() + return s, nil +} + +func (s *Server) routes() { + s.mux.Handle("/", s.middleware(http.HandlerFunc(s.handleIndex))) + s.mux.Handle("/api/manifests/parse", s.middleware(http.HandlerFunc(s.handleParseManifests))) + s.mux.Handle("/api/helm/render", s.middleware(http.HandlerFunc(s.handleHelmRender))) + s.mux.Handle("/api/git/import", s.middleware(http.HandlerFunc(s.handleGitImport))) + s.mux.Handle("/api/graph", s.middleware(http.HandlerFunc(s.handleGraph))) + s.mux.Handle("/api/diff", s.middleware(http.HandlerFunc(s.handleDiff))) + s.mux.Handle("/api/resources/", s.middleware(http.HandlerFunc(s.handleResource))) + s.mux.Handle("/api/export/svg", s.middleware(http.HandlerFunc(s.handleExportSVG))) + s.mux.Handle("/api/export/png", s.middleware(http.HandlerFunc(s.handleExportPNG))) + s.mux.Handle("/api/session/clear", s.middleware(http.HandlerFunc(s.handleClearSession))) + + staticSub, _ := fs.Sub(staticFS, "ui/static") + s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +func (s *Server) Shutdown(ctx context.Context) { + s.store.Stop() + _ = ctx +} + +func (s *Server) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + r = r.WithContext(ctx) + + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';") + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + + next.ServeHTTP(w, r) + }) +} + +func (s *Server) sessionID(w http.ResponseWriter, r *http.Request) (string, error) { + cookie, err := r.Cookie(sessionCookieName) + if err == nil && cookie.Value != "" { + return cookie.Value, nil + } + if !errors.Is(err, http.ErrNoCookie) && err != nil { + return "", err + } + sid, err := s.store.NewSessionID() + if err != nil { + return "", err + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sid, + Path: "/", + HttpOnly: true, + Secure: s.cfg.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(s.cfg.SessionTTL.Seconds()), + }) + return sid, nil +} + +func parseRelations(raw string) map[string]bool { + return parseCSVSet(raw) +} + +func parseCSVSet(raw string) map[string]bool { + if raw == "" { + return nil + } + out := map[string]bool{} + for _, item := range strings.Split(raw, ",") { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out[trimmed] = true + } + return out +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("failed writing JSON: %v", err) + } +} + +func subFS(fsys embed.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + return sub +} diff --git a/internal/httpserver/server_test.go b/internal/httpserver/server_test.go new file mode 100644 index 0000000..f727aad --- /dev/null +++ b/internal/httpserver/server_test.go @@ -0,0 +1,459 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "kubeviz/internal/config" +) + +func TestParseGraphAndResourceFlow(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: app-cfg + namespace: demo +` + body := map[string]string{"manifest": manifest} + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/manifests/parse", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusOK { + t.Fatalf("parse status: got %d body=%s", got, w.Body.String()) + } + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected session cookie") + } + + graphReq := httptest.NewRequest(http.MethodGet, "/api/graph", nil) + graphReq.AddCookie(cookies[0]) + graphW := httptest.NewRecorder() + handler.ServeHTTP(graphW, graphReq) + if got := graphW.Result().StatusCode; got != http.StatusOK { + t.Fatalf("graph status: got %d", got) + } + var graph map[string]any + if err := json.NewDecoder(graphW.Result().Body).Decode(&graph); err != nil { + t.Fatalf("graph decode failed: %v", err) + } + + nodes, _ := graph["nodes"].([]any) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + + resReq := httptest.NewRequest(http.MethodGet, "/api/resources/demo/ConfigMap/app-cfg", nil) + resReq.AddCookie(cookies[0]) + resW := httptest.NewRecorder() + handler.ServeHTTP(resW, resReq) + if got := resW.Result().StatusCode; got != http.StatusOK { + t.Fatalf("resource status: got %d", got) + } +} + +func TestParseMultipleUploadedFiles(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + f1, err := writer.CreateFormFile("manifestFile", "deployment.yaml") + if err != nil { + t.Fatalf("failed creating file part 1: %v", err) + } + _, _ = f1.Write([]byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: kubeviz + namespace: kubeviz +`)) + + f2, err := writer.CreateFormFile("manifestFile", "service.yaml") + if err != nil { + t.Fatalf("failed creating file part 2: %v", err) + } + _, _ = f2.Write([]byte(`apiVersion: v1 +kind: Service +metadata: + name: kubeviz + namespace: kubeviz +spec: + selector: + app: kubeviz +`)) + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/manifests/parse", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusOK { + t.Fatalf("parse status: got %d body=%s", got, w.Body.String()) + } + + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected session cookie") + } + + graphReq := httptest.NewRequest(http.MethodGet, "/api/graph", nil) + graphReq.AddCookie(cookies[0]) + graphW := httptest.NewRecorder() + handler.ServeHTTP(graphW, graphReq) + if got := graphW.Result().StatusCode; got != http.StatusOK { + t.Fatalf("graph status: got %d", got) + } + + var graph map[string]any + if err := json.NewDecoder(graphW.Result().Body).Decode(&graph); err != nil { + t.Fatalf("graph decode failed: %v", err) + } + nodes, _ := graph["nodes"].([]any) + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes from two files, got %d", len(nodes)) + } +} + +func TestParseMultipleUploadedFilesToleratesSingleInvalidFile(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + valid, err := writer.CreateFormFile("manifestFile", "service.yaml") + if err != nil { + t.Fatalf("failed creating valid file part: %v", err) + } + _, _ = valid.Write([]byte(`apiVersion: v1 +kind: Service +metadata: + name: kubeviz + namespace: kubeviz +spec: + selector: + app: kubeviz +`)) + + invalid, err := writer.CreateFormFile("manifestFile", "broken.yaml") + if err != nil { + t.Fatalf("failed creating invalid file part: %v", err) + } + _, _ = invalid.Write([]byte(`apiVersion: v1 +kind Service +metadata: + name: broken +`)) + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/manifests/parse", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusOK { + t.Fatalf("parse status: got %d", got) + } + + var resp struct { + Summary struct { + Resources int `json:"resources"` + Issues []struct { + Message string `json:"message"` + } `json:"issues"` + } `json:"summary"` + } + if err := json.NewDecoder(w.Result().Body).Decode(&resp); err != nil { + t.Fatalf("parse response decode failed: %v", err) + } + if resp.Summary.Resources != 1 { + t.Fatalf("expected 1 parsed resource, got %d", resp.Summary.Resources) + } + if len(resp.Summary.Issues) == 0 { + t.Fatalf("expected parse issue for invalid file") + } + if !strings.Contains(resp.Summary.Issues[0].Message, "broken.yaml") { + t.Fatalf("expected issue message to include filename, got: %q", resp.Summary.Issues[0].Message) + } +} + +func TestParseMultipleUploadedFilesAllInvalidReturnsIssues(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + f1, err := writer.CreateFormFile("manifestFile", "values.yaml") + if err != nil { + t.Fatalf("failed creating file part 1: %v", err) + } + _, _ = f1.Write([]byte(`replicaCount: 2 +image: + repository: nginx +`)) + + f2, err := writer.CreateFormFile("manifestFile", "Chart.yaml") + if err != nil { + t.Fatalf("failed creating file part 2: %v", err) + } + _, _ = f2.Write([]byte(`apiVersion: v2 +name: sample-chart +version: 0.1.0 +`)) + + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/manifests/parse", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusOK { + t.Fatalf("parse status: got %d body=%s", got, w.Body.String()) + } + + var resp struct { + Summary struct { + Resources int `json:"resources"` + Issues []struct { + Message string `json:"message"` + } `json:"issues"` + } `json:"summary"` + } + if err := json.NewDecoder(w.Result().Body).Decode(&resp); err != nil { + t.Fatalf("parse response decode failed: %v", err) + } + if resp.Summary.Resources != 0 { + t.Fatalf("expected 0 parsed resources, got %d", resp.Summary.Resources) + } + if len(resp.Summary.Issues) < 2 { + t.Fatalf("expected issues for all invalid files, got %d", len(resp.Summary.Issues)) + } +} + +func TestDiffEndpoint(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + body := map[string]string{ + "baseManifest": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cfg + namespace: demo +data: + a: "1" +`, + "targetManifest": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cfg + namespace: demo +data: + a: "2" +--- +apiVersion: v1 +kind: Service +metadata: + name: svc + namespace: demo +`, + } + payload, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/diff", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusOK { + t.Fatalf("diff status: got %d", got) + } + + var diff map[string]any + if err := json.NewDecoder(w.Result().Body).Decode(&diff); err != nil { + t.Fatalf("diff decode failed: %v", err) + } + changed, _ := diff["changed"].([]any) + added, _ := diff["added"].([]any) + if len(changed) != 1 { + t.Fatalf("expected 1 changed item, got %d", len(changed)) + } + if len(added) != 1 { + t.Fatalf("expected 1 added item, got %d", len(added)) + } +} + +func TestGraphEndpointProvidesFindingsAndGroups(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + manifest := `apiVersion: v1 +kind: Service +metadata: + name: app + namespace: demo +spec: + selector: + app: no-match +` + body := map[string]string{"manifest": manifest} + payload, _ := json.Marshal(body) + + parseReq := httptest.NewRequest(http.MethodPost, "/api/manifests/parse", bytes.NewReader(payload)) + parseReq.Header.Set("Content-Type", "application/json") + parseW := httptest.NewRecorder() + handler.ServeHTTP(parseW, parseReq) + if got := parseW.Result().StatusCode; got != http.StatusOK { + t.Fatalf("parse status: got %d", got) + } + cookies := parseW.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected session cookie") + } + + graphReq := httptest.NewRequest(http.MethodGet, "/api/graph?groupBy=namespace&collapsed=demo", nil) + graphReq.AddCookie(cookies[0]) + graphW := httptest.NewRecorder() + handler.ServeHTTP(graphW, graphReq) + if got := graphW.Result().StatusCode; got != http.StatusOK { + t.Fatalf("graph status: got %d", got) + } + + var graph map[string]any + if err := json.NewDecoder(graphW.Result().Body).Decode(&graph); err != nil { + t.Fatalf("graph decode failed: %v", err) + } + if findings, _ := graph["findings"].([]any); len(findings) == 0 { + t.Fatalf("expected findings in graph response") + } + if groups, _ := graph["groups"].([]any); len(groups) == 0 { + t.Fatalf("expected groups in graph response") + } +} + +func TestGitImportValidation(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + req := httptest.NewRequest(http.MethodPost, "/api/git/import", bytes.NewBufferString(`{"sourceType":"manifests"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusBadRequest { + t.Fatalf("expected 400 for missing repoURL, got %d", got) + } +} + +func TestHelmRenderValidation(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + req := httptest.NewRequest(http.MethodPost, "/api/helm/render", bytes.NewBufferString(`{}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusBadRequest { + t.Fatalf("expected 400 for missing repoURL, got %d", got) + } +} + +func TestGitImportRejectsNonHTTPSRepoURL(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + req := httptest.NewRequest(http.MethodPost, "/api/git/import", bytes.NewBufferString(`{"repoURL":"http://github.com/org/repo.git","sourceType":"manifests"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusBadRequest { + t.Fatalf("expected 400 for non-https repoURL, got %d", got) + } +} + +func TestGitImportRejectsDisallowedHost(t *testing.T) { + srv, err := New(config.Config{ + Addr: ":0", + SessionTTL: 30 * time.Minute, + MaxUploadSize: 1024 * 1024, + GitAllowedHosts: []string{"github.com"}, + }) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + req := httptest.NewRequest(http.MethodPost, "/api/git/import", bytes.NewBufferString(`{"repoURL":"https://evil.example/repo.git","sourceType":"manifests"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusBadRequest { + t.Fatalf("expected 400 for disallowed host, got %d", got) + } +} + +func TestDiffRejectsTooLargePayload(t *testing.T) { + srv, err := New(config.Config{Addr: ":0", SessionTTL: 30 * time.Minute, MaxUploadSize: 8 * 1024 * 1024}) + if err != nil { + t.Fatalf("server init failed: %v", err) + } + handler := srv.Handler() + + tooLarge := strings.Repeat("a", maxDiffFieldBytes+1) + body := map[string]string{ + "baseManifest": tooLarge, + "targetManifest": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: demo\n", + } + payload, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/diff", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if got := w.Result().StatusCode; got != http.StatusBadRequest { + t.Fatalf("expected 400 for oversized diff payload, got %d", got) + } +} diff --git a/internal/httpserver/ui/static/app.js b/internal/httpserver/ui/static/app.js new file mode 100644 index 0000000..7aaacbb --- /dev/null +++ b/internal/httpserver/ui/static/app.js @@ -0,0 +1,1723 @@ +window.KubeViz = { + graph: { nodes: [], edges: [], groups: [], findings: [] }, + positions: {}, + selectedId: null, + lastParsedResources: 0, + view: { + scale: 1, + minScale: 0.4, + maxScale: 3.0, + panX: 0, + panY: 0, + }, + dragging: { + nodeId: null, + panning: false, + panStartX: 0, + panStartY: 0, + }, + refreshTimer: null, + search: { + suggestions: [], + activeIndex: -1, + }, + path: { + nodes: new Set(), + edges: new Set(), + ids: [], + }, + pathSuggest: { + source: { items: [], index: -1 }, + target: { items: [], index: -1 }, + }, +}; + +const THEME_KEY = "kubeviz-theme"; +const THEME_ORDER = ["system", "light", "dark"]; +const SAVED_VIEWS_KEY = "kubeviz-saved-views"; + +document.addEventListener("DOMContentLoaded", () => { + initTheme(); + initTabs(); + initModal(); + + const parseForm = document.getElementById("manifest-form"); + const filterForm = document.getElementById("filter-form"); + const clearBtn = document.getElementById("clear-session"); + const resetBtn = document.getElementById("reset-view"); + const clearInlineBtn = document.getElementById("clear-filters-inline"); + const openDataTabBtn = document.getElementById("open-data-tab"); + const openFilterTabBtn = document.getElementById("open-filter-tab"); + const fileInput = document.getElementById("manifestFile"); + const filePickerBtn = document.getElementById("file-picker-btn"); + const fileCount = document.getElementById("file-count"); + const zoomInBtn = document.getElementById("zoom-in"); + const zoomOutBtn = document.getElementById("zoom-out"); + const zoomResetBtn = document.getElementById("zoom-reset"); + const runDiffBtn = document.getElementById("run-diff"); + const jumpBtn = document.getElementById("jump-to-search"); + const queryInput = document.getElementById("query"); + const layoutMode = document.getElementById("layout-mode"); + const resetChecksBtn = document.getElementById("reset-checks"); + const saveViewBtn = document.getElementById("save-view"); + const loadViewBtn = document.getElementById("load-view"); + const deleteViewBtn = document.getElementById("delete-view"); + const exportViewsBtn = document.getElementById("export-views"); + const importViewsBtn = document.getElementById("import-views"); + const importViewsFile = document.getElementById("import-views-file"); + const runPathBtn = document.getElementById("run-path"); + const clearPathBtn = document.getElementById("clear-path"); + const pathSourceInput = document.getElementById("path-source"); + const pathTargetInput = document.getElementById("path-target"); + const minimap = document.getElementById("minimap-canvas"); + const gitImportManifestsBtn = document.getElementById("git-import-manifests"); + const gitImportHelmBtn = document.getElementById("git-import-helm"); + + filePickerBtn.addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", () => { + const count = fileInput.files ? fileInput.files.length : 0; + if (count === 0) { + fileCount.textContent = "No files selected"; + return; + } + fileCount.textContent = count === 1 ? fileInput.files[0].name : `${count} files selected`; + }); + + parseForm.addEventListener("htmx:afterRequest", async (event) => { + const xhr = event.detail.xhr; + if (xhr.status < 200 || xhr.status >= 300) { + const detail = (xhr.responseText || "").trim(); + const suffix = detail ? ` ${detail}` : " Check YAML syntax and try again."; + setStatus(`Parse failed (${xhr.status}).${suffix}`, "error"); + renderParseIssues([]); + return; + } + + const payload = JSON.parse(xhr.responseText); + const parseIssues = payload.summary?.issues || []; + const issues = parseIssues.length; + window.KubeViz.lastParsedResources = payload.summary?.resources || 0; + setStatus(`Parsed ${window.KubeViz.lastParsedResources} resources (${formatIssueCount(issues)}).`, issues > 0 ? "warn" : "ok"); + renderParseIssues(parseIssues); + await fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); + }); + + filterForm.addEventListener("submit", (event) => event.preventDefault()); + bindLiveFilterEvents(); + + layoutMode.addEventListener("change", () => { + window.KubeViz.positions = {}; + renderGraph(); + }); + + jumpBtn.addEventListener("click", () => jumpToSearchMatch()); + queryInput.addEventListener("keydown", onSearchKeydown); + queryInput.addEventListener("blur", () => { + window.setTimeout(() => hideSearchSuggestions(), 100); + }); + + clearBtn.addEventListener("click", async () => { + await fetch("/api/session/clear", { method: "POST" }); + setStatus("Session cleared. Load manifests to build a graph.", "info"); + renderParseIssues([]); + + window.KubeViz.graph = { nodes: [], edges: [], groups: [], findings: [] }; + window.KubeViz.positions = {}; + window.KubeViz.selectedId = null; + window.KubeViz.lastParsedResources = 0; + window.KubeViz.path.nodes = new Set(); + window.KubeViz.path.edges = new Set(); + window.KubeViz.path.ids = []; + window.KubeViz.pathSuggest.source = { items: [], index: -1 }; + window.KubeViz.pathSuggest.target = { items: [], index: -1 }; + + document.getElementById("namespace").value = ""; + document.getElementById("kind").value = ""; + document.getElementById("focus").value = ""; + document.getElementById("query").value = ""; + document.getElementById("groupBy").value = ""; + document.getElementById("path-source").value = ""; + document.getElementById("path-target").value = ""; + document.getElementById("path-bidirectional").checked = false; + setPathResult("No path calculated."); + hidePathSuggestions("source"); + hidePathSuggestions("target"); + document.getElementById("selected-kind-name").textContent = "none"; + fileCount.textContent = "No files selected"; + + setSelectedResource(null); + updateStats({ totalNodes: 0, totalEdges: 0 }); + renderFindings([]); + renderCollapseControls([]); + renderActiveFilters(); + renderGraph(); + }); + + resetBtn.addEventListener("click", async () => { + resetFiltersToDefault(); + await fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); + }); + + clearInlineBtn.addEventListener("click", async () => { + resetFiltersToDefault(); + await fetchAndRenderGraph({ autoNamespace: false, resetLayout: true }); + }); + if (openDataTabBtn) openDataTabBtn.addEventListener("click", () => activateTab("tab-data")); + if (openFilterTabBtn) openFilterTabBtn.addEventListener("click", () => activateTab("tab-filter")); + + zoomInBtn.addEventListener("click", () => setZoom(window.KubeViz.view.scale * 1.15)); + zoomOutBtn.addEventListener("click", () => setZoom(window.KubeViz.view.scale / 1.15)); + zoomResetBtn.addEventListener("click", resetZoom); + if (saveViewBtn) saveViewBtn.addEventListener("click", saveCurrentView); + if (loadViewBtn) loadViewBtn.addEventListener("click", loadSelectedView); + if (deleteViewBtn) deleteViewBtn.addEventListener("click", deleteSelectedView); + if (exportViewsBtn) exportViewsBtn.addEventListener("click", exportSavedViews); + if (importViewsBtn) importViewsBtn.addEventListener("click", () => { + if (importViewsFile) importViewsFile.click(); + }); + if (importViewsFile) importViewsFile.addEventListener("change", importSavedViewsFromFile); + if (runPathBtn) runPathBtn.addEventListener("click", runPathFinder); + if (clearPathBtn) clearPathBtn.addEventListener("click", clearPathHighlight); + if (pathSourceInput) bindPathSuggestInput(pathSourceInput, "source"); + if (pathTargetInput) bindPathSuggestInput(pathTargetInput, "target"); + if (minimap) initMinimap(minimap); + if (gitImportManifestsBtn) gitImportManifestsBtn.addEventListener("click", () => importFromGit("manifests")); + if (gitImportHelmBtn) gitImportHelmBtn.addEventListener("click", () => importFromGit("helm")); + + runDiffBtn.addEventListener("click", runDiff); + if (resetChecksBtn) { + resetChecksBtn.addEventListener("click", () => { + document.querySelectorAll('input[name="checkRules"]').forEach((el) => { + el.checked = true; + }); + triggerGraphRefresh(); + }); + } + + renderSavedViews(); + setStatus("Paste YAML or upload files, then click Parse.", "info"); + renderParseIssues([]); + fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); +}); + +function initTabs() { + const buttons = Array.from(document.querySelectorAll(".tab-btn")); + + for (const btn of buttons) { + btn.addEventListener("click", () => { + activateTab(btn.dataset.tab); + }); + } +} + +function activateTab(tabId) { + const buttons = Array.from(document.querySelectorAll(".tab-btn")); + const panes = Array.from(document.querySelectorAll(".tab-pane")); + for (const b of buttons) b.classList.toggle("active", b.dataset.tab === tabId); + for (const p of panes) p.classList.toggle("active", p.id === tabId); +} + +function initTheme() { + const button = document.getElementById("theme-toggle"); + if (!button) return; + + const saved = localStorage.getItem(THEME_KEY); + const pref = THEME_ORDER.includes(saved) ? saved : "system"; + applyTheme(pref); + + button.addEventListener("click", () => { + const current = document.documentElement.dataset.themePreference || "system"; + const idx = THEME_ORDER.indexOf(current); + const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length]; + applyTheme(next); + }); +} + +function applyTheme(preference) { + const html = document.documentElement; + const button = document.getElementById("theme-toggle"); + const pref = THEME_ORDER.includes(preference) ? preference : "system"; + + html.dataset.themePreference = pref; + if (pref === "system") { + delete html.dataset.theme; + } else { + html.dataset.theme = pref; + } + + localStorage.setItem(THEME_KEY, pref); + if (button) button.textContent = `Theme: ${capitalize(pref)}`; + renderGraph(); +} + +function capitalize(value) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function initModal() { + const modal = document.getElementById("diff-modal"); + const openBtn = document.getElementById("open-diff"); + const closeBtn = document.getElementById("close-diff"); + const checksModal = document.getElementById("checks-modal"); + const openChecksBtn = document.getElementById("open-checks"); + const closeChecksBtn = document.getElementById("close-checks"); + + // Defensive init: modal must never start opened. + modal.classList.add("hidden"); + + openBtn.addEventListener("click", () => modal.classList.remove("hidden")); + closeBtn.addEventListener("click", () => modal.classList.add("hidden")); + if (openChecksBtn) openChecksBtn.addEventListener("click", () => checksModal.classList.remove("hidden")); + if (closeChecksBtn) closeChecksBtn.addEventListener("click", () => checksModal.classList.add("hidden")); + modal.addEventListener("click", (event) => { + if (event.target === modal) { + modal.classList.add("hidden"); + } + }); + if (checksModal) { + checksModal.addEventListener("click", (event) => { + if (event.target === checksModal) { + checksModal.classList.add("hidden"); + } + }); + } + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + modal.classList.add("hidden"); + if (checksModal) checksModal.classList.add("hidden"); + hideSearchSuggestions(); + hidePathSuggestions("source"); + hidePathSuggestions("target"); + } + }); + + document.addEventListener("keydown", (event) => { + if (isTypingContext(event.target)) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + + if (event.key === "/") { + event.preventDefault(); + activateTab("tab-filter"); + const query = document.getElementById("query"); + if (query) query.focus(); + return; + } + + if (event.key.toLowerCase() === "d") { + event.preventDefault(); + modal.classList.remove("hidden"); + return; + } + + if (event.key.toLowerCase() === "c" && checksModal) { + event.preventDefault(); + checksModal.classList.remove("hidden"); + } + }); +} + +function bindLiveFilterEvents() { + const selectors = [ + "#query", + "#namespace", + "#kind", + "#focus", + "#groupBy", + 'input[name="relations"]', + 'input[name="checkRules"]', + ]; + + for (const selector of selectors) { + const elements = document.querySelectorAll(selector); + elements.forEach((el) => { + const eventName = el.tagName === "INPUT" && el.type === "text" ? "input" : "change"; + el.addEventListener(eventName, () => { + if (el.id === "query") updateSearchSuggestions(); + triggerGraphRefresh(); + }); + }); + } +} + +function triggerGraphRefresh() { + if (window.KubeViz.refreshTimer) { + window.clearTimeout(window.KubeViz.refreshTimer); + } + window.KubeViz.refreshTimer = window.setTimeout(() => { + fetchAndRenderGraph({ autoNamespace: false, resetLayout: false }); + }, 180); +} + +function getFilterParams() { + const params = new URLSearchParams(); + const namespace = document.getElementById("namespace").value.trim(); + const kind = document.getElementById("kind").value.trim(); + const q = document.getElementById("query").value.trim(); + const focus = document.getElementById("focus").value.trim(); + const groupBy = document.getElementById("groupBy").value; + + if (namespace) params.set("namespace", namespace); + if (kind) params.set("kind", kind); + if (q) params.set("q", q); + if (focus) params.set("focus", focus); + if (groupBy) params.set("groupBy", groupBy); + + const rels = []; + document.querySelectorAll('input[name="relations"]:checked').forEach((el) => rels.push(el.value)); + if (rels.length) params.set("relations", rels.join(",")); + + const checkRules = []; + document.querySelectorAll('input[name="checkRules"]:checked').forEach((el) => checkRules.push(el.value)); + params.set("checkRules", checkRules.length ? checkRules.join(",") : "none"); + + const collapsed = []; + document.querySelectorAll('#collapse-list input[type="checkbox"][data-group-key]').forEach((el) => { + if (el.checked) collapsed.push(el.dataset.groupKey); + }); + if (collapsed.length) params.set("collapsed", collapsed.join(",")); + + return params; +} + +async function fetchAndRenderGraph(options = { autoNamespace: false, resetLayout: false }) { + const params = getFilterParams(); + const res = await fetch(`/api/graph?${params.toString()}`); + if (!res.ok) { + setStatus("Failed to load graph. Please retry parsing or reset filters.", "error"); + return; + } + + const payload = await res.json(); + const prevPositions = window.KubeViz.positions || {}; + + window.KubeViz.graph = payload; + window.KubeViz.positions = options.resetLayout ? {} : keepKnownPositions(payload.nodes || [], prevPositions); + + if (options.autoNamespace) applyNamespacePrefill(payload.nodes || []); + + updateStats(payload.stats || { totalNodes: 0, totalEdges: 0 }); + updateExportLinks(getFilterParams()); + renderFindings(payload.findings || []); + renderCollapseControls(payload.groups || []); + renderActiveFilters(); + updateSearchSuggestions(); + updatePathSuggestions("source"); + updatePathSuggestions("target"); + renderGraph(); +} + +function keepKnownPositions(nodes, prevPositions) { + const out = {}; + for (const node of nodes) { + if (prevPositions[node.id]) out[node.id] = prevPositions[node.id]; + } + return out; +} + +function applyNamespacePrefill(nodes) { + const namespaceInput = document.getElementById("namespace"); + if (namespaceInput.value.trim() !== "") return; + + const namespaces = new Set(); + for (const node of nodes) { + if (node.namespace) namespaces.add(node.namespace); + } + if (namespaces.size === 1) namespaceInput.value = [...namespaces][0]; +} + +function renderCollapseControls(groups) { + const wrapper = document.getElementById("collapse-controls"); + const list = document.getElementById("collapse-list"); + list.innerHTML = ""; + + if (!groups.length) { + wrapper.classList.add("hidden"); + return; + } + + wrapper.classList.remove("hidden"); + for (const group of groups) { + const row = document.createElement("label"); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!group.collapsed; + cb.dataset.groupKey = group.key; + cb.addEventListener("change", () => fetchAndRenderGraph({ autoNamespace: false, resetLayout: true })); + + const text = document.createElement("span"); + text.textContent = `${group.label} (${group.count})`; + + row.appendChild(cb); + row.appendChild(text); + list.appendChild(row); + } +} + +function renderActiveFilters() { + const box = document.getElementById("active-filters"); + box.innerHTML = ""; + + const chips = []; + const q = document.getElementById("query").value.trim(); + const ns = document.getElementById("namespace").value.trim(); + const kind = document.getElementById("kind").value.trim(); + const focus = document.getElementById("focus").value.trim(); + const groupBy = document.getElementById("groupBy").value; + + if (q) chips.push(`q:${q}`); + if (ns) chips.push(`ns:${ns}`); + if (kind) chips.push(`kind:${kind}`); + if (focus) chips.push(`focus:${focus}`); + if (groupBy) chips.push(`group:${groupBy}`); + + if (!chips.length) { + const empty = document.createElement("span"); + empty.className = "chip"; + empty.textContent = "No active filters"; + box.appendChild(empty); + return; + } + + chips.forEach((chipText) => { + const el = document.createElement("span"); + el.className = "chip"; + el.textContent = chipText; + box.appendChild(el); + }); +} + +function renderFindings(findings) { + const summary = document.getElementById("findings-summary"); + const list = document.getElementById("findings-list"); + list.innerHTML = ""; + + if (!findings.length) { + summary.textContent = "No findings."; + return; + } + + const errors = findings.filter((f) => f.severity === "error").length; + const warnings = findings.filter((f) => f.severity === "warning").length; + summary.textContent = `${errors} errors, ${warnings} warnings`; + + for (const f of findings.slice(0, 80)) { + const li = document.createElement("li"); + li.classList.add(`finding-${f.severity}`); + const target = f.resourceId ? ` [${f.resourceId}]` : ""; + li.textContent = `${f.category.toUpperCase()} [${f.rule}] ${f.severity.toUpperCase()}${target}: ${f.message}`; + list.appendChild(li); + } +} + +function updateExportLinks(params) { + const query = params.toString(); + document.getElementById("export-svg").href = `/api/export/svg${query ? `?${query}` : ""}`; + document.getElementById("export-png").href = `/api/export/png${query ? `?${query}` : ""}`; +} + +function renderGraph() { + const svg = document.getElementById("graph-canvas"); + const tooltip = document.getElementById("edge-tooltip"); + const noMatch = document.getElementById("no-match"); + const emptyState = document.getElementById("graph-empty"); + const { nodes = [], edges = [] } = window.KubeViz.graph; + const pathNodes = window.KubeViz.path.nodes || new Set(); + const pathEdges = window.KubeViz.path.edges || new Set(); + + svg.innerHTML = ""; + tooltip.classList.add("hidden"); + noMatch.classList.add("hidden"); + emptyState.classList.add("hidden"); + + attachZoomHandlers(svg); + + if (!nodes.length) { + if (window.KubeViz.lastParsedResources > 0) { + noMatch.classList.remove("hidden"); + } else { + emptyState.classList.remove("hidden"); + } + return; + } + + const viewport = document.createElementNS("http://www.w3.org/2000/svg", "g"); + viewport.setAttribute("id", "graph-viewport"); + svg.appendChild(viewport); + + const positions = assignPositions(nodes); + window.KubeViz.positions = positions; + + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + for (const edge of edges) { + const source = positions[edge.source]; + const target = positions[edge.target]; + if (!source || !target) continue; + + const key = edgeKey(edge.source, edge.target, edge.relationType); + const reverseKey = edgeKey(edge.target, edge.source, edge.relationType); + const isPathEdge = pathEdges.has(key) || pathEdges.has(reverseKey); + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("x1", source.x); + line.setAttribute("y1", source.y); + line.setAttribute("x2", target.x); + line.setAttribute("y2", target.y); + line.setAttribute("stroke", isPathEdge ? getCssVar("--accent", "#0f766e") : getCssVar("--line-strong", "#94a3b8")); + line.setAttribute("stroke-width", isPathEdge ? "3" : "1.5"); + line.setAttribute("data-relation", edge.relationType); + line.setAttribute("data-source", edge.source); + line.setAttribute("data-target", edge.target); + line.setAttribute("data-edge-key", key); + + line.addEventListener("mousemove", (event) => { + const src = nodeMap.get(edge.source); + const tgt = nodeMap.get(edge.target); + const srcLabel = src ? `${src.kind}/${src.name}` : edge.source; + const tgtLabel = tgt ? `${tgt.kind}/${tgt.name}` : edge.target; + const extra = edge.label ? `\n${edge.label}` : ""; + showTooltip(event.clientX, event.clientY, `${edge.relationType}: ${srcLabel} -> ${tgtLabel}${extra}`); + }); + line.addEventListener("mouseleave", hideTooltip); + + viewport.appendChild(line); + + if (edge.label) { + const edgeLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); + edgeLabel.setAttribute("class", "edge-label"); + edgeLabel.setAttribute("text-anchor", "middle"); + edgeLabel.setAttribute("data-edge-label", `${edge.source}|${edge.target}|${edge.relationType}`); + edgeLabel.textContent = edge.label; + viewport.appendChild(edgeLabel); + } + } + + for (const node of nodes) { + const pos = positions[node.id]; + + const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.setAttribute("class", "node"); + group.setAttribute("data-node-id", node.id); + + const isPathNode = pathNodes.has(node.id); + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", pos.x); + circle.setAttribute("cy", pos.y); + circle.setAttribute("r", node.isGroup ? "22" : "18"); + circle.setAttribute("fill", nodeColor(node)); + circle.setAttribute("opacity", "0.92"); + circle.style.cursor = "grab"; + circle.addEventListener("click", () => onSelectNode(node.id)); + circle.addEventListener("pointerdown", (event) => startNodeDrag(event, node.id)); + + if (isPathNode) { + circle.setAttribute("stroke", getCssVar("--accent", "#0f766e")); + circle.setAttribute("stroke-width", "3"); + } + + if (node.healthHint === "warning") { + circle.setAttribute("stroke", "#f59e0b"); + circle.setAttribute("stroke-width", "2"); + circle.setAttribute("data-warning", "true"); + } + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", pos.x); + text.setAttribute("y", pos.y + 32); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("font-size", "11"); + text.setAttribute("fill", getCssVar("--ink", "#0f172a")); + text.textContent = node.isGroup ? `${node.groupKey} (${node.memberCount})` : `${node.kind}/${node.name}`; + + group.appendChild(circle); + group.appendChild(text); + viewport.appendChild(group); + } + + attachDragAndPanHandlers(svg); + updateViewportTransform(); + refreshEdgeAndNodeGeometry(); + highlightSelected(); + renderMinimap(); +} + +function nodeColor(node) { + if (node.isGroup) return "#7c3aed"; + if (node.isSensitive) return "#dc2626"; + if (["Service", "Ingress", "NetworkPolicy"].includes(node.kind)) return "#2563eb"; + if (["ConfigMap", "PersistentVolumeClaim", "HorizontalPodAutoscaler"].includes(node.kind)) return "#14b8a6"; + if (["Deployment", "StatefulSet", "DaemonSet"].includes(node.kind)) return "#0f766e"; + return "#334155"; +} + +function assignPositions(nodes) { + const existing = window.KubeViz.positions || {}; + const needsLayout = nodes.some((n) => !existing[n.id]); + if (!needsLayout) return existing; + + const mode = document.getElementById("layout-mode").value; + const laidOut = layoutNodes(nodes, mode, 1200, 720); + for (const node of nodes) { + if (!existing[node.id]) existing[node.id] = laidOut[node.id]; + } + return existing; +} + +function layoutNodes(nodes, mode, width, height) { + switch (mode) { + case "hierarchical": + return hierarchicalLayout(nodes, width, height); + case "grid": + return gridLayout(nodes, width, height); + case "radial": + return radialLayout(nodes, width, height); + case "circular": + default: + return circularLayout(nodes, 600, 360, Math.min(260, 80 + nodes.length * 2)); + } +} + +function circularLayout(nodes, centerX, centerY, radius) { + const positions = {}; + const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); + sorted.forEach((node, index) => { + const angle = (2 * Math.PI * index) / sorted.length; + positions[node.id] = { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }; + }); + return positions; +} + +function hierarchicalLayout(nodes, width, height) { + const positions = {}; + const byKind = new Map(); + for (const n of nodes) { + if (!byKind.has(n.kind)) byKind.set(n.kind, []); + byKind.get(n.kind).push(n); + } + + const kinds = [...byKind.keys()].sort(); + const levelHeight = Math.max(80, (height - 140) / Math.max(1, kinds.length)); + + kinds.forEach((kind, level) => { + const levelNodes = byKind.get(kind).sort((a, b) => a.id.localeCompare(b.id)); + const span = width - 120; + const step = levelNodes.length > 1 ? span / (levelNodes.length - 1) : 0; + levelNodes.forEach((n, i) => { + positions[n.id] = { x: 60 + (levelNodes.length > 1 ? step * i : span / 2), y: 70 + level * levelHeight }; + }); + }); + return positions; +} + +function gridLayout(nodes, width, height) { + const positions = {}; + const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); + const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length))); + const rows = Math.max(1, Math.ceil(sorted.length / cols)); + const xGap = (width - 120) / Math.max(1, cols - 1); + const yGap = (height - 120) / Math.max(1, rows - 1); + + sorted.forEach((n, idx) => { + const r = Math.floor(idx / cols); + const c = idx % cols; + positions[n.id] = { x: 60 + c * xGap, y: 60 + r * yGap }; + }); + return positions; +} + +function radialLayout(nodes, width, height) { + const positions = {}; + const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); + const center = { x: width / 2, y: height / 2 }; + const rings = [120, 210, 300]; + + sorted.forEach((n, idx) => { + const ring = rings[idx % rings.length]; + const angle = (2 * Math.PI * idx) / sorted.length; + positions[n.id] = { x: center.x + ring * Math.cos(angle), y: center.y + ring * Math.sin(angle) }; + }); + return positions; +} + +async function onSelectNode(id) { + window.KubeViz.selectedId = id; + highlightSelected(); + + if (id.startsWith("group/")) { + document.getElementById("selected-kind-name").textContent = id; + document.getElementById("kind").value = ""; + setSelectedResource({ kind: "Group", name: id, namespace: "", raw: {}, isSensitive: false }); + return; + } + + const res = await fetch(`/api/resources/${id}`); + if (!res.ok) { + setSelectedResource(null); + return; + } + + const payload = await res.json(); + setSelectedResource(payload); + document.getElementById("focus").value = id; + document.getElementById("kind").value = payload.kind || ""; + document.getElementById("selected-kind-name").textContent = `${payload.kind || ""} ${payload.name || ""}`.trim(); + syncPathInputs(id); +} + +function jumpToSearchMatch() { + const needle = document.getElementById("query").value.trim().toLowerCase(); + if (!needle) { + hideSearchSuggestions(); + return; + } + + const nodes = window.KubeViz.graph.nodes || []; + const match = nodes.find((n) => { + const hay = `${n.id} ${n.kind} ${n.name} ${n.namespace || ""}`.toLowerCase(); + return hay.includes(needle); + }); + if (!match) return; + + onSelectNode(match.id); + centerOnNode(match.id); + hideSearchSuggestions(); +} + +function onSearchKeydown(event) { + const suggestions = window.KubeViz.search.suggestions || []; + if (!suggestions.length) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + window.KubeViz.search.activeIndex = (window.KubeViz.search.activeIndex + 1) % suggestions.length; + renderSearchSuggestions(); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + window.KubeViz.search.activeIndex = window.KubeViz.search.activeIndex <= 0 + ? suggestions.length - 1 + : window.KubeViz.search.activeIndex - 1; + renderSearchSuggestions(); + return; + } + + if (event.key === "Enter") { + const idx = window.KubeViz.search.activeIndex; + if (idx >= 0 && suggestions[idx]) { + event.preventDefault(); + pickSuggestion(suggestions[idx].id); + } + return; + } + + if (event.key === "Escape") hideSearchSuggestions(); +} + +function updateSearchSuggestions() { + const query = document.getElementById("query").value.trim().toLowerCase(); + if (!query) { + window.KubeViz.search.suggestions = []; + window.KubeViz.search.activeIndex = -1; + hideSearchSuggestions(); + return; + } + + const nodes = window.KubeViz.graph.nodes || []; + window.KubeViz.search.suggestions = nodes + .filter((n) => { + const hay = `${n.name} ${n.kind} ${n.namespace || ""} ${n.id}`.toLowerCase(); + return hay.includes(query); + }) + .slice(0, 8); + window.KubeViz.search.activeIndex = window.KubeViz.search.suggestions.length ? 0 : -1; + renderSearchSuggestions(); +} + +function renderSearchSuggestions() { + const box = document.getElementById("search-suggest"); + const suggestions = window.KubeViz.search.suggestions || []; + box.innerHTML = ""; + + if (!suggestions.length) { + hideSearchSuggestions(); + return; + } + + suggestions.forEach((item, idx) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = `suggest-item${idx === window.KubeViz.search.activeIndex ? " active" : ""}`; + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", idx === window.KubeViz.search.activeIndex ? "true" : "false"); + row.textContent = `${item.kind}/${item.name} (${item.namespace || "cluster"})`; + row.addEventListener("mousedown", (event) => { + event.preventDefault(); + pickSuggestion(item.id); + }); + box.appendChild(row); + }); + box.classList.remove("hidden"); +} + +function pickSuggestion(nodeId) { + onSelectNode(nodeId); + centerOnNode(nodeId); + hideSearchSuggestions(); +} + +function hideSearchSuggestions() { + const box = document.getElementById("search-suggest"); + box.classList.add("hidden"); +} + +function bindPathSuggestInput(input, field) { + input.addEventListener("input", () => updatePathSuggestions(field)); + input.addEventListener("keydown", (event) => onPathSuggestKeydown(event, field)); + input.addEventListener("blur", () => { + window.setTimeout(() => hidePathSuggestions(field), 120); + }); +} + +function updatePathSuggestions(field) { + const input = document.getElementById(`path-${field}`); + if (!input) return; + const query = input.value.trim().toLowerCase(); + const state = window.KubeViz.pathSuggest[field]; + + if (!query) { + state.items = []; + state.index = -1; + hidePathSuggestions(field); + return; + } + + const nodes = window.KubeViz.graph.nodes || []; + state.items = nodes + .filter((n) => { + const hay = `${n.id} ${n.kind} ${n.name} ${n.namespace || ""}`.toLowerCase(); + return hay.includes(query); + }) + .slice(0, 8); + state.index = state.items.length ? 0 : -1; + renderPathSuggestions(field); +} + +function renderPathSuggestions(field) { + const state = window.KubeViz.pathSuggest[field]; + const box = document.getElementById(`path-${field}-suggest`); + if (!box) return; + box.innerHTML = ""; + + if (!state.items.length) { + hidePathSuggestions(field); + return; + } + + state.items.forEach((item, idx) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = `suggest-item${idx === state.index ? " active" : ""}`; + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", idx === state.index ? "true" : "false"); + row.textContent = `${item.id} (${item.kind})`; + row.addEventListener("mousedown", (event) => { + event.preventDefault(); + pickPathSuggestion(field, item.id); + }); + box.appendChild(row); + }); + box.classList.remove("hidden"); +} + +function onPathSuggestKeydown(event, field) { + const state = window.KubeViz.pathSuggest[field]; + if (!state.items.length) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + state.index = (state.index + 1) % state.items.length; + renderPathSuggestions(field); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + state.index = state.index <= 0 ? state.items.length - 1 : state.index - 1; + renderPathSuggestions(field); + return; + } + + if (event.key === "Enter") { + if (state.index >= 0 && state.items[state.index]) { + event.preventDefault(); + pickPathSuggestion(field, state.items[state.index].id); + } + return; + } + + if (event.key === "Escape") hidePathSuggestions(field); +} + +function pickPathSuggestion(field, id) { + const input = document.getElementById(`path-${field}`); + if (!input) return; + input.value = id; + hidePathSuggestions(field); +} + +function hidePathSuggestions(field) { + const box = document.getElementById(`path-${field}-suggest`); + if (!box) return; + box.classList.add("hidden"); +} + +function initMinimap(minimap) { + minimap.addEventListener("click", (event) => { + const rect = minimap.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const x = ((event.clientX - rect.left) / rect.width) * 1200; + const y = ((event.clientY - rect.top) / rect.height) * 720; + const scale = window.KubeViz.view.scale || 1; + window.KubeViz.view.panX = (600 - x) * scale; + window.KubeViz.view.panY = (360 - y) * scale; + updateViewportTransform(); + }); +} + +function renderMinimap() { + const minimap = document.getElementById("minimap-canvas"); + if (!minimap) return; + + minimap.innerHTML = ""; + const { nodes = [], edges = [] } = window.KubeViz.graph; + const positions = window.KubeViz.positions || {}; + if (!nodes.length) return; + + for (const edge of edges) { + const source = positions[edge.source]; + const target = positions[edge.target]; + if (!source || !target) continue; + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("x1", source.x); + line.setAttribute("y1", source.y); + line.setAttribute("x2", target.x); + line.setAttribute("y2", target.y); + line.setAttribute("stroke", getCssVar("--line-strong", "#94a3b8")); + line.setAttribute("stroke-width", "3"); + line.setAttribute("opacity", "0.6"); + minimap.appendChild(line); + } + + for (const node of nodes) { + const pos = positions[node.id]; + if (!pos) continue; + const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + dot.setAttribute("cx", pos.x); + dot.setAttribute("cy", pos.y); + dot.setAttribute("r", "7"); + dot.setAttribute("fill", nodeColor(node)); + dot.setAttribute("opacity", "0.85"); + minimap.appendChild(dot); + } + + const viewportRect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + viewportRect.setAttribute("id", "minimap-viewport"); + viewportRect.setAttribute("fill", "none"); + viewportRect.setAttribute("stroke", getCssVar("--accent", "#0f766e")); + viewportRect.setAttribute("stroke-width", "6"); + minimap.appendChild(viewportRect); + updateMinimapViewport(); +} + +function updateMinimapViewport() { + const rect = document.getElementById("minimap-viewport"); + if (!rect) return; + const { scale, panX, panY } = window.KubeViz.view; + const safeScale = scale || 1; + const viewWidth = 1200 / safeScale; + const viewHeight = 720 / safeScale; + const centerX = 600 - panX / safeScale; + const centerY = 360 - panY / safeScale; + const x = centerX - viewWidth / 2; + const y = centerY - viewHeight / 2; + rect.setAttribute("x", x.toFixed(2)); + rect.setAttribute("y", y.toFixed(2)); + rect.setAttribute("width", viewWidth.toFixed(2)); + rect.setAttribute("height", viewHeight.toFixed(2)); +} + +function getCurrentView() { + const relations = []; + document.querySelectorAll('input[name="relations"]:checked').forEach((el) => relations.push(el.value)); + const checkRules = []; + document.querySelectorAll('input[name="checkRules"]:checked').forEach((el) => checkRules.push(el.value)); + + return { + query: document.getElementById("query").value.trim(), + namespace: document.getElementById("namespace").value.trim(), + kind: document.getElementById("kind").value.trim(), + focus: document.getElementById("focus").value.trim(), + groupBy: document.getElementById("groupBy").value, + layoutMode: document.getElementById("layout-mode").value, + relations, + checkRules, + }; +} + +function saveCurrentView() { + const name = document.getElementById("view-name").value.trim(); + if (!name) { + setStatus("Please provide a view name before saving.", "warn"); + return; + } + + const views = readSavedViews(); + const view = { name, config: getCurrentView() }; + const idx = views.findIndex((v) => v.name === name); + if (idx >= 0) views[idx] = view; + else views.push(view); + + localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(views)); + renderSavedViews(name); + setStatus(`Saved view '${name}'.`, "ok"); +} + +function loadSelectedView() { + const selected = document.getElementById("saved-views").value; + if (!selected) return; + + const views = readSavedViews(); + const entry = views.find((v) => v.name === selected); + if (!entry) return; + applySavedView(entry.config); + setStatus(`Loaded view '${entry.name}'.`, "info"); +} + +function deleteSelectedView() { + const selected = document.getElementById("saved-views").value; + if (!selected) return; + const next = readSavedViews().filter((v) => v.name !== selected); + localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(next)); + renderSavedViews(); + setStatus(`Deleted view '${selected}'.`, "info"); +} + +function exportSavedViews() { + const views = readSavedViews(); + if (!views.length) { + setStatus("No saved views to export.", "warn"); + return; + } + + const blob = new Blob([JSON.stringify(views, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "kubeviz-saved-views.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + setStatus(`Exported ${views.length} saved view(s).`, "info"); +} + +async function importSavedViewsFromFile(event) { + const input = event.target; + const file = input.files && input.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const imported = normalizeSavedViews(parsed); + if (!imported.length) { + setStatus("Import file has no valid saved views.", "warn"); + input.value = ""; + return; + } + + const existing = readSavedViews(); + const merged = mergeSavedViews(existing, imported); + localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(merged)); + renderSavedViews(); + setStatus(`Imported ${imported.length} view(s).`, "ok"); + } catch (_err) { + setStatus("Failed to import views JSON.", "error"); + } finally { + input.value = ""; + } +} + +function normalizeSavedViews(value) { + if (!Array.isArray(value)) return []; + return value + .filter((v) => v && typeof v.name === "string" && typeof v.config === "object") + .map((v) => ({ name: v.name.trim(), config: v.config })) + .filter((v) => !!v.name); +} + +function mergeSavedViews(existing, incoming) { + const map = new Map(); + existing.forEach((v) => map.set(v.name, v)); + incoming.forEach((v) => map.set(v.name, v)); + return [...map.values()]; +} + +function applySavedView(config) { + document.getElementById("query").value = config.query || ""; + document.getElementById("namespace").value = config.namespace || ""; + document.getElementById("kind").value = config.kind || ""; + document.getElementById("focus").value = config.focus || ""; + document.getElementById("groupBy").value = config.groupBy || ""; + document.getElementById("layout-mode").value = config.layoutMode || "circular"; + + document.querySelectorAll('input[name="relations"]').forEach((el) => { + el.checked = (config.relations || []).includes(el.value); + }); + document.querySelectorAll('input[name="checkRules"]').forEach((el) => { + el.checked = (config.checkRules || []).includes(el.value); + }); + + window.KubeViz.positions = {}; + fetchAndRenderGraph({ autoNamespace: false, resetLayout: true }); +} + +function renderSavedViews(selectedName = "") { + const select = document.getElementById("saved-views"); + const views = readSavedViews(); + select.innerHTML = ""; + + if (!views.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No saved views"; + select.appendChild(option); + return; + } + + views.forEach((view) => { + const option = document.createElement("option"); + option.value = view.name; + option.textContent = view.name; + if (selectedName && view.name === selectedName) option.selected = true; + select.appendChild(option); + }); +} + +function readSavedViews() { + try { + const raw = localStorage.getItem(SAVED_VIEWS_KEY); + if (!raw) return []; + return normalizeSavedViews(JSON.parse(raw)); + } catch (_err) { + return []; + } +} + +function runPathFinder() { + const source = document.getElementById("path-source").value.trim(); + const target = document.getElementById("path-target").value.trim(); + const bidirectional = document.getElementById("path-bidirectional").checked; + if (!source || !target) { + setPathResult("Please provide source and target resource IDs."); + return; + } + + const path = findPath(source, target, bidirectional); + if (!path) { + clearPathHighlight(false); + setPathResult(`No path found from ${source} to ${target}.`); + return; + } + + const nodeSet = new Set(path.nodes); + const edgeSet = new Set(path.edges.map((e) => edgeKey(e.source, e.target, e.relationType))); + window.KubeViz.path.nodes = nodeSet; + window.KubeViz.path.edges = edgeSet; + window.KubeViz.path.ids = path.nodes; + renderGraph(); + setPathResult(`Path (${path.nodes.length} nodes)`, path.nodes); +} + +function clearPathHighlight(withMessage = true) { + window.KubeViz.path.nodes = new Set(); + window.KubeViz.path.edges = new Set(); + window.KubeViz.path.ids = []; + renderGraph(); + if (withMessage) setPathResult("Path highlight cleared."); +} + +function setPathResult(message, steps = []) { + const box = document.getElementById("path-result"); + box.innerHTML = ""; + + const head = document.createElement("div"); + head.textContent = message; + box.appendChild(head); + + if (!steps.length) return; + + const flow = document.createElement("div"); + flow.className = "path-steps"; + steps.forEach((id, idx) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "path-step"; + btn.textContent = id; + btn.addEventListener("click", async () => { + await onSelectNode(id); + centerOnNode(id); + }); + flow.appendChild(btn); + + if (idx < steps.length - 1) { + const arrow = document.createElement("span"); + arrow.className = "path-arrow"; + arrow.textContent = "->"; + flow.appendChild(arrow); + } + }); + box.appendChild(flow); +} + +function findPath(source, target, bidirectional) { + const { nodes = [], edges = [] } = window.KubeViz.graph; + const nodeIds = new Set(nodes.map((n) => n.id)); + if (!nodeIds.has(source) || !nodeIds.has(target)) return null; + + const adjacency = new Map(); + nodes.forEach((n) => adjacency.set(n.id, [])); + edges.forEach((e) => { + adjacency.get(e.source)?.push({ next: e.target, edge: e }); + if (bidirectional) { + adjacency.get(e.target)?.push({ next: e.source, edge: { ...e, source: e.target, target: e.source } }); + } + }); + + const queue = [source]; + const visited = new Set([source]); + const prev = new Map(); + + while (queue.length) { + const cur = queue.shift(); + if (cur === target) break; + const nexts = adjacency.get(cur) || []; + for (const item of nexts) { + if (visited.has(item.next)) continue; + visited.add(item.next); + prev.set(item.next, { node: cur, edge: item.edge }); + queue.push(item.next); + } + } + + if (!visited.has(target)) return null; + + const outNodes = []; + const outEdges = []; + let cur = target; + while (cur) { + outNodes.push(cur); + const entry = prev.get(cur); + if (!entry) break; + outEdges.push(entry.edge); + cur = entry.node; + } + + outNodes.reverse(); + outEdges.reverse(); + return { nodes: outNodes, edges: outEdges }; +} + +function syncPathInputs(nodeID) { + const source = document.getElementById("path-source"); + const target = document.getElementById("path-target"); + if (!source.value.trim()) { + source.value = nodeID; + return; + } + if (!target.value.trim()) { + target.value = nodeID; + return; + } + source.value = nodeID; +} + +function edgeKey(source, target, relationType) { + return `${source}|${target}|${relationType}`; +} + +function centerOnNode(nodeId) { + const pos = window.KubeViz.positions[nodeId]; + if (!pos) return; + const scale = window.KubeViz.view.scale || 1; + window.KubeViz.view.panX = (600 - pos.x) * scale; + window.KubeViz.view.panY = (360 - pos.y) * scale; + updateViewportTransform(); +} + +function highlightSelected() { + const selectedID = window.KubeViz.selectedId; + const svg = document.getElementById("graph-canvas"); + + svg.querySelectorAll("circle").forEach((circle) => { + if (circle.getAttribute("data-warning") === "true") return; + circle.setAttribute("stroke", "none"); + circle.setAttribute("stroke-width", "0"); + }); + + if (!selectedID) return; + + const group = svg.querySelector(`g[data-node-id="${cssEscape(selectedID)}"]`); + const circle = group ? group.querySelector("circle") : null; + if (circle) { + circle.setAttribute("stroke", "#f59e0b"); + circle.setAttribute("stroke-width", "4"); + } +} + +function updateStats(stats) { + document.getElementById("total-nodes").textContent = String(stats.totalNodes || 0); + document.getElementById("total-edges").textContent = String(stats.totalEdges || 0); +} + +function setSelectedResource(resource) { + const emptyEl = document.getElementById("details-empty"); + const contentEl = document.getElementById("details-content"); + const kindEl = document.getElementById("details-kind"); + const nameEl = document.getElementById("details-name"); + const nsEl = document.getElementById("details-namespace"); + const sensitiveEl = document.getElementById("details-sensitive"); + const rawEl = document.getElementById("details-raw"); + + if (!resource) { + emptyEl.classList.remove("hidden"); + contentEl.classList.add("hidden"); + return; + } + + kindEl.textContent = resource.kind || ""; + nameEl.textContent = resource.name || ""; + nsEl.textContent = resource.namespace || "(cluster-scoped)"; + rawEl.textContent = JSON.stringify(resource.raw || {}, null, 2); + + if (resource.isSensitive) sensitiveEl.classList.remove("hidden"); + else sensitiveEl.classList.add("hidden"); + + emptyEl.classList.add("hidden"); + contentEl.classList.remove("hidden"); +} + +function attachZoomHandlers(svg) { + svg.onwheel = (event) => { + event.preventDefault(); + const factor = event.deltaY < 0 ? 1.1 : 0.9; + setZoom(window.KubeViz.view.scale * factor); + }; +} + +function setZoom(nextScale) { + const view = window.KubeViz.view; + view.scale = Math.max(view.minScale, Math.min(view.maxScale, nextScale)); + updateViewportTransform(); +} + +function resetZoom() { + window.KubeViz.view.scale = 1; + window.KubeViz.view.panX = 0; + window.KubeViz.view.panY = 0; + updateViewportTransform(); +} + +function updateViewportTransform() { + const viewport = document.getElementById("graph-viewport"); + if (!viewport) return; + + const { scale, panX, panY } = window.KubeViz.view; + viewport.setAttribute("transform", `translate(${600 + panX} ${360 + panY}) scale(${scale}) translate(-600 -360)`); + updateMinimapViewport(); +} + +function attachDragAndPanHandlers(svg) { + svg.onpointerdown = (event) => { + if (event.target.closest("g.node")) return; + window.KubeViz.dragging.panning = true; + window.KubeViz.dragging.panStartX = event.clientX; + window.KubeViz.dragging.panStartY = event.clientY; + }; + + svg.onpointermove = (event) => { + const draggingID = window.KubeViz.dragging.nodeId; + if (draggingID) { + const pt = svgPointFromEvent(svg, event); + window.KubeViz.positions[draggingID] = { x: pt.x, y: pt.y }; + refreshEdgeAndNodeGeometry(); + return; + } + + if (window.KubeViz.dragging.panning) { + const dx = event.clientX - window.KubeViz.dragging.panStartX; + const dy = event.clientY - window.KubeViz.dragging.panStartY; + window.KubeViz.dragging.panStartX = event.clientX; + window.KubeViz.dragging.panStartY = event.clientY; + window.KubeViz.view.panX += dx; + window.KubeViz.view.panY += dy; + updateViewportTransform(); + } + }; + + svg.onpointerup = stopPointerInteractions; + svg.onpointerleave = stopPointerInteractions; +} + +function startNodeDrag(event, nodeId) { + event.preventDefault(); + event.stopPropagation(); + window.KubeViz.dragging.nodeId = nodeId; + const circle = event.currentTarget; + if (circle) circle.style.cursor = "grabbing"; +} + +function stopPointerInteractions() { + window.KubeViz.dragging.nodeId = null; + window.KubeViz.dragging.panning = false; + document.querySelectorAll("#graph-canvas circle").forEach((c) => { + c.style.cursor = "grab"; + }); +} + +function refreshEdgeAndNodeGeometry() { + const svg = document.getElementById("graph-canvas"); + const positions = window.KubeViz.positions || {}; + const { edges = [] } = window.KubeViz.graph; + + svg.querySelectorAll("g.node").forEach((group) => { + const nodeId = group.getAttribute("data-node-id"); + const pos = positions[nodeId]; + if (!pos) return; + + const circle = group.querySelector("circle"); + const text = group.querySelector("text"); + if (circle) { + circle.setAttribute("cx", pos.x); + circle.setAttribute("cy", pos.y); + } + if (text) { + text.setAttribute("x", pos.x); + text.setAttribute("y", pos.y + 32); + } + }); + + svg.querySelectorAll("line[data-source][data-target]").forEach((line) => { + const source = positions[line.getAttribute("data-source")]; + const target = positions[line.getAttribute("data-target")]; + if (!source || !target) return; + + line.setAttribute("x1", source.x); + line.setAttribute("y1", source.y); + line.setAttribute("x2", target.x); + line.setAttribute("y2", target.y); + }); + + for (const edge of edges) { + if (!edge.label) continue; + + const source = positions[edge.source]; + const target = positions[edge.target]; + if (!source || !target) continue; + + const selector = `text[data-edge-label="${cssEscape(`${edge.source}|${edge.target}|${edge.relationType}`)}"]`; + const label = svg.querySelector(selector); + if (!label) continue; + + label.setAttribute("x", ((source.x + target.x) / 2).toFixed(1)); + label.setAttribute("y", (((source.y + target.y) / 2) - 6).toFixed(1)); + } +} + +function svgPointFromEvent(svg, event) { + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + + const ctm = svg.getScreenCTM(); + if (!ctm) return { x: 0, y: 0 }; + + const transformed = pt.matrixTransform(ctm.inverse()); + const { scale, panX, panY } = window.KubeViz.view; + return { + x: (transformed.x - (600 + panX)) / scale + 600, + y: (transformed.y - (360 + panY)) / scale + 360, + }; +} + +function showTooltip(clientX, clientY, text) { + const tooltip = document.getElementById("edge-tooltip"); + tooltip.textContent = text; + tooltip.classList.remove("hidden"); + + const panel = document.querySelector(".graph-panel"); + const rect = panel.getBoundingClientRect(); + tooltip.style.left = `${clientX - rect.left + 12}px`; + tooltip.style.top = `${clientY - rect.top + 12}px`; +} + +function hideTooltip() { + document.getElementById("edge-tooltip").classList.add("hidden"); +} + +function resetFiltersToDefault() { + document.getElementById("query").value = ""; + document.getElementById("namespace").value = ""; + document.getElementById("kind").value = ""; + document.getElementById("focus").value = ""; + document.getElementById("groupBy").value = ""; + document.querySelectorAll('input[name="relations"]').forEach((el) => { el.checked = true; }); + window.KubeViz.positions = {}; + window.KubeViz.view.panX = 0; + window.KubeViz.view.panY = 0; +} + +async function runDiff() { + const output = document.getElementById("diff-output"); + const base = document.getElementById("diff-base").value; + const target = document.getElementById("diff-target").value; + + if (!base.trim() || !target.trim()) { + output.textContent = "Please provide both base and target manifests."; + return; + } + + const res = await fetch("/api/diff", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseManifest: base, targetManifest: target }), + }); + + if (!res.ok) { + output.textContent = `Diff failed: ${await res.text()}`; + return; + } + + const diff = await res.json(); + output.innerHTML = ""; + + const summary = document.createElement("div"); + summary.textContent = `Added: ${diff.added.length}, Removed: ${diff.removed.length}, Changed: ${diff.changed.length}`; + output.appendChild(summary); + + appendDiffList(output, "Added", diff.added); + appendDiffList(output, "Removed", diff.removed); + appendDiffList(output, "Changed", diff.changed); +} + +async function importFromGit(mode) { + const repoURL = document.getElementById("git-repo-url").value.trim(); + const ref = document.getElementById("git-ref").value.trim(); + const path = document.getElementById("git-path").value.trim(); + const valuesYAML = document.getElementById("helm-values").value; + + if (!repoURL) { + setStatus("Git repo URL is required.", "warn"); + return; + } + + const payload = { + repoURL, + ref, + path, + sourceType: mode, + valuesYAML, + }; + + setStatus(mode === "helm" ? "Rendering Helm chart from Git..." : "Importing manifests from Git...", "info"); + const res = await fetch("/api/git/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + setStatus(`Git import failed: ${await res.text()}`, "error"); + return; + } + + const data = await res.json(); + const parseIssues = data.summary?.issues || []; + const issues = parseIssues.length; + const resources = data.summary?.resources || 0; + setStatus(`${mode === "helm" ? "Helm rendered" : "Git imported"}: ${resources} resources (${formatIssueCount(issues)}).`, issues > 0 ? "warn" : "ok"); + renderParseIssues(parseIssues); + await fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); +} + +function appendDiffList(root, title, items) { + const header = document.createElement("strong"); + header.textContent = title; + root.appendChild(header); + + const ul = document.createElement("ul"); + ul.style.marginTop = "0.25rem"; + ul.style.marginBottom = "0.5rem"; + for (const item of items.slice(0, 20)) { + const li = document.createElement("li"); + li.textContent = `${item.id} (${item.kind})`; + ul.appendChild(li); + } + if (!items.length) { + const li = document.createElement("li"); + li.textContent = "none"; + ul.appendChild(li); + } + root.appendChild(ul); +} + +function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === "function") { + return window.CSS.escape(value); + } + return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); +} + +function getCssVar(name, fallback = "") { + const value = getComputedStyle(document.documentElement).getPropertyValue(name); + const normalized = value ? value.trim() : ""; + return normalized || fallback; +} + +function isTypingContext(target) { + if (!target || !(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; +} + +function setStatus(message, level = "info") { + const status = document.getElementById("parse-status"); + status.textContent = message; + status.classList.remove("status-error", "status-ok", "status-warn", "status-info"); + status.classList.add(`status-${level}`); +} + +function formatIssueCount(count) { + return `${count} ${count === 1 ? "issue" : "issues"}`; +} + +function renderParseIssues(issues) { + const wrapper = document.getElementById("parse-issues"); + const list = document.getElementById("parse-issues-list"); + if (!wrapper || !list) return; + + list.innerHTML = ""; + if (!issues || issues.length === 0) { + wrapper.classList.add("hidden"); + return; + } + + for (const issue of issues.slice(0, 30)) { + const li = document.createElement("li"); + const docInfo = Number.isFinite(issue.document) && issue.document > 0 ? ` (doc ${issue.document})` : ""; + li.textContent = `${issue.message}${docInfo}`; + list.appendChild(li); + } + wrapper.classList.remove("hidden"); +} diff --git a/internal/httpserver/ui/static/logo.svg b/internal/httpserver/ui/static/logo.svg new file mode 100644 index 0000000..5c512f7 --- /dev/null +++ b/internal/httpserver/ui/static/logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/httpserver/ui/static/styles.css b/internal/httpserver/ui/static/styles.css new file mode 100644 index 0000000..09132fd --- /dev/null +++ b/internal/httpserver/ui/static/styles.css @@ -0,0 +1,971 @@ +:root { + color-scheme: light dark; + --font-sans: "IBM Plex Sans", "Inter", "Segoe UI", sans-serif; + --radius-xs: 8px; + --radius-sm: 10px; + --radius-md: 14px; + --shadow-sm: 0 2px 10px rgba(15, 23, 42, 0.06); + --shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08); + + --bg: #edf2fa; + --bg-elevated: #f7faff; + --panel: #ffffff; + --ink: #12213b; + --muted: #4d5d79; + --line: #c9d6e6; + --line-strong: #aebfd4; + --accent: #0f766e; + --accent-ink: #ffffff; + --chip: #eff5fd; + --canvas-top: #f8fbff; + --canvas-bottom: #edf3fb; + --tooltip-bg: #111827; + --tooltip-ink: #e5e7eb; + --code-bg: #0f172a; + --code-ink: #e2e8f0; + --danger: #b91c1c; + --overlay: rgba(15, 23, 42, 0.45); + --brand-start: #0f172a; + --brand-end: #1d4ed8; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b1220; + --bg-elevated: #101a2e; + --panel: #111c31; + --ink: #e2e8f0; + --muted: #b8c6da; + --line: #274160; + --line-strong: #3b5d87; + --accent: #22c3b7; + --accent-ink: #052323; + --chip: #13253f; + --canvas-top: #0d182b; + --canvas-bottom: #0a1426; + --tooltip-bg: #e2e8f0; + --tooltip-ink: #0b1220; + --code-bg: #0a1426; + --code-ink: #d6e2f4; + --danger: #f87171; + --overlay: rgba(2, 7, 17, 0.58); + --brand-start: #0a1120; + --brand-end: #12326d; + } +} + +:root[data-theme="light"] { + --bg: #edf2fa; + --bg-elevated: #f7faff; + --panel: #ffffff; + --ink: #12213b; + --muted: #4d5d79; + --line: #c9d6e6; + --line-strong: #aebfd4; + --accent: #0f766e; + --accent-ink: #ffffff; + --chip: #eff5fd; + --canvas-top: #f8fbff; + --canvas-bottom: #edf3fb; + --tooltip-bg: #111827; + --tooltip-ink: #e5e7eb; + --code-bg: #0f172a; + --code-ink: #e2e8f0; + --danger: #b91c1c; + --overlay: rgba(15, 23, 42, 0.45); + --brand-start: #0f172a; + --brand-end: #1d4ed8; +} + +:root[data-theme="dark"] { + --bg: #0b1220; + --bg-elevated: #101a2e; + --panel: #111c31; + --ink: #e2e8f0; + --muted: #b8c6da; + --line: #274160; + --line-strong: #3b5d87; + --accent: #22c3b7; + --accent-ink: #052323; + --chip: #13253f; + --canvas-top: #0d182b; + --canvas-bottom: #0a1426; + --tooltip-bg: #e2e8f0; + --tooltip-ink: #0b1220; + --code-bg: #0a1426; + --code-ink: #d6e2f4; + --danger: #f87171; + --overlay: rgba(2, 7, 17, 0.58); + --brand-start: #0a1120; + --brand-end: #12326d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: + radial-gradient(1200px 500px at top right, color-mix(in srgb, var(--accent) 12%, transparent), transparent), + linear-gradient(180deg, var(--bg-elevated), var(--bg)); + color: var(--ink); +} + +.topbar { + padding: 0.85rem 1.2rem; + border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent); + background: linear-gradient(90deg, var(--brand-start), var(--brand-end)); + color: #fff; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.brand-logo { + width: 34px; + height: 34px; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} + +.topbar h1 { + margin: 0; + font-size: 2rem; + line-height: 1; + letter-spacing: -0.03em; +} + +.topbar p { + margin: 0.2rem 0 0; + color: #dbeafe; + font-size: 0.97rem; +} + +.layout { + display: grid; + grid-template-columns: 330px 1fr 360px; + gap: 0.9rem; + padding: 0.9rem; + min-height: calc(100vh - 84px); +} + +.left-panel, +.graph-panel, +.details-panel { + background: color-mix(in srgb, var(--panel) 96%, transparent); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 0.95rem; + box-shadow: var(--shadow-sm); + backdrop-filter: blur(2px); +} + +.tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem; + margin-bottom: 0.6rem; +} + +.tab-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 86%, var(--bg)); + color: var(--muted); + cursor: pointer; + font-weight: 600; +} + +.tab-btn.active { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--panel)); +} + +.tab-icon { + width: 0.58rem; + height: 0.58rem; + border-radius: 999px; + border: 1.5px solid currentColor; + opacity: 0.72; +} + +.tab-btn.active .tab-icon { + background: currentColor; + opacity: 0.95; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +h2, +h3 { + margin: 0.15rem 0 0.65rem; +} + +h2 { + font-size: 2.2rem; + letter-spacing: -0.02em; +} + +h3 { + font-size: 1.02rem; +} + +label, +legend, +summary { + display: block; + margin-top: 0.55rem; + font-weight: 600; + color: var(--muted); + font-size: 0.92rem; +} + +.help-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.05rem; + height: 1.05rem; + margin-left: 0.32rem; + border-radius: 999px; + background: #2563eb; + color: #ffffff; + font-size: 0.72rem; + font-weight: 700; + cursor: help; + position: relative; + vertical-align: middle; + outline: none; +} + +.help-tip:focus { + box-shadow: 0 0 0 2px color-mix(in srgb, #2563eb 35%, transparent); +} + +.help-tooltip { + position: absolute; + left: 1.45rem; + top: -0.25rem; + width: min(420px, 80vw); + padding: 0.5rem 0.6rem; + border: 1px solid color-mix(in srgb, var(--line) 75%, transparent); + border-radius: var(--radius-xs); + background: color-mix(in srgb, var(--panel) 96%, var(--bg)); + color: var(--ink); + font-size: 0.79rem; + line-height: 1.35; + box-shadow: var(--shadow-sm); + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity 120ms ease, transform 120ms ease; + z-index: 20; +} + +.help-tip:hover .help-tooltip, +.help-tip:focus .help-tooltip { + opacity: 1; + transform: translateY(0); +} + +textarea, +input, +select { + width: 100%; + margin-top: 0.2rem; + padding: 0.54rem 0.58rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 90%, var(--bg)); + color: var(--ink); + font-size: 0.97rem; +} + +textarea::placeholder, +input::placeholder { + color: color-mix(in srgb, var(--muted) 55%, transparent); +} + +textarea { + resize: vertical; +} + +textarea:focus, +input:focus, +select:focus, +button:focus, +a:focus { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 1px; +} + +.file-upload { + width: 100%; + margin-top: 0.2rem; + padding: 0.45rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + display: flex; + gap: 0.55rem; + align-items: center; + flex-wrap: wrap; + background: color-mix(in srgb, var(--panel) 88%, var(--bg)); +} + +#file-picker-btn { + background: color-mix(in srgb, var(--panel) 50%, var(--bg)); + color: var(--ink); + border: 1px solid var(--line-strong); +} + +#file-count { + color: var(--muted); + font-size: 0.95rem; + font-weight: 600; +} + +.visually-hidden-file { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +fieldset { + margin: 0.65rem 0; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +fieldset label { + font-weight: 500; + margin-top: 0.3rem; +} + +.advanced-filters { + margin-top: 0.55rem; + padding: 0.2rem 0; +} + +.advanced-filters > summary, +.check-help > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.advanced-filters > summary::-webkit-details-marker, +.check-help > summary::-webkit-details-marker { + display: none; +} + +.advanced-filters > summary::after, +.check-help > summary::after { + content: ""; + width: 0.5rem; + height: 0.5rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + transition: transform 140ms ease; + opacity: 0.75; + margin-top: -0.15rem; +} + +.advanced-filters[open] > summary::after, +.check-help[open] > summary::after { + transform: rotate(225deg); + margin-top: 0.1rem; +} + +.summary-title { + display: inline-flex; + align-items: center; +} + +.row { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin-top: 0.6rem; +} + +.row.compact { + margin-top: 0.45rem; +} + +button, +.exports a, +.chip { + padding: 0.42rem 0.72rem; + border-radius: var(--radius-sm); + text-decoration: none; + cursor: pointer; + font-weight: 600; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; +} + +button:hover, +.exports a:hover { + transform: translateY(-1px); +} + +.btn-primary, +button[type="submit"] { + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-ink); +} + +.exports a { + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-ink); +} + +.btn-primary:hover, +button[type="submit"]:hover { + background: color-mix(in srgb, var(--accent) 90%, #000); + border-color: color-mix(in srgb, var(--accent) 90%, #000); +} + +.btn-secondary, +button[type="button"], +#close-diff, +#close-checks { + border: 1px solid var(--line-strong); + background: color-mix(in srgb, var(--panel) 88%, var(--bg)); + color: var(--accent); +} + +.toggle-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.btn-icon { + width: 0.62rem; + height: 0.62rem; + border-radius: 2px; + border: 1.5px solid currentColor; + position: relative; +} + +.btn-icon::after { + content: ""; + position: absolute; + right: -0.26rem; + top: 0.14rem; + width: 0.26rem; + height: 0.26rem; + border-top: 1.5px solid currentColor; + border-right: 1.5px solid currentColor; + transform: rotate(45deg); +} + +.btn-secondary:hover, +button[type="button"]:hover, +#close-diff:hover, +#close-checks:hover { + border-color: color-mix(in srgb, var(--accent) 25%, var(--line-strong)); + background: color-mix(in srgb, var(--panel) 70%, var(--bg)); +} + +.theme-toggle { + min-width: 132px; +} + +.status { + margin-top: 0.6rem; + font-size: 0.95rem; + border-radius: var(--radius-sm); + padding: 0.25rem 0.1rem; +} + +.status-info { + color: var(--muted); +} + +.status-ok { + color: color-mix(in srgb, var(--accent) 88%, var(--ink)); +} + +.status-warn { + color: #b45309; +} + +.status-error { + color: var(--danger); +} + +.parse-issues { + margin-top: 0.45rem; + padding: 0.5rem 0.55rem; + border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 84%, var(--bg)); +} + +.parse-issues strong { + display: block; + font-size: 0.86rem; + color: var(--muted); +} + +#parse-issues-list { + margin: 0.35rem 0 0; + padding-left: 1rem; +} + +#parse-issues-list li { + margin: 0.2rem 0; + font-size: 0.84rem; + color: var(--ink); + word-break: break-word; +} + +.tool-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.65rem; +} + +.field-hint { + margin: 0.32rem 0 0; + color: var(--muted); + font-size: 0.82rem; +} + +.path-result { + margin-top: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.45rem 0.55rem; + background: color-mix(in srgb, var(--panel) 88%, var(--bg)); + color: var(--muted); + font-size: 0.84rem; + word-break: break-word; +} + +.path-steps { + margin-top: 0.45rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.path-step { + border: 1px solid var(--line-strong); + background: color-mix(in srgb, var(--panel) 86%, var(--bg)); + color: var(--ink); + border-radius: 999px; + padding: 0.2rem 0.48rem; + font-size: 0.78rem; +} + +.path-step:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--line-strong)); + background: color-mix(in srgb, var(--accent) 12%, var(--panel)); +} + +.path-arrow { + color: var(--muted); + font-size: 0.8rem; +} + +.graph-panel { + display: flex; + flex-direction: column; + position: relative; +} + +.toolbar { + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; + color: var(--muted); + margin-bottom: 0.4rem; +} + +.kbd-hint { + margin-left: auto; + font-size: 0.78rem; + color: var(--muted); +} + +.kbd-hint kbd { + border: 1px solid var(--line-strong); + border-bottom-width: 2px; + border-radius: 6px; + padding: 0.08rem 0.3rem; + background: color-mix(in srgb, var(--panel) 85%, var(--bg)); + color: var(--ink); + font-family: var(--font-sans); +} + +.toolbar select { + width: auto; + margin-top: 0; + padding: 0.25rem 0.4rem; +} + +.toolbar button { + padding: 0.25rem 0.55rem; + font-size: 0.85rem; +} + +.chips { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + margin-bottom: 0.45rem; + min-height: 1.7rem; +} + +.chip { + background: var(--chip); + color: var(--muted); + border: 1px solid var(--line); + font-weight: 600; + padding: 0.22rem 0.5rem; +} + +.legend { + display: flex; + gap: 0.7rem; + flex-wrap: wrap; + color: var(--muted); + font-size: 0.85rem; + margin-bottom: 0.45rem; +} + +.dot { + width: 10px; + height: 10px; + display: inline-block; + border-radius: 50%; + margin-right: 0.3rem; + border: 1px solid rgba(15, 23, 42, 0.18); +} + +.kind-workload { + background: #0f766e; +} + +.kind-network { + background: #2563eb; +} + +.kind-config { + background: #14b8a6; +} + +.kind-secret { + background: #dc2626; +} + +.kind-group { + background: #7c3aed; +} + +.health-warning { + background: #f59e0b; +} + +#graph-canvas { + width: 100%; + flex: 1; + min-height: 620px; + border: 1px dashed var(--line); + border-radius: var(--radius-sm); + background: linear-gradient(180deg, var(--canvas-top), var(--canvas-bottom)); +} + +.graph-overlay, +.no-match { + position: absolute; + top: 46%; + left: 50%; + transform: translate(-50%, -50%); + background: color-mix(in srgb, var(--panel) 94%, transparent); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + padding: 0.7rem 0.85rem; + min-width: min(420px, 85%); + display: flex; + flex-direction: column; + gap: 0.45rem; + align-items: flex-start; + box-shadow: var(--shadow-sm); +} + +.graph-overlay span, +.no-match span { + color: var(--muted); + font-size: 0.9rem; +} + +.details-panel pre { + max-height: 72vh; + overflow: auto; + background: var(--code-bg); + color: var(--code-ink); + padding: 0.7rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--line) 30%, transparent); +} + +.sensitive { + color: var(--danger); + font-weight: 700; +} + +.hidden { + display: none; +} + +.edge-label { + font-size: 10px; + fill: var(--muted); + pointer-events: none; + user-select: none; +} + +.findings-list { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + max-height: 55vh; + overflow: auto; +} + +.findings-list li { + padding: 0.35rem 0.45rem; + margin-bottom: 0.35rem; + border: 1px solid var(--line); + border-radius: var(--radius-xs); + font-size: 0.85rem; + background: color-mix(in srgb, var(--panel) 90%, var(--bg)); +} + +.finding-error { + border-color: #ef4444; +} + +.finding-warning { + border-color: #f59e0b; +} + +.check-help { + margin-bottom: 0.55rem; +} + +.check-help p { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.check-config { + max-height: 220px; + overflow: auto; +} + +.collapse-controls { + margin-top: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.45rem; + background: color-mix(in srgb, var(--panel) 85%, var(--bg)); +} + +.collapse-controls label { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.25rem; +} + +.search-suggest { + margin-top: 0.35rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 94%, var(--bg)); + padding: 0.25rem; + box-shadow: var(--shadow-sm); +} + +.suggest-item { + width: 100%; + text-align: left; + border: 1px solid transparent; + background: transparent; + color: var(--ink); + padding: 0.38rem 0.45rem; + border-radius: var(--radius-xs); + cursor: pointer; +} + +.suggest-item:hover, +.suggest-item.active { + border-color: var(--line); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); +} + +.edge-tooltip { + position: absolute; + pointer-events: none; + background: var(--tooltip-bg); + color: var(--tooltip-ink); + border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); + border-radius: var(--radius-xs); + padding: 0.35rem 0.45rem; + font-size: 0.78rem; + max-width: 280px; + z-index: 20; + white-space: pre-line; +} + +.minimap-wrap { + position: absolute; + right: 0.9rem; + bottom: 0.9rem; + width: 220px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 92%, var(--bg)); + box-shadow: var(--shadow-sm); + padding: 0.35rem; +} + +.minimap-wrap strong { + font-size: 0.78rem; + color: var(--muted); +} + +#minimap-canvas { + margin-top: 0.3rem; + width: 100%; + height: 132px; + border-radius: 6px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--canvas-top) 70%, var(--canvas-bottom)); + cursor: crosshair; +} + +.modal { + position: fixed; + inset: 0; + background: var(--overlay); + display: flex; + justify-content: center; + align-items: center; + z-index: 40; +} + +.modal.hidden { + display: none; +} + +.modal-card { + width: min(780px, 92vw); + max-height: 88vh; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 0.8rem; + box-shadow: var(--shadow-md); +} + +.modal-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.diff-output { + margin-top: 0.6rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.5rem; + font-size: 0.85rem; + max-height: 220px; + overflow: auto; + background: color-mix(in srgb, var(--panel) 88%, var(--bg)); +} + +@media (max-width: 1280px) { + .layout { + grid-template-columns: 300px 1fr; + } + + .details-panel { + grid-column: 1 / -1; + } +} + +@media (max-width: 980px) { + .topbar { + flex-direction: column; + align-items: flex-start; + } + + .theme-toggle { + align-self: flex-end; + } + + .layout { + grid-template-columns: 1fr; + } + + #graph-canvas { + min-height: 420px; + } + + .kbd-hint { + width: 100%; + margin-left: 0; + } + + .minimap-wrap { + position: static; + width: 100%; + margin-top: 0.5rem; + } +} diff --git a/internal/httpserver/ui/templates/index.html b/internal/httpserver/ui/templates/index.html new file mode 100644 index 0000000..3f878c8 --- /dev/null +++ b/internal/httpserver/ui/templates/index.html @@ -0,0 +1,291 @@ + + + + + + {{ .Title }} + + + + + +
+
+ +
+

KubeViz

+

Kubernetes manifest relationship visualizer

+
+
+ +
+ +
+
+ + +
+
+ + + + +
+ + + No files selected +
+ +
+ + +
+
+ +
+ +
+ Selected: + none +
+ +
+ + +
+ + + +
+ Import From Git / Helm + + + + + + + + + + + + +
+ + +
+
+
+ +
+
+

Quick Filters

+ + +

Search by name, kind, namespace, or full ID (for example: default/Service/web).

+ +
+ +
+ + + + +
+ Relations + + + + + + +
+ +
+ Advanced Filters + + + + + + + + + + + +
+ +
+ +
+ +

Saved Views

+ + +
+ +
+ + +
+ + +
+
+ + + +
+ +

Path Finder

+ + + + + + + +
+ + +
+
No path calculated.
+ +
+
+
+ +
+
+ Total Nodes: 0 + Total Edges: 0 + + + + + Shortcuts: / Search, d Diff, c Checks +
+ +
+ +
+ Workload + Network + Config + Sensitive + Group + Warning +
+ + + + + +
+ Minimap + +
+
+ + +
+ + + + + + diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..9f874d0 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,121 @@ +package model + +import "time" + +// Resource represents a normalized Kubernetes manifest. +type Resource struct { + ID string `json:"id"` + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` + ClusterScoped bool `json:"clusterScoped"` + Labels map[string]string `json:"labels,omitempty"` + Raw map[string]any `json:"raw"` + IsSensitive bool `json:"isSensitive"` + KeyNames []string `json:"keyNames,omitempty"` + References []ResourceReference `json:"references,omitempty"` + OwnerRefs []OwnerReference `json:"ownerReferences,omitempty"` + WorkloadMeta *WorkloadMetadata `json:"workloadMeta,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type ResourceReference struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Relation string `json:"relation"` +} + +type OwnerReference struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +type WorkloadMetadata struct { + PodTemplateLabels map[string]string `json:"podTemplateLabels,omitempty"` + ServiceSelectors map[string]string `json:"serviceSelectors,omitempty"` +} + +type ParseIssue struct { + Document int `json:"document"` + Message string `json:"message"` +} + +type ParseSummary struct { + Resources int `json:"resources"` + Issues []ParseIssue `json:"issues"` +} + +type Dataset struct { + Resources map[string]*Resource `json:"resources"` + Summary ParseSummary `json:"summary"` + Duplicates []string `json:"duplicates,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ModifiedAt time.Time `json:"modifiedAt"` +} + +type GraphNode struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels,omitempty"` + HealthHint string `json:"healthHint,omitempty"` + IsSensitive bool `json:"isSensitive"` + IsGroup bool `json:"isGroup,omitempty"` + GroupBy string `json:"groupBy,omitempty"` + GroupKey string `json:"groupKey,omitempty"` + MemberCount int `json:"memberCount,omitempty"` +} + +type GraphEdge struct { + Source string `json:"source"` + Target string `json:"target"` + RelationType string `json:"relationType"` + Label string `json:"label,omitempty"` +} + +type GraphStats struct { + TotalNodes int `json:"totalNodes"` + TotalEdges int `json:"totalEdges"` + Kinds map[string]int `json:"kinds"` +} + +type GraphResponse struct { + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` + Stats GraphStats `json:"stats"` + Groups []GraphGroup `json:"groups,omitempty"` + Findings []Finding `json:"findings,omitempty"` +} + +type GraphGroup struct { + Key string `json:"key"` + Label string `json:"label"` + Mode string `json:"mode"` + Count int `json:"count"` + Collapsed bool `json:"collapsed"` +} + +type Finding struct { + ID string `json:"id"` + Category string `json:"category"` + Rule string `json:"rule"` + Severity string `json:"severity"` + ResourceID string `json:"resourceId,omitempty"` + Message string `json:"message"` +} + +type DiffItem struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +type DiffResponse struct { + Added []DiffItem `json:"added"` + Removed []DiffItem `json:"removed"` + Changed []DiffItem `json:"changed"` +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..19816bd --- /dev/null +++ b/internal/parser/parser.go @@ -0,0 +1,524 @@ +package parser + +import ( + "fmt" + "sort" + "strings" + "time" + + "kubeviz/internal/model" +) + +var clusterScopedKinds = map[string]bool{ + "Namespace": true, + "Node": true, + "PersistentVolume": true, + "CustomResourceDefinition": true, + "ClusterRole": true, + "ClusterRoleBinding": true, + "MutatingWebhookConfiguration": true, + "ValidatingWebhookConfiguration": true, + "StorageClass": true, + "PriorityClass": true, + "APIService": true, +} + +func ParseManifests(input []byte) (*model.Dataset, error) { + dataset := &model.Dataset{ + Resources: make(map[string]*model.Resource), + CreatedAt: time.Now(), + } + + docs, err := parseYAMLDocuments(input) + if err != nil { + return nil, err + } + + for docNum, doc := range docs { + if doc == nil { + continue + } + if err := parseDocument(doc, docNum+1, dataset); err != nil { + dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{ + Document: docNum + 1, + Message: err.Error(), + }) + } + } + + dataset.Summary.Resources = len(dataset.Resources) + dataset.ModifiedAt = time.Now() + return dataset, nil +} + +func parseDocument(doc any, docNum int, dataset *model.Dataset) error { + normalized, ok := normalizeMap(doc).(map[string]any) + if !ok { + return fmt.Errorf("document is not an object") + } + + kind, _ := normalized["kind"].(string) + if kind == "List" { + items, _ := normalized["items"].([]any) + for idx, item := range items { + itemMap, ok := normalizeMap(item).(map[string]any) + if !ok { + dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{ + Document: docNum, + Message: fmt.Sprintf("item %d is not an object", idx), + }) + continue + } + res, err := normalizeResource(itemMap) + if err != nil { + dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{ + Document: docNum, + Message: fmt.Sprintf("item %d: %v", idx, err), + }) + continue + } + if _, exists := dataset.Resources[res.ID]; exists { + dataset.Duplicates = append(dataset.Duplicates, res.ID) + dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{ + Document: docNum, + Message: fmt.Sprintf("duplicate resource id %q detected", res.ID), + }) + } + dataset.Resources[res.ID] = res + } + return nil + } + + res, err := normalizeResource(normalized) + if err != nil { + return err + } + if _, exists := dataset.Resources[res.ID]; exists { + dataset.Duplicates = append(dataset.Duplicates, res.ID) + } + dataset.Resources[res.ID] = res + return nil +} + +func normalizeResource(raw map[string]any) (*model.Resource, error) { + apiVersion, _ := raw["apiVersion"].(string) + kind, _ := raw["kind"].(string) + meta, _ := raw["metadata"].(map[string]any) + name, _ := meta["name"].(string) + + if apiVersion == "" { + return nil, fmt.Errorf("missing apiVersion") + } + if kind == "" { + return nil, fmt.Errorf("missing kind") + } + if name == "" { + return nil, fmt.Errorf("missing metadata.name") + } + + namespace := "default" + clusterScoped := clusterScopedKinds[kind] + if ns, ok := meta["namespace"].(string); ok && ns != "" { + namespace = ns + } + if clusterScoped { + namespace = "" + } + + labels := extractStringMap(meta["labels"]) + id := resourceID(namespace, kind, name) + + res := &model.Resource{ + ID: id, + APIVersion: apiVersion, + Kind: kind, + Name: name, + Namespace: namespace, + ClusterScoped: clusterScoped, + Labels: labels, + Raw: deepCopy(raw), + IsSensitive: strings.EqualFold(kind, "Secret"), + CreatedAt: time.Now(), + } + + if res.IsSensitive { + res.KeyNames = extractSecretKeyNames(raw) + redactSecretValues(res.Raw) + } + + res.OwnerRefs = extractOwnerRefs(meta) + res.References = append(res.References, extractGenericRefs(raw, namespace)...) + res.References = append(res.References, extractTypedRefs(raw, kind, namespace)...) + res.WorkloadMeta = extractWorkloadMeta(raw, kind) + res.References = dedupeRefs(res.References) + + return res, nil +} + +func resourceID(namespace, kind, name string) string { + if namespace == "" { + return fmt.Sprintf("%s/%s", kind, name) + } + return fmt.Sprintf("%s/%s/%s", namespace, kind, name) +} + +func ResourceID(namespace, kind, name string) string { + return resourceID(namespace, kind, name) +} + +func normalizeMap(v any) any { + switch t := v.(type) { + case map[string]any: + m := map[string]any{} + for k, val := range t { + m[k] = normalizeMap(val) + } + return m + case map[any]any: + m := map[string]any{} + for k, val := range t { + m[fmt.Sprint(k)] = normalizeMap(val) + } + return m + case []any: + out := make([]any, 0, len(t)) + for _, item := range t { + out = append(out, normalizeMap(item)) + } + return out + default: + return t + } +} + +func extractStringMap(v any) map[string]string { + src, ok := v.(map[string]any) + if !ok { + return nil + } + out := make(map[string]string) + for k, val := range src { + if s, ok := val.(string); ok { + out[k] = s + } + } + if len(out) == 0 { + return nil + } + return out +} + +func extractOwnerRefs(meta map[string]any) []model.OwnerReference { + owners, _ := meta["ownerReferences"].([]any) + out := make([]model.OwnerReference, 0, len(owners)) + for _, entry := range owners { + m, ok := entry.(map[string]any) + if !ok { + continue + } + kind, _ := m["kind"].(string) + name, _ := m["name"].(string) + if kind == "" || name == "" { + continue + } + out = append(out, model.OwnerReference{Kind: kind, Name: name}) + } + return out +} + +func extractSecretKeyNames(raw map[string]any) []string { + keys := map[string]struct{}{} + if data, ok := raw["data"].(map[string]any); ok { + for k := range data { + keys[k] = struct{}{} + } + } + if data, ok := raw["stringData"].(map[string]any); ok { + for k := range data { + keys[k] = struct{}{} + } + } + out := make([]string, 0, len(keys)) + for k := range keys { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func redactSecretValues(raw map[string]any) { + for _, key := range []string{"data", "stringData"} { + data, ok := raw[key].(map[string]any) + if !ok { + continue + } + for k := range data { + data[k] = "" + } + } +} + +func extractWorkloadMeta(raw map[string]any, kind string) *model.WorkloadMetadata { + meta := &model.WorkloadMetadata{} + switch kind { + case "Deployment", "StatefulSet", "DaemonSet": + spec, _ := raw["spec"].(map[string]any) + tpl, _ := spec["template"].(map[string]any) + tplMeta, _ := tpl["metadata"].(map[string]any) + meta.PodTemplateLabels = extractStringMap(tplMeta["labels"]) + case "Service": + spec, _ := raw["spec"].(map[string]any) + meta.ServiceSelectors = extractStringMap(spec["selector"]) + } + + if len(meta.PodTemplateLabels) == 0 && len(meta.ServiceSelectors) == 0 { + return nil + } + return meta +} + +func extractTypedRefs(raw map[string]any, kind, defaultNamespace string) []model.ResourceReference { + refs := make([]model.ResourceReference, 0) + + spec, _ := raw["spec"].(map[string]any) + if spec != nil { + refs = append(refs, extractRefsFromPodSpec(raw, defaultNamespace)...) + + if kind == "Ingress" { + refs = append(refs, extractIngressRefs(spec, defaultNamespace)...) + } + if kind == "HorizontalPodAutoscaler" { + if target, ok := spec["scaleTargetRef"].(map[string]any); ok { + tKind, _ := target["kind"].(string) + tName, _ := target["name"].(string) + if tKind != "" && tName != "" { + refs = append(refs, model.ResourceReference{Kind: tKind, Name: tName, Namespace: defaultNamespace, Relation: "scales"}) + } + } + } + } + + return refs +} + +func extractIngressRefs(spec map[string]any, namespace string) []model.ResourceReference { + refs := []model.ResourceReference{} + if backend, ok := spec["defaultBackend"].(map[string]any); ok { + if svc := serviceFromBackend(backend); svc != "" { + refs = append(refs, model.ResourceReference{Kind: "Service", Name: svc, Namespace: namespace, Relation: "routesTo"}) + } + } + rules, _ := spec["rules"].([]any) + for _, r := range rules { + rule, ok := r.(map[string]any) + if !ok { + continue + } + http, _ := rule["http"].(map[string]any) + paths, _ := http["paths"].([]any) + for _, p := range paths { + path, ok := p.(map[string]any) + if !ok { + continue + } + backend, _ := path["backend"].(map[string]any) + if svc := serviceFromBackend(backend); svc != "" { + refs = append(refs, model.ResourceReference{Kind: "Service", Name: svc, Namespace: namespace, Relation: "routesTo"}) + } + } + } + return refs +} + +func serviceFromBackend(backend map[string]any) string { + svc, _ := backend["service"].(map[string]any) + name, _ := svc["name"].(string) + return name +} + +func extractRefsFromPodSpec(raw map[string]any, namespace string) []model.ResourceReference { + podSpec := findPodSpec(raw) + if podSpec == nil { + return nil + } + refs := []model.ResourceReference{} + if vols, ok := podSpec["volumes"].([]any); ok { + for _, v := range vols { + vol, ok := v.(map[string]any) + if !ok { + continue + } + if cm, ok := vol["configMap"].(map[string]any); ok { + if name, _ := cm["name"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "ConfigMap", Name: name, Namespace: namespace, Relation: "mounts"}) + } + } + if sec, ok := vol["secret"].(map[string]any); ok { + if name, _ := sec["secretName"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "Secret", Name: name, Namespace: namespace, Relation: "mounts"}) + } + } + if pvc, ok := vol["persistentVolumeClaim"].(map[string]any); ok { + if name, _ := pvc["claimName"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "PersistentVolumeClaim", Name: name, Namespace: namespace, Relation: "mounts"}) + } + } + } + } + + for _, containerType := range []string{"containers", "initContainers"} { + containers, _ := podSpec[containerType].([]any) + for _, c := range containers { + container, ok := c.(map[string]any) + if !ok { + continue + } + env, _ := container["env"].([]any) + for _, e := range env { + envVar, ok := e.(map[string]any) + if !ok { + continue + } + valueFrom, _ := envVar["valueFrom"].(map[string]any) + if cmRef, ok := valueFrom["configMapKeyRef"].(map[string]any); ok { + if name, _ := cmRef["name"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "ConfigMap", Name: name, Namespace: namespace, Relation: "references"}) + } + } + if secRef, ok := valueFrom["secretKeyRef"].(map[string]any); ok { + if name, _ := secRef["name"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "Secret", Name: name, Namespace: namespace, Relation: "references"}) + } + } + } + envFrom, _ := container["envFrom"].([]any) + for _, ef := range envFrom { + entry, ok := ef.(map[string]any) + if !ok { + continue + } + if cmRef, ok := entry["configMapRef"].(map[string]any); ok { + if name, _ := cmRef["name"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "ConfigMap", Name: name, Namespace: namespace, Relation: "references"}) + } + } + if secRef, ok := entry["secretRef"].(map[string]any); ok { + if name, _ := secRef["name"].(string); name != "" { + refs = append(refs, model.ResourceReference{Kind: "Secret", Name: name, Namespace: namespace, Relation: "references"}) + } + } + } + } + } + + return refs +} + +func findPodSpec(raw map[string]any) map[string]any { + spec, _ := raw["spec"].(map[string]any) + if spec == nil { + return nil + } + if template, ok := spec["template"].(map[string]any); ok { + if tplSpec, ok := template["spec"].(map[string]any); ok { + return tplSpec + } + } + if containers, ok := spec["containers"]; ok { + if _, valid := containers.([]any); valid { + return spec + } + } + if jobTemplate, ok := spec["jobTemplate"].(map[string]any); ok { + if jtSpec, ok := jobTemplate["spec"].(map[string]any); ok { + if template, ok := jtSpec["template"].(map[string]any); ok { + if tplSpec, ok := template["spec"].(map[string]any); ok { + return tplSpec + } + } + } + } + return nil +} + +func extractGenericRefs(raw map[string]any, namespace string) []model.ResourceReference { + refs := []model.ResourceReference{} + walkMap(raw, func(k string, v any) { + if strings.HasSuffix(k, "Name") { + if name, ok := v.(string); ok && name != "" { + kind := guessKindFromField(k) + if kind != "" { + refs = append(refs, model.ResourceReference{Kind: kind, Name: name, Namespace: namespace, Relation: "references"}) + } + } + } + }) + return refs +} + +func walkMap(v any, fn func(string, any)) { + switch m := v.(type) { + case map[string]any: + for k, value := range m { + fn(k, value) + walkMap(value, fn) + } + case []any: + for _, item := range m { + walkMap(item, fn) + } + } +} + +func guessKindFromField(field string) string { + lower := strings.ToLower(field) + switch { + case strings.Contains(lower, "secret"): + return "Secret" + case strings.Contains(lower, "configmap"): + return "ConfigMap" + case strings.Contains(lower, "service"): + return "Service" + case strings.Contains(lower, "claim"): + return "PersistentVolumeClaim" + default: + return "" + } +} + +func deepCopy(src map[string]any) map[string]any { + out := make(map[string]any, len(src)) + for k, v := range src { + switch typed := v.(type) { + case map[string]any: + out[k] = deepCopy(typed) + case []any: + copied := make([]any, len(typed)) + for i := range typed { + if m, ok := typed[i].(map[string]any); ok { + copied[i] = deepCopy(m) + } else { + copied[i] = typed[i] + } + } + out[k] = copied + default: + out[k] = v + } + } + return out +} + +func dedupeRefs(refs []model.ResourceReference) []model.ResourceReference { + seen := map[string]struct{}{} + out := make([]model.ResourceReference, 0, len(refs)) + for _, ref := range refs { + key := fmt.Sprintf("%s|%s|%s|%s", ref.Kind, ref.Name, ref.Namespace, ref.Relation) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, ref) + } + return out +} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go new file mode 100644 index 0000000..08b56e7 --- /dev/null +++ b/internal/parser/parser_test.go @@ -0,0 +1,145 @@ +package parser + +import ( + "testing" +) + +func TestParseManifestsMultiDocAndSecretRedaction(t *testing.T) { + input := []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: demo +spec: + template: + metadata: + labels: + app: web + spec: + containers: + - name: app + image: nginx + env: + - name: PASSWORD + valueFrom: + secretKeyRef: + name: app-secret + key: password +--- +apiVersion: v1 +kind: Service +metadata: + name: web + namespace: demo +spec: + selector: + app: web +--- +apiVersion: v1 +kind: Secret +metadata: + name: app-secret + namespace: demo +data: + password: c2VjcmV0 +`) + + ds, err := ParseManifests(input) + if err != nil { + t.Fatalf("ParseManifests returned error: %v", err) + } + if got, want := ds.Summary.Resources, 3; got != want { + t.Fatalf("resource count mismatch: got %d want %d", got, want) + } + + sec := ds.Resources["demo/Secret/app-secret"] + if sec == nil { + t.Fatalf("secret not found") + } + if !sec.IsSensitive { + t.Fatalf("secret should be sensitive") + } + data, ok := sec.Raw["data"].(map[string]any) + if !ok { + t.Fatalf("secret data should exist") + } + if got := data["password"]; got != "" { + t.Fatalf("secret value was not redacted: %v", got) + } +} + +func TestParseManifestsInvalidYAML(t *testing.T) { + _, err := ParseManifests([]byte("apiVersion: v1\nkind")) + if err == nil { + t.Fatalf("expected parse error for invalid yaml") + } +} + +func TestParseDeploymentWithEnvFromConfigMapRef(t *testing.T) { + input := []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: kubeviz + namespace: kubeviz +spec: + selector: + matchLabels: + app: kubeviz + template: + metadata: + labels: + app: kubeviz + spec: + containers: + - name: kubeviz + image: kubeviz:latest + envFrom: + - configMapRef: + name: kubeviz-config +`) + + ds, err := ParseManifests(input) + if err != nil { + t.Fatalf("ParseManifests returned error: %v", err) + } + if got, want := ds.Summary.Resources, 1; got != want { + t.Fatalf("resource count mismatch: got %d want %d", got, want) + } +} + +func TestParseIngressTLSHostsAndSecretName(t *testing.T) { + input := []byte(`apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kubeviz + namespace: kubeviz +spec: + tls: + - hosts: + - kubeviz.local + secretName: kubeviz-tls + rules: + - host: kubeviz.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kubeviz + port: + number: 80 +`) + + ds, err := ParseManifests(input) + if err != nil { + t.Fatalf("ParseManifests returned error: %v", err) + } + if got, want := ds.Summary.Resources, 1; got != want { + t.Fatalf("resource count mismatch: got %d want %d", got, want) + } + if len(ds.Summary.Issues) != 0 { + t.Fatalf("expected no parse issues, got: %+v", ds.Summary.Issues) + } +} diff --git a/internal/parser/yamlmini.go b/internal/parser/yamlmini.go new file mode 100644 index 0000000..71e03c4 --- /dev/null +++ b/internal/parser/yamlmini.go @@ -0,0 +1,304 @@ +package parser + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +func parseYAMLDocuments(input []byte) ([]any, error) { + raw := string(input) + docsRaw := splitYAMLDocuments(raw) + docs := make([]any, 0, len(docsRaw)) + for _, doc := range docsRaw { + trimmed := strings.TrimSpace(doc) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + var parsed any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, fmt.Errorf("json parse failed: %w", err) + } + docs = append(docs, parsed) + continue + } + parsed, err := parseYAMLDoc(trimmed) + if err != nil { + return nil, err + } + docs = append(docs, parsed) + } + return docs, nil +} + +func splitYAMLDocuments(input string) []string { + lines := strings.Split(input, "\n") + parts := []string{} + cur := []string{} + for _, line := range lines { + if strings.TrimSpace(line) == "---" { + parts = append(parts, strings.Join(cur, "\n")) + cur = cur[:0] + continue + } + cur = append(cur, line) + } + parts = append(parts, strings.Join(cur, "\n")) + return parts +} + +func parseYAMLDoc(doc string) (any, error) { + lines := preprocessLines(doc) + if len(lines) == 0 { + return nil, nil + } + v, next, err := parseBlock(lines, 0, 0) + if err != nil { + return nil, err + } + if next < len(lines) { + return nil, fmt.Errorf("unexpected content near line %d", lines[next].num) + } + return v, nil +} + +type yamlLine struct { + num int + indent int + text string +} + +func preprocessLines(doc string) []yamlLine { + raw := strings.Split(doc, "\n") + out := make([]yamlLine, 0, len(raw)) + for i, line := range raw { + if strings.TrimSpace(line) == "" { + continue + } + clean := stripInlineComment(line) + if strings.TrimSpace(clean) == "" { + continue + } + indent := countIndent(clean) + text := strings.TrimSpace(clean) + out = append(out, yamlLine{num: i + 1, indent: indent, text: text}) + } + return out +} + +func parseBlock(lines []yamlLine, idx int, indent int) (any, int, error) { + if idx >= len(lines) { + return nil, idx, nil + } + if lines[idx].indent < indent { + return nil, idx, nil + } + + if strings.HasPrefix(lines[idx].text, "- ") || lines[idx].text == "-" { + return parseList(lines, idx, indent) + } + return parseMap(lines, idx, indent) +} + +func parseMap(lines []yamlLine, idx int, indent int) (map[string]any, int, error) { + out := map[string]any{} + for idx < len(lines) { + line := lines[idx] + if line.indent < indent { + break + } + if line.indent > indent { + break + } + if strings.HasPrefix(line.text, "- ") || line.text == "-" { + break + } + key, value, valid, hasValue := splitKeyValue(line.text) + if !valid { + return nil, idx, fmt.Errorf("line %d: invalid mapping", line.num) + } + if hasValue { + out[key] = parseScalar(value) + idx++ + continue + } + + childIndent := indent + 2 + if idx+1 < len(lines) && lines[idx+1].indent >= indent { + childIndent = lines[idx+1].indent + } + nested, next, err := parseBlock(lines, idx+1, childIndent) + if err != nil { + return nil, idx, err + } + out[key] = nested + idx = next + } + return out, idx, nil +} + +func parseList(lines []yamlLine, idx int, indent int) ([]any, int, error) { + out := []any{} + for idx < len(lines) { + line := lines[idx] + if line.indent < indent { + break + } + if line.indent > indent { + break + } + if !strings.HasPrefix(line.text, "-") { + break + } + itemText := strings.TrimSpace(strings.TrimPrefix(line.text, "-")) + idx++ + if itemText == "" { + nested, next, err := parseBlock(lines, idx, indent+2) + if err != nil { + return nil, idx, err + } + out = append(out, nested) + idx = next + continue + } + + if k, v, valid, hasValue := splitKeyValue(itemText); valid { + m := map[string]any{} + if hasValue { + m[k] = parseScalar(v) + if idx < len(lines) && lines[idx].indent > indent { + nested, next, err := parseBlock(lines, idx, indent+2) + if err != nil { + return nil, idx, err + } + if nestedMap, ok := nested.(map[string]any); ok { + for nk, nv := range nestedMap { + m[nk] = nv + } + } + idx = next + } + } else { + childIndent := indent + 2 + if idx < len(lines) && lines[idx].indent >= indent { + childIndent = lines[idx].indent + } + nested, next, err := parseBlock(lines, idx, childIndent) + if err != nil { + return nil, idx, err + } + m[k] = nested + idx = next + // YAML list items may continue with sibling keys after an inline key without value: + // - hosts: + // - example.local + // secretName: demo-tls + if idx < len(lines) && lines[idx].indent >= childIndent && !strings.HasPrefix(lines[idx].text, "-") { + extra, nextMap, err := parseMap(lines, idx, childIndent) + if err != nil { + return nil, idx, err + } + for ek, ev := range extra { + m[ek] = ev + } + idx = nextMap + } + } + out = append(out, m) + continue + } + + out = append(out, parseScalar(itemText)) + } + return out, idx, nil +} + +func splitKeyValue(text string) (string, string, bool, bool) { + parts := strings.SplitN(text, ":", 2) + if len(parts) != 2 { + return "", "", false, false + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if key == "" { + return "", "", false, false + } + if value == "" { + return key, "", true, false + } + return key, value, true, true +} + +func parseScalar(s string) any { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") && len(s) >= 2 { + return strings.Trim(s, "\"") + } + if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") && len(s) >= 2 { + return strings.Trim(s, "'") + } + lower := strings.ToLower(s) + if lower == "true" { + return true + } + if lower == "false" { + return false + } + if lower == "null" || s == "~" { + return nil + } + if i, err := strconv.Atoi(s); err == nil { + return i + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + if strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[") { + var out any + if err := json.Unmarshal([]byte(s), &out); err == nil { + return out + } + } + return s +} + +func stripInlineComment(line string) string { + inSingle := false + inDouble := false + for i, r := range line { + switch r { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case '#': + if !inSingle && !inDouble { + if i == 0 || line[i-1] == ' ' || line[i-1] == '\t' { + return strings.TrimRight(line[:i], " \t") + } + } + } + } + return line +} + +func countIndent(line string) int { + count := 0 + for _, r := range line { + if r == ' ' { + count++ + continue + } + break + } + return count +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..7493dfb --- /dev/null +++ b/internal/session/store.go @@ -0,0 +1,95 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" + + "kubeviz/internal/model" +) + +type sessionData struct { + Dataset *model.Dataset + LastAccess time.Time +} + +type Store struct { + mu sync.RWMutex + sessions map[string]*sessionData + ttl time.Duration + stopCh chan struct{} +} + +func NewStore(ttl time.Duration) *Store { + s := &Store{ + sessions: make(map[string]*sessionData), + ttl: ttl, + stopCh: make(chan struct{}), + } + go s.cleanupLoop() + return s +} + +func (s *Store) Stop() { + close(s.stopCh) +} + +func (s *Store) NewSessionID() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func (s *Store) GetDataset(sessionID string) *model.Dataset { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.sessions[sessionID] + if !ok { + return nil + } + entry.LastAccess = time.Now() + return entry.Dataset +} + +func (s *Store) SetDataset(sessionID string, ds *model.Dataset) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sessionID] = &sessionData{Dataset: ds, LastAccess: time.Now()} +} + +func (s *Store) Clear(sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) +} + +func (s *Store) cleanupLoop() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.cleanupExpired() + } + } +} + +func (s *Store) cleanupExpired() { + if s.ttl <= 0 { + return + } + cutoff := time.Now().Add(-s.ttl) + s.mu.Lock() + defer s.mu.Unlock() + for id, sess := range s.sessions { + if sess.LastAccess.Before(cutoff) { + delete(s.sessions, id) + } + } +} diff --git a/scripts/deploy-with-podman.sh b/scripts/deploy-with-podman.sh new file mode 100755 index 0000000..c4f361e --- /dev/null +++ b/scripts/deploy-with-podman.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_REPO="${IMAGE_REPO:-localhost/kubeviz}" +IMAGE_TAG="${IMAGE_TAG:-prod}" +SERVICE_NAME="${SERVICE_NAME:-kubeviz.service}" + +if git rev-parse --short=12 HEAD >/dev/null 2>&1; then + BUILD_ID="$(git rev-parse --short=12 HEAD)" +else + BUILD_ID="$(date +%s)" +fi + +SOURCE_IMAGE="${IMAGE_REPO}:ci-${BUILD_ID}" +RELEASE_IMAGE="${IMAGE_REPO}:${IMAGE_TAG}" + +echo "Building ${SOURCE_IMAGE}" +sudo podman build --pull=always -t "${SOURCE_IMAGE}" . + +echo "Tagging ${RELEASE_IMAGE}" +sudo podman tag "${SOURCE_IMAGE}" "${RELEASE_IMAGE}" + +echo "Restarting ${SERVICE_NAME}" +sudo systemctl restart "${SERVICE_NAME}" +sudo systemctl is-active --quiet "${SERVICE_NAME}" + +echo "Deployment successful: ${RELEASE_IMAGE}" diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..5912668 --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,20 @@ +# KubeViz Testdaten + +Dieses Verzeichnis enthält sofort nutzbare Testdaten für: +- Multi-File Upload (`testdata/manifests/...`) +- Fehlertolerantes Parsing mit defekten Dateien (`testdata/manifests/partials`) +- Secret-Redaction (`testdata/manifests/sensitive`) +- Helm-Rendering (`testdata/helm/kubeviz-sample`) + +## Empfohlene Tests + +1. Upload `testdata/manifests/kube-stack/*.yaml` +2. Upload `testdata/manifests/partials/*.yaml` (enthält eine defekte Datei) +3. Upload `testdata/manifests/sensitive/*.yaml` und prüfe Secret-Redaction +4. Git/Helm: nutze das Chart unter `testdata/helm/kubeviz-sample` + +## Helm lokal testen + +```bash +helm template demo ./testdata/helm/kubeviz-sample --namespace demo +``` diff --git a/testdata/helm/kubeviz-sample/Chart.yaml b/testdata/helm/kubeviz-sample/Chart.yaml new file mode 100644 index 0000000..40761c0 --- /dev/null +++ b/testdata/helm/kubeviz-sample/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: kubeviz-sample +description: Sample chart for KubeViz graph testing +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/testdata/helm/kubeviz-sample/templates/_helpers.tpl b/testdata/helm/kubeviz-sample/templates/_helpers.tpl new file mode 100644 index 0000000..3948354 --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/_helpers.tpl @@ -0,0 +1,24 @@ +{{- define "kubeviz-sample.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "kubeviz-sample.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name (include "kubeviz-sample.name" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "kubeviz-sample.labels" -}} +app.kubernetes.io/name: {{ include "kubeviz-sample.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "kubeviz-sample.namespace" -}} +{{- if .Values.namespaceOverride -}} +{{- .Values.namespaceOverride -}} +{{- else -}} +{{- .Release.Namespace -}} +{{- end -}} +{{- end -}} diff --git a/testdata/helm/kubeviz-sample/templates/configmap.yaml b/testdata/helm/kubeviz-sample/templates/configmap.yaml new file mode 100644 index 0000000..00074f8 --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kubeviz-sample.fullname" . }}-config + namespace: {{ include "kubeviz-sample.namespace" . }} + labels: + {{- include "kubeviz-sample.labels" . | nindent 4 }} +data: + APP_MODE: {{ .Values.config.appMode | quote }} diff --git a/testdata/helm/kubeviz-sample/templates/deployment.yaml b/testdata/helm/kubeviz-sample/templates/deployment.yaml new file mode 100644 index 0000000..6b03c7e --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/deployment.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kubeviz-sample.fullname" . }} + namespace: {{ include "kubeviz-sample.namespace" . }} + labels: + {{- include "kubeviz-sample.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "kubeviz-sample.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "kubeviz-sample.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + - name: APP_MODE + valueFrom: + configMapKeyRef: + name: {{ include "kubeviz-sample.fullname" . }}-config + key: APP_MODE + - name: API_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "kubeviz-sample.fullname" . }}-secret + key: API_TOKEN diff --git a/testdata/helm/kubeviz-sample/templates/hpa.yaml b/testdata/helm/kubeviz-sample/templates/hpa.yaml new file mode 100644 index 0000000..c13682f --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.hpa.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "kubeviz-sample.fullname" . }} + namespace: {{ include "kubeviz-sample.namespace" . }} + labels: + {{- include "kubeviz-sample.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "kubeviz-sample.fullname" . }} + minReplicas: {{ .Values.hpa.minReplicas }} + maxReplicas: {{ .Values.hpa.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.hpa.averageCPUUtilization }} +{{- end }} diff --git a/testdata/helm/kubeviz-sample/templates/ingress.yaml b/testdata/helm/kubeviz-sample/templates/ingress.yaml new file mode 100644 index 0000000..8f49606 --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/ingress.yaml @@ -0,0 +1,24 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "kubeviz-sample.fullname" . }} + namespace: {{ include "kubeviz-sample.namespace" . }} + labels: + {{- include "kubeviz-sample.labels" . | nindent 4 }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: Prefix + backend: + service: + name: {{ include "kubeviz-sample.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/testdata/helm/kubeviz-sample/templates/secret.yaml b/testdata/helm/kubeviz-sample/templates/secret.yaml new file mode 100644 index 0000000..f3697a6 --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "kubeviz-sample.fullname" . }}-secret + namespace: {{ include "kubeviz-sample.namespace" . }} + labels: + {{- include "kubeviz-sample.labels" . | nindent 4 }} +type: Opaque +stringData: + API_TOKEN: {{ .Values.secret.apiToken | quote }} diff --git a/testdata/helm/kubeviz-sample/templates/service.yaml b/testdata/helm/kubeviz-sample/templates/service.yaml new file mode 100644 index 0000000..8a98f0f --- /dev/null +++ b/testdata/helm/kubeviz-sample/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kubeviz-sample.fullname" . }} + namespace: {{ include "kubeviz-sample.namespace" . }} + labels: + {{- include "kubeviz-sample.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "kubeviz-sample.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: {{ .Values.service.protocol }} diff --git a/testdata/helm/kubeviz-sample/values.yaml b/testdata/helm/kubeviz-sample/values.yaml new file mode 100644 index 0000000..879d384 --- /dev/null +++ b/testdata/helm/kubeviz-sample/values.yaml @@ -0,0 +1,35 @@ +nameOverride: "" +fullnameOverride: "" + +namespaceOverride: "" + +replicaCount: 2 + +image: + repository: nginx + tag: "1.27" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + targetPort: 8080 + protocol: TCP + +ingress: + enabled: true + className: "" + host: chart.demo.local + path: / + +config: + appMode: production + +secret: + apiToken: change-me + +hpa: + enabled: true + minReplicas: 2 + maxReplicas: 5 + averageCPUUtilization: 70 diff --git a/testdata/manifests/kube-stack/00-namespace.yaml b/testdata/manifests/kube-stack/00-namespace.yaml new file mode 100644 index 0000000..18434a6 --- /dev/null +++ b/testdata/manifests/kube-stack/00-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: demo diff --git a/testdata/manifests/kube-stack/10-configmap.yaml b/testdata/manifests/kube-stack/10-configmap.yaml new file mode 100644 index 0000000..508b9dc --- /dev/null +++ b/testdata/manifests/kube-stack/10-configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web-config + namespace: demo +data: + APP_MODE: production + FEATURE_FLAG_X: "true" diff --git a/testdata/manifests/kube-stack/20-secret.yaml b/testdata/manifests/kube-stack/20-secret.yaml new file mode 100644 index 0000000..7383e00 --- /dev/null +++ b/testdata/manifests/kube-stack/20-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: web-secret + namespace: demo +type: Opaque +stringData: + DB_PASSWORD: super-secret + API_TOKEN: please-redact diff --git a/testdata/manifests/kube-stack/30-pvc.yaml b/testdata/manifests/kube-stack/30-pvc.yaml new file mode 100644 index 0000000..8be0a6e --- /dev/null +++ b/testdata/manifests/kube-stack/30-pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: web-data + namespace: demo +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/testdata/manifests/kube-stack/40-deployment.yaml b/testdata/manifests/kube-stack/40-deployment.yaml new file mode 100644 index 0000000..c5e525f --- /dev/null +++ b/testdata/manifests/kube-stack/40-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: demo +spec: + replicas: 2 + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: nginx:1.27 + ports: + - containerPort: 8080 + env: + - name: APP_MODE + valueFrom: + configMapKeyRef: + name: web-config + key: APP_MODE + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: web-secret + key: DB_PASSWORD + volumeMounts: + - name: data + mountPath: /var/lib/data + volumes: + - name: data + persistentVolumeClaim: + claimName: web-data diff --git a/testdata/manifests/kube-stack/50-service.yaml b/testdata/manifests/kube-stack/50-service.yaml new file mode 100644 index 0000000..54dce3d --- /dev/null +++ b/testdata/manifests/kube-stack/50-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: web + namespace: demo +spec: + selector: + app: web + ports: + - name: http + port: 80 + targetPort: 8080 + protocol: TCP diff --git a/testdata/manifests/kube-stack/60-ingress.yaml b/testdata/manifests/kube-stack/60-ingress.yaml new file mode 100644 index 0000000..7339cc8 --- /dev/null +++ b/testdata/manifests/kube-stack/60-ingress.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: web + namespace: demo +spec: + rules: + - host: web.demo.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: web + port: + number: 80 diff --git a/testdata/manifests/kube-stack/70-hpa.yaml b/testdata/manifests/kube-stack/70-hpa.yaml new file mode 100644 index 0000000..1c85ad9 --- /dev/null +++ b/testdata/manifests/kube-stack/70-hpa.yaml @@ -0,0 +1,19 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: web + namespace: demo +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: web + minReplicas: 2 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 diff --git a/testdata/manifests/partials/broken.yaml b/testdata/manifests/partials/broken.yaml new file mode 100644 index 0000000..541a9cf --- /dev/null +++ b/testdata/manifests/partials/broken.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind Service +metadata: + name: broken + namespace: partial diff --git a/testdata/manifests/partials/ok-deployment.yaml b/testdata/manifests/partials/ok-deployment.yaml new file mode 100644 index 0000000..51811bc --- /dev/null +++ b/testdata/manifests/partials/ok-deployment.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: partial-ok + namespace: partial +spec: + replicas: 1 + selector: + matchLabels: + app: partial-ok + template: + metadata: + labels: + app: partial-ok + spec: + containers: + - name: app + image: nginx:1.27 + ports: + - containerPort: 8080 diff --git a/testdata/manifests/partials/ok-service.yaml b/testdata/manifests/partials/ok-service.yaml new file mode 100644 index 0000000..87d7647 --- /dev/null +++ b/testdata/manifests/partials/ok-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: partial-ok + namespace: partial +spec: + selector: + app: partial-ok + ports: + - port: 80 + targetPort: 8080 diff --git a/testdata/manifests/sensitive/secret-only.yaml b/testdata/manifests/sensitive/secret-only.yaml new file mode 100644 index 0000000..fd33775 --- /dev/null +++ b/testdata/manifests/sensitive/secret-only.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: only-secret + namespace: sensitive +type: Opaque +data: + password: c3VwZXJzZWNyZXQ= + token: c2hvdWxkLWJlLXJlZGFjdGVk