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"}) }