diff --git a/.agents/skills/add-lang/SKILL.md b/.agents/skills/add-lang/SKILL.md new file mode 100644 index 000000000..41df7553d --- /dev/null +++ b/.agents/skills/add-lang/SKILL.md @@ -0,0 +1,219 @@ +--- +name: add-lang +description: Add tree-sitter language support to codegraph end-to-end — wire the grammar + extractor, write tests, then benchmark extraction quality and retrieval value on 3 popular real-world repos. Use when the user runs /add-lang or asks to add/support a new language (e.g. Lua, Elixir, Zig, OCaml) in codegraph. +--- + +# Add a language to CodeGraph + +Wire a new tree-sitter language into codegraph's extraction pipeline, prove it +extracts real symbols on popular repos, and prove it beats no-codegraph for an +agent. Runs **fully autonomously** — pick repos, benchmark, update docs, then +report. **Never commit, push, publish, or tag** (house rule); leave all changes +for the user to review. + +The argument is the language token used throughout the `Language` union, e.g. +`lua`, `elixir`, `zig`. If none was given, ask which language. Use the lowercase +single-token form everywhere (`csharp`, not `c#`). + +## Prerequisites +- Run from the codegraph repo root. `node`, `git`, `gh`, and a logged-in + `Codex` CLI (the benchmark spawns real `Codex -p` runs). +- The benchmark uses the local dev build — Step 8 builds + links it on PATH. + +## Workflow + +Copy this checklist and work through it in order: +``` +- [ ] 1. Resolve language; bail early if already supported (just benchmark) +- [ ] 2. Find a grammar + health-check it (ABI / heap corruption) +- [ ] 3. Discover the grammar's AST node types (dump-ast.mjs) +- [ ] 4. Wire the language (4 files; sometimes a 5th core touch) +- [ ] 5. Build + verify-extraction loop until PASS +- [ ] 6. Add extraction tests; make them green +- [ ] 7. Auto-pick 3 popular repos by size tier; add to corpus.json +- [ ] 8. Benchmark all 3: extraction + with/without A/B +- [ ] 9. Update README + CHANGELOG +- [ ] 10. Report; do NOT commit +``` + +### Step 1 — Resolve + short-circuit + +Check whether the language is already wired: look for the token in the +`LANGUAGES` const (`src/types.ts`) and the `EXTRACTORS` map +(`src/extraction/languages/index.ts`). If it is already supported (e.g. +`typescript`, `rust`), **skip Steps 2–6** and go straight to benchmarking +(Steps 7–8) to validate/measure it — note in the report that no code changed. + +### Step 2 — Find a grammar, then health-check it + +```bash +ls node_modules/tree-sitter-wasms/out/ | grep -i # csharp -> c_sharp +``` +- **Present** → likely off-the-shelf; `grammars.ts` resolves it from + `tree-sitter-wasms` automatically. (Many languages: elixir, zig, ocaml, + solidity, toml, yaml, …) +- **Absent** → vendor a `.wasm` into `src/extraction/wasm/` (like `pascal` / + `scala` / `lua`) and add the token to the vendored branch in Step 4. + +**Always health-check before writing an extractor — a *present* grammar can +still be unusable:** +```bash +node scripts/add-lang/check-grammar.mjs path/to/valid-sample. +``` +It prints the grammar's ABI version and parses a valid sample many times in a +multi-grammar runtime. If it **FAILs** (ERROR trees on valid code — an old ABI +corrupting the shared WASM heap, which silently drops nested calls/imports on +every file after the first; e.g. the tree-sitter-wasms **Lua** grammar is ABI 13 +and fails), do NOT use that wasm. **Vendor a newer (ABI 14/15) build instead:** +```bash +npm pack @tree-sitter-grammars/tree-sitter- # often ships a prebuilt *.wasm +# or build one: npx tree-sitter build --wasm (needs Docker/emscripten) +cp .wasm src/extraction/wasm/tree-sitter-.wasm +``` +then add the token to the vendored branch in Step 4 and re-run check-grammar on +the vendored path until it PASSes. **If you cannot obtain a healthy wasm, STOP +and tell the user.** + +### Step 3 — Discover AST node types + +Get a representative source file (write a small sample covering functions, +classes/structs, imports, enums; or `curl` a raw file from a known repo), then: +```bash +node scripts/add-lang/dump-ast.mjs path/to/sample. +# vendored grammar: pass the wasm path instead of the token +node scripts/add-lang/dump-ast.mjs src/extraction/wasm/tree-sitter-.wasm sample. +``` +The frequency table + field names (`name:`, `parameters:`, `body:`, +`return_type:`) tell you what to map. Open the existing extractor closest to the +language's paradigm as a model: `rust.ts`/`scala.ts` (functional, traits), +`java.ts`/`csharp.ts` (OO), `python.ts`/`ruby.ts` (scripting), `go.ts` +(top-level methods + receivers). + +### Step 4 — Wire the language (4 files) + +These are exact, fragile wiring — match the existing style precisely: + +1. **`src/types.ts`** — TWO edits: + - add `'',` to the `LANGUAGES` const (before `'unknown'`); + - add `'**/*.',` to `DEFAULT_CONFIG.include`. **Don't skip this** — it's + the file-scan allowlist; without the glob, `codegraph init` finds **0 + files** even though detection/extraction are wired. +2. **`src/extraction/grammars.ts`** — three maps: + - `WASM_GRAMMAR_FILES`: `: 'tree-sitter-.wasm',` + - `EXTENSION_MAP`: each file extension → `''` (e.g. `'.lua': 'lua',`) + - `getLanguageDisplayName`: `: '',` + - **vendored only**: add `` to the + `(lang === 'pascal' || lang === 'scala' || …)` wasm-path branch. +3. **`src/extraction/languages/.ts`** — new file exporting + `export const Extractor: LanguageExtractor = { … }`. Map the node types + from Step 3. Required fields: `functionTypes`, `classTypes`, `methodTypes`, + `interfaceTypes`, `structTypes`, `enumTypes`, `typeAliasTypes`, + `importTypes`, `callTypes`, `variableTypes`, `nameField`, `bodyField`, + `paramsField`. Add hooks as the grammar needs them (`getSignature`, + `getVisibility`, `isExported`, `extractImport`, `visitNode`, `getReceiverType`, + `interfaceKind`, `enumMemberTypes`, etc. — see + `src/extraction/tree-sitter-types.ts`). +4. **`src/extraction/languages/index.ts`** — `import { Extractor } from + './';` and add `: Extractor,` to `EXTRACTORS`. + +**Sometimes a 5th, core touch in `src/extraction/tree-sitter.ts`** — variable +extraction has per-language branches in `extractVariable` (the generic fallback +only finds direct `identifier`/`variable_declarator` children). If the grammar +nests declared names (e.g. Lua's `variable_declaration → variable_list`), add a +`} else if (this.language === '')` branch there, mirroring the existing +ts/python/go ones. Import forms that aren't a distinct node (Lua/Ruby `require` +is a *call*) are handled in the extractor's `visitNode` hook instead. + +### Step 5 — Build + verify loop + +```bash +npm run build # tsc + copy-assets (copies any vendored *.wasm into dist/) +``` +Index a small sample repo and check extraction: +```bash +( cd && codegraph init -i ) +node scripts/add-lang/verify-extraction.mjs +``` +`verify-extraction.mjs` fails (exit 1) if the language isn't detected or only +`file`/`import` nodes were produced — the classic symptom of wrong node-type +names. On FAIL or a thin WARN: re-run `dump-ast.mjs` on a richer file, fix the +mappings in `.ts`, `npm run build`, re-index, re-verify. **Repeat until +PASS.** + +### Step 6 — Tests + +Add to `__tests__/extraction.test.ts`, modeled on the `Rust Extraction` block: +- a `detectLanguage` assertion in `describe('Language Detection')` +- a `describe(' Extraction')` block asserting functions/classes/imports + are extracted from an inline source string. +```bash +npx vitest run __tests__/extraction.test.ts +``` +Green before continuing. + +### Step 7 — Auto-pick 3 repos + corpus + +Pick **without asking**. Find candidates, then curate 3 that are genuinely +``-dominant, one per size tier: +```bash +gh search repos --language= --sort=stars --limit 40 \ + --json fullName,stargazerCount,description +``` +Tiers (match `corpus.json`): **Small** <~150 files · **Medium** ~150–1500 · +**Large** >~1500. Skip repos that are tagged `` but mostly another +language. Write one cross-file architecture **question** per repo (the kind that +needs tracing across files). Add a `""` block to +`.Codex/skills/agent-eval/corpus.json` (fields: `name`, `repo`, `size`, +`files`, `question`) so `/agent-eval` can reuse them. + +### Step 8 — Benchmark all 3 (extraction + A/B) + +Make the dev build the codegraph on PATH **once**, then loop: +```bash +npm run build && ./scripts/local-install.sh +scripts/add-lang/bench.sh "" headless # ×3 +``` +`bench.sh` clones (shared `/tmp/codegraph-corpus`), wipes + indexes, runs +`verify-extraction.mjs`, then the with/without retrieval A/B via +`scripts/agent-eval/run-all.sh` (skips the paid A/B if extraction is broken). +Read each `parse-run.mjs` summary printed by `run-all.sh`: tool calls, file +`Read`s, Grep/Bash, codegraph-tool calls, duration, and **cost** — for both the +`with` and `without` arms. After the loop, restore the dev link if needed: +`./scripts/local-install.sh`. + +### Step 9 — Docs + CHANGELOG + +- **README.md**: add `` to the "19+ Languages" feature bullet, and add a + row to the **Supported Languages** table: + `| | \`.ext\` | Full support (classes, methods, …) |`. +- **CHANGELOG.md**: add an `## [Unreleased]` section at the top (above the + latest version) with `### Added` → a user-perspective bullet, e.g. + *"CodeGraph now indexes **** (`.ext`) — functions, classes, imports, and + call edges."* If `## [Unreleased]` already exists, append under it. (It's + folded into the next versioned block at release time.) + +### Step 10 — Report (do NOT commit) + +Summarize for review: +- **Files changed**: the 4 wiring edits + new extractor + tests + README + + CHANGELOG + corpus.json (+ any vendored `.wasm`). +- **Extraction** per repo: files / nodes / edges / `verify-extraction` result. +- **A/B** per repo: `with` vs `without` (tool calls, file Reads, cost) and a + one-line verdict — did codegraph reduce effort, and did both arms reach a + correct answer? +- **Gaps / follow-ups** (node types not yet mapped, resolution edges missing, + framework routes, etc.). + +Hand the changes to the user. **Do not** run `git commit`/`push` or publish — +releases go through the GitHub Actions Release workflow. + +## Notes +- The A/B spawns real **paid** `Codex -p` runs (opus, `--max-budget-usd`), + 2 arms × 3 repos. The corpus dir `/tmp/codegraph-corpus` is shared with + `/agent-eval`, so clones are reused across runs. +- Any new `*.wasm` must live in `src/extraction/wasm/` — `copy-assets` (run by + `npm run build`) ships it; otherwise it won't be in `dist/`. +- An index must be served by the **same** binary that built it. Step 8 builds + + links the dev build first, so this holds. +- If a grammar can't be obtained, or extraction can't reach PASS, **STOP and + report** — don't ship a half-wired language. diff --git a/.agents/skills/agent-eval/SKILL.md b/.agents/skills/agent-eval/SKILL.md new file mode 100644 index 000000000..d38615fdb --- /dev/null +++ b/.agents/skills/agent-eval/SKILL.md @@ -0,0 +1,74 @@ +--- +name: agent-eval +description: Benchmark CodeGraph retrieval quality on a real codebase by comparing agent behavior with vs without CodeGraph. Use when the user runs /agent-eval or asks to test, benchmark, audit, or validate a codegraph version (the local dev build or a published npm version) against a language's repo. +--- + +# CodeGraph Quality Audit + +Measures how much CodeGraph helps an agent versus plain grep/read, for a chosen +codegraph version on a chosen real-world repo. Drives the harness in +`scripts/agent-eval/`. + +## Prerequisites +- `tmux` 3+, a logged-in `Codex` CLI, `node`, `git` (macOS/Linux). +- Run from the codegraph repo root. + +## Workflow + +Copy this checklist: +``` +- [ ] 1. Pick version (local or npm) +- [ ] 2. Pick language +- [ ] 3. Pick repo by size +- [ ] 4. Pick harness (headless / tmux / both) +- [ ] 5. Run audit.sh in the background +- [ ] 6. Report results +``` + +**Step 1 — version.** Ask with `AskUserQuestion`: which codegraph version to test. +Offer "Local dev build" and "Latest published"; the free-text "Other" lets the +user type a specific version (e.g. `0.7.10`). Map the answer to a VERSION token: +- "Local dev build" → `local` +- "Latest published" → `latest` +- a typed version → that string (e.g. `0.7.10`) + +**Step 2 — language.** Read `.Codex/skills/agent-eval/corpus.json`. Ask with +`AskUserQuestion` which language to test, listing the languages that have entries. + +**Step 3 — repo.** From the chosen language's entries, ask which repo. Label each +option with its size and file count, e.g. `excalidraw — Medium (~600 files)`. +Each entry carries the `repo` URL and a representative `question`. + +**Step 4 — harness.** Ask with `AskUserQuestion` which harness to run, and map +the answer to a MODE token: +- "Headless" → `headless` — `Codex -p` with stream-json: exact tokens/cost and a + clean tool sequence (2 runs, fast, no TTY). +- "Interactive (tmux)" → `tmux` — drives the real Codex TUI in tmux: faithful + Explore-subagent behavior, metrics from session logs (2 runs, slower). +- "Both" → `all` — headless + interactive (4 runs). + +**Step 5 — run.** Launch in the background (sets the version, clones if missing, +wipes + re-indexes, runs the chosen arms — several minutes): +```bash +scripts/agent-eval/audit.sh "" +``` + +**Step 6 — report.** When the job finishes, read the log and report per arm: +- Headless (`parse-run.mjs`): total tool calls, file `Read`s, Grep/Bash, + codegraph-tool calls, duration, **total cost**. +- Interactive (`parse-session.mjs`): the `VERDICT: codegraph_explore used Nx | + Read N | Grep/Bash N` and `TOKENS:` lines. + +Lead with cost + tool/Read counts — they are the reliable signals; raw token +in/out are confounded by subagent delegation and prompt caching. State whether +codegraph reduced effort and whether both arms reached a correct answer. + +## Notes +- The index is rebuilt every run (`audit.sh` wipes `.codegraph`) — different + versions extract differently, so an index must be served by the same binary + that built it. +- `audit.sh` temporarily mutates the global `codegraph` install for the test, + then restores your dev link via `local-install.sh`. +- Corpus repos are cloned to `/tmp/codegraph-corpus` (reused if already present). +- Add or edit repos in `corpus.json` (fields: `name`, `repo`, `size`, `files`, + `question`). diff --git a/.agents/skills/agent-eval/corpus.json b/.agents/skills/agent-eval/corpus.json new file mode 100644 index 000000000..2cfedac4f --- /dev/null +++ b/.agents/skills/agent-eval/corpus.json @@ -0,0 +1,98 @@ +{ + "_comment": "Test corpus for /agent-eval. Add entries freely. size: Small (<~150 files), Medium (~150-1500), Large (>~1500). 'question' is a representative architectural question that exercises cross-file understanding.", + "TypeScript": [ + { "name": "ky", "repo": "https://github.com/sindresorhus/ky", "size": "Small", "files": "~25", "question": "How does ky implement request retries and timeouts?" }, + { "name": "excalidraw", "repo": "https://github.com/excalidraw/excalidraw", "size": "Medium", "files": "~600", "question": "How does Excalidraw render and update canvas elements?" }, + { "name": "vscode", "repo": "https://github.com/microsoft/vscode", "size": "Large", "files": "~10000", "question": "How does the extension host communicate with the main process?" } + ], + "JavaScript": [ + { "name": "express", "repo": "https://github.com/expressjs/express", "size": "Small", "files": "~50", "question": "How does Express route a request through its middleware stack?" } + ], + "Go": [ + { "name": "cobra", "repo": "https://github.com/spf13/cobra", "size": "Small", "files": "~50", "question": "How does cobra parse commands and flags?" }, + { "name": "gin", "repo": "https://github.com/gin-gonic/gin", "size": "Medium", "files": "~150", "question": "How does gin route requests through its middleware chain?" }, + { "name": "terraform", "repo": "https://github.com/hashicorp/terraform", "size": "Large", "files": "~4000", "question": "How does Terraform build and walk the resource dependency graph?" }, + { "name": "cosmos-sdk", "repo": "https://github.com/cosmos/cosmos-sdk", "size": "Large", "files": "~5000", "question": "How does a bank module MsgSend message reach the account balance update? Trace the cross-module call path from the bank keeper's Send handler through to the account/balance store update." } + ], + "Python": [ + { "name": "click", "repo": "https://github.com/pallets/click", "size": "Small", "files": "~60", "question": "How does click parse command-line arguments into commands?" }, + { "name": "flask", "repo": "https://github.com/pallets/flask", "size": "Medium", "files": "~90", "question": "How does Flask dispatch a request to a view function?" }, + { "name": "django", "repo": "https://github.com/django/django", "size": "Large", "files": "~2700", "question": "How does Django's ORM build and execute a query from a QuerySet?" } + ], + "Rust": [ + { "name": "clap", "repo": "https://github.com/clap-rs/clap", "size": "Medium", "files": "~200", "question": "How does clap parse arguments against a derived command definition?" }, + { "name": "tokio", "repo": "https://github.com/tokio-rs/tokio", "size": "Large", "files": "~700", "question": "How does tokio schedule and run async tasks on its runtime?" }, + { "name": "deno", "repo": "https://github.com/denoland/deno", "size": "Large", "files": "~1500", "question": "How does Deno load and execute a TypeScript module?" } + ], + "Java": [ + { "name": "gson", "repo": "https://github.com/google/gson", "size": "Medium", "files": "~200", "question": "How does Gson serialize an object to JSON?" }, + { "name": "okhttp", "repo": "https://github.com/square/okhttp", "size": "Medium", "files": "~640", "question": "How does OkHttp process a request through its interceptor chain?" }, + { "name": "guava", "repo": "https://github.com/google/guava", "size": "Large", "files": "~3000", "question": "How does Guava's CacheBuilder build and configure a cache?" } + ], + "Kotlin": [ + { "name": "koin", "repo": "https://github.com/InsertKoinIO/koin", "size": "Medium", "files": "~300", "question": "How does Koin resolve and inject dependencies?" }, + { "name": "leakcanary", "repo": "https://github.com/square/leakcanary", "size": "Medium", "files": "~250", "question": "How does LeakCanary detect and analyze a memory leak?" } + ], + "Swift": [ + { "name": "alamofire", "repo": "https://github.com/Alamofire/Alamofire", "size": "Small", "files": "~100", "question": "How does Alamofire build, send, and validate a request?" } + ], + "C#": [ + { "name": "serilog", "repo": "https://github.com/serilog/serilog", "size": "Medium", "files": "~250", "question": "How does Serilog route a log event to its sinks?" }, + { "name": "jellyfin", "repo": "https://github.com/jellyfin/jellyfin", "size": "Large", "files": "~2500", "question": "How does Jellyfin scan and identify items in a media library?" } + ], + "Ruby": [ + { "name": "sinatra", "repo": "https://github.com/sinatra/sinatra", "size": "Small", "files": "~60", "question": "How does Sinatra match a request to a route handler?" }, + { "name": "discourse", "repo": "https://github.com/discourse/discourse", "size": "Large", "files": "~3000", "question": "How does Discourse create and render a new post?" } + ], + "PHP": [ + { "name": "slim", "repo": "https://github.com/slimphp/Slim", "size": "Small", "files": "~80", "question": "How does Slim handle a request through its middleware?" }, + { "name": "laravel", "repo": "https://github.com/laravel/framework", "size": "Large", "files": "~3000", "question": "How does Laravel resolve and dispatch a route to a controller?" } + ], + "C": [ + { "name": "redis", "repo": "https://github.com/redis/redis", "size": "Large", "files": "~600", "question": "How does Redis parse and dispatch a client command?" } + ], + "C++": [ + { "name": "json", "repo": "https://github.com/nlohmann/json", "size": "Small", "files": "~100", "question": "How does nlohmann::json parse a JSON string into a value?" }, + { "name": "grpc", "repo": "https://github.com/grpc/grpc", "size": "Large", "files": "~3000", "question": "How does gRPC dispatch an incoming RPC to its handler?" } + ], + "Dart": [ + { "name": "flutter", "repo": "https://github.com/flutter/flutter", "size": "Large", "files": "~6000", "question": "How does Flutter build and lay out a widget tree?" } + ], + "Svelte": [ + { "name": "shadcn-svelte", "repo": "https://github.com/huntabyte/shadcn-svelte", "size": "Medium", "files": "~600", "question": "How do shadcn-svelte components compose and apply their styling?" } + ], + "Lua": [ + { "name": "lualine.nvim", "repo": "https://github.com/nvim-lualine/lualine.nvim", "size": "Small", "files": "~120", "question": "How does lualine assemble and render its statusline sections and components?" }, + { "name": "telescope.nvim", "repo": "https://github.com/nvim-telescope/telescope.nvim", "size": "Medium", "files": "~80", "question": "How does Telescope wire a picker to its finder, sorter, and previewer?" }, + { "name": "kong", "repo": "https://github.com/Kong/kong", "size": "Large", "files": "~1330", "question": "How does Kong execute plugins across a request's lifecycle phases?" } + ], + "Luau": [ + { "name": "Knit", "repo": "https://github.com/Sleitnick/Knit", "size": "Small", "files": "~10", "question": "How does Knit register services and expose them to clients?" }, + { "name": "vide", "repo": "https://github.com/centau/vide", "size": "Small", "files": "~40", "question": "How does vide track reactive sources and re-run effects when state changes?" }, + { "name": "Fusion", "repo": "https://github.com/dphfox/Fusion", "size": "Medium", "files": "~115", "question": "How does Fusion build and update its reactive UI graph from state objects?" } + ], + "Objective-C": [ + { "name": "Masonry", "repo": "https://github.com/SnapKit/Masonry", "size": "Small", "files": "~50", "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" }, + { "name": "FMDB", "repo": "https://github.com/ccgus/fmdb", "size": "Medium", "files": "~80", "question": "How does FMDB execute a prepared SQL statement and bind parameters?" }, + { "name": "SDWebImage", "repo": "https://github.com/SDWebImage/SDWebImage", "size": "Large", "files": "~400", "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" } + ], + "Mixed iOS (Swift+ObjC)": [ + { "name": "Charts", "repo": "https://github.com/danielgindi/Charts", "size": "Small", "files": "~270", "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" }, + { "name": "realm-swift", "repo": "https://github.com/realm/realm-swift", "size": "Medium", "files": "~370", "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" }, + { "name": "wikipedia-ios", "repo": "https://github.com/wikimedia/wikipedia-ios", "size": "Large", "files": "~1700", "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" } + ], + "React Native (legacy bridge + TurboModule)": [ + { "name": "@react-native-async-storage", "repo": "https://github.com/react-native-async-storage/async-storage", "size": "Small", "files": "~60", "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" }, + { "name": "react-native-svg", "repo": "https://github.com/software-mansion/react-native-svg", "size": "Medium", "files": "~700", "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" }, + { "name": "react-native-firebase", "repo": "https://github.com/invertase/react-native-firebase", "size": "Large", "files": "~1100", "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" } + ], + "Expo Modules": [ + { "name": "expo-haptics", "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", "size": "Small", "files": "~15", "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" }, + { "name": "expo-camera", "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", "size": "Medium", "files": "~70", "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" } + ], + "React Native Fabric (view components)": [ + { "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `` reach the native onChange handler on iOS/Android?" }, + { "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `` reach the native RNSScreenStackView component?" }, + { "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `` JSX usage reach the iOS / Android native renderer?" } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..0d5c98aa2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.codegraph +.worktrees +node_modules +dist +npm-debug.log +coverage diff --git a/.gitignore b/.gitignore index da6c8ef6e..e7c71b417 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ release/ .antigravitycli/ +.worktrees/ + # Local-only: browser-based tmux session launcher (see tmux-web/README.md) tmux-web/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..436e21757 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,269 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +CodeGraph is a local-first code intelligence library + CLI + MCP server. It parses any supported codebase with tree-sitter, stores symbols/edges/files in SQLite (FTS5), and exposes a knowledge graph to AI agents (Codex, Cursor, Codex CLI, opencode) over MCP. Per-project data lives in `.codegraph/`. Extraction is deterministic — derived from AST, not LLM-summarized. + +Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer, indexer, and MCP server. + +## Build, Test, Run + +```bash +npm run build # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js +npm run dev # tsc --watch +npm run clean # rm -rf dist + +npm test # vitest run (all) +npm run test:watch +npm run test:eval # only __tests__/evaluation/ +npm run eval # build then run __tests__/evaluation/runner.ts via tsx + +npm run cli # build then run the local dist binary + +# Single test file / pattern +npx vitest run __tests__/installer-targets.test.ts +npx vitest run __tests__/extraction.test.ts -t "TypeScript" +``` + +`copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.** + +Node engines: `>=18.0.0 <25.0.0`. There is a hard exit on Node 25.x (see `src/bin/node-version-check.ts`). + +## Architecture + +### Layered pipeline + +``` +files → ExtractionOrchestrator (tree-sitter) → DB (nodes/edges/files) + ↓ + ReferenceResolver (imports, name-matching, framework patterns) + ↓ + GraphQueryManager / GraphTraverser (callers, callees, impact) + ↓ + ContextBuilder (markdown/JSON for AI consumption) +``` + +The public API surface is `src/index.ts` — the `CodeGraph` class wires all the layers and re-exports types. Library users only touch this file; the MCP server and CLI also drive it. + +### Module layout + +- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`. +- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`. Backed by `better-sqlite3` (native) when available, transparently falls back to `node-sqlite3-wasm`. `codegraph status` surfaces which backend is live; wasm is the slow path. +- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. +- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. +- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries). +- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. +- `src/search/` — full-text query parser and helpers for FTS5. +- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers. +- `src/mcp/` — MCP server (`MCPServer`, `tools.ts`, `transport.ts`). `server-instructions.ts` is what the server returns in the MCP `initialize` response — keep it in sync with the user-facing tool guidance. +- `src/installer/` — see below. +- `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`. +- `src/ui/` — terminal UI (shimmer progress, worker). + +### NodeKind / EdgeKind + +Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings. + +- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`. +- **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`. + +### Multi-agent installer + +`src/installer/` is the entry point for `codegraph install` (and the bare `codegraph`/`npx @colbymchenry/codegraph` invocation). Architecture: + +- `targets/registry.ts` lists every supported agent. +- `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location and MCP-server JSON/TOML/JSONC writing. (Targets no longer write an instructions file — see below.) +- Current targets: `Codex.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`. +- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency. +- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. +- `instructions-template.ts` no longer holds an instructions body — it exports only the ``/`` markers. The installer **stopped writing** a `## CodeGraph` block into each agent's instructions file (`AGENTS.md` / `~/.codex/AGENTS.md` / `~/.config/opencode/AGENTS.md` / `~/.gemini/GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering doc) because it duplicated the MCP `initialize` instructions verbatim (issue #529). Each target's `install` (self-heal on upgrade) and `uninstall` use the markers to **strip** a block a previous install left behind. `server-instructions.ts` is the single source of truth for agent-facing guidance. +- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex. + +### Cursor MCP working-directory quirk + +Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` in `initialize`. The installer injects `--path` into Cursor's MCP args — absolute path for local installs, `${workspaceFolder}` for global installs. If you touch Cursor wiring, preserve this. + +### MCP server instructions + +`src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools, and as of issue #529 it is the **single source of truth** for agent-facing tool guidance — the installer no longer writes a duplicate `## CodeGraph` instructions block into `AGENTS.md` / `AGENTS.md` / `.cursor/rules/codegraph.mdc`. Edit tool guidance here and nowhere else. + +## Retrieval performance & dynamic-dispatch coverage (do not regress) + +CodeGraph's core value is letting an agent answer **structural/flow** questions ("how does X reach Y", trace, impact, callers) with a few **fast** codegraph calls and **zero Read/Grep**. The optimization target is **wall-clock latency + tool-call count** — *don't optimize for token cost*. (Cost is **lower**, not "flat" as earlier framing claimed: a current-build with-vs-without A/B across the 7 README repos, median of 4, saved on average **35% cost · 57% tokens · 46% time · 71% tool calls** — reproducing the published README. The mechanism is **far fewer turns over a much smaller accumulated context** — NOT cache-ability: the without-arm's huge token volume is *mostly* cheap cache-reads, which is why token-count savings (57%) look bigger than cost savings (35%). Measure tokens by **summing per-turn assistant usage**, not `result.usage` (last-turn only in current Codex). See `docs/benchmarks/call-sequence-analysis.md`.) The mechanism that drives everything here: **an agent falls back to Read/Grep the instant a codegraph answer is insufficient.** So every change is judged by one question — is codegraph's answer sufficient enough to *stop* the agent from reading? + +**Target behavior:** a flow question resolves in **1 codegraph call on small repos, scaling to 3–5 on large**, with **Read/Grep = 0**. When reviewing a PR or trying something new, do not regress this. + +### Adapt the tool to the agent — don't try to change the agent + +The lever that decides whether a retrieval change lands. **Test before building anything here: does this make a tool the agent _already calls_ do more with the input it _already gives_? If it instead needs the agent to behave differently — pick a different tool, query differently, learn from examples — it hits the low-salience wall and won't land.** + +CodeGraph's only channels to influence the agent are low-salience: the MCP `initialize` instructions (`server-instructions.ts`) and the tool descriptions. Changing them does **not** reliably move the agent's tool _choice_ or query style — validated: trace-first steering ported into the server-instructions + tool descriptions (3 wording variants) never reproduced what a CLI `--append-system-prompt` achieved, and **regressed** wall-clock vs baseline. New tools fare worse (rarely chosen — the agent under-picks even `trace`); "better examples" is the same steering. The agent's tool-choice does improve on its own as host models get better at tool use — but that is not ours to force. + +What works is meeting the agent where it already is: +- **explore-flow** — `codegraph_explore` is the PRIMARY tool the agent reliably calls; its query is a precise bag of symbol names (incl. qualified `Class.method`) spanning the flow the agent is after; explore finds the call path _among those named symbols_ (riding synthesized edges) and leads its output with it. (`buildFlowFromNamedSymbols`: segment/co-naming disambiguation; ≤1 unnamed bridge so it never wanders a god-function's fan-out. Overload-aware: a PascalCase type token in the query biases an overloaded name to that type's own def — `DataRequest task` → DataRequest's `task`, not the abstract base; named-symbol files sort first.) +- **Sufficiency** — make the tool's output complete enough that the agent stops. `codegraph_node` returns the full body + the caller/callee trail, and for an AMBIGUOUS name returns **every overload's body in one call** (so the agent never Reads a file to find the right overload — validated on Alamofire/gin). This is the after-explore depth tool (labeled SECONDARY). +- **Errors teach abandonment** — one or two `isError: true` responses early in a session and the agent stops calling codegraph entirely (maintainer-observed, repeatedly). `isError` is reserved for genuine "stop trying" cases: security refusals (`PathRefusalError`) and real malfunctions (which carry a retry-once note). Every expected/recoverable condition — project not indexed, symbol not found, file not in the index — returns a **SUCCESS-shaped response carrying the guidance** (`NotIndexedError` → `textResult`, see `ToolHandler.execute`'s catch). The same principle session-wide: an **unindexed workspace serves an empty `tools/list` + a 2-line "inactive" instructions variant** instead of 8 tools that all fail — absence is the one signal an agent can't misread, and indexing is deliberately the user's call, never the agent's. + +What fails is the inverse — folding a precise answer into a **fuzzy-input** tool: the now-removed `codegraph_context` took a description, not symbols, so it couldn't disambiguate a flow's endpoints and surfaced the _wrong feature_ (which is why it was cut). Precise output needs precise input — explore takes a symbol bag for exactly this reason. (`codegraph_trace` was likewise removed: explore-flow does its job and the agent under-picked it.) + +The remaining lever under this axis is **coverage**: every flow made to connect statically (a new dynamic-dispatch synthesizer, or extracting symbols static parsing skipped — e.g. object-literal store actions in `create((set,get)=>({...}))`) is then surfaced automatically by explore-flow, no agent change needed. Reactive/reconciler runtimes (Halo's `ReactiveExtensionClient`, MediatR, Vue Proxy) are the frontier — flows there have no static edges, so nothing surfaces (correctly — silent beats wrong). Full investigation + A/B record: `docs/benchmarks/call-sequence-analysis.md` + auto-memory `project_codegraph_read_displacement`. + +### Explore budget — keep BOTH budgets monotonic with repo size + +Two functions in `src/mcp/tools.ts` scale explore with indexed file count. This is the expected resolution (a regression here silently forces agents back to Read): + +| Repo | files | explore calls | chars/call | per-file | +|---|---|---|---|---| +| express (small) | 147 | 1 | 18K | 3800 | +| excalidraw/django (medium) | 643–3043 | 2 | 28K | 6500 | +| vscode (large) | 10446 | 3 | 35K | 7000 | +| ~20k / ~40k | — | 4 / 5 | 38K | 7000 | + +- `getExploreBudget(fileCount)` → **call** budget: `<500→1, <5000→2, <15000→3, <25000→4, ≥25000→5` (max 5). +- `getExploreOutputBudget(fileCount)` → **per-call** output (chars / files / per-file). **Invariant: a larger tier must never get a smaller `maxCharsPerFile` than a smaller tier.** (Regression that motivated this doc: the `<5000` tier's 2500 was *below* the `<500` tier's 3800, so on a god-file repo — excalidraw's 415 KB `App.tsx` — one explore returned <1% of the file and forced a Read.) +- Explore output must **never tell the agent to "use Read"** — steer to another `codegraph_explore` and "treat returned source as already Read." + +### Dynamic-dispatch coverage — the flow must EXIST in the graph end-to-end + +Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), django ORM descriptor. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail. + +**Principle: partial coverage is WORSE than none.** Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Measured on excalidraw: react-render alone *raised* reads to 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow. + +### Validation methodology (REQUIRED for every new language/framework) + +For each **language × framework**, validate on **small, medium, and large** real repos with **≥3 different flow prompts** each: + +1. **Pick the canonical flow** for the framework ("how does X reach Y": state→render, request→handler→view, query→SQL, action→reducer→store…). +2. **Deterministic probes** (`scripts/agent-eval/probe-{node,explore}.mjs` against the built `dist/`): `codegraph_explore` with the flow's symbol names connects from→to end-to-end with no break (its Flow section shows the path); **no node explosion** (`select count(*) from nodes` stable before/after re-index); synthesized-edge **precision** spot-check (`select … where provenance='heuristic'`). +3. **Agent A/B** (`scripts/agent-eval/run-all.sh ""`): with vs without codegraph, **≥2 runs/arm** (run-to-run variance is large — never conclude from n=1). Record **duration, total tool calls, Read, Grep**. Optional forced-Read-0 sufficiency proof via the block-read hook (`scripts/agent-eval/hook-settings.json`). + - **Model policy — every A/B arm runs Codex with `--model sonnet --effort high`. Always. Never Opus/Fable.** All `scripts/agent-eval/*.sh` default to this (`MODEL`/`EFFORT` env override exists — don't raise it without an explicit reason from the maintainer). Two reasons, and the second matters more than cost: (a) Sonnet doesn't burn tokens; (b) **Sonnet is the deliberate floor model** — codegraph's real users attach it to whatever agent they already run (Cursor Composer, Gemini, etc.), so we validate on a "dumber" model on purpose: a stronger model's tool-use covers up the salience/sufficiency problems a weaker one exposes. An affordance that lands on Sonnet generalizes up to every host; one that only works on Opus/Fable doesn't generalize down to the agents most users actually have. Both arms always use the same model. + - **MCP attach is a startup-latency issue, not a hard block.** On a multi-step task the agent dives into Read/grep before codegraph finishes its ~2-3s startup (worse when the eval is itself run nested inside a Codex session, under CPU contention), so it runs with no codegraph. Fix: **pre-warm a persistent daemon** for the target (`CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` high; spawn `serve --mcp --path "" [baseline-ref]` (it bakes in the pre-warm). +4. **Pass bar:** a normal flow question reaches **~0 Read/Grep within the repo's explore-call budget**, runs **faster** than without-codegraph, and shows **no regression on a control repo**. Record the numbers in `docs/design/dynamic-dispatch-coverage-playbook.md` (the coverage matrix). + +Full playbook + per-mechanism design: `docs/design/dynamic-dispatch-coverage-playbook.md` and `docs/design/callback-edge-synthesis.md`. + +### Worked example — Excalidraw (TS/React, medium, 643 files) + +The template to replicate per language/framework. Question: *"how does updating an element re-render the canvas on screen?"* (the full flow crosses three React boundaries: observer callback, `setState`→`render`, and JSX child). + +| Stage | duration | Read | Grep | codegraph | +|---|---|---|---|---| +| Without codegraph | 115–139s | 9–10 | 10–11 | 0 | +| Broken (explore-budget regression) | 131–139s | 5–10 | 3–5 | 6–14 | +| Fixed (budget + msgs + synthesis) | 64–112s | 0–2 | 2–4 | 3–**10** | +| + trace-first steering | **51–74s** | **0–2** | 0–4 | **3–4** | + +n=4 unhooked runs/stage, same prompt. After steering flow questions to `codegraph_trace` first: **best run 0 Read / 0 Grep / 3 codegraph / 51s**; **2 of 4 fully clean** (0 Read, 0 Grep). Steering eliminated the over-drill variance — call count tightened from 3–10 to 3–4, trace adoption went 3/4 → 4/4, and the `search`+`callers` path-reconstruction floundering dropped to 0. Run-to-run variance is still real; report the range, never a single run. **Residual reads/greps are all the nonce data-flow** (`canvasNonce` — a local prop with no graph edges); that's the def-use/data-flow frontier, left deliberately uncovered (tracking every local would explode the graph). Validated: `trace(mutateElement, renderStaticScene)` connects in **6 hops** across all three boundaries (`mutateElement → triggerUpdate → [callback] triggerRender → [react-render] render → [jsx] StaticCanvas → renderStaticScene`), each hop showing inline source + the wiring site; node count stable at 9,289; 1 callback + 46 react-render + 280 jsx-render synthesized edges (no explosion, precision-checked). + +## Tests + +Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond the obvious: + +- `installer-targets.test.ts` — parameterized contract suite across all 4 agent targets (see installer notes above). +- `evaluation/` — `runner.ts` + `test-cases.ts` exercise codegraph against synthetic projects and score the results; run via `npm run eval` (builds first). Not part of `npm test`. +- `sqlite-backend.test.ts` — covers native + wasm backend selection and fallback. +- `pr19-improvements.test.ts`, `frameworks-integration.test.ts` — regression coverage for specific past PRs/incidents; don't rename these, the names anchor to git history. + +Tests create temp dirs with `fs.mkdtempSync` and clean up in `afterEach`. They write real files and exercise real SQLite — there is no DB mocking. + +### Windows-gated tests + +Behavior that differs by platform (path resolution, drive letters, `SENSITIVE_PATHS`, `%APPDATA%` config dirs, CRLF) must be gated, not assumed. Use `it.runIf(process.platform === 'win32')(...)` for Windows-only assertions and `it.runIf(process.platform !== 'win32')(...)` for POSIX-only ones — e.g. `/etc` is sensitive on POSIX but resolves to `C:\etc` (non-existent) on Windows, so an ungated `/etc` assertion fails on Windows. Validate the Windows side for real (see below); don't merge a Windows-gated test you haven't seen run. + +## Cross-platform validation + +The dev machine — and the default `npm test` target — is **macOS**, so local runs cover the macOS path. The other two platforms aren't here; when a change is platform-sensitive (file watching, sockets / named pipes, path & symlink handling, process lifecycle, inotify budget) validate them for real rather than guessing. + +### Linux (Docker) + +When asked to test or validate on Linux, use **Docker** — there's no Linux box, but Docker runs on the macOS host. Build a throwaway image from the repo and run the suite inside it: + +- `FROM node:22-bookworm`; `COPY` the repo with a `.dockerignore` excluding `node_modules`/`dist`/`.git`/`.codegraph`; `RUN npm ci && npm run build`. Don't reuse the Mac `node_modules` — `esbuild`/`rollup` ship platform-specific binaries. +- Run with **`docker run --rm --init`**. The `--init` is load-bearing for any process-lifecycle test (daemon reaping, the #277 PPID watchdog, idle-timeout): without a zombie-reaping PID 1, a SIGKILL'd/exited process lingers as a zombie and `process.kill(pid, 0)` still reports it *alive*, so exit-detection assertions false-fail even though the process did exit. +- Linux is where the inotify watch budget actually bites: count a process's watches via `/proc//fdinfo/*` (sum `^inotify ` lines on the fd whose `readlink` is `anon_inode:inotify`). + +### Windows (Parallels VM + SSH) + +For any Windows-specific PR, bug, or implementation, validate it on the real Windows VM rather than guessing. Connection details live in the gitignored **`.parallels`** file at the repo root (VM name, guest IP, SSH user/key). `prlctl exec` needs Parallels Pro and is unavailable, so SSH is the bridge. + +- Connect / run from the Mac host: `ssh @ "..."`. For multi-line work, pipe PowerShell over stdin and **refresh PATH from the registry** first (sshd's session has a stale PATH after winget installs): + ``` + ssh colby@10.211.55.3 "powershell -NoProfile -ExecutionPolicy Bypass -Command -" <<'PS' + $env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User") + Set-Location C:\dev\codegraph + PS + ``` +- Clone fresh into a **Windows-local** path (`C:\dev\codegraph`) and `npm ci` there — never run npm against the shared Mac repo, since `esbuild`/`rollup` ship platform-specific binaries. +- Guest toolchain (winget): Node LTS, Git, and the **VC++ ARM64 redistributable** (required by `@rollup/rollup-win32-arm64-msvc`, which vitest pulls in). +- Fetch a contributor PR head straight from their fork to dodge `pull//head` lag: `git fetch ` then `git checkout -f FETCH_HEAD`. +- Known pre-existing Windows failures (they reproduce on `main`, unrelated to your change — confirm against `origin/main` before blaming your PR, and don't let them mask new regressions): `security.test.ts > Session marker symlink resistance > does not follow a pre-planted symlink` (symlink creation needs privileges on Windows); and the `mcp-initialize.test.ts` / `mcp-roots.test.ts` suites, which fail in `afterEach` with `EPERM` removing the temp dir because a spawned `serve --mcp` (its `--liftoff-only` re-exec grandchild) still holds the cwd / SQLite file open — a Windows file-locking quirk, not a logic bug. + +## Releases + +Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it. + +### Writing changelog entries + +**Default: write entries under `## [Unreleased]`** — that's the section reserved for work landing between releases. **Don't pre-create a `## [X.Y.Z]` block** for the next release: the Release workflow's first step is `scripts/prepare-release.mjs`, which automatically promotes everything under `[Unreleased]` into a new `## [X.Y.Z] - ` block at release time (or merges into a pre-existing `[X.Y.Z]` block if one exists — but you don't need one). Pre-staging is what caused the v0.9.5 sparse-release-notes incident: a sparse `[0.9.5]` block hand-added before the rest of the work landed got picked by the extractor over the much-larger `[Unreleased]` section above it. Don't do that. + +Formatting rules for any entry (anywhere — `[Unreleased]` or otherwise): + +1. **Write friendly, user-facing notes — not engineer-facing ones.** Group under `### New Features` and `### Fixes` (sentence-case). Surface `### Breaking Changes` and `### Security` as their own sections **only when the release has them**; fold improvement-flavored changes into New Features. Omit empty sections. (This replaces the old Keep-a-Changelog `Added/Changed/Fixed/Removed/Deprecated` grouping: the GitHub Release page extracts each version block **verbatim** via `scripts/extract-release-notes.mjs`, and the old dense, implementation-focused entries rendered as an unreadable wall of text — so the whole CHANGELOG was rewritten to this format and every published release re-noted to match.) +2. **One plain-language sentence per bullet:** what changed and why it matters to a user. Lead with the capability, or with the symptom that's now fixed. +3. **Strip the internals.** No internal file paths (`src/...`), no internal symbol / function / class names, no benchmark numbers / percentages / node-or-edge counts. **Keep:** language & framework names (Go, Spring, NestJS, …), things a user types or sets (`codegraph install`, `codegraph_explore`, the `CODEGRAPH_*` env vars), agent / IDE names (Codex, Cursor, opencode, Kiro, …), and a brief `Thanks @user` when a contributor is credited. +4. Issue / PR references in entries are by number (`(#403)` etc.); the GitHub renderer auto-links them in the published release notes. +5. **Don't add a `[X.Y.Z]: https://...` link reference yourself** — `prepare-release.mjs` appends it automatically when it promotes the version (idempotent: a re-run is a no-op if it already exists). + +Multi-word headings like `### New Features` are safe on the normal release path: `prepare-release.mjs` **Case A** moves the whole `[Unreleased]` body verbatim into `[X.Y.Z]`. (Only its rarely-used **Case B** *merge* splits sub-sections with a single-word `^### (\w+)$` regex that wouldn't match them — and Case B fires only if a `[X.Y.Z]` block was pre-created, which rule above already forbids.) + +### Release flow (the user runs these) + +Releases are built and published by the **GitHub Actions "Release" workflow** +(`.github/workflows/release.yml`). It runs `scripts/prepare-release.mjs` to +promote `[Unreleased]` into `[]` (and auto-commit + push that +CHANGELOG change back to `main` so on-disk truth matches the published +notes), then bundles a Node runtime per platform (`scripts/build-bundle.sh`) +and publishes both the GitHub Release and the npm thin-installer +(`scripts/pack-npm.sh`: a shim package + per-platform packages). +Publishing manually is **wrong** now — a plain `npm publish` ships the root +package (non-bundled), which breaks anyone on Node < 22.5. + +**Codex does NOT bump the version unless explicitly asked.** The maintainer +typically does it themselves — often by editing `package.json` directly via +the GitHub web UI. Don't proactively commit a version bump as part of +unrelated work, and don't propose one when summarizing a PR. + +When the maintainer DOES bump the version, the only edit strictly required is +to `package.json` — the workflow's "Sync package-lock.json" step detects a +mismatch between `package.json` and `package-lock.json`, runs +`npm install --package-lock-only --ignore-scripts` to rewrite the lock file's +version fields (top-level + `packages.""`), and auto-commits + pushes the +result back to `main` with `[skip ci]`. So a GitHub-web-UI single-file edit to +`package.json` is enough to kick off a clean release. (If they edit both files +locally, that's fine too — the sync step no-ops.) + +Once `package.json` is at the target version on `main`, trigger +**Actions → Release → Run workflow** (on `main`). The workflow: + +1. Syncs `package-lock.json` to `package.json`'s version if they've drifted; commits + pushes that change. +2. Runs `prepare-release.mjs ` → promotes `[Unreleased]` → `[X.Y.Z] - ` in `CHANGELOG.md`, appends the link reference, commits + pushes the move with `[skip ci]`. +3. Builds every platform bundle on one runner, generates `SHA256SUMS`. +4. Creates the GitHub Release with notes from the freshly-promoted `[X.Y.Z]` block. +5. Publishes the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret. + +**Do not run `npm publish`, `git push`, or `git tag` yourself** — these are +publish actions on shared state. Write the files, hand the user the commands. + +## House rules + +- The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently. +- When changing what the MCP tools do or how agents should use them, edit `src/mcp/server-instructions.ts` — it is the **single source of truth** for agent-facing tool guidance (issue #529). The installer no longer writes a duplicate instructions block into `AGENTS.md` / `AGENTS.md` / `GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering, so there's nothing to keep in sync anymore. (The repo's own checked-in `.cursor/rules/codegraph.mdc` is dogfooding config — update it too if you use Cursor on this repo, but it ships nowhere.) +- CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you. +- **When the user references issues, PR comments, or external reports, anchor them to a date and version before drawing conclusions.** Check the comment's `createdAt` against: + - The **last released version** — `grep -m1 '^## \[' CHANGELOG.md` shows the top-of-file version (older releases follow). A comment dated before the latest `## [X.Y.Z] - YYYY-MM-DD` is reacting to *released* state — work that's only on `main` or on an unmerged branch doesn't apply. + - The **last main commit** — `git log --first-parent main -1 --format='%ai %h %s'`. A comment after the last release but before a fix on main may already be addressed there but unreleased. + - The **current branch's tip** — your own unmerged work obviously can't be what the comment is reacting to. + Always disambiguate "released," "merged-but-unreleased," and "in-progress" before agreeing that a user-reported problem is unfixed (or that a fix is incomplete). A user saying "your fix only covers X" about a recent PR is usually pointing at the *released* shortcomings — your in-flight branch may already address them but they have no way to know that. +- **Version-tag every image referenced in `README.md`.** GitHub caches README images (`raw.githubusercontent.com` with a 5-minute TTL; third-party hosts sit behind the long-lived camo proxy), so updating an asset in place can keep showing the stale version. Give each README image URL a `?v=N` query tag and **bump `N` in the same commit whenever the asset bytes change** — e.g. `assets/waitlist.svg?v=2`. The changed URL sidesteps every cache so the new image shows immediately instead of waiting on a TTL to expire. diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf4c5d4e..ea3f1db81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- CodeGraph's HTTP MCP server can now expose many separately indexed repositories through one stable tool set with `codegraph serve --mcp --http --gateway-repo-paths`, using a required `repoId` argument instead of expanding every repository into its own prefixed tools. +- CodeGraph can now run its MCP server over Streamable HTTP with `codegraph serve --mcp --http`, giving remote-capable MCP clients a local `/mcp` endpoint while keeping stdio as the default. - **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore ""` and `codegraph node ` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704) - **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone. - **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769) diff --git a/README.md b/README.md index bb86a697b..8c1ebcfb3 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ npm i -g @colbymchenry/codegraph **Upgrade any time** with `codegraph upgrade` — it detects how you installed (bundle, npm, or npx) and updates in place. Add `--check` to see if an update is available, or `codegraph upgrade ` to pin one. -### 2. Wire up your agent(s) +### 2. Choose how agents connect -In a **new terminal**, run the installer to connect CodeGraph to the agents you use: +For one developer on one checkout, use the local MCP server. In a **new terminal**, run the installer to connect CodeGraph to the agents you use: ```bash codegraph install @@ -69,14 +69,16 @@ codegraph install Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) -### 3. Initialize each project +For a team or platform setup, expose CodeGraph as one HTTP MCP server instead: run repository runtimes in Kubernetes and connect every agent to the shared `/mcp` URL. Each repository keeps its own checkout and index, while the gateway prefixes tools by repo so one agent session can query many repositories in parallel. See [Cloud-Native Multi-Repo Gateway](#cloud-native-multi-repo-gateway). + +### 3. Initialize local projects ```bash cd your-project codegraph init -i ``` -`codegraph init` just creates the local `.codegraph/` index directory; adding `-i` (`--index`) also builds the initial graph in the same step. Without `-i`, run `codegraph index` afterwards to populate it. +For the local MCP path, `codegraph init` creates the local `.codegraph/` index directory; adding `-i` (`--index`) also builds the initial graph in the same step. Without `-i`, run `codegraph index` afterwards to populate it. In the cloud-native gateway path, the operator runs the sync/index Job for each `CodeGraphRepository` instead.
@@ -92,7 +94,7 @@ Changed your mind? One command removes CodeGraph from every agent it configured: codegraph uninstall ``` -Reverses the installer — strips CodeGraph's MCP server config, instructions, and permissions from each configured agent. Your project indexes (`.codegraph/`) are left untouched; remove those per-project with `codegraph uninit`. Use `--target` to remove from specific agents, or `--yes` to run non-interactively. +Reverses the installer — strips CodeGraph's MCP server config and permissions from each configured agent. Your project indexes (`.codegraph/`) are left untouched; remove those per-project with `codegraph uninit`. Use `--target` to remove from specific agents, or `--yes` to run non-interactively. --- @@ -326,7 +328,7 @@ The installer will: - Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one -- Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` / `codegraph node` commands, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. +- Write each chosen agent's MCP server config. The MCP server itself delivers the agent-facing usage guidance during `initialize`. - Set up auto-allow permissions when Claude Code is one of the targets - Initialize your current project (local installs only) @@ -362,6 +364,52 @@ Builds the per-project knowledge graph index. A single global `codegraph install That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists. +### Cloud-Native Multi-Repo Gateway + +Use the Kubernetes operator when you want one HTTP MCP address for many repositories instead of one local `.codegraph/` index per workspace. + +The deployment shape is: + +- One `CodeGraphRepository` per Git repo. Each repo gets its own PVC, sync/index Job, runtime Deployment, Service, and `.codegraph` index. +- Repository runtimes serve their local graph with `codegraph serve --mcp --http`. +- One `CodeGraphGateway` runs `codegraph serve --mcp --http --gateway-repos ...`, fans out to the repository Services, and exposes a single `/mcp` endpoint. +- Agents configure one remote MCP server URL. Gateway tools are named `__`, so a single Codex session can inspect several repositories without switching MCP servers. + +```bash +# Build a local runtime image for Rancher Desktop / local Kubernetes +docker build -f deploy/operator/runtime.Containerfile -t codegraph-runtime:local . + +# Install the repository and gateway CRDs +kubectl apply -f deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml +kubectl apply -f deploy/operator/config/crd/codegraph.dev_codegraphgateways.yaml + +# Run the operator locally while validating +cd deploy/operator +go run ./cmd/manager --route-mode=ingress --runtime-image=codegraph-runtime:local +``` + +Create one `CodeGraphRepository` per backend repo, then one `CodeGraphGateway` for the shared HTTP entrypoint. For a local five-repo verification gateway, start from: + +```bash +kubectl apply -f deploy/operator/config/samples/codegraphgateway-local-verify.yaml +``` + +The gateway exposes one MCP endpoint: + +```text +http://127.0.0.1/mcp +``` + +Codex `config.toml` needs one server entry: + +```toml +[mcp_servers.codegraph_k8s] +url = "http://127.0.0.1/mcp" +enabled = true +``` + +Full operator details, production image publishing, Git authentication, and Gateway API/Ingress routing options are in `deploy/operator/README.md`. +
Manual Setup (Alternative) @@ -413,7 +461,7 @@ CodeGraph's MCP server delivers its usage guidance to your agent **automatically - **Trust the results — don't re-verify with grep**, and check the staleness banner after edits. - In a workspace with no index, CodeGraph announces itself inactive and serves no tools — indexing stays your decision. -The exact text is `src/mcp/server-instructions.ts` — the single source of truth for the main agent. Because subagents and non-MCP harnesses never see the MCP guidance, the installer also writes a four-line marker-fenced section into the agent's instructions file pointing at the `codegraph explore` / `codegraph node` CLI equivalents. +The exact text is `src/mcp/server-instructions.ts` — the single source of truth for agent-facing tool guidance.
diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 344a0f6c9..a6a92014c 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -609,7 +609,10 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => { describe('JVM FQN imports — end-to-end', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -627,7 +630,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar'); @@ -644,7 +647,6 @@ describe('JVM FQN imports — end-to-end', () => { .find((e) => e.kind === 'imports'); expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined(); - cg.close(); }); it('resolves a Kotlin top-level function import', async () => { @@ -658,7 +660,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util'); @@ -679,7 +681,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar'); @@ -711,7 +713,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar'); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c00d528f6..6929418b0 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -38,6 +38,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; import { getDaemonSocketPath } from '../src/mcp/daemon-paths'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); @@ -49,7 +50,7 @@ interface SpawnedServer { } function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer { - const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], { + const child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], // #618: the daemon-attach log line is now off by default; opt the test @@ -161,10 +162,36 @@ function killTree(...procs: ChildProcessWithoutNullStreams[]): void { } } +async function waitForProcessTreeExit(...procs: ChildProcessWithoutNullStreams[]): Promise { + await Promise.all(procs.map((p) => { + if (!p.pid || p.exitCode !== null || p.signalCode !== null) return Promise.resolve(false); + return waitProcessExit(p.pid, 3000); + })); +} + async function waitProcessExit(pid: number, timeoutMs: number): Promise { return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false); } +async function rmDirWhenUnlocked(dir: string, timeoutMs = 5000): Promise { + const started = Date.now(); + let lastError: unknown; + while (Date.now() - started <= timeoutMs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'ENOTEMPTY') { + throw err; + } + lastError = err; + await new Promise((r) => setTimeout(r, 50)); + } + } + throw lastError; +} + describe('Shared MCP daemon (issue #411)', () => { let tempDir: string; // the (possibly symlinked) path processes are spawned with let realRoot: string; // its canonical form — what the daemon keys paths on @@ -179,6 +206,7 @@ describe('Shared MCP daemon (issue #411)', () => { afterEach(async () => { killTree(...servers.map((s) => s.child)); + await waitForProcessTreeExit(...servers.map((s) => s.child)); // The daemon is detached (not a tracked child) — reap it explicitly via the // pid it recorded, so a test can't leak a background daemon. Guard against // our own pid: the version-mismatch test plants `pid: process.pid` in the @@ -186,10 +214,10 @@ describe('Shared MCP daemon (issue #411)', () => { const daemonPid = readLockPid(realRoot); if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + await waitProcessExit(daemonPid, 3000); } - await new Promise((r) => setTimeout(r, 50)); servers.length = 0; - fs.rmSync(tempDir, { recursive: true, force: true }); + await rmDirWhenUnlocked(tempDir); }); it('two invocations share ONE detached daemon; both attach as proxies', async () => { diff --git a/__tests__/mcp-gateway.test.ts b/__tests__/mcp-gateway.test.ts new file mode 100644 index 000000000..b29630225 --- /dev/null +++ b/__tests__/mcp-gateway.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as http from 'http'; +import type { AddressInfo } from 'net'; +import * as path from 'path'; +import * as fs from 'fs'; +import { CodeGraph } from '../src'; +import { MCPGatewayHttpServer, MCPRepositoryRouterHttpServer } from '../src/mcp/gateway'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +type JsonRpcMessage = { + jsonrpc: '2.0'; + id?: string | number; + method?: string; + params?: { + name?: string; + arguments?: Record; + }; +}; + +type Backend = { + url: string; + calls: JsonRpcMessage[]; + stop: () => Promise; +}; + +const headers = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', +}; + +async function startBackend(toolName: string, callText: string): Promise { + const calls: JsonRpcMessage[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString('utf8'); + }); + req.on('end', () => { + const message = JSON.parse(body) as JsonRpcMessage; + calls.push(message); + res.writeHead(200, { 'content-type': 'application/json' }); + if (message.method === 'tools/list') { + res.end(JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + tools: [{ + name: toolName, + description: `Tool ${toolName}`, + inputSchema: { type: 'object', properties: {} }, + }], + }, + })); + return; + } + if (message.method === 'tools/call') { + res.end(JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + content: [{ type: 'text', text: `${callText}:${message.params?.name}` }], + }, + })); + return; + } + res.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${address.port}/mcp`, + calls, + stop: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function post(url: string, body: JsonRpcMessage): Promise { + return fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + +function waitForHttpUrl(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for HTTP server URL. stderr=${stderr}`)); + }, 5000); + const onData = (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + const match = stderr.match(/listening on (http:\/\/[^\s]+)/); + if (match?.[1]) { + cleanup(); + resolve(match[1]); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`server exited before listening, code=${code}, stderr=${stderr}`)); + }; + const cleanup = () => { + clearTimeout(timer); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + child.stderr.on('data', onData); + child.on('exit', onExit); + }); +} + +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +describe('MCP gateway HTTP server', () => { + const cleanup: Array<() => Promise | void> = []; + + afterEach(async () => { + while (cleanup.length > 0) { + await cleanup.pop()?.(); + } + }); + + it('initializes as one gateway MCP server', async () => { + const gateway = new MCPGatewayHttpServer({ repositories: [] }); + cleanup.push(() => gateway.stop()); + const url = await gateway.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }); + + expect(res.status).toBe(200); + const json = await res.json() as { + result: { serverInfo: { name: string }; capabilities: { tools: unknown } }; + }; + expect(json.result.serverInfo.name).toBe('codegraph-gateway'); + expect(json.result.capabilities.tools).toBeDefined(); + }); + + it('lists backend tools with repository prefixes', async () => { + const first = await startBackend('codegraph_explore', 'first'); + const second = await startBackend('codegraph_status', 'second'); + cleanup.push(first.stop, second.stop); + const gateway = new MCPGatewayHttpServer({ + repositories: [ + { repoId: 'hello-1', url: first.url }, + { repoId: 'hello-2', url: second.url }, + ], + }); + cleanup.push(() => gateway.stop()); + const url = await gateway.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + }); + + expect(res.status).toBe(200); + const json = await res.json() as { result: { tools: Array<{ name: string }> } }; + expect(json.result.tools.map((tool) => tool.name)).toEqual([ + 'hello-1__codegraph_explore', + 'hello-2__codegraph_status', + ]); + }); + + it('dispatches prefixed tool calls to the matching backend with the original tool name', async () => { + const first = await startBackend('codegraph_explore', 'first'); + const second = await startBackend('codegraph_status', 'second'); + cleanup.push(first.stop, second.stop); + const gateway = new MCPGatewayHttpServer({ + repositories: [ + { repoId: 'hello-1', url: first.url }, + { repoId: 'hello-2', url: second.url }, + ], + }); + cleanup.push(() => gateway.stop()); + const url = await gateway.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'hello-2__codegraph_status', + arguments: { verbose: true }, + }, + }); + + expect(res.status).toBe(200); + const json = await res.json() as { result: { content: Array<{ text: string }> } }; + expect(json.result.content[0]?.text).toBe('second:codegraph_status'); + expect(second.calls.at(-1)?.params?.name).toBe('codegraph_status'); + expect(second.calls.at(-1)?.params?.arguments).toEqual({ verbose: true }); + expect(first.calls.some((call) => call.method === 'tools/call')).toBe(false); + }); + + it('rejects unknown repository tool prefixes', async () => { + const gateway = new MCPGatewayHttpServer({ repositories: [] }); + cleanup.push(() => gateway.stop()); + const url = await gateway.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'missing__codegraph_status', arguments: {} }, + }); + + expect(res.status).toBe(200); + const json = await res.json() as { error: { code: number; message: string } }; + expect(json.error.code).toBe(-32602); + expect(json.error.message).toContain('Unknown gateway tool'); + }); + + it('starts the gateway from the serve --mcp --http CLI branch', async () => { + const backend = await startBackend('codegraph_status', 'backend'); + cleanup.push(backend.stop); + const child = spawn( + process.execPath, + [ + ...WASM_RUNTIME_FLAGS, + BIN, + 'serve', + '--mcp', + '--http', + '--port', + '0', + '--gateway-repos', + JSON.stringify([{ repoId: 'hello-1', url: backend.url }]), + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ) as ChildProcessWithoutNullStreams; + cleanup.push(() => stopChild(child)); + const url = await waitForHttpUrl(child); + + const res = await post(url, { + jsonrpc: '2.0', + id: 5, + method: 'tools/list', + }); + + expect(res.status).toBe(200); + const json = await res.json() as { result: { tools: Array<{ name: string }> } }; + expect(json.result.tools.map((tool) => tool.name)).toEqual(['hello-1__codegraph_status']); + }, 10000); + + it('starts the repository router from the serve --mcp --http CLI branch', async () => { + const repo = path.join(__dirname, '..', '.tmp-mcp-router-cli'); + fs.rmSync(repo, { recursive: true, force: true }); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync(path.join(repo, 'router-cli.ts'), 'export function routerCliMarker() { return 1; }\n'); + const cg = await CodeGraph.init(repo, { index: true }); + cg.close(); + cleanup.push(() => { + fs.rmSync(repo, { recursive: true, force: true }); + }); + + const child = spawn( + process.execPath, + [ + ...WASM_RUNTIME_FLAGS, + BIN, + 'serve', + '--mcp', + '--http', + '--port', + '0', + '--gateway-repo-paths', + JSON.stringify([{ repoId: 'router-cli', path: repo }]), + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ) as ChildProcessWithoutNullStreams; + cleanup.push(() => stopChild(child)); + const url = await waitForHttpUrl(child); + + const res = await post(url, { + jsonrpc: '2.0', + id: 8, + method: 'tools/list', + }); + + expect(res.status).toBe(200); + const json = await res.json() as { + result: { + tools: Array<{ + name: string; + inputSchema: { properties: Record; required?: string[] }; + }>; + }; + }; + expect(json.result.tools.map((tool) => tool.name)).toContain('codegraph_explore'); + expect(json.result.tools.map((tool) => tool.name)).not.toContain('router-cli__codegraph_explore'); + expect(json.result.tools[0]?.inputSchema.properties.repoId).toBeDefined(); + expect(json.result.tools[0]?.inputSchema.required).toContain('repoId'); + }, 15000); +}); + +describe('MCP repository router HTTP server', () => { + const cleanup: Array<() => Promise | void> = []; + + afterEach(async () => { + while (cleanup.length > 0) { + await cleanup.pop()?.(); + } + }); + + it('lists one shared tool set with a required repoId argument', async () => { + const router = new MCPRepositoryRouterHttpServer({ + repositories: [ + { repoId: 'repo-a', path: path.resolve('fixtures/repo-a') }, + { repoId: 'repo-b', path: path.resolve('fixtures/repo-b') }, + ], + }); + cleanup.push(() => router.stop()); + const url = await router.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 6, + method: 'tools/list', + }); + + expect(res.status).toBe(200); + const json = await res.json() as { + result: { + tools: Array<{ + name: string; + inputSchema: { properties: Record; required?: string[] }; + }>; + }; + }; + const names = json.result.tools.map((tool) => tool.name); + expect(names).toContain('codegraph_explore'); + expect(names).toContain('codegraph_repos'); + expect(names).not.toContain('repo-a__codegraph_explore'); + expect(new Set(names).size).toBe(names.length); + for (const tool of json.result.tools) { + if (tool.name === 'codegraph_repos') continue; + expect(tool.inputSchema.properties.repoId).toBeDefined(); + expect(tool.inputSchema.required).toContain('repoId'); + expect(JSON.stringify(tool.inputSchema.properties.repoId)).not.toContain('repo-a'); + } + }); + + it('lists configured repositories on demand instead of repeating them in every tool schema', async () => { + const router = new MCPRepositoryRouterHttpServer({ + repositories: [ + { repoId: 'repo-a', path: path.resolve('fixtures/repo-a') }, + { repoId: 'repo-b', path: path.resolve('fixtures/repo-b') }, + ], + }); + cleanup.push(() => router.stop()); + const url = await router.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 9, + method: 'tools/call', + params: { + name: 'codegraph_repos', + arguments: {}, + }, + }); + + expect(res.status).toBe(200); + const json = await res.json() as { result: { content: Array<{ text: string }> } }; + expect(json.result.content[0]?.text).toContain('repo-a'); + expect(json.result.content[0]?.text).toContain('repo-b'); + }); + + it('routes shared tool calls to the project mapped by repoId', async () => { + const repoA = path.join(__dirname, '..', '.tmp-mcp-router-a'); + const repoB = path.join(__dirname, '..', '.tmp-mcp-router-b'); + fs.rmSync(repoA, { recursive: true, force: true }); + fs.rmSync(repoB, { recursive: true, force: true }); + fs.mkdirSync(repoA, { recursive: true }); + fs.mkdirSync(repoB, { recursive: true }); + fs.writeFileSync(path.join(repoA, 'only-a.ts'), 'export function repoAMarker() { return 1; }\n'); + fs.writeFileSync(path.join(repoB, 'only-b.ts'), 'export function repoBMarker() { return 2; }\n'); + const a = await CodeGraph.init(repoA, { index: true }); + a.close(); + const b = await CodeGraph.init(repoB, { index: true }); + b.close(); + cleanup.push(() => { + fs.rmSync(repoA, { recursive: true, force: true }); + fs.rmSync(repoB, { recursive: true, force: true }); + }); + + const router = new MCPRepositoryRouterHttpServer({ + repositories: [ + { repoId: 'repo-a', path: repoA }, + { repoId: 'repo-b', path: repoB }, + ], + }); + cleanup.push(() => router.stop()); + const url = await router.start(); + + const res = await post(url, { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { + name: 'codegraph_search', + arguments: { repoId: 'repo-b', query: 'repoBMarker' }, + }, + }); + + expect(res.status).toBe(200); + const json = await res.json() as { result: { content: Array<{ text: string }> } }; + expect(json.result.content[0]?.text).toContain('repoBMarker'); + expect(json.result.content[0]?.text).toContain('only-b.ts'); + expect(json.result.content[0]?.text).not.toContain('repoAMarker'); + }); +}); diff --git a/__tests__/mcp-http.test.ts b/__tests__/mcp-http.test.ts new file mode 100644 index 000000000..57c617eb0 --- /dev/null +++ b/__tests__/mcp-http.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnHttpServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn( + process.execPath, + [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--http', '--port', '0', '--no-watch'], + { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + }, + ) as ChildProcessWithoutNullStreams; +} + +function waitForHttpUrl(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for HTTP server URL. stderr=${stderr}`)); + }, 5000); + const onData = (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + const match = stderr.match(/listening on (http:\/\/[^\s]+)/); + if (match?.[1]) { + cleanup(); + resolve(match[1]); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`server exited before listening, code=${code}, stderr=${stderr}`)); + }; + const cleanup = () => { + clearTimeout(timer); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + child.stderr.on('data', onData); + child.on('exit', onExit); + }); +} + +function initializeBody(projectPath: string) { + return { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }, + }; +} + +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + const cleanup = () => { + clearTimeout(timer); + resolve(); + }; + child.once('exit', cleanup); + child.kill('SIGTERM'); + }); +} + +describe('MCP Streamable HTTP transport', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-http-')); + }); + + afterEach(async () => { + if (child) { + await stopChild(child); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('serves initialize over POST /mcp as application/json', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify(initializeBody(tempDir)), + }); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/application\/json/); + const json = await res.json() as { + jsonrpc: string; + id: number; + result: { serverInfo: { name: string }; capabilities: { tools: unknown } }; + }; + expect(json.jsonrpc).toBe('2.0'); + expect(json.id).toBe(0); + expect(json.result.serverInfo.name).toBe('codegraph'); + expect(json.result.capabilities.tools).toBeDefined(); + }, 10000); + + it('accepts notifications with 202 and no response body', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }), + }); + + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }, 10000); + + it('accepts JSON-RPC responses with 202 and no response body', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'client-1', result: {} }), + }); + + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }, 10000); + + it('does not offer a standalone GET SSE stream yet', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'GET', + headers: { accept: 'text/event-stream' }, + }); + + expect(res.status).toBe(405); + }, 10000); + + it('rejects invalid Origin headers to prevent local DNS rebinding', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + origin: 'https://evil.example', + }, + body: JSON.stringify(initializeBody(tempDir)), + }); + + expect(res.status).toBe(403); + }, 10000); +}); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 0a320773d..555b031af 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -16,11 +16,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function spawnServer(cwd: string): ChildProcessWithoutNullStreams { - return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], // Pin to direct (in-process) mode. #172 is a contract about the in-process @@ -34,6 +35,23 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { }) as ChildProcessWithoutNullStreams; } +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) { const msg = JSON.stringify({ jsonrpc: '2.0', @@ -107,9 +125,9 @@ describe('MCP initialize handshake (issue #172)', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + if (child) { + await stopChild(child); child = null; } fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts index 8e1d4520d..189984b7f 100644 --- a/__tests__/mcp-roots.test.ts +++ b/__tests__/mcp-roots.test.ts @@ -21,17 +21,35 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function spawnServer(cwd: string): ChildProcessWithoutNullStreams { // --no-watch keeps the test deterministic and avoids watcher startup noise. - return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], { + return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--no-watch'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], }) as ChildProcessWithoutNullStreams; } +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + /** Parse every JSON-RPC message the server writes to stdout into an array. */ function collectMessages(child: ChildProcessWithoutNullStreams): Array> { const messages: Array> = []; @@ -84,9 +102,9 @@ describe('MCP project resolution via roots/list (issue #196)', () => { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + if (child) { + await stopChild(child); child = null; } fs.rmSync(cwdDir, { recursive: true, force: true }); diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts index 52b4d1ccb..44539babf 100644 --- a/__tests__/mcp-unindexed.test.ts +++ b/__tests__/mcp-unindexed.test.ts @@ -17,11 +17,12 @@ import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import { ToolHandler } from '../src/mcp/tools'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function spawnServer(cwd: string): ChildProcessWithoutNullStreams { - return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], // Direct (in-process) mode — the unindexed path never has a daemon @@ -31,6 +32,23 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { }) as ChildProcessWithoutNullStreams; } +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + /** Send a JSON-RPC request and resolve with the response matching its id. */ function request( child: ChildProcessWithoutNullStreams, @@ -85,9 +103,9 @@ describe('Unindexed-workspace session policy', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-')); }); - afterEach(() => { + afterEach(async () => { if (child) { - child.kill('SIGKILL'); + await stopChild(child); child = null; } fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3059392d4..582dc8bdb 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1965,6 +1965,8 @@ func main() { (r) => r.dstKind === 'file' && r.dstPath && r.dstPath.endsWith('vector') ); expect(stdlibFile).toBeUndefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } @@ -2024,6 +2026,8 @@ func main() { (r) => r.dstKind === 'file' && r.dstPath === 'src/lib.php' ); expect(resolved, 'page.php → src/lib.php imports edge missing').toBeDefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } @@ -2058,6 +2062,8 @@ func main() { rows.find((r) => r.dstKind === 'file' && r.dstPath === 'inc/db.php'), 'index.php → inc/db.php imports edge missing' ).toBeDefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } @@ -2097,6 +2103,8 @@ func main() { rows.find((r) => r.dstKind === 'file' && r.dstPath === 'lib/inc/db.php'), 'app/page.php must NOT mis-connect to unrelated lib/inc/db.php' ).toBeUndefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } diff --git a/deploy/operator/Makefile b/deploy/operator/Makefile new file mode 100644 index 000000000..85e71001f --- /dev/null +++ b/deploy/operator/Makefile @@ -0,0 +1,19 @@ +SHELL := /bin/sh + +.PHONY: tidy generate manifests test build + +tidy: + go mod tidy + +generate: + go run sigs.k8s.io/controller-tools/cmd/controller-gen object paths="./api/v1alpha1" + +manifests: + mkdir -p config/crd + go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:allowDangerousTypes=true paths="./api/v1alpha1" output:crd:artifacts:config=config/crd + +test: + go test ./... + +build: + go build ./cmd/manager diff --git a/deploy/operator/README.md b/deploy/operator/README.md new file mode 100644 index 000000000..76810ac60 --- /dev/null +++ b/deploy/operator/README.md @@ -0,0 +1,176 @@ +# CodeGraph Operator + +The CodeGraph Operator manages `CodeGraphRepository` and `CodeGraphGateway` custom resources. Repository CRs describe Git repositories to check out, index, and serve inside the cluster. Gateway CRs expose one shared external MCP endpoint that fans out to multiple repository runtime Services. + +For each `CodeGraphRepository`, the operator owns: + +- A checkout/index PVC that stores the cloned repository and its `.codegraph` index. +- A sync/index Job that clones the configured Git ref and runs `codegraph init`. +- A runtime Deployment that serves the indexed repository. +- A Service that exposes the runtime pod inside the cluster. + +The first operator version does not run a Python proxy. The runtime Deployment reuses the existing CodeGraph HTTP MCP server directly with `codegraph serve --mcp --http`. + +For each `CodeGraphGateway`, the operator owns: + +- A ConfigMap containing `repos.json` for the gateway backend list. +- A Deployment that runs `codegraph serve --mcp --http --host 0.0.0.0 --port 3000 --gateway-repos /etc/codegraph-gateway/repos.json`. +- A Service on port 3000. +- A route, either Gateway API `HTTPRoute` or Kubernetes `Ingress`, that exposes the shared gateway path. + +## Multi-repo gateway path + +Use this operator when agents should connect to one HTTP MCP endpoint while CodeGraph indexes and serves many repositories in parallel. + +1. Build and publish one CodeGraph runtime image. +2. Create one `CodeGraphRepository` per Git repository. Each repository gets an isolated checkout/index PVC, sync/index Job, runtime Deployment, and Service. +3. Create one `CodeGraphGateway` that lists those repository Services. +4. Point Codex, Cursor, or another MCP client at the gateway URL, usually `https:///mcp`. + +The gateway prefixes every backend tool with its `repoId`, for example `api__codegraph_explore` and `web__codegraph_node`. Agents keep one MCP server configured and choose the repository by tool prefix. + +## Runtime image + +The sync Job and runtime Deployment use the same CodeGraph runtime image. Build and push that image from the repository root: + +```sh +docker build -f deploy/operator/runtime.Containerfile -t registry.example.com/codegraph-runtime:1.0.1 . +docker push registry.example.com/codegraph-runtime:1.0.1 +``` + +Then either set `spec.image` on each `CodeGraphRepository`, or start the controller with a cluster-wide default: + +```sh +go run ./cmd/manager --runtime-image=registry.example.com/codegraph-runtime:1.0.1 +``` + +If neither `spec.image` nor `--runtime-image` is set, the controller marks the repository `Degraded` with reason `RuntimeImageMissing` and does not create a new PVC or sync Job. Existing runtime resources are left in place so the last healthy repository can keep serving until a valid image is restored. + +## MCP endpoint + +Repository runtimes listen on `/mcp` inside the cluster. A `CodeGraphGateway` exposes a single external endpoint: + +```text +https:///mcp +``` + +A gateway with `host: codegraph.example.com` and `path: /mcp` is served at: + +```text +https://codegraph.example.com/mcp +``` + +The gateway prefixes backend tools with the configured `repoId`, and each backend URL in `repos.json` is generated as: + +```text +http://..svc.cluster.local:3000/mcp +``` + +Repository CRs still create the in-cluster runtime Services. Use a `CodeGraphGateway` route for external access instead of exposing one address per repository. + +Multiple repository runtimes can index, sync, and serve at the same time. The gateway does not merge their SQLite databases; it fans out MCP calls to the selected backend Service based on the prefixed tool name. + +For a local Rancher Desktop deployment exposed through `127.0.0.1`, Codex only needs one MCP server entry: + +```toml +[mcp_servers.codegraph_k8s] +url = "http://127.0.0.1/mcp" +enabled = true +``` + +## Sample repository + +Apply the sample manifest after installing the CRD and starting the controller: + +```sh +kubectl create namespace codegraph +kubectl apply -f config/samples/codegraphrepository.yaml +kubectl -n codegraph get codegraphrepository api-service +``` + +The sample uses manual sync, a 20Gi PVC, an explicit runtime image, and a Git authentication secret reference. Replace the image with the registry tag you built and pushed. + +## Sample gateway + +Apply a gateway after the repository Services exist: + +```sh +kubectl apply -f config/samples/codegraphgateway.yaml +kubectl -n codegraph get codegraphgateway team-gateway +``` + +The sample exposes `https://codegraph.example.com/mcp` and points to the `codegraph-api-service` and `codegraph-web-client` Services in the same namespace. + +## Status fields + +`status.resolvedRef` records the requested `spec.git.ref` observed by the latest completed sync in this first operator version. It is not a resolved commit SHA. `status.lastSyncTime` records the sync Job completion time. + +## Git authentication + +`spec.git.authSecretRef` points to an optional Kubernetes Secret in the same namespace as the `CodeGraphRepository`. + +For HTTPS repositories, provide `GIT_USERNAME` and `GIT_PASSWORD` keys: + +```sh +kubectl -n codegraph create secret generic api-service-git-auth \ + --from-literal=GIT_USERNAME= \ + --from-literal=GIT_PASSWORD= +``` + +For SSH repositories, provide the standard `ssh-privatekey` key: + +```sh +kubectl -n codegraph create secret generic api-service-git-auth \ + --from-file=ssh-privatekey=./id_ed25519 +``` + +The sync/index Job mounts the same secret as environment variables and as an SSH key volume. HTTPS credentials are used through `GIT_ASKPASS`; SSH credentials are used through `GIT_SSH_COMMAND`. + +## Routing modes + +The controller supports two routing modes: + +- `gateway`: creates a Gateway API `HTTPRoute`. This is the default mode. Use `--gateway-name` and optionally `--gateway-namespace` to select the parent Gateway. +- `ingress`: creates a Kubernetes `Ingress` for the nginx ingress class. + +For `CodeGraphGateway`, both modes expose `spec.host` and `spec.path` directly to the gateway Service. For `CodeGraphRepository`, the legacy route exposes `spec.mcp.host` and `spec.mcp.path`, then rewrites `/mcp/` to the pod-local `/mcp` endpoint. + +Gateway mode requires the Gateway API CRDs to be installed and a compatible `Gateway` that allows `HTTPRoute` attachment from the repository namespace. Ingress mode assumes an nginx ingress controller that honors the rewrite annotations emitted by the operator. + +## Local development + +From `deploy/operator`: + +```sh +go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:allowDangerousTypes=true paths="./api/v1alpha1" output:crd:artifacts:config=config/crd +go test ./... +go run ./cmd/manager --route-mode=gateway --gateway-name=codegraph --runtime-image=registry.example.com/codegraph-runtime:1.0.1 +``` + +On Windows PowerShell, install Go and ensure `go` is on `PATH`, then run the same commands with PowerShell quoting: + +```powershell +go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:allowDangerousTypes=true paths="./api/v1alpha1" output:crd:artifacts:config=config/crd +go test ./... +``` + +Useful local checks: + +```sh +kubectl apply -f config/crd +kubectl apply -f config/samples/codegraphrepository.yaml +kubectl apply -f config/samples/codegraphgateway.yaml +kubectl -n codegraph describe codegraphrepository api-service +kubectl -n codegraph describe codegraphgateway team-gateway +kubectl -n codegraph get pvc,job,deploy,svc,cm +``` + +## Root validation + +From the repository root, run the operator validation entry point with: + +```sh +npm run test:operator +``` + +This runs only the Go operator tests under `deploy/operator`; it does not run the full TypeScript test suite. diff --git a/deploy/operator/api/v1alpha1/codegraphgateway_types.go b/deploy/operator/api/v1alpha1/codegraphgateway_types.go new file mode 100644 index 000000000..c7cd7dce2 --- /dev/null +++ b/deploy/operator/api/v1alpha1/codegraphgateway_types.go @@ -0,0 +1,108 @@ +package v1alpha1 + +import ( + "net" + "strings" + + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// CodeGraphGatewaySpec defines the desired state of CodeGraphGateway. +type CodeGraphGatewaySpec struct { + // Host is the shared external MCP host. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` + Host string `json:"host"` + + // Path is the shared external MCP path exposed by the gateway. + // +kubebuilder:default=/mcp + // +kubebuilder:validation:Pattern=`^/[A-Za-z0-9._~!$&'()*+,;=:@/-]*$` + Path string `json:"path"` + + // Repositories are the backend CodeGraph MCP services served by this gateway. + // +listType=map + // +listMapKey=repoId + Repositories []GatewayRepository `json:"repositories"` +} + +type GatewayRepository struct { + // RepoID is the stable identifier used to prefix tools exposed by the gateway. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=63 + RepoID string `json:"repoId"` + + // ServiceName is the in-cluster service name for the repository runtime. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=63 + ServiceName string `json:"serviceName"` +} + +// CodeGraphGatewayStatus defines the observed state of CodeGraphGateway. +type CodeGraphGatewayStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Phase string `json:"phase,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + RouteName string `json:"routeName,omitempty"` + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type CodeGraphGateway struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CodeGraphGatewaySpec `json:"spec,omitempty"` + Status CodeGraphGatewayStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type CodeGraphGatewayList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CodeGraphGateway `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CodeGraphGateway{}, &CodeGraphGatewayList{}) +} + +func (g *CodeGraphGateway) GatewayPath() string { + path := g.Spec.Path + if path == "" { + path = "/mcp" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func (g *CodeGraphGateway) Endpoint() string { + host := strings.TrimRight(g.Spec.Host, "/") + scheme := "https" + if isLocalHTTPHost(host) { + scheme = "http" + } + return scheme + "://" + host + g.GatewayPath() +} + +func (g *CodeGraphGateway) SetCondition(condition metav1.Condition) { + condition.ObservedGeneration = g.Generation + apiMeta.SetStatusCondition(&g.Status.Conditions, condition) +} + +func isLocalHTTPHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} diff --git a/deploy/operator/api/v1alpha1/codegraphgateway_types_test.go b/deploy/operator/api/v1alpha1/codegraphgateway_types_test.go new file mode 100644 index 000000000..f7c3c7655 --- /dev/null +++ b/deploy/operator/api/v1alpha1/codegraphgateway_types_test.go @@ -0,0 +1,141 @@ +package v1alpha1 + +import ( + "os" + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" +) + +func TestGatewayEndpointDefaultsToMCPPath(t *testing.T) { + gateway := &CodeGraphGateway{ + Spec: CodeGraphGatewaySpec{ + Host: "codegraph.example.com", + }, + } + + if got := gateway.GatewayPath(); got != "/mcp" { + t.Fatalf("GatewayPath() = %q", got) + } + if got := gateway.Endpoint(); got != "https://codegraph.example.com/mcp" { + t.Fatalf("Endpoint() = %q", got) + } +} + +func TestGatewayEndpointUsesHTTPForLocalAddresses(t *testing.T) { + gateway := &CodeGraphGateway{ + Spec: CodeGraphGatewaySpec{ + Host: "127.0.0.1", + Path: "/mcp", + }, + } + + if got := gateway.Endpoint(); got != "http://127.0.0.1/mcp" { + t.Fatalf("Endpoint() = %q", got) + } +} + +func TestGatewaySetConditionReplacesSameType(t *testing.T) { + gateway := &CodeGraphGateway{} + gateway.SetCondition(metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionFalse, + Reason: "Pending", + Message: "gateway is pending", + }) + gateway.SetCondition(metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionTrue, + Reason: "RuntimeAvailable", + Message: "gateway is ready", + }) + + if len(gateway.Status.Conditions) != 1 { + t.Fatalf("expected 1 condition, got %d", len(gateway.Status.Conditions)) + } + if gateway.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatalf("condition status = %s", gateway.Status.Conditions[0].Status) + } +} + +func TestAddToSchemeRegistersCodeGraphGateway(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme() error = %v", err) + } + + obj, err := scheme.New(GroupVersion.WithKind("CodeGraphGateway")) + if err != nil { + t.Fatalf("scheme.New(CodeGraphGateway) error = %v", err) + } + if _, ok := obj.(*CodeGraphGateway); !ok { + t.Fatalf("scheme.New(CodeGraphGateway) = %T", obj) + } +} + +func TestGeneratedGatewayCRDIncludesSchemaMarkers(t *testing.T) { + schema := readGeneratedGatewayOpenAPISchema(t) + + path := gatewaySchemaProperty(t, schema, "spec", "path") + if got := path["default"]; got != "/mcp" { + t.Fatalf("spec.path default = %v", got) + } + if got := path["pattern"]; got != `^/[A-Za-z0-9._~!$&'()*+,;=:@/-]*$` { + t.Fatalf("spec.path pattern = %v", got) + } + + repositories := gatewaySchemaProperty(t, schema, "spec", "repositories") + if got := repositories["x-kubernetes-list-type"]; got != "map" { + t.Fatalf("spec.repositories x-kubernetes-list-type = %v", got) + } + if got := repositories["x-kubernetes-list-map-keys"]; !reflect.DeepEqual(got, []any{"repoId"}) { + t.Fatalf("spec.repositories x-kubernetes-list-map-keys = %#v", got) + } + + conditions := gatewaySchemaProperty(t, schema, "status", "conditions") + if got := conditions["x-kubernetes-list-type"]; got != "map" { + t.Fatalf("status.conditions x-kubernetes-list-type = %v", got) + } + if got := conditions["x-kubernetes-list-map-keys"]; !reflect.DeepEqual(got, []any{"type"}) { + t.Fatalf("status.conditions x-kubernetes-list-map-keys = %#v", got) + } +} + +func readGeneratedGatewayOpenAPISchema(t *testing.T) map[string]any { + t.Helper() + + crdBytes, err := os.ReadFile("../../config/crd/codegraph.dev_codegraphgateways.yaml") + if err != nil { + t.Fatalf("read CRD: %v", err) + } + + var crd map[string]any + if err := yaml.Unmarshal(crdBytes, &crd); err != nil { + t.Fatalf("parse CRD: %v", err) + } + + spec := mapValue(t, crd, "spec") + versions, ok := spec["versions"].([]any) + if !ok || len(versions) == 0 { + t.Fatalf("spec.versions = %#v", spec["versions"]) + } + firstVersion, ok := versions[0].(map[string]any) + if !ok { + t.Fatalf("spec.versions[0] = %T", versions[0]) + } + return mapValue(t, mapValue(t, firstVersion, "schema"), "openAPIV3Schema") +} + +func gatewaySchemaProperty(t *testing.T, schema map[string]any, path ...string) map[string]any { + t.Helper() + + current := schema + for _, segment := range path { + properties := mapValue(t, current, "properties") + current = mapValue(t, properties, segment) + } + return current +} diff --git a/deploy/operator/api/v1alpha1/codegraphrepository_types.go b/deploy/operator/api/v1alpha1/codegraphrepository_types.go new file mode 100644 index 000000000..ebe78905a --- /dev/null +++ b/deploy/operator/api/v1alpha1/codegraphrepository_types.go @@ -0,0 +1,176 @@ +package v1alpha1 + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ConditionReady = "Ready" + ConditionIndexed = "Indexed" + + PhasePending = "Pending" + PhaseSyncing = "Syncing" + PhaseIndexing = "Indexing" + PhaseReady = "Ready" + PhaseDegraded = "Degraded" +) + +type SyncMode string + +const ( + SyncModeManual SyncMode = "manual" +) + +// CodeGraphRepositorySpec defines the desired state of CodeGraphRepository. +// +kubebuilder:validation:XValidation:rule="self.mcp.path == '/mcp/' + self.repoId",message="spec.mcp.path must equal /mcp/" +type CodeGraphRepositorySpec struct { + // RepoID is the stable path and resource identifier. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=63 + RepoID string `json:"repoId"` + + Git GitSpec `json:"git"` + + MCP MCPSpec `json:"mcp"` + + Storage StorageSpec `json:"storage"` + + // +kubebuilder:default={mode:manual} + Sync SyncSpec `json:"sync,omitempty"` + + // Image overrides the operator default CodeGraph runtime image. Set this when the controller was not started with --runtime-image. + // +kubebuilder:validation:MinLength=1 + // +optional + Image string `json:"image,omitempty"` + + // Resources applies to both runtime and sync/index containers. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // NodeSelector constrains runtime and sync/index pods. + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // Tolerations apply to runtime and sync/index pods. + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Affinity applies to runtime and sync/index pods. + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` +} + +type GitSpec struct { + // URL is the repository clone URL. + // +kubebuilder:validation:MinLength=1 + URL string `json:"url"` + + // Ref is the branch, tag, or commit to index. + // +kubebuilder:validation:MinLength=1 + Ref string `json:"ref"` + + // AuthSecretRef points to credentials used by git. + // +optional + AuthSecretRef *corev1.LocalObjectReference `json:"authSecretRef,omitempty"` +} + +type MCPSpec struct { + // Host is the shared external MCP host. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` + Host string `json:"host"` + + // Path is the external path, normally /mcp/. + // +kubebuilder:validation:Pattern=`^/mcp/[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + Path string `json:"path"` +} + +type StorageSpec struct { + // Size is the PVC request for checkout and .codegraph data. + Size resource.Quantity `json:"size"` + + // StorageClassName selects the storage class. + // +optional + StorageClassName *string `json:"storageClassName,omitempty"` +} + +type SyncSpec struct { + // Mode controls repository refresh behavior. + // +kubebuilder:validation:Enum=manual + // +kubebuilder:default=manual + Mode SyncMode `json:"mode,omitempty"` +} + +// CodeGraphRepositoryStatus defines the observed state of CodeGraphRepository. +type CodeGraphRepositoryStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Phase string `json:"phase,omitempty"` + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + // ResolvedRef is the requested spec.git.ref observed by the latest completed sync in this first version; it is not a resolved commit SHA. + ResolvedRef string `json:"resolvedRef,omitempty"` + LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + RouteName string `json:"routeName,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Repo",type=string,JSONPath=`.spec.repoId` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type CodeGraphRepository struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CodeGraphRepositorySpec `json:"spec,omitempty"` + Status CodeGraphRepositoryStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type CodeGraphRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CodeGraphRepository `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CodeGraphRepository{}, &CodeGraphRepositoryList{}) +} + +func (r *CodeGraphRepository) Endpoint() string { + host := strings.TrimRight(r.Spec.MCP.Host, "/") + path := r.Spec.MCP.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return "https://" + host + path +} + +func (r *CodeGraphRepository) ExpectedMCPPath() string { + return "/mcp/" + r.Spec.RepoID +} + +func (r *CodeGraphRepository) MCPPathMatchesRepoID() bool { + return r.Spec.MCP.Path == r.ExpectedMCPPath() +} + +func (r *CodeGraphRepository) RuntimeImage(defaultImage string) string { + if r.Spec.Image != "" { + return r.Spec.Image + } + return defaultImage +} + +func (r *CodeGraphRepository) SetCondition(condition metav1.Condition) { + condition.ObservedGeneration = r.Generation + apiMeta.SetStatusCondition(&r.Status.Conditions, condition) +} diff --git a/deploy/operator/api/v1alpha1/codegraphrepository_types_test.go b/deploy/operator/api/v1alpha1/codegraphrepository_types_test.go new file mode 100644 index 000000000..de60ce358 --- /dev/null +++ b/deploy/operator/api/v1alpha1/codegraphrepository_types_test.go @@ -0,0 +1,222 @@ +package v1alpha1 + +import ( + "os" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" +) + +func TestEndpointBuildsFromHostAndPath(t *testing.T) { + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + RepoID: "api-service", + MCP: MCPSpec{ + Host: "codegraph.example.com", + Path: "/mcp/api-service", + }, + }, + } + + if got := repo.Endpoint(); got != "https://codegraph.example.com/mcp/api-service" { + t.Fatalf("Endpoint() = %q", got) + } +} + +func TestMCPPathHelpersMatchRepoID(t *testing.T) { + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + RepoID: "api-service", + MCP: MCPSpec{ + Path: "/mcp/api-service", + }, + }, + } + + if got := repo.ExpectedMCPPath(); got != "/mcp/api-service" { + t.Fatalf("ExpectedMCPPath() = %q", got) + } + if !repo.MCPPathMatchesRepoID() { + t.Fatalf("MCPPathMatchesRepoID() = false, want true") + } + + repo.Spec.MCP.Path = "/mcp/other-service" + if repo.MCPPathMatchesRepoID() { + t.Fatalf("MCPPathMatchesRepoID() = true, want false") + } +} + +func TestDefaultImageUsesSpecImageWhenSet(t *testing.T) { + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + Image: "registry.example.com/codegraph:v1", + }, + } + + if got := repo.RuntimeImage("fallback:image"); got != "registry.example.com/codegraph:v1" { + t.Fatalf("RuntimeImage() = %q", got) + } +} + +func TestDefaultImageUsesFallbackWhenSpecImageEmpty(t *testing.T) { + repo := &CodeGraphRepository{} + + if got := repo.RuntimeImage("fallback:image"); got != "fallback:image" { + t.Fatalf("RuntimeImage() = %q", got) + } +} + +func TestDefaultImageCanBeEmptyWhenImageUnset(t *testing.T) { + repo := &CodeGraphRepository{} + + if got := repo.RuntimeImage(""); got != "" { + t.Fatalf("RuntimeImage() = %q", got) + } +} + +func TestSetConditionReplacesSameType(t *testing.T) { + repo := &CodeGraphRepository{} + repo.SetCondition(metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionFalse, + Reason: "Pending", + Message: "runtime is not ready", + }) + repo.SetCondition(metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionTrue, + Reason: "RuntimeAvailable", + Message: "runtime is ready", + }) + + if len(repo.Status.Conditions) != 1 { + t.Fatalf("expected 1 condition, got %d", len(repo.Status.Conditions)) + } + if repo.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatalf("condition status = %s", repo.Status.Conditions[0].Status) + } +} + +func TestStorageSizeUsesQuantity(t *testing.T) { + size := resource.MustParse("20Gi") + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + Storage: StorageSpec{Size: size}, + }, + } + + if repo.Spec.Storage.Size.String() != "20Gi" { + t.Fatalf("storage size = %s", repo.Spec.Storage.Size.String()) + } +} + +func TestAddToSchemeRegistersCodeGraphRepository(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme() error = %v", err) + } + + obj, err := scheme.New(GroupVersion.WithKind("CodeGraphRepository")) + if err != nil { + t.Fatalf("scheme.New(CodeGraphRepository) error = %v", err) + } + if _, ok := obj.(*CodeGraphRepository); !ok { + t.Fatalf("scheme.New(CodeGraphRepository) = %T", obj) + } +} + +func TestGeneratedCRDIncludesStructuralSchemaMarkers(t *testing.T) { + schema := readGeneratedOpenAPISchema(t) + + conditions := schemaProperty(t, schema, "status", "conditions") + if got := conditions["x-kubernetes-list-type"]; got != "map" { + t.Fatalf("status.conditions x-kubernetes-list-type = %v", got) + } + if got := conditions["x-kubernetes-list-map-keys"]; !reflect.DeepEqual(got, []any{"type"}) { + t.Fatalf("status.conditions x-kubernetes-list-map-keys = %#v", got) + } + + sync := schemaProperty(t, schema, "spec", "sync") + defaultValue, ok := sync["default"].(map[string]any) + if !ok { + t.Fatalf("spec.sync default = %T, want map", sync["default"]) + } + if got := defaultValue["mode"]; got != "manual" { + t.Fatalf("spec.sync default.mode = %v", got) + } + + host := schemaProperty(t, schema, "spec", "mcp", "host") + if got := host["pattern"]; got != `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` { + t.Fatalf("spec.mcp.host pattern = %v", got) + } + + spec := schemaProperty(t, schema, "spec") + validations, ok := spec["x-kubernetes-validations"].([]any) + if !ok { + t.Fatalf("spec x-kubernetes-validations = %T, want list", spec["x-kubernetes-validations"]) + } + for _, validation := range validations { + item, ok := validation.(map[string]any) + if !ok { + t.Fatalf("validation = %T, want map", validation) + } + if item["rule"] == "self.mcp.path == '/mcp/' + self.repoId" { + return + } + } + t.Fatalf("missing MCP path validation in %#v", validations) +} + +func readGeneratedOpenAPISchema(t *testing.T) map[string]any { + t.Helper() + + crdBytes, err := os.ReadFile("../../config/crd/codegraph.dev_codegraphrepositories.yaml") + if err != nil { + t.Fatalf("read CRD: %v", err) + } + + var crd map[string]any + if err := yaml.Unmarshal(crdBytes, &crd); err != nil { + t.Fatalf("parse CRD: %v", err) + } + + spec := mapValue(t, crd, "spec") + versions, ok := spec["versions"].([]any) + if !ok || len(versions) == 0 { + t.Fatalf("spec.versions = %#v", spec["versions"]) + } + firstVersion, ok := versions[0].(map[string]any) + if !ok { + t.Fatalf("spec.versions[0] = %T", versions[0]) + } + return mapValue(t, mapValue(t, firstVersion, "schema"), "openAPIV3Schema") +} + +func schemaProperty(t *testing.T, schema map[string]any, path ...string) map[string]any { + t.Helper() + + current := schema + for _, segment := range path { + properties := mapValue(t, current, "properties") + current = mapValue(t, properties, segment) + } + return current +} + +func mapValue(t *testing.T, values map[string]any, key string) map[string]any { + t.Helper() + + value, ok := values[key] + if !ok { + t.Fatalf("missing key %q in %#v", key, values) + } + mapped, ok := value.(map[string]any) + if !ok { + t.Fatalf("%q = %T, want map", key, value) + } + return mapped +} diff --git a/deploy/operator/api/v1alpha1/groupversion_info.go b/deploy/operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 000000000..79f6aed77 --- /dev/null +++ b/deploy/operator/api/v1alpha1/groupversion_info.go @@ -0,0 +1,15 @@ +// Package v1alpha1 contains API Schema definitions for the codegraph v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=codegraph.dev +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "codegraph.dev", Version: "v1alpha1"} + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go b/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..6a73b026e --- /dev/null +++ b/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,322 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphGateway) DeepCopyInto(out *CodeGraphGateway) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphGateway. +func (in *CodeGraphGateway) DeepCopy() *CodeGraphGateway { + if in == nil { + return nil + } + out := new(CodeGraphGateway) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CodeGraphGateway) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphGatewayList) DeepCopyInto(out *CodeGraphGatewayList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CodeGraphGateway, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphGatewayList. +func (in *CodeGraphGatewayList) DeepCopy() *CodeGraphGatewayList { + if in == nil { + return nil + } + out := new(CodeGraphGatewayList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CodeGraphGatewayList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphGatewaySpec) DeepCopyInto(out *CodeGraphGatewaySpec) { + *out = *in + if in.Repositories != nil { + in, out := &in.Repositories, &out.Repositories + *out = make([]GatewayRepository, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphGatewaySpec. +func (in *CodeGraphGatewaySpec) DeepCopy() *CodeGraphGatewaySpec { + if in == nil { + return nil + } + out := new(CodeGraphGatewaySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphGatewayStatus) DeepCopyInto(out *CodeGraphGatewayStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphGatewayStatus. +func (in *CodeGraphGatewayStatus) DeepCopy() *CodeGraphGatewayStatus { + if in == nil { + return nil + } + out := new(CodeGraphGatewayStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphRepository) DeepCopyInto(out *CodeGraphRepository) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphRepository. +func (in *CodeGraphRepository) DeepCopy() *CodeGraphRepository { + if in == nil { + return nil + } + out := new(CodeGraphRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CodeGraphRepository) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphRepositoryList) DeepCopyInto(out *CodeGraphRepositoryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CodeGraphRepository, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphRepositoryList. +func (in *CodeGraphRepositoryList) DeepCopy() *CodeGraphRepositoryList { + if in == nil { + return nil + } + out := new(CodeGraphRepositoryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CodeGraphRepositoryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphRepositorySpec) DeepCopyInto(out *CodeGraphRepositorySpec) { + *out = *in + in.Git.DeepCopyInto(&out.Git) + out.MCP = in.MCP + in.Storage.DeepCopyInto(&out.Storage) + out.Sync = in.Sync + in.Resources.DeepCopyInto(&out.Resources) + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphRepositorySpec. +func (in *CodeGraphRepositorySpec) DeepCopy() *CodeGraphRepositorySpec { + if in == nil { + return nil + } + out := new(CodeGraphRepositorySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CodeGraphRepositoryStatus) DeepCopyInto(out *CodeGraphRepositoryStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastSyncTime != nil { + in, out := &in.LastSyncTime, &out.LastSyncTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodeGraphRepositoryStatus. +func (in *CodeGraphRepositoryStatus) DeepCopy() *CodeGraphRepositoryStatus { + if in == nil { + return nil + } + out := new(CodeGraphRepositoryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayRepository) DeepCopyInto(out *GatewayRepository) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayRepository. +func (in *GatewayRepository) DeepCopy() *GatewayRepository { + if in == nil { + return nil + } + out := new(GatewayRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitSpec) DeepCopyInto(out *GitSpec) { + *out = *in + if in.AuthSecretRef != nil { + in, out := &in.AuthSecretRef, &out.AuthSecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitSpec. +func (in *GitSpec) DeepCopy() *GitSpec { + if in == nil { + return nil + } + out := new(GitSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPSpec) DeepCopyInto(out *MCPSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPSpec. +func (in *MCPSpec) DeepCopy() *MCPSpec { + if in == nil { + return nil + } + out := new(MCPSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StorageSpec) DeepCopyInto(out *StorageSpec) { + *out = *in + out.Size = in.Size.DeepCopy() + if in.StorageClassName != nil { + in, out := &in.StorageClassName, &out.StorageClassName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StorageSpec. +func (in *StorageSpec) DeepCopy() *StorageSpec { + if in == nil { + return nil + } + out := new(StorageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SyncSpec) DeepCopyInto(out *SyncSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncSpec. +func (in *SyncSpec) DeepCopy() *SyncSpec { + if in == nil { + return nil + } + out := new(SyncSpec) + in.DeepCopyInto(out) + return out +} diff --git a/deploy/operator/cmd/manager/main.go b/deploy/operator/cmd/manager/main.go new file mode 100644 index 000000000..4c87d1c69 --- /dev/null +++ b/deploy/operator/cmd/manager/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "flag" + "fmt" + "os" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + "github.com/colbymchenry/codegraph/deploy/operator/internal/controller" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(codegraphv1alpha1.AddToScheme(scheme)) + utilruntime.Must(gatewayv1.Install(scheme)) +} + +func main() { + var metricsAddr string + var probeAddr string + var enableLeaderElection bool + var routeMode string + var gatewayName string + var gatewayNamespace string + var runtimeImage string + zapOptions := zap.Options{} + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.StringVar(&routeMode, "route-mode", "gateway", "Routing mode: gateway or ingress.") + flag.StringVar(&gatewayName, "gateway-name", "codegraph", "Gateway name used when route-mode=gateway.") + flag.StringVar(&gatewayNamespace, "gateway-namespace", "", "Gateway namespace used when route-mode=gateway. Defaults to each repository namespace.") + flag.StringVar(&runtimeImage, "runtime-image", "", "Default CodeGraph runtime image used when spec.image is omitted.") + zapOptions.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOptions))) + if err := validateRouteMode(routeMode); err != nil { + ctrl.Log.Error(err, "invalid route mode") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "codegraph.dev", + }) + if err != nil { + ctrl.Log.Error(err, "unable to start manager") + os.Exit(1) + } + + reconciler := &controller.CodeGraphRepositoryReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + DefaultImage: runtimeImage, + RouteMode: routeMode, + GatewayName: gatewayName, + GatewayNamespace: gatewayNamespace, + } + if err := reconciler.SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to create controller") + os.Exit(1) + } + gatewayReconciler := &controller.CodeGraphGatewayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + DefaultImage: runtimeImage, + RouteMode: routeMode, + GatewayName: gatewayName, + GatewayNamespace: gatewayNamespace, + } + if err := gatewayReconciler.SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to create gateway controller") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + ctrl.Log.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + ctrl.Log.Error(err, "unable to set up ready check") + os.Exit(1) + } + + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + ctrl.Log.Error(err, "problem running manager") + os.Exit(1) + } +} + +func validateRouteMode(routeMode string) error { + switch routeMode { + case "", "gateway", "ingress": + return nil + default: + return fmt.Errorf("route-mode must be gateway or ingress, got %q", routeMode) + } +} diff --git a/deploy/operator/cmd/manager/main_test.go b/deploy/operator/cmd/manager/main_test.go new file mode 100644 index 000000000..0d0b205f1 --- /dev/null +++ b/deploy/operator/cmd/manager/main_test.go @@ -0,0 +1,15 @@ +package main + +import "testing" + +func TestValidateRouteMode(t *testing.T) { + for _, mode := range []string{"", "gateway", "ingress"} { + if err := validateRouteMode(mode); err != nil { + t.Fatalf("validateRouteMode(%q) error = %v", mode, err) + } + } + + if err := validateRouteMode("mesh"); err == nil { + t.Fatalf("validateRouteMode(mesh) error = nil, want error") + } +} diff --git a/deploy/operator/config/crd/codegraph.dev_codegraphgateways.yaml b/deploy/operator/config/crd/codegraph.dev_codegraphgateways.yaml new file mode 100644 index 000000000..799c99e15 --- /dev/null +++ b/deploy/operator/config/crd/codegraph.dev_codegraphgateways.yaml @@ -0,0 +1,169 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: codegraphgateways.codegraph.dev +spec: + group: codegraph.dev + names: + kind: CodeGraphGateway + listKind: CodeGraphGatewayList + plural: codegraphgateways + singular: codegraphgateway + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.endpoint + name: Endpoint + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: CodeGraphGatewaySpec defines the desired state of CodeGraphGateway. + properties: + host: + description: Host is the shared external MCP host. + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + path: + default: /mcp + description: Path is the shared external MCP path exposed by the gateway. + pattern: ^/[A-Za-z0-9._~!$&'()*+,;=:@/-]*$ + type: string + repositories: + description: Repositories are the backend CodeGraph MCP services served + by this gateway. + items: + properties: + repoId: + description: RepoID is the stable identifier used to prefix + tools exposed by the gateway. + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + serviceName: + description: ServiceName is the in-cluster service name for + the repository runtime. + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - repoId + - serviceName + type: object + type: array + x-kubernetes-list-map-keys: + - repoId + x-kubernetes-list-type: map + required: + - host + - path + - repositories + type: object + status: + description: CodeGraphGatewayStatus defines the observed state of CodeGraphGateway. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + endpoint: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + routeName: + type: string + serviceName: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml b/deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml new file mode 100644 index 000000000..db4e6b679 --- /dev/null +++ b/deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml @@ -0,0 +1,1252 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: codegraphrepositories.codegraph.dev +spec: + group: codegraph.dev + names: + kind: CodeGraphRepository + listKind: CodeGraphRepositoryList + plural: codegraphrepositories + singular: codegraphrepository + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.repoId + name: Repo + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.endpoint + name: Endpoint + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: CodeGraphRepositorySpec defines the desired state of CodeGraphRepository. + properties: + affinity: + description: Affinity applies to runtime and sync/index pods. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + git: + properties: + authSecretRef: + description: AuthSecretRef points to credentials used by git. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + ref: + description: Ref is the branch, tag, or commit to index. + minLength: 1 + type: string + url: + description: URL is the repository clone URL. + minLength: 1 + type: string + required: + - ref + - url + type: object + image: + description: Image overrides the operator default CodeGraph runtime + image. Set this when the controller was not started with --runtime-image. + minLength: 1 + type: string + mcp: + properties: + host: + description: Host is the shared external MCP host. + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + path: + description: Path is the external path, normally /mcp/. + pattern: ^/mcp/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - host + - path + type: object + nodeSelector: + additionalProperties: + type: string + description: NodeSelector constrains runtime and sync/index pods. + type: object + repoId: + description: RepoID is the stable path and resource identifier. + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + resources: + description: Resources applies to both runtime and sync/index containers. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + storage: + properties: + size: + anyOf: + - type: integer + - type: string + description: Size is the PVC request for checkout and .codegraph + data. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + storageClassName: + description: StorageClassName selects the storage class. + type: string + required: + - size + type: object + sync: + default: + mode: manual + properties: + mode: + default: manual + description: Mode controls repository refresh behavior. + enum: + - manual + type: string + type: object + tolerations: + description: Tolerations apply to runtime and sync/index pods. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + required: + - git + - mcp + - repoId + - storage + type: object + x-kubernetes-validations: + - message: spec.mcp.path must equal /mcp/ + rule: self.mcp.path == '/mcp/' + self.repoId + status: + description: CodeGraphRepositoryStatus defines the observed state of CodeGraphRepository. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + endpoint: + type: string + lastSyncTime: + format: date-time + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resolvedRef: + description: ResolvedRef is the requested spec.git.ref observed by + the latest completed sync in this first version; it is not a resolved + commit SHA. + type: string + routeName: + type: string + serviceName: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/operator/config/samples/codegraphgateway-local-verify.yaml b/deploy/operator/config/samples/codegraphgateway-local-verify.yaml new file mode 100644 index 000000000..bd281b49c --- /dev/null +++ b/deploy/operator/config/samples/codegraphgateway-local-verify.yaml @@ -0,0 +1,19 @@ +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphGateway +metadata: + name: local + namespace: codegraph-verify +spec: + host: "127.0.0.1" + path: /mcp + repositories: + - repoId: hello-1 + serviceName: codegraph-hello-1 + - repoId: hello-2 + serviceName: codegraph-hello-2 + - repoId: hello-3 + serviceName: codegraph-hello-3 + - repoId: hello-4 + serviceName: codegraph-hello-4 + - repoId: hello-5 + serviceName: codegraph-hello-5 diff --git a/deploy/operator/config/samples/codegraphgateway.yaml b/deploy/operator/config/samples/codegraphgateway.yaml new file mode 100644 index 000000000..a4d8c9111 --- /dev/null +++ b/deploy/operator/config/samples/codegraphgateway.yaml @@ -0,0 +1,13 @@ +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphGateway +metadata: + name: team-gateway + namespace: codegraph +spec: + host: codegraph.example.com + path: /mcp + repositories: + - repoId: api-service + serviceName: codegraph-api-service + - repoId: web-client + serviceName: codegraph-web-client diff --git a/deploy/operator/config/samples/codegraphrepository.yaml b/deploy/operator/config/samples/codegraphrepository.yaml new file mode 100644 index 000000000..5648c5e46 --- /dev/null +++ b/deploy/operator/config/samples/codegraphrepository.yaml @@ -0,0 +1,20 @@ +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphRepository +metadata: + name: api-service + namespace: codegraph +spec: + repoId: api-service + image: registry.example.com/codegraph-runtime:1.0.1 + git: + url: https://github.com/example/api-service.git + ref: main + authSecretRef: + name: api-service-git-auth + mcp: + host: codegraph.example.com + path: /mcp/api-service + storage: + size: 20Gi + sync: + mode: manual diff --git a/deploy/operator/go.mod b/deploy/operator/go.mod new file mode 100644 index 000000000..4abeb2497 --- /dev/null +++ b/deploy/operator/go.mod @@ -0,0 +1,79 @@ +module github.com/colbymchenry/codegraph/deploy/operator + +go 1.23.0 + +toolchain go1.23.12 + +require ( + k8s.io/api v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 + sigs.k8s.io/controller-runtime v0.20.2 + sigs.k8s.io/controller-tools v0.17.2 + sigs.k8s.io/gateway-api v1.2.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/mod v0.23.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 // indirect + golang.org/x/time v0.7.0 // indirect + golang.org/x/tools v0.30.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.32.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/deploy/operator/go.sum b/deploy/operator/go.sum new file mode 100644 index 000000000..f7205efbe --- /dev/null +++ b/deploy/operator/go.sum @@ -0,0 +1,215 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= +github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= +github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= +k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= +k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= +k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= +k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= +k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= +sigs.k8s.io/controller-runtime v0.20.2/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/controller-tools v0.17.2 h1:jNFOKps8WnaRKZU2R+4vRCHnXyJanVmXBWqkuUPFyFg= +sigs.k8s.io/controller-tools v0.17.2/go.mod h1:4q5tZG2JniS5M5bkiXY2/potOiXyhoZVw/U48vLkXk0= +sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= +sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/deploy/operator/internal/controller/codegraphgateway_controller.go b/deploy/operator/internal/controller/codegraphgateway_controller.go new file mode 100644 index 000000000..d33824064 --- /dev/null +++ b/deploy/operator/internal/controller/codegraphgateway_controller.go @@ -0,0 +1,258 @@ +package controller + +import ( + "context" + "fmt" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + "github.com/colbymchenry/codegraph/deploy/operator/internal/resources" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type CodeGraphGatewayReconciler struct { + client.Client + Scheme *runtime.Scheme + + DefaultImage string + RouteMode string + GatewayName string + GatewayNamespace string +} + +func (r *CodeGraphGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var gateway codegraphv1alpha1.CodeGraphGateway + if err := r.Get(ctx, req.NamespacedName, &gateway); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + if !r.supportsRouteMode() { + err := fmt.Errorf("unsupported route mode %q", r.RouteMode) + if updateErr := r.patchStatus(ctx, &gateway, func() { + setGatewayBaseStatus(&gateway) + gateway.Status.Phase = codegraphv1alpha1.PhaseDegraded + gateway.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "UnsupportedRouteMode", + Message: err.Error(), + }) + }); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, nil + } + if r.DefaultImage == "" { + return r.markMissingRuntimeImage(ctx, &gateway) + } + + if err := r.ensure(ctx, &gateway, resources.BuildGatewayConfigMap(&gateway)); err != nil { + return r.markDegraded(ctx, &gateway, "ConfigMapApplyFailed", err) + } + if err := r.ensure(ctx, &gateway, resources.BuildGatewayDeployment(&gateway, r.DefaultImage)); err != nil { + return r.markDegraded(ctx, &gateway, "DeploymentApplyFailed", err) + } + if err := r.ensure(ctx, &gateway, resources.BuildGatewayService(&gateway)); err != nil { + return r.markDegraded(ctx, &gateway, "ServiceApplyFailed", err) + } + if err := r.ensureRoute(ctx, &gateway); err != nil { + return r.markDegraded(ctx, &gateway, "RouteApplyFailed", err) + } + + deployment, found, err := r.getDeployment(ctx, &gateway) + if err != nil { + return r.markDegraded(ctx, &gateway, "DeploymentReadFailed", err) + } + if !found || !deploymentRuntimeReady(deployment) { + return r.markRuntimePending(ctx, &gateway) + } + return r.markReady(ctx, &gateway) +} + +func (r *CodeGraphGatewayReconciler) getDeployment(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway) (*appsv1.Deployment, bool, error) { + names := resources.GatewayNamesFor(gateway) + var deployment appsv1.Deployment + err := r.Get(ctx, client.ObjectKey{Namespace: gateway.Namespace, Name: names.Deployment}, &deployment) + if apierrors.IsNotFound(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &deployment, true, nil +} + +func (r *CodeGraphGatewayReconciler) ensureRoute(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway) error { + switch r.RouteMode { + case "", "gateway": + if err := r.deleteIfExists(ctx, resources.BuildGatewayIngress(gateway)); err != nil { + return err + } + return r.ensure(ctx, gateway, resources.BuildGatewayHTTPRoute(gateway, resources.RouteConfig{ + GatewayName: r.gatewayName(), + GatewayNamespace: r.GatewayNamespace, + })) + case "ingress": + if err := r.deleteIfExists(ctx, resources.BuildGatewayHTTPRoute(gateway, resources.RouteConfig{})); err != nil { + return err + } + return r.ensure(ctx, gateway, resources.BuildGatewayIngress(gateway)) + default: + return fmt.Errorf("unsupported route mode %q", r.RouteMode) + } +} + +func (r *CodeGraphGatewayReconciler) ensure(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway, desired client.Object) error { + if err := controllerutil.SetControllerReference(gateway, desired, r.Scheme); err != nil { + return err + } + + current := desired.DeepCopyObject().(client.Object) + err := r.Get(ctx, client.ObjectKeyFromObject(desired), current) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + + before := current.DeepCopyObject().(client.Object) + applyObjectMeta(current, desired) + applyObjectSpec(current, desired) + if apiequality.Semantic.DeepEqual(before, current) { + return nil + } + return r.Update(ctx, current) +} + +func (r *CodeGraphGatewayReconciler) deleteIfExists(ctx context.Context, object client.Object) error { + err := r.Delete(ctx, object) + if apierrors.IsNotFound(err) || apiMeta.IsNoMatchError(err) { + return nil + } + return err +} + +func (r *CodeGraphGatewayReconciler) supportsRouteMode() bool { + switch r.RouteMode { + case "", "gateway", "ingress": + return true + default: + return false + } +} + +func (r *CodeGraphGatewayReconciler) gatewayName() string { + if r.GatewayName != "" { + return r.GatewayName + } + return defaultGatewayName +} + +func (r *CodeGraphGatewayReconciler) markRuntimePending(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway) (ctrl.Result, error) { + if err := r.patchStatus(ctx, gateway, func() { + setGatewayBaseStatus(gateway) + gateway.Status.Phase = codegraphv1alpha1.PhasePending + gateway.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "RuntimeUnavailable", + Message: "waiting for gateway deployment to become available", + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphGatewayReconciler) markReady(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway) (ctrl.Result, error) { + if err := r.patchStatus(ctx, gateway, func() { + setGatewayBaseStatus(gateway) + gateway.Status.Phase = codegraphv1alpha1.PhaseReady + gateway.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionTrue, + Reason: "RuntimeAvailable", + Message: "gateway deployment is available", + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphGatewayReconciler) markMissingRuntimeImage(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway) (ctrl.Result, error) { + message := "start the controller with --runtime-image" + if err := r.patchStatus(ctx, gateway, func() { + setGatewayBaseStatus(gateway) + gateway.Status.Phase = codegraphv1alpha1.PhaseDegraded + gateway.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "RuntimeImageMissing", + Message: message, + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphGatewayReconciler) markDegraded(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway, reason string, err error) (ctrl.Result, error) { + if updateErr := r.patchStatus(ctx, gateway, func() { + setGatewayBaseStatus(gateway) + gateway.Status.Phase = codegraphv1alpha1.PhaseDegraded + gateway.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) + }); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func setGatewayBaseStatus(gateway *codegraphv1alpha1.CodeGraphGateway) { + names := resources.GatewayNamesFor(gateway) + gateway.Status.ObservedGeneration = gateway.Generation + gateway.Status.Endpoint = gateway.Endpoint() + gateway.Status.ServiceName = names.Service + gateway.Status.RouteName = names.Route +} + +func (r *CodeGraphGatewayReconciler) patchStatus(ctx context.Context, gateway *codegraphv1alpha1.CodeGraphGateway, mutate func()) error { + base := gateway.DeepCopy() + mutate() + return r.Status().Patch(ctx, gateway, client.MergeFrom(base)) +} + +func (r *CodeGraphGatewayReconciler) SetupWithManager(mgr ctrl.Manager) error { + builder := ctrl.NewControllerManagedBy(mgr). + For(&codegraphv1alpha1.CodeGraphGateway{}). + Owns(&corev1.ConfigMap{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}) + + switch r.RouteMode { + case "ingress": + builder = builder.Owns(&networkingv1.Ingress{}) + default: + builder = builder.Owns(&gatewayv1.HTTPRoute{}) + } + + return builder.Complete(r) +} diff --git a/deploy/operator/internal/controller/codegraphgateway_controller_test.go b/deploy/operator/internal/controller/codegraphgateway_controller_test.go new file mode 100644 index 000000000..0d5660cff --- /dev/null +++ b/deploy/operator/internal/controller/codegraphgateway_controller_test.go @@ -0,0 +1,252 @@ +package controller + +import ( + "context" + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestGatewayReconcileCreatesGatewayResourcesWithHTTPRoute(t *testing.T) { + ctx := context.Background() + gateway := controllerGateway() + reconciler := newGatewayTestReconciler(t, gateway) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(gateway)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertGatewayExistsAndOwned(t, ctx, reconciler.Client, &corev1.ConfigMap{}, gateway, "codegraph-team-gateway") + assertGatewayExistsAndOwned(t, ctx, reconciler.Client, &appsv1.Deployment{}, gateway, "codegraph-team-gateway") + assertGatewayExistsAndOwned(t, ctx, reconciler.Client, &corev1.Service{}, gateway, "codegraph-team-gateway") + route := assertGatewayExistsAndOwned(t, ctx, reconciler.Client, &gatewayv1.HTTPRoute{}, gateway, "codegraph-team-gateway") + if got := string(route.(*gatewayv1.HTTPRoute).Spec.ParentRefs[0].Name); got != "codegraph-gateway" { + t.Fatalf("gateway parent name = %q", got) + } + + assertGatewayPendingStatus(t, ctx, reconciler.Client, gateway) +} + +func TestGatewayReconcileMarksReadyWhenDeploymentIsReady(t *testing.T) { + ctx := context.Background() + gateway := controllerGateway() + reconciler := newGatewayTestReconciler(t, gateway) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(gateway)}) + if err != nil { + t.Fatalf("first Reconcile() error = %v", err) + } + + var deployment appsv1.Deployment + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: gateway.Namespace, Name: "codegraph-team-gateway"}, &deployment); err != nil { + t.Fatalf("get deployment: %v", err) + } + deployment.Generation = 2 + if err := reconciler.Update(ctx, &deployment); err != nil { + t.Fatalf("update deployment generation: %v", err) + } + deployment.Status.ObservedGeneration = deployment.Generation + deployment.Status.UpdatedReplicas = 1 + deployment.Status.AvailableReplicas = 1 + deployment.Status.UnavailableReplicas = 0 + if err := reconciler.Status().Update(ctx, &deployment); err != nil { + t.Fatalf("update deployment status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(gateway)}) + if err != nil { + t.Fatalf("second Reconcile() error = %v", err) + } + + assertGatewayReadyStatus(t, ctx, reconciler.Client, gateway) +} + +func TestGatewayReconcileCreatesIngressWhenConfigured(t *testing.T) { + ctx := context.Background() + gateway := controllerGateway() + reconciler := newGatewayTestReconciler(t, gateway) + reconciler.RouteMode = "ingress" + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(gateway)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertGatewayExistsAndOwned(t, ctx, reconciler.Client, &networkingv1.Ingress{}, gateway, "codegraph-team-gateway") + + route := &gatewayv1.HTTPRoute{} + err = reconciler.Get(ctx, types.NamespacedName{Namespace: gateway.Namespace, Name: "codegraph-team-gateway"}, route) + if !apierrors.IsNotFound(err) { + t.Fatalf("HTTPRoute get error = %v, want NotFound", err) + } +} + +func TestGatewayReconcileMarksDegradedWhenRuntimeImageMissing(t *testing.T) { + ctx := context.Background() + gateway := controllerGateway() + reconciler := newGatewayTestReconciler(t, gateway) + reconciler.DefaultImage = "" + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(gateway)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var configMap corev1.ConfigMap + err = reconciler.Get(ctx, types.NamespacedName{Namespace: gateway.Namespace, Name: "codegraph-team-gateway"}, &configMap) + if !apierrors.IsNotFound(err) { + t.Fatalf("configmap get error = %v, want NotFound", err) + } + assertGatewayRuntimeImageMissingStatus(t, ctx, reconciler.Client, gateway) +} + +func newGatewayTestReconciler(t *testing.T, objects ...client.Object) *CodeGraphGatewayReconciler { + t.Helper() + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("client-go AddToScheme: %v", err) + } + if err := codegraphv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("codegraph AddToScheme: %v", err) + } + if err := gatewayv1.Install(scheme); err != nil { + t.Fatalf("gateway Install: %v", err) + } + + gateway, ok := objects[0].(*codegraphv1alpha1.CodeGraphGateway) + if !ok { + t.Fatalf("first test object must be CodeGraphGateway, got %T", objects[0]) + } + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(gateway, &appsv1.Deployment{}). + Build() + + return &CodeGraphGatewayReconciler{ + Client: client, + Scheme: scheme, + DefaultImage: "ghcr.io/acme/codegraph:runtime", + RouteMode: "gateway", + GatewayName: "codegraph-gateway", + GatewayNamespace: "platform", + } +} + +func controllerGateway() *codegraphv1alpha1.CodeGraphGateway { + return &codegraphv1alpha1.CodeGraphGateway{ + TypeMeta: metav1.TypeMeta{ + APIVersion: codegraphv1alpha1.GroupVersion.String(), + Kind: "CodeGraphGateway", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "team-gateway", + Namespace: "default", + Generation: 1, + UID: types.UID("gateway-uid-123"), + }, + Spec: codegraphv1alpha1.CodeGraphGatewaySpec{ + Host: "codegraph.example.com", + Path: "/mcp", + Repositories: []codegraphv1alpha1.GatewayRepository{ + {RepoID: "api-service", ServiceName: "codegraph-api-service"}, + }, + }, + } +} + +func assertGatewayExistsAndOwned(t *testing.T, ctx context.Context, c client.Client, object client.Object, gateway *codegraphv1alpha1.CodeGraphGateway, name string) client.Object { + t.Helper() + + if err := c.Get(ctx, types.NamespacedName{Namespace: gateway.Namespace, Name: name}, object); err != nil { + t.Fatalf("get %T: %v", object, err) + } + owners := object.GetOwnerReferences() + if len(owners) != 1 { + t.Fatalf("%T ownerReferences = %#v", object, owners) + } + owner := owners[0] + if owner.APIVersion != codegraphv1alpha1.GroupVersion.String() || owner.Kind != "CodeGraphGateway" || owner.Name != gateway.Name || owner.UID != gateway.UID { + t.Fatalf("%T owner reference = %#v", object, owner) + } + if owner.Controller == nil || !*owner.Controller { + t.Fatalf("%T owner controller = %v", object, owner.Controller) + } + return object +} + +func assertGatewayPendingStatus(t *testing.T, ctx context.Context, c client.Client, gateway *codegraphv1alpha1.CodeGraphGateway) { + t.Helper() + + updated := assertGatewayStatusNames(t, ctx, c, gateway) + if updated.Status.Phase != codegraphv1alpha1.PhasePending { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "RuntimeUnavailable" { + t.Fatalf("Ready condition = %#v", ready) + } +} + +func assertGatewayReadyStatus(t *testing.T, ctx context.Context, c client.Client, gateway *codegraphv1alpha1.CodeGraphGateway) { + t.Helper() + + updated := assertGatewayStatusNames(t, ctx, c, gateway) + if updated.Status.Phase != codegraphv1alpha1.PhaseReady { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionTrue || ready.Reason != "RuntimeAvailable" { + t.Fatalf("Ready condition = %#v", ready) + } +} + +func assertGatewayRuntimeImageMissingStatus(t *testing.T, ctx context.Context, c client.Client, gateway *codegraphv1alpha1.CodeGraphGateway) { + t.Helper() + + updated := assertGatewayStatusNames(t, ctx, c, gateway) + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "RuntimeImageMissing" { + t.Fatalf("Ready condition = %#v", ready) + } +} + +func assertGatewayStatusNames(t *testing.T, ctx context.Context, c client.Client, gateway *codegraphv1alpha1.CodeGraphGateway) codegraphv1alpha1.CodeGraphGateway { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphGateway + if err := c.Get(ctx, client.ObjectKeyFromObject(gateway), &updated); err != nil { + t.Fatalf("get updated gateway: %v", err) + } + if updated.Status.ObservedGeneration != gateway.Generation { + t.Fatalf("observedGeneration = %d", updated.Status.ObservedGeneration) + } + if updated.Status.Endpoint != "https://codegraph.example.com/mcp" { + t.Fatalf("endpoint = %q", updated.Status.Endpoint) + } + if updated.Status.ServiceName != "codegraph-team-gateway" { + t.Fatalf("serviceName = %q", updated.Status.ServiceName) + } + if updated.Status.RouteName != "codegraph-team-gateway" { + t.Fatalf("routeName = %q", updated.Status.RouteName) + } + return updated +} diff --git a/deploy/operator/internal/controller/codegraphrepository_controller.go b/deploy/operator/internal/controller/codegraphrepository_controller.go new file mode 100644 index 000000000..6024565db --- /dev/null +++ b/deploy/operator/internal/controller/codegraphrepository_controller.go @@ -0,0 +1,626 @@ +package controller + +import ( + "context" + "fmt" + "time" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + "github.com/colbymchenry/codegraph/deploy/operator/internal/resources" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const ( + defaultGatewayName = "codegraph" + staleRuntimeRequeueAfter = 5 * time.Second +) + +type CodeGraphRepositoryReconciler struct { + client.Client + Scheme *runtime.Scheme + + DefaultImage string + RouteMode string + GatewayName string + GatewayNamespace string +} + +func (r *CodeGraphRepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var repo codegraphv1alpha1.CodeGraphRepository + if err := r.Get(ctx, req.NamespacedName, &repo); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + if !r.supportsRouteMode() { + err := fmt.Errorf("unsupported route mode %q", r.RouteMode) + if updateErr := r.patchStatus(ctx, &repo, func() { + repo.Status.ObservedGeneration = repo.Generation + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "UnsupportedRouteMode", + Message: err.Error(), + }) + repo.SetCondition(indexedFalseCondition("UnsupportedRouteMode", err.Error())) + }); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, nil + } + if !repo.MCPPathMatchesRepoID() { + return r.markInvalidMCPPath(ctx, &repo) + } + if repo.RuntimeImage(r.DefaultImage) == "" { + return r.markMissingRuntimeImage(ctx, &repo) + } + + if err := r.ensurePVC(ctx, &repo, resources.BuildPVC(&repo)); err != nil { + return r.markDegraded(ctx, &repo, "PVCApplyFailed", err) + } + if result, blocked, err := r.shutdownStaleRuntimeBeforeSync(ctx, &repo); blocked || err != nil { + return result, err + } + if result, blocked, err := r.waitForPreviousSyncJobs(ctx, &repo); blocked || err != nil { + return result, err + } + if err := r.ensureJob(ctx, &repo, resources.BuildSyncJob(&repo, r.DefaultImage)); err != nil { + return r.markDegraded(ctx, &repo, "SyncJobApplyFailed", err) + } + if err := r.ensure(ctx, &repo, resources.BuildService(&repo)); err != nil { + return r.markDegraded(ctx, &repo, "ServiceApplyFailed", err) + } + if err := r.ensureRoute(ctx, &repo); err != nil { + return r.markDegraded(ctx, &repo, "RouteApplyFailed", err) + } + + job, found, err := r.getSyncJob(ctx, &repo) + if err != nil { + return r.markDegraded(ctx, &repo, "SyncJobReadFailed", err) + } + if !found { + return r.markIndexWaiting(ctx, &repo, codegraphv1alpha1.PhasePending, "SyncJobMissing", "waiting for sync/index job to be created") + } + if job.Status.Succeeded == 0 { + if jobTerminalFailed(job) { + return r.markIndexFailed(ctx, &repo) + } + return r.markIndexWaiting(ctx, &repo, codegraphv1alpha1.PhaseIndexing, "IndexRunning", "waiting for sync/index job to complete") + } + + if err := r.ensure(ctx, &repo, resources.BuildDeployment(&repo, r.DefaultImage)); err != nil { + return r.markDegradedWithSucceededSync(ctx, &repo, "DeploymentApplyFailed", err, job) + } + deployment, found, err := r.getDeployment(ctx, &repo) + if err != nil { + return r.markDegradedWithSucceededSync(ctx, &repo, "DeploymentReadFailed", err, job) + } + if !found || !deploymentRuntimeReady(deployment) { + return r.markRuntimePending(ctx, &repo, job) + } + return r.markReady(ctx, &repo, job) +} + +func (r *CodeGraphRepositoryReconciler) getSyncJob(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (*batchv1.Job, bool, error) { + names := resources.NamesFor(repo) + var job batchv1.Job + err := r.Get(ctx, client.ObjectKey{Namespace: repo.Namespace, Name: names.SyncJob}, &job) + if apierrors.IsNotFound(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &job, true, nil +} + +func (r *CodeGraphRepositoryReconciler) getDeployment(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (*appsv1.Deployment, bool, error) { + names := resources.NamesFor(repo) + var deployment appsv1.Deployment + err := r.Get(ctx, client.ObjectKey{Namespace: repo.Namespace, Name: names.Deployment}, &deployment) + if apierrors.IsNotFound(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &deployment, true, nil +} + +func jobTerminalFailed(job *batchv1.Job) bool { + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobFailed && condition.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +func deploymentRuntimeReady(deployment *appsv1.Deployment) bool { + desired := int32(1) + if deployment.Spec.Replicas != nil { + desired = *deployment.Spec.Replicas + } + + return deployment.Status.ObservedGeneration >= deployment.Generation && + deployment.Status.UpdatedReplicas >= desired && + deployment.Status.AvailableReplicas >= desired && + deployment.Status.UnavailableReplicas == 0 +} + +func deploymentRepositoryGeneration(deployment *appsv1.Deployment) string { + if deployment.Spec.Template.Annotations == nil { + return "" + } + return deployment.Spec.Template.Annotations[resources.RepositoryGenerationAnnotation] +} + +func podRepositoryGeneration(pod corev1.Pod) string { + if pod.Annotations == nil { + return "" + } + return pod.Annotations[resources.RepositoryGenerationAnnotation] +} + +func (r *CodeGraphRepositoryReconciler) shutdownStaleRuntimeBeforeSync(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (ctrl.Result, bool, error) { + _, jobFound, err := r.getSyncJob(ctx, repo) + if err != nil { + result, markErr := r.markDegraded(ctx, repo, "SyncJobReadFailed", err) + return result, true, markErr + } + if jobFound { + return ctrl.Result{}, false, nil + } + + deployment, found, err := r.getDeployment(ctx, repo) + if err != nil { + result, markErr := r.markDegraded(ctx, repo, "DeploymentReadFailed", err) + return result, true, markErr + } + currentGeneration := fmt.Sprintf("%d", repo.Generation) + if found && deploymentRepositoryGeneration(deployment) != currentGeneration { + if err := r.Delete(ctx, deployment); err != nil && !apierrors.IsNotFound(err) { + result, markErr := r.markDegraded(ctx, repo, "DeploymentDeleteFailed", err) + return result, true, markErr + } + result, err := r.markIndexWaiting(ctx, repo, codegraphv1alpha1.PhasePending, "RuntimeShutdown", "waiting for stale runtime deployment to stop before syncing") + result.RequeueAfter = staleRuntimeRequeueAfter + return result, true, err + } + + pods := &corev1.PodList{} + if err := r.List(ctx, pods, client.InNamespace(repo.Namespace), client.MatchingLabels(resources.RuntimeSelectorFor(repo))); err != nil { + result, markErr := r.markDegraded(ctx, repo, "RuntimePodsReadFailed", err) + return result, true, markErr + } + for _, pod := range pods.Items { + if podRepositoryGeneration(pod) != currentGeneration { + result, err := r.markIndexWaiting(ctx, repo, codegraphv1alpha1.PhasePending, "RuntimeShutdown", "waiting for stale runtime pods to stop before syncing") + result.RequeueAfter = staleRuntimeRequeueAfter + return result, true, err + } + } + return ctrl.Result{}, false, nil +} + +func (r *CodeGraphRepositoryReconciler) waitForPreviousSyncJobs(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (ctrl.Result, bool, error) { + names := resources.NamesFor(repo) + var jobs batchv1.JobList + if err := r.List(ctx, &jobs, client.InNamespace(repo.Namespace)); err != nil { + result, markErr := r.markDegraded(ctx, repo, "SyncJobsReadFailed", err) + return result, true, markErr + } + for _, job := range jobs.Items { + if job.Name == names.SyncJob || !ownedByRepository(&job, repo) { + continue + } + if job.Status.Succeeded == 0 && !jobTerminalFailed(&job) { + result, err := r.markIndexWaiting(ctx, repo, codegraphv1alpha1.PhasePending, "SyncInProgress", "waiting for previous sync/index job to finish before starting a new sync") + result.RequeueAfter = staleRuntimeRequeueAfter + return result, true, err + } + } + return ctrl.Result{}, false, nil +} + +func ownedByRepository(object client.Object, repo *codegraphv1alpha1.CodeGraphRepository) bool { + for _, owner := range object.GetOwnerReferences() { + if owner.UID == repo.UID && owner.Kind == "CodeGraphRepository" && owner.APIVersion == codegraphv1alpha1.GroupVersion.String() { + return true + } + } + return false +} + +func (r *CodeGraphRepositoryReconciler) ensureRoute(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) error { + switch r.RouteMode { + case "", "gateway": + if err := r.deleteIfExists(ctx, resources.BuildIngress(repo)); err != nil { + return err + } + return r.ensure(ctx, repo, resources.BuildHTTPRoute(repo, resources.RouteConfig{ + GatewayName: r.gatewayName(), + GatewayNamespace: r.GatewayNamespace, + })) + case "ingress": + if err := r.deleteIfExists(ctx, resources.BuildHTTPRoute(repo, resources.RouteConfig{})); err != nil { + return err + } + return r.ensure(ctx, repo, resources.BuildIngress(repo)) + default: + return fmt.Errorf("unsupported route mode %q", r.RouteMode) + } +} + +func (r *CodeGraphRepositoryReconciler) supportsRouteMode() bool { + switch r.RouteMode { + case "", "gateway", "ingress": + return true + default: + return false + } +} + +func (r *CodeGraphRepositoryReconciler) gatewayName() string { + if r.GatewayName != "" { + return r.GatewayName + } + return defaultGatewayName +} + +func (r *CodeGraphRepositoryReconciler) ensurePVC(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, desired *corev1.PersistentVolumeClaim) error { + if err := controllerutil.SetControllerReference(repo, desired, r.Scheme); err != nil { + return err + } + + var current corev1.PersistentVolumeClaim + err := r.Get(ctx, client.ObjectKeyFromObject(desired), ¤t) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + + before := current.DeepCopyObject().(client.Object) + applyObjectMeta(¤t, desired) + if apiequality.Semantic.DeepEqual(before, ¤t) { + return nil + } + return r.Update(ctx, ¤t) +} + +func (r *CodeGraphRepositoryReconciler) ensureJob(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, desired *batchv1.Job) error { + if err := controllerutil.SetControllerReference(repo, desired, r.Scheme); err != nil { + return err + } + + var current batchv1.Job + err := r.Get(ctx, client.ObjectKeyFromObject(desired), ¤t) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + return err +} + +func (r *CodeGraphRepositoryReconciler) ensure(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, desired client.Object) error { + if err := controllerutil.SetControllerReference(repo, desired, r.Scheme); err != nil { + return err + } + + current := desired.DeepCopyObject().(client.Object) + err := r.Get(ctx, client.ObjectKeyFromObject(desired), current) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + + before := current.DeepCopyObject().(client.Object) + applyObjectMeta(current, desired) + applyObjectSpec(current, desired) + if apiequality.Semantic.DeepEqual(before, current) { + return nil + } + return r.Update(ctx, current) +} + +func (r *CodeGraphRepositoryReconciler) deleteIfExists(ctx context.Context, object client.Object) error { + err := r.Delete(ctx, object) + if apierrors.IsNotFound(err) || apiMeta.IsNoMatchError(err) { + return nil + } + return err +} + +func applyObjectMeta(current client.Object, desired client.Object) { + current.SetLabels(desired.GetLabels()) + mergeObjectAnnotations(current, desired) + current.SetOwnerReferences(desired.GetOwnerReferences()) +} + +func mergeObjectAnnotations(current client.Object, desired client.Object) { + desiredAnnotations := desired.GetAnnotations() + if len(desiredAnnotations) == 0 { + return + } + + annotations := current.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + for key, value := range desiredAnnotations { + annotations[key] = value + } + current.SetAnnotations(annotations) +} + +func applyObjectSpec(current client.Object, desired client.Object) { + switch current := current.(type) { + case *corev1.Service: + clusterIP := current.Spec.ClusterIP + clusterIPs := current.Spec.ClusterIPs + ipFamilies := current.Spec.IPFamilies + ipFamilyPolicy := current.Spec.IPFamilyPolicy + healthCheckNodePort := current.Spec.HealthCheckNodePort + current.Spec = desired.(*corev1.Service).Spec + current.Spec.ClusterIP = clusterIP + current.Spec.ClusterIPs = clusterIPs + current.Spec.IPFamilies = ipFamilies + current.Spec.IPFamilyPolicy = ipFamilyPolicy + current.Spec.HealthCheckNodePort = healthCheckNodePort + case *networkingv1.Ingress: + current.Spec = desired.(*networkingv1.Ingress).Spec + case *gatewayv1.HTTPRoute: + current.Spec = desired.(*gatewayv1.HTTPRoute).Spec + case *appsv1.Deployment: + current.Spec = desired.(*appsv1.Deployment).Spec + case *corev1.ConfigMap: + desiredConfigMap := desired.(*corev1.ConfigMap) + current.Immutable = desiredConfigMap.Immutable + current.Data = desiredConfigMap.Data + current.BinaryData = desiredConfigMap.BinaryData + } +} + +func (r *CodeGraphRepositoryReconciler) markIndexWaiting(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, phase string, reason string, message string) (ctrl.Result, error) { + if err := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + repo.Status.Phase = phase + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: message, + }) + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionIndexed, + Status: metav1.ConditionFalse, + Reason: reason, + Message: message, + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphRepositoryReconciler) markIndexFailed(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (ctrl.Result, error) { + if err := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "IndexFailed", + Message: "sync/index job failed", + }) + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionIndexed, + Status: metav1.ConditionFalse, + Reason: "IndexFailed", + Message: "sync/index job failed", + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphRepositoryReconciler) markRuntimePending(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, job ...*batchv1.Job) (ctrl.Result, error) { + if err := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + if len(job) > 0 && job[0] != nil { + setSucceededSyncStatus(repo, job[0]) + } + repo.Status.Phase = codegraphv1alpha1.PhasePending + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "RuntimeUnavailable", + Message: "waiting for runtime deployment to become available", + }) + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionIndexed, + Status: metav1.ConditionTrue, + Reason: "IndexSucceeded", + Message: "sync/index job completed", + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphRepositoryReconciler) markReady(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, job *batchv1.Job) (ctrl.Result, error) { + if err := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + setSucceededSyncStatus(repo, job) + repo.Status.Phase = codegraphv1alpha1.PhaseReady + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionTrue, + Reason: "RuntimeAvailable", + Message: "runtime deployment is available", + }) + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionIndexed, + Status: metav1.ConditionTrue, + Reason: "IndexSucceeded", + Message: "sync/index job completed", + }) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphRepositoryReconciler) markInvalidMCPPath(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (ctrl.Result, error) { + message := fmt.Sprintf("spec.mcp.path must equal %q", repo.ExpectedMCPPath()) + if err := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "InvalidMCPPath", + Message: message, + }) + repo.SetCondition(indexedFalseCondition("InvalidMCPPath", message)) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *CodeGraphRepositoryReconciler) markMissingRuntimeImage(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (ctrl.Result, error) { + message := "set spec.image or start the controller with --runtime-image" + if err := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "RuntimeImageMissing", + Message: message, + }) + repo.SetCondition(indexedFalseCondition("RuntimeImageMissing", message)) + }); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func setBaseStatus(repo *codegraphv1alpha1.CodeGraphRepository) { + names := resources.NamesFor(repo) + repo.Status.ObservedGeneration = repo.Generation + repo.Status.Endpoint = repo.Endpoint() + repo.Status.ServiceName = names.Service + repo.Status.RouteName = names.Route +} + +func setSucceededSyncStatus(repo *codegraphv1alpha1.CodeGraphRepository, job *batchv1.Job) { + repo.Status.ResolvedRef = repo.Spec.Git.Ref + if job == nil { + return + } + if job.Status.CompletionTime != nil { + repo.Status.LastSyncTime = job.Status.CompletionTime.DeepCopy() + } +} + +func (r *CodeGraphRepositoryReconciler) markDegraded(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, reason string, err error) (ctrl.Result, error) { + return r.markDegradedWithIndexed(ctx, repo, reason, err, indexedFalseCondition(reason, err.Error())) +} + +func (r *CodeGraphRepositoryReconciler) markDegradedWithSucceededSync(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, reason string, err error, job *batchv1.Job) (ctrl.Result, error) { + if updateErr := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + setSucceededSyncStatus(repo, job) + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) + repo.SetCondition(indexedSucceededCondition()) + }); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func (r *CodeGraphRepositoryReconciler) markDegradedWithIndexed(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, reason string, err error, indexed metav1.Condition) (ctrl.Result, error) { + if updateErr := r.patchStatus(ctx, repo, func() { + setBaseStatus(repo) + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) + repo.SetCondition(indexed) + }); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func indexedFalseCondition(reason string, message string) metav1.Condition { + return metav1.Condition{ + Type: codegraphv1alpha1.ConditionIndexed, + Status: metav1.ConditionFalse, + Reason: reason, + Message: message, + } +} + +func indexedSucceededCondition() metav1.Condition { + return metav1.Condition{ + Type: codegraphv1alpha1.ConditionIndexed, + Status: metav1.ConditionTrue, + Reason: "IndexSucceeded", + Message: "sync/index job completed", + } +} + +func (r *CodeGraphRepositoryReconciler) patchStatus(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, mutate func()) error { + base := repo.DeepCopy() + mutate() + return r.Status().Patch(ctx, repo, client.MergeFrom(base)) +} + +func (r *CodeGraphRepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error { + builder := ctrl.NewControllerManagedBy(mgr). + For(&codegraphv1alpha1.CodeGraphRepository{}). + Owns(&corev1.PersistentVolumeClaim{}). + Owns(&batchv1.Job{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}) + + switch r.RouteMode { + case "ingress": + builder = builder.Owns(&networkingv1.Ingress{}) + default: + builder = builder.Owns(&gatewayv1.HTTPRoute{}) + } + + return builder.Complete(r) +} diff --git a/deploy/operator/internal/controller/codegraphrepository_controller_test.go b/deploy/operator/internal/controller/codegraphrepository_controller_test.go new file mode 100644 index 000000000..55d886d51 --- /dev/null +++ b/deploy/operator/internal/controller/codegraphrepository_controller_test.go @@ -0,0 +1,979 @@ +package controller + +import ( + "context" + "errors" + "testing" + "time" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + "github.com/colbymchenry/codegraph/deploy/operator/internal/resources" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestReconcileCreatesRepositoryResourcesWithGatewayRoute(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &corev1.PersistentVolumeClaim{}, repo, "codegraph-api-service") + assertExistsAndOwned(t, ctx, reconciler.Client, &batchv1.Job{}, repo, "codegraph-api-service-sync-1") + assertExistsAndOwned(t, ctx, reconciler.Client, &corev1.Service{}, repo, "codegraph-api-service") + route := assertExistsAndOwned(t, ctx, reconciler.Client, &gatewayv1.HTTPRoute{}, repo, "codegraph-api-service") + if got := string(route.(*gatewayv1.HTTPRoute).Spec.ParentRefs[0].Name); got != "codegraph-gateway" { + t.Fatalf("gateway parent name = %q", got) + } + + deployment := &appsv1.Deployment{} + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, deployment) + if !apierrors.IsNotFound(err) { + t.Fatalf("deployment get error = %v, want NotFound", err) + } + + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileMarksDegradedWhenRuntimeImageMissing(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + reconciler.DefaultImage = "" + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var pvc corev1.PersistentVolumeClaim + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &pvc) + if !apierrors.IsNotFound(err) { + t.Fatalf("pvc get error = %v, want NotFound", err) + } + var job batchv1.Job + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-1"}, &job) + if !apierrors.IsNotFound(err) { + t.Fatalf("sync job get error = %v, want NotFound", err) + } + assertRepositoryRuntimeImageMissingStatus(t, ctx, reconciler.Client, repo) +} + +func TestReconcileMarksReadyWhenJobAndDeploymentAreReady(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("first Reconcile() error = %v", err) + } + + var job batchv1.Job + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-1"}, &job); err != nil { + t.Fatalf("get job: %v", err) + } + job.Status.Failed = 1 + job.Status.Succeeded = 1 + if err := reconciler.Status().Update(ctx, &job); err != nil { + t.Fatalf("update job status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("second Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &appsv1.Deployment{}, repo, "codegraph-api-service") + assertRepositoryRuntimePendingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") + + var deployment appsv1.Deployment + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &deployment); err != nil { + t.Fatalf("get deployment: %v", err) + } + deployment.Generation = 2 + if err := reconciler.Update(ctx, &deployment); err != nil { + t.Fatalf("update deployment generation: %v", err) + } + deployment.Status.AvailableReplicas = 1 + if err := reconciler.Status().Update(ctx, &deployment); err != nil { + t.Fatalf("update deployment status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("third Reconcile() error = %v", err) + } + + assertRepositoryRuntimePendingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") + + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &deployment); err != nil { + t.Fatalf("get deployment: %v", err) + } + deployment.Status.ObservedGeneration = deployment.Generation + deployment.Status.UpdatedReplicas = 1 + deployment.Status.AvailableReplicas = 1 + deployment.Status.UnavailableReplicas = 0 + if err := reconciler.Status().Update(ctx, &deployment); err != nil { + t.Fatalf("update deployment ready status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("fourth Reconcile() error = %v", err) + } + + assertRepositoryReadyStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileDeletesStaleRuntimeBeforeCreatingNextGenerationSyncJob(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Generation = 2 + staleRuntime := resources.BuildDeployment(repo, "ghcr.io/acme/codegraph:runtime") + staleRuntime.Spec.Template.Annotations[resources.RepositoryGenerationAnnotation] = "1" + reconciler := newTestReconciler(t, repo, staleRuntime) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var deployment appsv1.Deployment + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &deployment) + if !apierrors.IsNotFound(err) { + t.Fatalf("deployment get error = %v, want NotFound", err) + } + var job batchv1.Job + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-2"}, &job) + if !apierrors.IsNotFound(err) { + t.Fatalf("sync job get error = %v, want NotFound", err) + } + assertRepositoryRuntimeShutdownStatus(t, ctx, reconciler.Client, repo) +} + +func TestReconcileBlocksNextGenerationSyncWhileStaleRuntimePodExists(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Generation = 2 + staleRuntimePod := runtimePod(repo, "codegraph-api-service-stale", "1") + staleRuntimePod.DeletionTimestamp = &metav1.Time{Time: time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC)} + staleRuntimePod.Finalizers = []string{"codegraph.dev/test-finalizer"} + reconciler := newTestReconciler(t, repo, staleRuntimePod) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.RequeueAfter <= 0 { + t.Fatalf("RequeueAfter = %s, want positive duration", result.RequeueAfter) + } + + var job batchv1.Job + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-2"}, &job) + if !apierrors.IsNotFound(err) { + t.Fatalf("sync job get error = %v, want NotFound", err) + } + assertRepositoryRuntimeShutdownStatus(t, ctx, reconciler.Client, repo) +} + +func TestReconcileIgnoresStaleSyncJobPodWhenStartingNextGenerationSync(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Generation = 2 + staleSyncPod := syncJobPod(repo, "codegraph-api-service-sync-1-pod") + reconciler := newTestReconciler(t, repo, staleSyncPod) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &batchv1.Job{}, repo, "codegraph-api-service-sync-2") + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileWaitsForPreviousGenerationSyncJobBeforeStartingNextSync(t *testing.T) { + ctx := context.Background() + previousRepo := controllerRepository() + previousJob := resources.BuildSyncJob(previousRepo, "ghcr.io/acme/codegraph:runtime") + repo := controllerRepository() + repo.Generation = 2 + reconciler := newTestReconciler(t, repo, previousJob) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.RequeueAfter <= 0 { + t.Fatalf("RequeueAfter = %s, want positive duration", result.RequeueAfter) + } + + var job batchv1.Job + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-2"}, &job) + if !apierrors.IsNotFound(err) { + t.Fatalf("sync job get error = %v, want NotFound", err) + } + assertRepositorySyncInProgressStatus(t, ctx, reconciler.Client, repo) +} + +func TestReconcileStartsNextSyncAfterPreviousGenerationSyncJobCompletes(t *testing.T) { + ctx := context.Background() + previousRepo := controllerRepository() + previousJob := resources.BuildSyncJob(previousRepo, "ghcr.io/acme/codegraph:runtime") + previousJob.Status.Succeeded = 1 + repo := controllerRepository() + repo.Generation = 2 + reconciler := newTestReconciler(t, repo, previousJob) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &batchv1.Job{}, repo, "codegraph-api-service-sync-2") + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileCreatesNextGenerationSyncWhenRuntimePodsAreCurrent(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Generation = 2 + currentRuntimePod := runtimePod(repo, "codegraph-api-service-current", "2") + reconciler := newTestReconciler(t, repo, currentRuntimePod) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &batchv1.Job{}, repo, "codegraph-api-service-sync-2") + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileKeepsIndexingWhenCurrentSyncJobPodExists(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Generation = 2 + currentJob := resources.BuildSyncJob(repo, "ghcr.io/acme/codegraph:runtime") + currentJobPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "codegraph-api-service-sync-2-pod", + Namespace: repo.Namespace, + Labels: resources.LabelsFor(repo), + }, + } + reconciler := newTestReconciler(t, repo, currentJob, currentJobPod) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileAllowsDeploymentRefreshAfterCurrentGenerationJobSucceeded(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Generation = 2 + staleRuntime := resources.BuildDeployment(repo, "ghcr.io/acme/codegraph:runtime") + staleRuntime.Spec.Template.Annotations[resources.RepositoryGenerationAnnotation] = "1" + succeededJob := resources.BuildSyncJob(repo, "ghcr.io/acme/codegraph:runtime") + succeededJob.Status.Succeeded = 1 + reconciler := newTestReconciler(t, repo, staleRuntime, succeededJob) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var deployment appsv1.Deployment + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &deployment); err != nil { + t.Fatalf("get deployment: %v", err) + } + if got := deployment.Spec.Template.Annotations[resources.RepositoryGenerationAnnotation]; got != "2" { + t.Fatalf("deployment repository generation annotation = %q, want 2", got) + } +} + +func TestReconcileRecordsRequestedRefAndLastSyncTimeFromSucceededJob(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Spec.Git.Ref = "release/2026-06" + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("first Reconcile() error = %v", err) + } + + completion := metav1.NewTime(time.Date(2026, 6, 16, 12, 34, 56, 0, time.UTC)) + var job batchv1.Job + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-1"}, &job); err != nil { + t.Fatalf("get job: %v", err) + } + job.Status.Succeeded = 1 + job.Status.CompletionTime = &completion + if err := reconciler.Status().Update(ctx, &job); err != nil { + t.Fatalf("update job status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("second Reconcile() error = %v", err) + } + + var updated codegraphv1alpha1.CodeGraphRepository + if err := reconciler.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.ResolvedRef != "release/2026-06" { + t.Fatalf("resolvedRef = %q", updated.Status.ResolvedRef) + } + if updated.Status.LastSyncTime == nil || !completion.Equal(updated.Status.LastSyncTime) { + t.Fatalf("lastSyncTime = %#v, want %s", updated.Status.LastSyncTime, completion.Time) + } +} + +func TestReconcileMarksInvalidMCPPathDegradedWithoutCreatingResources(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + repo.Spec.MCP.Path = "/mcp/other-service" + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + for _, object := range []client.Object{ + &corev1.PersistentVolumeClaim{}, + &batchv1.Job{}, + &corev1.Service{}, + &gatewayv1.HTTPRoute{}, + } { + err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, object) + if !apierrors.IsNotFound(err) { + t.Fatalf("%T get error = %v, want NotFound", object, err) + } + } + + var updated codegraphv1alpha1.CodeGraphRepository + if err := reconciler.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "InvalidMCPPath" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "InvalidMCPPath" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func TestReconcilePreservesIndexSucceededWhenRuntimeApplyFails(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("first Reconcile() error = %v", err) + } + + var job batchv1.Job + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-1"}, &job); err != nil { + t.Fatalf("get job: %v", err) + } + job.Status.Succeeded = 1 + if err := reconciler.Status().Update(ctx, &job); err != nil { + t.Fatalf("update job status: %v", err) + } + + reconciler.Client = getDeploymentErrorClient{ + Client: reconciler.Client, + err: errors.New("deployment read failed"), + } + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err == nil { + t.Fatalf("second Reconcile() error = nil, want deployment error") + } + + var updated codegraphv1alpha1.CodeGraphRepository + if err := reconciler.Client.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "DeploymentApplyFailed" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionTrue || indexed.Reason != "IndexSucceeded" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func TestReconcileMarksDegradedWhenJobFails(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("first Reconcile() error = %v", err) + } + + var job batchv1.Job + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-1"}, &job); err != nil { + t.Fatalf("get job: %v", err) + } + job.Status.Failed = 1 + job.Status.Conditions = []batchv1.JobCondition{ + { + Type: batchv1.JobFailed, + Status: corev1.ConditionTrue, + }, + } + if err := reconciler.Status().Update(ctx, &job); err != nil { + t.Fatalf("update job status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("second Reconcile() error = %v", err) + } + + var deployment appsv1.Deployment + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &deployment) + if !apierrors.IsNotFound(err) { + t.Fatalf("deployment get error = %v, want NotFound", err) + } + assertRepositoryDegradedIndexStatus(t, ctx, reconciler.Client, repo) +} + +func TestReconcileKeepsRetryingJobIndexingUntilTerminalFailure(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("first Reconcile() error = %v", err) + } + + var job batchv1.Job + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service-sync-1"}, &job); err != nil { + t.Fatalf("get job: %v", err) + } + job.Status.Failed = 1 + if err := reconciler.Status().Update(ctx, &job); err != nil { + t.Fatalf("update job status: %v", err) + } + + _, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("second Reconcile() error = %v", err) + } + + var deployment appsv1.Deployment + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, &deployment) + if !apierrors.IsNotFound(err) { + t.Fatalf("deployment get error = %v, want NotFound", err) + } + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcileCreatesIngressWhenConfigured(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + reconciler.RouteMode = "ingress" + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &networkingv1.Ingress{}, repo, "codegraph-api-service") + + route := &gatewayv1.HTTPRoute{} + err = reconciler.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: "codegraph-api-service"}, route) + if !apierrors.IsNotFound(err) { + t.Fatalf("HTTPRoute get error = %v, want NotFound", err) + } + + assertRepositoryIndexingStatus(t, ctx, reconciler.Client, repo, "codegraph-api-service", "codegraph-api-service") +} + +func TestReconcilePreservesExistingPVCSpec(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + storageClass := "fast" + existing := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "codegraph-api-service", + Namespace: "default", + Labels: map[string]string{ + "stale": "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, + StorageClassName: &storageClass, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + }, + } + reconciler := newTestReconciler(t, repo, existing) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var pvc corev1.PersistentVolumeClaim + if err := reconciler.Get(ctx, types.NamespacedName{Namespace: "default", Name: "codegraph-api-service"}, &pvc); err != nil { + t.Fatalf("get pvc: %v", err) + } + if got := pvc.Spec.Resources.Requests[corev1.ResourceStorage]; got.Cmp(resource.MustParse("5Gi")) != 0 { + t.Fatalf("PVC storage request = %s, want 5Gi", got.String()) + } + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "fast" { + t.Fatalf("PVC storageClassName = %v", pvc.Spec.StorageClassName) + } + if len(pvc.Spec.AccessModes) != 1 || pvc.Spec.AccessModes[0] != corev1.ReadWriteMany { + t.Fatalf("PVC accessModes = %#v", pvc.Spec.AccessModes) + } + if pvc.Labels["codegraph.dev/repo-id"] != "api-service" || pvc.Labels["stale"] != "" { + t.Fatalf("PVC labels = %#v", pvc.Labels) + } + assertOwnedByRepository(t, pvc.OwnerReferences, repo) +} + +func TestReconcileGatewayRouteDeletesExistingIngress(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + existingIngress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "codegraph-api-service", + Namespace: "default", + }, + } + reconciler := newTestReconciler(t, repo, existingIngress) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &gatewayv1.HTTPRoute{}, repo, "codegraph-api-service") + var ingress networkingv1.Ingress + err = reconciler.Get(ctx, types.NamespacedName{Namespace: "default", Name: "codegraph-api-service"}, &ingress) + if !apierrors.IsNotFound(err) { + t.Fatalf("Ingress get error = %v, want NotFound", err) + } +} + +func TestReconcileIngressDeletesExistingHTTPRoute(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + existingRoute := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "codegraph-api-service", + Namespace: "default", + }, + } + reconciler := newTestReconciler(t, repo, existingRoute) + reconciler.RouteMode = "ingress" + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + assertExistsAndOwned(t, ctx, reconciler.Client, &networkingv1.Ingress{}, repo, "codegraph-api-service") + var route gatewayv1.HTTPRoute + err = reconciler.Get(ctx, types.NamespacedName{Namespace: "default", Name: "codegraph-api-service"}, &route) + if !apierrors.IsNotFound(err) { + t.Fatalf("HTTPRoute get error = %v, want NotFound", err) + } +} + +func TestReconcileMarksUnsupportedRouteModeDegraded(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + reconciler.RouteMode = "mesh" + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(repo)}) + if err != nil { + t.Fatalf("Reconcile() error = %v, want nil", err) + } + + var updated codegraphv1alpha1.CodeGraphRepository + if getErr := reconciler.Get(ctx, client.ObjectKeyFromObject(repo), &updated); getErr != nil { + t.Fatalf("get updated repo: %v", getErr) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "UnsupportedRouteMode" { + t.Fatalf("Ready condition = %#v", ready) + } +} + +func TestMarkDegradedClearsStaleIndexedCondition(t *testing.T) { + ctx := context.Background() + repo := controllerRepository() + reconciler := newTestReconciler(t, repo) + + if _, err := reconciler.markRuntimePending(ctx, repo); err != nil { + t.Fatalf("markRuntimePending() error = %v", err) + } + + wantErr := errors.New("pvc apply failed") + if _, err := reconciler.markDegraded(ctx, repo, "PVCApplyFailed", wantErr); err != wantErr { + t.Fatalf("markDegraded() error = %v, want %v", err, wantErr) + } + + var updated codegraphv1alpha1.CodeGraphRepository + if err := reconciler.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "PVCApplyFailed" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +type getDeploymentErrorClient struct { + client.Client + err error +} + +func (c getDeploymentErrorClient) Get(ctx context.Context, key client.ObjectKey, object client.Object, opts ...client.GetOption) error { + if _, ok := object.(*appsv1.Deployment); ok { + return c.err + } + return c.Client.Get(ctx, key, object, opts...) +} + +func newTestReconciler(t *testing.T, objects ...client.Object) *CodeGraphRepositoryReconciler { + t.Helper() + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("client-go AddToScheme: %v", err) + } + if err := codegraphv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("codegraph AddToScheme: %v", err) + } + if err := gatewayv1.Install(scheme); err != nil { + t.Fatalf("gateway Install: %v", err) + } + + repo, ok := objects[0].(*codegraphv1alpha1.CodeGraphRepository) + if !ok { + t.Fatalf("first test object must be CodeGraphRepository, got %T", objects[0]) + } + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(repo, &batchv1.Job{}, &appsv1.Deployment{}). + Build() + + return &CodeGraphRepositoryReconciler{ + Client: client, + Scheme: scheme, + DefaultImage: "ghcr.io/acme/codegraph:runtime", + RouteMode: "gateway", + GatewayName: "codegraph-gateway", + GatewayNamespace: "platform", + } +} + +func controllerRepository() *codegraphv1alpha1.CodeGraphRepository { + return &codegraphv1alpha1.CodeGraphRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: codegraphv1alpha1.GroupVersion.String(), + Kind: "CodeGraphRepository", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "api-service", + Namespace: "default", + Generation: 1, + UID: types.UID("repo-uid-123"), + }, + Spec: codegraphv1alpha1.CodeGraphRepositorySpec{ + RepoID: "api-service", + Git: codegraphv1alpha1.GitSpec{ + URL: "https://github.com/acme/api-service.git", + Ref: "main", + }, + MCP: codegraphv1alpha1.MCPSpec{ + Host: "codegraph.example.com", + Path: "/mcp/api-service", + }, + Storage: codegraphv1alpha1.StorageSpec{ + Size: resource.MustParse("20Gi"), + }, + }, + } +} + +func runtimePod(repo *codegraphv1alpha1.CodeGraphRepository, name string, generation string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: repo.Namespace, + Labels: resources.RuntimeSelectorFor(repo), + Annotations: map[string]string{resources.RepositoryGenerationAnnotation: generation}, + }, + } +} + +func syncJobPod(repo *codegraphv1alpha1.CodeGraphRepository, name string) *corev1.Pod { + labels := resources.LabelsFor(repo) + labels[resources.WorkloadLabel] = resources.WorkloadSync + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: repo.Namespace, + Labels: labels, + }, + } +} + +func assertExistsAndOwned(t *testing.T, ctx context.Context, c client.Client, object client.Object, repo *codegraphv1alpha1.CodeGraphRepository, name string) client.Object { + t.Helper() + + if err := c.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: name}, object); err != nil { + t.Fatalf("get %T: %v", object, err) + } + owners := object.GetOwnerReferences() + if len(owners) != 1 { + t.Fatalf("%T ownerReferences = %#v", object, owners) + } + owner := owners[0] + if owner.APIVersion != codegraphv1alpha1.GroupVersion.String() || owner.Kind != "CodeGraphRepository" || owner.Name != repo.Name || owner.UID != repo.UID { + t.Fatalf("%T owner reference = %#v", object, owner) + } + if owner.Controller == nil || !*owner.Controller { + t.Fatalf("%T owner controller = %v", object, owner.Controller) + } + return object +} + +func assertOwnedByRepository(t *testing.T, owners []metav1.OwnerReference, repo *codegraphv1alpha1.CodeGraphRepository) { + t.Helper() + if len(owners) != 1 { + t.Fatalf("ownerReferences = %#v", owners) + } + owner := owners[0] + if owner.APIVersion != codegraphv1alpha1.GroupVersion.String() || owner.Kind != "CodeGraphRepository" || owner.Name != repo.Name || owner.UID != repo.UID { + t.Fatalf("owner reference = %#v", owner) + } + if owner.Controller == nil || !*owner.Controller { + t.Fatalf("owner controller = %v", owner.Controller) + } +} + +func assertRepositoryIndexingStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository, serviceName string, routeName string) { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.ObservedGeneration != repo.Generation { + t.Fatalf("observedGeneration = %d", updated.Status.ObservedGeneration) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseIndexing { + t.Fatalf("phase = %q", updated.Status.Phase) + } + if updated.Status.Endpoint != "https://codegraph.example.com/mcp/api-service" { + t.Fatalf("endpoint = %q", updated.Status.Endpoint) + } + if updated.Status.ServiceName != serviceName { + t.Fatalf("serviceName = %q", updated.Status.ServiceName) + } + if updated.Status.RouteName != routeName { + t.Fatalf("routeName = %q", updated.Status.RouteName) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "IndexRunning" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "IndexRunning" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositoryRuntimePendingStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository, serviceName string, routeName string) { + t.Helper() + + updated := assertRepositoryStatusNames(t, ctx, c, repo, serviceName, routeName) + if updated.Status.Phase != codegraphv1alpha1.PhasePending { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "RuntimeUnavailable" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionTrue || indexed.Reason != "IndexSucceeded" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositoryReadyStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository, serviceName string, routeName string) { + t.Helper() + + updated := assertRepositoryStatusNames(t, ctx, c, repo, serviceName, routeName) + if updated.Status.Phase != codegraphv1alpha1.PhaseReady { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionTrue || ready.Reason != "RuntimeAvailable" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionTrue || indexed.Reason != "IndexSucceeded" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositoryDegradedIndexStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository) { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "IndexFailed" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "IndexFailed" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositoryRuntimeShutdownStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository) { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.Phase != codegraphv1alpha1.PhasePending { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "RuntimeShutdown" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "RuntimeShutdown" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositorySyncInProgressStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository) { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.Phase != codegraphv1alpha1.PhasePending { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "SyncInProgress" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "SyncInProgress" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositoryRuntimeImageMissingStatus(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository) { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.ObservedGeneration != repo.Generation { + t.Fatalf("observedGeneration = %d", updated.Status.ObservedGeneration) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } + ready := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "RuntimeImageMissing" { + t.Fatalf("Ready condition = %#v", ready) + } + indexed := apiMeta.FindStatusCondition(updated.Status.Conditions, codegraphv1alpha1.ConditionIndexed) + if indexed == nil || indexed.Status != metav1.ConditionFalse || indexed.Reason != "RuntimeImageMissing" { + t.Fatalf("Indexed condition = %#v", indexed) + } +} + +func assertRepositoryStatusNames(t *testing.T, ctx context.Context, c client.Client, repo *codegraphv1alpha1.CodeGraphRepository, serviceName string, routeName string) codegraphv1alpha1.CodeGraphRepository { + t.Helper() + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, client.ObjectKeyFromObject(repo), &updated); err != nil { + t.Fatalf("get updated repo: %v", err) + } + if updated.Status.ObservedGeneration != repo.Generation { + t.Fatalf("observedGeneration = %d", updated.Status.ObservedGeneration) + } + if updated.Status.Endpoint != "https://codegraph.example.com/mcp/api-service" { + t.Fatalf("endpoint = %q", updated.Status.Endpoint) + } + if updated.Status.ServiceName != serviceName { + t.Fatalf("serviceName = %q", updated.Status.ServiceName) + } + if updated.Status.RouteName != routeName { + t.Fatalf("routeName = %q", updated.Status.RouteName) + } + return updated +} diff --git a/deploy/operator/internal/resources/common.go b/deploy/operator/internal/resources/common.go new file mode 100644 index 000000000..1fb487e67 --- /dev/null +++ b/deploy/operator/internal/resources/common.go @@ -0,0 +1,149 @@ +package resources + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + AppName = "codegraph" + ComponentRepositoryMCP = "repository-mcp" + ComponentGatewayMCP = "gateway-mcp" + WorkloadLabel = "codegraph.dev/workload" + WorkloadRuntime = "runtime" + WorkloadSync = "sync" + WorkloadGateway = "gateway" + maxResourceNameLength = 63 + shortHashLength = 8 +) + +type Names struct { + Base string + PVC string + SyncJob string + Deployment string + Service string + Route string +} + +type GatewayNames struct { + Base string + ConfigMap string + Deployment string + Service string + Route string +} + +func NamesFor(repo *codegraphv1alpha1.CodeGraphRepository) Names { + base := boundedRepoName(repo.Spec.RepoID, maxResourceNameLength) + syncSuffix := fmt.Sprintf("-sync-%d", repo.Generation) + return Names{ + Base: base, + PVC: base, + SyncJob: boundedRepoName(repo.Spec.RepoID, maxResourceNameLength-len(syncSuffix)) + syncSuffix, + Deployment: base, + Service: base, + Route: base, + } +} + +func GatewayNamesFor(gateway *codegraphv1alpha1.CodeGraphGateway) GatewayNames { + base := boundedCodeGraphName(gateway.Name, maxResourceNameLength) + return GatewayNames{ + Base: base, + ConfigMap: base, + Deployment: base, + Service: base, + Route: base, + } +} + +func LabelsFor(repo *codegraphv1alpha1.CodeGraphRepository) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": AppName, + "app.kubernetes.io/component": ComponentRepositoryMCP, + "app.kubernetes.io/managed-by": "codegraph-operator", + "codegraph.dev/repo-id": repo.Spec.RepoID, + } +} + +func GatewayLabelsFor(gateway *codegraphv1alpha1.CodeGraphGateway) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": AppName, + "app.kubernetes.io/component": ComponentGatewayMCP, + "app.kubernetes.io/managed-by": "codegraph-operator", + "codegraph.dev/gateway": gateway.Name, + } +} + +func SelectorFor(repo *codegraphv1alpha1.CodeGraphRepository) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": AppName, + "app.kubernetes.io/component": ComponentRepositoryMCP, + "codegraph.dev/repo-id": repo.Spec.RepoID, + } +} + +func RuntimeSelectorFor(repo *codegraphv1alpha1.CodeGraphRepository) map[string]string { + selector := SelectorFor(repo) + selector[WorkloadLabel] = WorkloadRuntime + return selector +} + +func GatewaySelectorFor(gateway *codegraphv1alpha1.CodeGraphGateway) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": AppName, + "app.kubernetes.io/component": ComponentGatewayMCP, + "codegraph.dev/gateway": gateway.Name, + WorkloadLabel: WorkloadGateway, + } +} + +func OwnerFor(repo *codegraphv1alpha1.CodeGraphRepository) []metav1.OwnerReference { + return []metav1.OwnerReference{ + *metav1.NewControllerRef(repo, codegraphv1alpha1.GroupVersion.WithKind("CodeGraphRepository")), + } +} + +func GatewayOwnerFor(gateway *codegraphv1alpha1.CodeGraphGateway) []metav1.OwnerReference { + return []metav1.OwnerReference{ + *metav1.NewControllerRef(gateway, codegraphv1alpha1.GroupVersion.WithKind("CodeGraphGateway")), + } +} + +func boundedCodeGraphName(name string, maxLength int) string { + value := "codegraph-" + name + if len(value) <= maxLength { + return value + } + + hash := shortRepoHash(name) + keep := maxLength - len(hash) - 1 + if keep < 1 { + return hash[:maxLength] + } + return value[:keep] + "-" + hash +} + +func boundedRepoName(repoID string, maxLength int) string { + name := "codegraph-" + repoID + if len(name) <= maxLength { + return name + } + + hash := shortRepoHash(repoID) + keep := maxLength - len(hash) - 1 + if keep < 1 { + return hash[:maxLength] + } + return name[:keep] + "-" + hash +} + +func shortRepoHash(repoID string) string { + sum := sha256.Sum256([]byte(repoID)) + return hex.EncodeToString(sum[:])[:shortHashLength] +} diff --git a/deploy/operator/internal/resources/common_test.go b/deploy/operator/internal/resources/common_test.go new file mode 100644 index 000000000..9a3bd9131 --- /dev/null +++ b/deploy/operator/internal/resources/common_test.go @@ -0,0 +1,138 @@ +package resources + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestNamesUseRepoID(t *testing.T) { + repo := repository("api-service") + names := NamesFor(repo) + + if names.Base != "codegraph-api-service" { + t.Fatalf("Base = %q", names.Base) + } + if names.Service != "codegraph-api-service" { + t.Fatalf("Service = %q", names.Service) + } + if names.SyncJob != "codegraph-api-service-sync-7" { + t.Fatalf("SyncJob = %q", names.SyncJob) + } +} + +func TestLabelsIncludeRepoID(t *testing.T) { + repo := repository("api-service") + labels := LabelsFor(repo) + + if labels["app.kubernetes.io/name"] != "codegraph" { + t.Fatalf("missing app label") + } + if labels["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("repo label = %q", labels["codegraph.dev/repo-id"]) + } +} + +func TestNamesForLongRepoIDAreBoundedAndStable(t *testing.T) { + repoID := strings.Repeat("a", 62) + "x" + otherRepoID := strings.Repeat("a", 62) + "y" + + names := NamesFor(repository(repoID)) + again := NamesFor(repository(repoID)) + other := NamesFor(repository(otherRepoID)) + + for field, name := range map[string]string{ + "Base": names.Base, + "PVC": names.PVC, + "SyncJob": names.SyncJob, + "Deployment": names.Deployment, + "Service": names.Service, + "Route": names.Route, + } { + if len(name) > 63 { + t.Fatalf("%s length = %d, name = %q", field, len(name), name) + } + } + + hash := shortHash(repoID) + if !strings.Contains(names.Base, hash) { + t.Fatalf("Base %q does not contain hash %q", names.Base, hash) + } + if !strings.Contains(names.SyncJob, hash) { + t.Fatalf("SyncJob %q does not contain hash %q", names.SyncJob, hash) + } + if !strings.HasSuffix(names.SyncJob, "-sync-7") { + t.Fatalf("SyncJob = %q, want generation suffix", names.SyncJob) + } + if names != again { + t.Fatalf("NamesFor is not stable: %#v != %#v", names, again) + } + if names.Base == other.Base { + t.Fatalf("long repo IDs collided: %q", names.Base) + } +} + +func TestSelectorForIsLabelsSubset(t *testing.T) { + repo := repository("api-service") + labels := LabelsFor(repo) + selector := SelectorFor(repo) + + for key, value := range selector { + if labels[key] != value { + t.Fatalf("selector %q=%q not present in labels: %q", key, value, labels[key]) + } + } +} + +func TestOwnerForUsesControllerReference(t *testing.T) { + repo := repository("api-service") + repo.UID = types.UID("repo-uid-123") + + owners := OwnerFor(repo) + + if len(owners) != 1 { + t.Fatalf("len(owners) = %d", len(owners)) + } + owner := owners[0] + if owner.APIVersion != codegraphv1alpha1.GroupVersion.String() { + t.Fatalf("APIVersion = %q", owner.APIVersion) + } + if owner.Kind != "CodeGraphRepository" { + t.Fatalf("Kind = %q", owner.Kind) + } + if owner.Name != "api-service" { + t.Fatalf("Name = %q", owner.Name) + } + if owner.UID != types.UID("repo-uid-123") { + t.Fatalf("UID = %q", owner.UID) + } + if owner.Controller == nil || !*owner.Controller { + t.Fatalf("Controller = %v", owner.Controller) + } + if owner.BlockOwnerDeletion == nil || !*owner.BlockOwnerDeletion { + t.Fatalf("BlockOwnerDeletion = %v", owner.BlockOwnerDeletion) + } +} + +func repository(repoID string) *codegraphv1alpha1.CodeGraphRepository { + return &codegraphv1alpha1.CodeGraphRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-service", + Namespace: "default", + Generation: 7, + }, + Spec: codegraphv1alpha1.CodeGraphRepositorySpec{ + RepoID: repoID, + }, + } +} + +func shortHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:8] +} diff --git a/deploy/operator/internal/resources/gateway.go b/deploy/operator/internal/resources/gateway.go new file mode 100644 index 000000000..9518b871a --- /dev/null +++ b/deploy/operator/internal/resources/gateway.go @@ -0,0 +1,191 @@ +package resources + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + GatewayReposVolume = "gateway-repos" + GatewayReposMountPath = "/etc/codegraph-gateway" + GatewayReposFilePath = GatewayReposMountPath + "/repos.json" + + gatewayReposHashAnnotation = "codegraph.dev/gateway-repos-hash" +) + +type gatewayReposConfig struct { + RepoID string `json:"repoId"` + URL string `json:"url"` +} + +func BuildGatewayConfigMap(gateway *codegraphv1alpha1.CodeGraphGateway) *corev1.ConfigMap { + names := GatewayNamesFor(gateway) + reposJSON := gatewayReposJSON(gateway) + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.ConfigMap, + Namespace: gateway.Namespace, + Labels: GatewayLabelsFor(gateway), + OwnerReferences: GatewayOwnerFor(gateway), + }, + Data: map[string]string{ + "repos.json": reposJSON, + }, + } +} + +func BuildGatewayService(gateway *codegraphv1alpha1.CodeGraphGateway) *corev1.Service { + names := GatewayNamesFor(gateway) + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Service, + Namespace: gateway.Namespace, + Labels: GatewayLabelsFor(gateway), + OwnerReferences: GatewayOwnerFor(gateway), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: GatewaySelectorFor(gateway), + Ports: []corev1.ServicePort{ + { + Name: MCPPortName, + Port: MCPPort, + TargetPort: intstr.FromString(MCPPortName), + }, + }, + }, + } +} + +func BuildGatewayDeployment(gateway *codegraphv1alpha1.CodeGraphGateway, image string) *appsv1.Deployment { + names := GatewayNamesFor(gateway) + labels := GatewayLabelsFor(gateway) + selector := GatewaySelectorFor(gateway) + podLabels := GatewayLabelsFor(gateway) + podLabels[WorkloadLabel] = WorkloadGateway + + podSpec := gatewayPodSpecFor([]corev1.Container{{ + Name: "codegraph", + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"codegraph"}, + Args: []string{"serve", "--mcp", "--http", "--host", "0.0.0.0", "--port", "3000", "--gateway-repos", GatewayReposFilePath}, + Ports: []corev1.ContainerPort{ + { + Name: MCPPortName, + ContainerPort: MCPPort, + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromString(MCPPortName), + }, + }, + FailureThreshold: 3, + PeriodSeconds: 10, + SuccessThreshold: 1, + TimeoutSeconds: 1, + }, + TerminationMessagePath: corev1.TerminationMessagePathDefault, + TerminationMessagePolicy: corev1.TerminationMessageReadFile, + VolumeMounts: []corev1.VolumeMount{ + { + Name: GatewayReposVolume, + MountPath: GatewayReposMountPath, + ReadOnly: true, + }, + }, + }}, names.ConfigMap) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Deployment, + Namespace: gateway.Namespace, + Labels: labels, + OwnerReferences: GatewayOwnerFor(gateway), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(1), + RevisionHistoryLimit: int32Ptr(10), + ProgressDeadlineSeconds: int32Ptr(600), + Selector: &metav1.LabelSelector{MatchLabels: selector}, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxUnavailable: intstrPtr(intstr.FromString("25%")), + MaxSurge: intstrPtr(intstr.FromString("25%")), + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: podLabels, + Annotations: map[string]string{ + gatewayReposHashAnnotation: shortGatewayReposHash(gatewayReposJSON(gateway)), + }, + }, + Spec: podSpec, + }, + }, + } +} + +func gatewayPodSpecFor(containers []corev1.Container, configMapName string) corev1.PodSpec { + return corev1.PodSpec{ + Containers: containers, + Volumes: gatewayVolumes(configMapName), + DNSPolicy: corev1.DNSClusterFirst, + RestartPolicy: corev1.RestartPolicyAlways, + SchedulerName: corev1.DefaultSchedulerName, + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: boolPtr(true), + RunAsUser: int64Ptr(1000), + RunAsGroup: int64Ptr(1000), + FSGroup: int64Ptr(1000), + }, + TerminationGracePeriodSeconds: int64Ptr(defaultTerminationGracePeriodSeconds), + } +} + +func gatewayVolumes(configMapName string) []corev1.Volume { + return []corev1.Volume{ + { + Name: GatewayReposVolume, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + }, + } +} + +func gatewayReposJSON(gateway *codegraphv1alpha1.CodeGraphGateway) string { + repos := make([]gatewayReposConfig, 0, len(gateway.Spec.Repositories)) + for _, repo := range gateway.Spec.Repositories { + repos = append(repos, gatewayReposConfig{ + RepoID: repo.RepoID, + URL: fmt.Sprintf("http://%s.%s.svc.cluster.local:%d/mcp", repo.ServiceName, gateway.Namespace, MCPPort), + }) + } + data, err := json.Marshal(repos) + if err != nil { + return "[]" + } + return string(data) +} + +func shortGatewayReposHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:shortHashLength] +} diff --git a/deploy/operator/internal/resources/gateway_test.go b/deploy/operator/internal/resources/gateway_test.go new file mode 100644 index 000000000..81af489b2 --- /dev/null +++ b/deploy/operator/internal/resources/gateway_test.go @@ -0,0 +1,216 @@ +package resources + +import ( + "encoding/json" + "reflect" + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestBuildGatewayConfigMapWritesBackendRepositoryURLs(t *testing.T) { + gateway := workloadGateway() + + configMap := BuildGatewayConfigMap(gateway) + + if configMap.Name != "codegraph-team-gateway" { + t.Fatalf("Name = %q", configMap.Name) + } + if configMap.Namespace != "default" { + t.Fatalf("Namespace = %q", configMap.Namespace) + } + assertOwnedByGateway(t, configMap.OwnerReferences) + + var repos []map[string]string + if err := json.Unmarshal([]byte(configMap.Data["repos.json"]), &repos); err != nil { + t.Fatalf("repos.json parse error = %v, data = %q", err, configMap.Data["repos.json"]) + } + want := []map[string]string{ + { + "repoId": "api-service", + "url": "http://codegraph-api-service.default.svc.cluster.local:3000/mcp", + }, + { + "repoId": "web-client", + "url": "http://codegraph-web-client.default.svc.cluster.local:3000/mcp", + }, + } + if !reflect.DeepEqual(repos, want) { + t.Fatalf("repos = %#v", repos) + } +} + +func TestBuildGatewayDeploymentRunsGatewayWithReposConfigMap(t *testing.T) { + gateway := workloadGateway() + + deployment := BuildGatewayDeployment(gateway, "ghcr.io/acme/codegraph:runtime") + + if deployment.Name != "codegraph-team-gateway" { + t.Fatalf("Name = %q", deployment.Name) + } + assertOwnedByGateway(t, deployment.OwnerReferences) + if deployment.Spec.Selector.MatchLabels[WorkloadLabel] != WorkloadGateway { + t.Fatalf("selector workload = %q", deployment.Spec.Selector.MatchLabels[WorkloadLabel]) + } + if deployment.Spec.Template.Labels[WorkloadLabel] != WorkloadGateway { + t.Fatalf("pod workload label = %q", deployment.Spec.Template.Labels[WorkloadLabel]) + } + if deployment.Spec.Replicas == nil || *deployment.Spec.Replicas != 1 { + t.Fatalf("Replicas = %v", deployment.Spec.Replicas) + } + + podSpec := deployment.Spec.Template.Spec + container := onlyContainer(t, podSpec.Containers) + gotCommand := append(append([]string{}, container.Command...), container.Args...) + wantCommand := []string{"codegraph", "serve", "--mcp", "--http", "--host", "0.0.0.0", "--port", "3000", "--gateway-repos", "/etc/codegraph-gateway/repos.json"} + if !reflect.DeepEqual(gotCommand, wantCommand) { + t.Fatalf("command = %#v", gotCommand) + } + if container.Image != "ghcr.io/acme/codegraph:runtime" { + t.Fatalf("Image = %q", container.Image) + } + assertGatewayConfigMapVolume(t, podSpec) + assertGatewayConfigMapMount(t, container) +} + +func TestBuildGatewayServiceUsesGatewaySelectorAndMCPPort(t *testing.T) { + gateway := workloadGateway() + + service := BuildGatewayService(gateway) + + if service.Name != "codegraph-team-gateway" { + t.Fatalf("Name = %q", service.Name) + } + if service.Spec.Selector[WorkloadLabel] != WorkloadGateway { + t.Fatalf("selector workload = %q", service.Spec.Selector[WorkloadLabel]) + } + if len(service.Spec.Ports) != 1 { + t.Fatalf("len(ports) = %d", len(service.Spec.Ports)) + } + port := service.Spec.Ports[0] + if port.Name != "mcp" || port.Port != 3000 || port.TargetPort.StrVal != "mcp" { + t.Fatalf("port = %#v", port) + } + assertOwnedByGateway(t, service.OwnerReferences) +} + +func TestBuildGatewayHTTPRouteExposesSharedMCPPath(t *testing.T) { + gateway := workloadGateway() + + route := BuildGatewayHTTPRoute(gateway, RouteConfig{GatewayName: "edge", GatewayNamespace: "platform"}) + + if route.Name != "codegraph-team-gateway" { + t.Fatalf("Name = %q", route.Name) + } + assertOwnedByGateway(t, route.OwnerReferences) + if len(route.Spec.Hostnames) != 1 || string(route.Spec.Hostnames[0]) != "codegraph.example.com" { + t.Fatalf("Hostnames = %#v", route.Spec.Hostnames) + } + if len(route.Spec.ParentRefs) != 1 || string(route.Spec.ParentRefs[0].Name) != "edge" { + t.Fatalf("ParentRefs = %#v", route.Spec.ParentRefs) + } + if route.Spec.ParentRefs[0].Namespace == nil || string(*route.Spec.ParentRefs[0].Namespace) != "platform" { + t.Fatalf("parent namespace = %v", route.Spec.ParentRefs[0].Namespace) + } + rule := route.Spec.Rules[0] + path := rule.Matches[0].Path + if path.Value == nil || *path.Value != "/mcp" { + t.Fatalf("path value = %v", path.Value) + } + if len(rule.Filters) != 0 { + t.Fatalf("Filters = %#v", rule.Filters) + } + if string(rule.BackendRefs[0].Name) != "codegraph-team-gateway" { + t.Fatalf("backend name = %q", rule.BackendRefs[0].Name) + } +} + +func TestBuildGatewayIngressExposesSharedMCPPath(t *testing.T) { + gateway := workloadGateway() + + ingress := BuildGatewayIngress(gateway) + + if ingress.Name != "codegraph-team-gateway" { + t.Fatalf("Name = %q", ingress.Name) + } + if ingress.Spec.IngressClassName != nil { + t.Fatalf("ingress class = %v", *ingress.Spec.IngressClassName) + } + assertOwnedByGateway(t, ingress.OwnerReferences) + if ingress.Annotations[nginxRewriteTargetAnnotation] != "" { + t.Fatalf("annotations = %#v", ingress.Annotations) + } + path := ingress.Spec.Rules[0].HTTP.Paths[0] + if path.Path != "/mcp" { + t.Fatalf("path = %q", path.Path) + } + if path.PathType == nil || *path.PathType != networkingv1.PathTypeExact { + t.Fatalf("path type = %v", path.PathType) + } + if path.Backend.Service == nil || path.Backend.Service.Name != "codegraph-team-gateway" { + t.Fatalf("backend service = %#v", path.Backend.Service) + } +} + +func TestBuildGatewayIngressOmitsHostWhenGatewayHostIsLocalIP(t *testing.T) { + gateway := workloadGateway() + gateway.Spec.Host = "127.0.0.1" + + ingress := BuildGatewayIngress(gateway) + + if ingress.Spec.Rules[0].Host != "" { + t.Fatalf("Host = %q", ingress.Spec.Rules[0].Host) + } +} + +func workloadGateway() *codegraphv1alpha1.CodeGraphGateway { + return &codegraphv1alpha1.CodeGraphGateway{ + ObjectMeta: metav1.ObjectMeta{Name: "team-gateway", Namespace: "default", Generation: 1, UID: types.UID("gateway-uid-123")}, + Spec: codegraphv1alpha1.CodeGraphGatewaySpec{ + Host: "codegraph.example.com", + Path: "/mcp", + Repositories: []codegraphv1alpha1.GatewayRepository{ + {RepoID: "api-service", ServiceName: "codegraph-api-service"}, + {RepoID: "web-client", ServiceName: "codegraph-web-client"}, + }, + }, + } +} + +func assertOwnedByGateway(t *testing.T, owners []metav1.OwnerReference) { + t.Helper() + if len(owners) != 1 { + t.Fatalf("len(ownerReferences) = %d", len(owners)) + } + owner := owners[0] + if owner.APIVersion != codegraphv1alpha1.GroupVersion.String() || owner.Kind != "CodeGraphGateway" || owner.Name != "team-gateway" || owner.UID != types.UID("gateway-uid-123") { + t.Fatalf("owner reference = %#v", owner) + } + if owner.Controller == nil || !*owner.Controller { + t.Fatalf("owner controller = %v", owner.Controller) + } +} + +func assertGatewayConfigMapVolume(t *testing.T, podSpec corev1.PodSpec) { + t.Helper() + for _, volume := range podSpec.Volumes { + if volume.Name == GatewayReposVolume && volume.ConfigMap != nil && volume.ConfigMap.Name == "codegraph-team-gateway" { + return + } + } + t.Fatalf("gateway configmap volume not found: %#v", podSpec.Volumes) +} + +func assertGatewayConfigMapMount(t *testing.T, container corev1.Container) { + t.Helper() + for _, mount := range container.VolumeMounts { + if mount.Name == GatewayReposVolume && mount.MountPath == GatewayReposMountPath && mount.ReadOnly { + return + } + } + t.Fatalf("gateway configmap mount not found: %#v", container.VolumeMounts) +} diff --git a/deploy/operator/internal/resources/routes.go b/deploy/operator/internal/resources/routes.go new file mode 100644 index 000000000..fe737aff6 --- /dev/null +++ b/deploy/operator/internal/resources/routes.go @@ -0,0 +1,219 @@ +package resources + +import ( + "net" + "strings" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const ( + nginxIngressClassName = "nginx" + nginxRewriteTargetAnnotation = "nginx.ingress.kubernetes.io/rewrite-target" +) + +type RouteConfig struct { + GatewayName string + GatewayNamespace string +} + +func BuildHTTPRoute(repo *codegraphv1alpha1.CodeGraphRepository, config RouteConfig) *gatewayv1.HTTPRoute { + names := NamesFor(repo) + pathType := gatewayv1.PathMatchPathPrefix + pathValue := repo.Spec.MCP.Path + rewriteType := gatewayv1.PrefixMatchHTTPPathModifier + rewritePrefix := "/mcp" + backendPort := gatewayv1.PortNumber(MCPPort) + parentRef := gatewayv1.ParentReference{Name: gatewayv1.ObjectName(config.GatewayName)} + if config.GatewayNamespace != "" { + namespace := gatewayv1.Namespace(config.GatewayNamespace) + parentRef.Namespace = &namespace + } + + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Route, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{parentRef}, + }, + Hostnames: []gatewayv1.Hostname{gatewayv1.Hostname(repo.Spec.MCP.Host)}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &pathType, + Value: &pathValue, + }, + }, + }, + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Path: &gatewayv1.HTTPPathModifier{ + Type: rewriteType, + ReplacePrefixMatch: &rewritePrefix, + }, + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(names.Service), + Port: &backendPort, + }, + }, + }, + }, + }, + }, + }, + } +} + +func BuildIngress(repo *codegraphv1alpha1.CodeGraphRepository) *networkingv1.Ingress { + names := NamesFor(repo) + pathType := networkingv1.PathTypeExact + ingressClassName := nginxIngressClassName + + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Route, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + Annotations: map[string]string{ + nginxRewriteTargetAnnotation: "/mcp", + }, + OwnerReferences: OwnerFor(repo), + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: &ingressClassName, + Rules: []networkingv1.IngressRule{ + { + Host: repo.Spec.MCP.Host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: repo.Spec.MCP.Path, + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: names.Service, + Port: networkingv1.ServiceBackendPort{Number: MCPPort}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func BuildGatewayHTTPRoute(gateway *codegraphv1alpha1.CodeGraphGateway, config RouteConfig) *gatewayv1.HTTPRoute { + names := GatewayNamesFor(gateway) + pathType := gatewayv1.PathMatchPathPrefix + pathValue := gateway.GatewayPath() + backendPort := gatewayv1.PortNumber(MCPPort) + parentRef := gatewayv1.ParentReference{Name: gatewayv1.ObjectName(config.GatewayName)} + if config.GatewayNamespace != "" { + namespace := gatewayv1.Namespace(config.GatewayNamespace) + parentRef.Namespace = &namespace + } + + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Route, + Namespace: gateway.Namespace, + Labels: GatewayLabelsFor(gateway), + OwnerReferences: GatewayOwnerFor(gateway), + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{parentRef}, + }, + Hostnames: []gatewayv1.Hostname{gatewayv1.Hostname(gateway.Spec.Host)}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &pathType, + Value: &pathValue, + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(names.Service), + Port: &backendPort, + }, + }, + }, + }, + }, + }, + }, + } +} + +func BuildGatewayIngress(gateway *codegraphv1alpha1.CodeGraphGateway) *networkingv1.Ingress { + names := GatewayNamesFor(gateway) + pathType := networkingv1.PathTypeExact + host := gateway.Spec.Host + if isIPHost(host) { + host = "" + } + + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Route, + Namespace: gateway.Namespace, + Labels: GatewayLabelsFor(gateway), + OwnerReferences: GatewayOwnerFor(gateway), + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: gateway.GatewayPath(), + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: names.Service, + Port: networkingv1.ServiceBackendPort{Number: MCPPort}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func isIPHost(host string) bool { + return net.ParseIP(strings.Trim(host, "[]")) != nil +} diff --git a/deploy/operator/internal/resources/routes_test.go b/deploy/operator/internal/resources/routes_test.go new file mode 100644 index 000000000..4a21c1d06 --- /dev/null +++ b/deploy/operator/internal/resources/routes_test.go @@ -0,0 +1,134 @@ +package resources + +import ( + "testing" + + networkingv1 "k8s.io/api/networking/v1" +) + +func TestBuildHTTPRouteRoutesRepositoryMCPPathThroughGateway(t *testing.T) { + repo := workloadRepository() + + route := BuildHTTPRoute(repo, RouteConfig{GatewayName: "codegraph", GatewayNamespace: "platform"}) + + if route.Name != "codegraph-api-service" { + t.Fatalf("Name = %q", route.Name) + } + if route.Namespace != "default" { + t.Fatalf("Namespace = %q", route.Namespace) + } + assertOwnedByRepository(t, route.OwnerReferences) + if route.Labels["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("labels = %#v", route.Labels) + } + if len(route.Spec.Hostnames) != 1 || string(route.Spec.Hostnames[0]) != "codegraph.example.com" { + t.Fatalf("Hostnames = %#v", route.Spec.Hostnames) + } + if len(route.Spec.ParentRefs) != 1 { + t.Fatalf("ParentRefs = %#v", route.Spec.ParentRefs) + } + parent := route.Spec.ParentRefs[0] + if string(parent.Name) != "codegraph" { + t.Fatalf("parent name = %q", parent.Name) + } + if parent.Namespace == nil || string(*parent.Namespace) != "platform" { + t.Fatalf("parent namespace = %v", parent.Namespace) + } + if len(route.Spec.Rules) != 1 { + t.Fatalf("Rules = %#v", route.Spec.Rules) + } + rule := route.Spec.Rules[0] + if len(rule.Matches) != 1 || rule.Matches[0].Path == nil { + t.Fatalf("Matches = %#v", rule.Matches) + } + path := rule.Matches[0].Path + if path.Type == nil || string(*path.Type) != "PathPrefix" { + t.Fatalf("path type = %v", path.Type) + } + if path.Value == nil || *path.Value != "/mcp/api-service" { + t.Fatalf("path value = %v", path.Value) + } + if len(rule.Filters) != 1 || rule.Filters[0].URLRewrite == nil || rule.Filters[0].URLRewrite.Path == nil { + t.Fatalf("Filters = %#v", rule.Filters) + } + rewrite := rule.Filters[0].URLRewrite.Path + if string(rewrite.Type) != "ReplacePrefixMatch" { + t.Fatalf("rewrite type = %q", rewrite.Type) + } + if rewrite.ReplacePrefixMatch == nil || *rewrite.ReplacePrefixMatch != "/mcp" { + t.Fatalf("rewrite prefix = %v", rewrite.ReplacePrefixMatch) + } + if len(rule.BackendRefs) != 1 { + t.Fatalf("BackendRefs = %#v", rule.BackendRefs) + } + backend := rule.BackendRefs[0] + if string(backend.Name) != "codegraph-api-service" { + t.Fatalf("backend name = %q", backend.Name) + } + if backend.Port == nil || int32(*backend.Port) != 3000 { + t.Fatalf("backend port = %v", backend.Port) + } +} + +func TestBuildIngressFallbackRoutesOnlyExactRepositoryMCPEndpointToService(t *testing.T) { + repo := workloadRepository() + + ingress := BuildIngress(repo) + + if ingress.Name != "codegraph-api-service" { + t.Fatalf("Name = %q", ingress.Name) + } + if ingress.Namespace != "default" { + t.Fatalf("Namespace = %q", ingress.Namespace) + } + assertOwnedByRepository(t, ingress.OwnerReferences) + if ingress.Labels["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("labels = %#v", ingress.Labels) + } + if ingress.Annotations["nginx.ingress.kubernetes.io/rewrite-target"] != "/mcp" { + t.Fatalf("annotations = %#v", ingress.Annotations) + } + if ingress.Spec.IngressClassName == nil || *ingress.Spec.IngressClassName != "nginx" { + t.Fatalf("ingress class = %v", ingress.Spec.IngressClassName) + } + if len(ingress.Spec.Rules) != 1 { + t.Fatalf("Rules = %#v", ingress.Spec.Rules) + } + rule := ingress.Spec.Rules[0] + if rule.Host != "codegraph.example.com" { + t.Fatalf("host = %q", rule.Host) + } + if rule.HTTP == nil || len(rule.HTTP.Paths) != 1 { + t.Fatalf("HTTP paths = %#v", rule.HTTP) + } + path := rule.HTTP.Paths[0] + if path.Path != "/mcp/api-service" { + t.Fatalf("path = %q", path.Path) + } + if path.PathType == nil || *path.PathType != networkingv1.PathTypeExact { + t.Fatalf("path type = %v", path.PathType) + } + if path.Backend.Service == nil { + t.Fatalf("backend service is nil") + } + service := path.Backend.Service + if service.Name != "codegraph-api-service" { + t.Fatalf("service name = %q", service.Name) + } + if service.Port.Number != 3000 { + t.Fatalf("service port = %d", service.Port.Number) + } +} + +func TestBuildHTTPRouteOmitsEmptyGatewayNamespace(t *testing.T) { + repo := workloadRepository() + + route := BuildHTTPRoute(repo, RouteConfig{GatewayName: "codegraph"}) + + if len(route.Spec.ParentRefs) != 1 { + t.Fatalf("ParentRefs = %#v", route.Spec.ParentRefs) + } + if route.Spec.ParentRefs[0].Namespace != nil { + t.Fatalf("parent namespace = %v", route.Spec.ParentRefs[0].Namespace) + } +} diff --git a/deploy/operator/internal/resources/workloads.go b/deploy/operator/internal/resources/workloads.go new file mode 100644 index 000000000..16310e921 --- /dev/null +++ b/deploy/operator/internal/resources/workloads.go @@ -0,0 +1,302 @@ +package resources + +import ( + "strconv" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + WorkspaceVolume = "workspace" + WorkspaceMountPath = "/workspace" + RepoPath = "/workspace/repo" + MCPPortName = "mcp" + MCPPort = int32(3000) + + gitSSHVolume = "git-ssh" + gitSSHPath = "/git-ssh" +) + +const ( + RepositoryGenerationAnnotation = "codegraph.dev/repository-generation" +) + +const ( + defaultTerminationGracePeriodSeconds = int64(30) +) + +func BuildPVC(repo *codegraphv1alpha1.CodeGraphRepository) *corev1.PersistentVolumeClaim { + names := NamesFor(repo) + storage := repo.Spec.Storage.Size + if storage.IsZero() { + storage = resource.MustParse("10Gi") + } + + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.PVC, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + StorageClassName: repo.Spec.Storage.StorageClassName, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storage, + }, + }, + }, + } +} + +func BuildService(repo *codegraphv1alpha1.CodeGraphRepository) *corev1.Service { + names := NamesFor(repo) + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Service, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: RuntimeSelectorFor(repo), + Ports: []corev1.ServicePort{ + { + Name: MCPPortName, + Port: MCPPort, + TargetPort: intstr.FromString(MCPPortName), + }, + }, + }, + } +} + +func BuildDeployment(repo *codegraphv1alpha1.CodeGraphRepository, defaultImage string) *appsv1.Deployment { + names := NamesFor(repo) + labels := LabelsFor(repo) + selector := RuntimeSelectorFor(repo) + podLabels := LabelsFor(repo) + podLabels[WorkloadLabel] = WorkloadRuntime + + podSpec := podSpecFor(repo, []corev1.Container{{ + Name: "codegraph", + Image: repo.RuntimeImage(defaultImage), + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"codegraph"}, + Args: []string{"serve", "--mcp", "--http", "--host", "0.0.0.0", "--port", "3000", "--path", RepoPath}, + Resources: repo.Spec.Resources, + Ports: []corev1.ContainerPort{ + { + Name: MCPPortName, + ContainerPort: MCPPort, + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromString(MCPPortName), + }, + }, + FailureThreshold: 3, + PeriodSeconds: 10, + SuccessThreshold: 1, + TimeoutSeconds: 1, + }, + TerminationMessagePath: corev1.TerminationMessagePathDefault, + TerminationMessagePolicy: corev1.TerminationMessageReadFile, + VolumeMounts: workspaceMounts(), + }}) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Deployment, + Namespace: repo.Namespace, + Labels: labels, + OwnerReferences: OwnerFor(repo), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(1), + RevisionHistoryLimit: int32Ptr(10), + ProgressDeadlineSeconds: int32Ptr(600), + Selector: &metav1.LabelSelector{MatchLabels: selector}, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxUnavailable: intstrPtr(intstr.FromString("25%")), + MaxSurge: intstrPtr(intstr.FromString("25%")), + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: podLabels, + Annotations: map[string]string{ + RepositoryGenerationAnnotation: strconv.FormatInt(repo.Generation, 10), + }, + }, + Spec: podSpec, + }, + }, + } +} + +func BuildSyncJob(repo *codegraphv1alpha1.CodeGraphRepository, defaultImage string) *batchv1.Job { + names := NamesFor(repo) + labels := LabelsFor(repo) + podLabels := LabelsFor(repo) + podLabels[WorkloadLabel] = WorkloadSync + container := corev1.Container{ + Name: "sync", + Image: repo.RuntimeImage(defaultImage), + Command: []string{"/bin/sh", "-c"}, + Args: []string{syncScript()}, + Resources: repo.Spec.Resources, + Env: []corev1.EnvVar{ + {Name: "GIT_URL", Value: repo.Spec.Git.URL}, + {Name: "GIT_REF", Value: repo.Spec.Git.Ref}, + }, + VolumeMounts: workspaceMounts(), + } + + volumes := workspaceVolumes(names.PVC) + if repo.Spec.Git.AuthSecretRef != nil { + secretName := repo.Spec.Git.AuthSecretRef.Name + container.EnvFrom = append(container.EnvFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Optional: boolPtr(true), + }, + }) + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: gitSSHVolume, + MountPath: gitSSHPath, + ReadOnly: true, + }) + volumes = append(volumes, corev1.Volume{ + Name: gitSSHVolume, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + Optional: boolPtr(true), + }, + }, + }) + } + + podSpec := podSpecFor(repo, []corev1.Container{container}) + podSpec.RestartPolicy = corev1.RestartPolicyNever + podSpec.Volumes = volumes + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.SyncJob, + Namespace: repo.Namespace, + Labels: labels, + OwnerReferences: OwnerFor(repo), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: int32Ptr(1), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: podLabels}, + Spec: podSpec, + }, + }, + } +} + +func syncScript() string { + return `set -eu +if [ -n "${GIT_USERNAME:-}" ] && [ -n "${GIT_PASSWORD:-}" ]; then + cat > /tmp/codegraph-git-askpass <<'EOF' +#!/bin/sh +case "$1" in + *Username*) printf '%s\n' "$GIT_USERNAME" ;; + *Password*) printf '%s\n' "$GIT_PASSWORD" ;; + *) printf '\n' ;; +esac +EOF + chmod 700 /tmp/codegraph-git-askpass + export GIT_ASKPASS=/tmp/codegraph-git-askpass +fi +if [ -f /git-ssh/ssh-privatekey ]; then + cp /git-ssh/ssh-privatekey /tmp/codegraph-ssh-key + chmod 600 /tmp/codegraph-ssh-key + export GIT_SSH_COMMAND="ssh -i /tmp/codegraph-ssh-key -o StrictHostKeyChecking=accept-new" +fi +rm -rf /workspace/repo-next +rm -f /workspace/.resolved-ref-next +git clone "$GIT_URL" /workspace/repo-next +git -C /workspace/repo-next checkout "$GIT_REF" +cd /workspace/repo-next +codegraph init +git -C /workspace/repo-next rev-parse HEAD > /workspace/.resolved-ref-next +rm -rf /workspace/repo-previous +if [ -d /workspace/repo ]; then mv /workspace/repo /workspace/repo-previous; fi +mv /workspace/repo-next /workspace/repo +mv /workspace/.resolved-ref-next /workspace/.resolved-ref` +} + +func podSpecFor(repo *codegraphv1alpha1.CodeGraphRepository, containers []corev1.Container) corev1.PodSpec { + return corev1.PodSpec{ + Containers: containers, + NodeSelector: repo.Spec.NodeSelector, + Tolerations: repo.Spec.Tolerations, + Affinity: repo.Spec.Affinity, + Volumes: workspaceVolumes(NamesFor(repo).PVC), + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + SchedulerName: corev1.DefaultSchedulerName, + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: boolPtr(true), + RunAsUser: int64Ptr(1000), + RunAsGroup: int64Ptr(1000), + FSGroup: int64Ptr(1000), + }, + TerminationGracePeriodSeconds: int64Ptr(defaultTerminationGracePeriodSeconds), + } +} + +func workspaceVolumes(claimName string) []corev1.Volume { + return []corev1.Volume{ + { + Name: WorkspaceVolume, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: claimName}, + }, + }, + } +} + +func workspaceMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + Name: WorkspaceVolume, + MountPath: WorkspaceMountPath, + }, + } +} + +func boolPtr(value bool) *bool { + return &value +} + +func int32Ptr(value int32) *int32 { + return &value +} + +func int64Ptr(value int64) *int64 { + return &value +} + +func intstrPtr(value intstr.IntOrString) *intstr.IntOrString { + return &value +} diff --git a/deploy/operator/internal/resources/workloads_test.go b/deploy/operator/internal/resources/workloads_test.go new file mode 100644 index 000000000..18b50b7b4 --- /dev/null +++ b/deploy/operator/internal/resources/workloads_test.go @@ -0,0 +1,371 @@ +package resources + +import ( + "reflect" + "strings" + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestBuildPVCUsesRepoNameAndStorageRequest(t *testing.T) { + repo := workloadRepository() + + pvc := BuildPVC(repo) + + if pvc.Name != "codegraph-api-service" { + t.Fatalf("Name = %q", pvc.Name) + } + if pvc.Namespace != "default" { + t.Fatalf("Namespace = %q", pvc.Namespace) + } + if len(pvc.Spec.AccessModes) != 1 || pvc.Spec.AccessModes[0] != corev1.ReadWriteOnce { + t.Fatalf("AccessModes = %#v", pvc.Spec.AccessModes) + } + got := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if got.Cmp(resource.MustParse("20Gi")) != 0 { + t.Fatalf("storage request = %s", got.String()) + } + assertOwnedByRepository(t, pvc.OwnerReferences) +} + +func TestBuildPVCDefaultsStorageWhenUnset(t *testing.T) { + repo := workloadRepository() + repo.Spec.Storage.Size = resource.Quantity{} + + pvc := BuildPVC(repo) + + got := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if got.Cmp(resource.MustParse("10Gi")) != 0 { + t.Fatalf("storage request = %s", got.String()) + } +} + +func TestBuildServiceUsesMCPPortAndRepoSelector(t *testing.T) { + repo := workloadRepository() + + service := BuildService(repo) + + if service.Name != "codegraph-api-service" { + t.Fatalf("Name = %q", service.Name) + } + if service.Spec.Type != corev1.ServiceTypeClusterIP { + t.Fatalf("Type = %q", service.Spec.Type) + } + if service.Spec.Selector["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("selector repo id = %q", service.Spec.Selector["codegraph.dev/repo-id"]) + } + if service.Spec.Selector[WorkloadLabel] != WorkloadRuntime { + t.Fatalf("selector workload = %q", service.Spec.Selector[WorkloadLabel]) + } + if len(service.Spec.Ports) != 1 { + t.Fatalf("len(ports) = %d", len(service.Spec.Ports)) + } + port := service.Spec.Ports[0] + if port.Name != "mcp" { + t.Fatalf("port name = %q", port.Name) + } + if port.Port != 3000 { + t.Fatalf("port = %d", port.Port) + } + if port.TargetPort.StrVal != "mcp" { + t.Fatalf("target port = %#v", port.TargetPort) + } + assertOwnedByRepository(t, service.OwnerReferences) +} + +func TestBuildServiceSelectorExcludesSyncJobPods(t *testing.T) { + repo := workloadRepository() + + service := BuildService(repo) + job := BuildSyncJob(repo, "ghcr.io/acme/codegraph:default") + + if selectorMatchesLabels(service.Spec.Selector, job.Spec.Template.Labels) { + t.Fatalf("service selector %#v unexpectedly matches sync job pod labels %#v", service.Spec.Selector, job.Spec.Template.Labels) + } +} + +func TestBuildDeploymentRunsHTTPMCPServerWithOverrideImageAndPVC(t *testing.T) { + repo := workloadRepository() + repo.Spec.Image = "ghcr.io/acme/codegraph:repo" + + deployment := BuildDeployment(repo, "ghcr.io/acme/codegraph:default") + + if deployment.Name != "codegraph-api-service" { + t.Fatalf("Name = %q", deployment.Name) + } + assertOwnedByRepository(t, deployment.OwnerReferences) + assertDeploymentSelectorMatchesRepo(t, deployment) + if deployment.Spec.Selector.MatchLabels[WorkloadLabel] != WorkloadRuntime { + t.Fatalf("selector workload = %q", deployment.Spec.Selector.MatchLabels[WorkloadLabel]) + } + if deployment.Spec.Template.Labels[WorkloadLabel] != WorkloadRuntime { + t.Fatalf("pod workload label = %q", deployment.Spec.Template.Labels[WorkloadLabel]) + } + if deployment.Spec.Replicas == nil || *deployment.Spec.Replicas != 1 { + t.Fatalf("Replicas = %v", deployment.Spec.Replicas) + } + if deployment.Spec.RevisionHistoryLimit == nil || *deployment.Spec.RevisionHistoryLimit != 10 { + t.Fatalf("RevisionHistoryLimit = %v", deployment.Spec.RevisionHistoryLimit) + } + if deployment.Spec.ProgressDeadlineSeconds == nil || *deployment.Spec.ProgressDeadlineSeconds != 600 { + t.Fatalf("ProgressDeadlineSeconds = %v", deployment.Spec.ProgressDeadlineSeconds) + } + if deployment.Spec.Strategy.Type != appsv1.RollingUpdateDeploymentStrategyType { + t.Fatalf("Strategy.Type = %q", deployment.Spec.Strategy.Type) + } + if deployment.Spec.Template.Annotations["codegraph.dev/repository-generation"] != "1" { + t.Fatalf("pod annotations = %#v", deployment.Spec.Template.Annotations) + } + + podSpec := deployment.Spec.Template.Spec + container := onlyContainer(t, podSpec.Containers) + if container.Image != "ghcr.io/acme/codegraph:repo" { + t.Fatalf("Image = %q", container.Image) + } + if container.ImagePullPolicy != corev1.PullIfNotPresent { + t.Fatalf("ImagePullPolicy = %q", container.ImagePullPolicy) + } + if container.TerminationMessagePath != corev1.TerminationMessagePathDefault { + t.Fatalf("TerminationMessagePath = %q", container.TerminationMessagePath) + } + if container.TerminationMessagePolicy != corev1.TerminationMessageReadFile { + t.Fatalf("TerminationMessagePolicy = %q", container.TerminationMessagePolicy) + } + gotCommand := append(append([]string{}, container.Command...), container.Args...) + wantCommand := []string{"codegraph", "serve", "--mcp", "--http", "--host", "0.0.0.0", "--port", "3000", "--path", "/workspace/repo"} + if !reflect.DeepEqual(gotCommand, wantCommand) { + t.Fatalf("command = %#v", gotCommand) + } + if container.ReadinessProbe == nil || container.ReadinessProbe.TCPSocket == nil { + t.Fatalf("missing TCP readiness probe") + } + if container.ReadinessProbe.HTTPGet != nil { + t.Fatalf("readiness probe uses HTTP GET: %#v", container.ReadinessProbe.HTTPGet) + } + if container.ReadinessProbe.TCPSocket.Port.StrVal != "mcp" { + t.Fatalf("readiness TCP port = %#v", container.ReadinessProbe.TCPSocket.Port) + } + if podSpec.DNSPolicy != corev1.DNSClusterFirst { + t.Fatalf("DNSPolicy = %q", podSpec.DNSPolicy) + } + if podSpec.SchedulerName != corev1.DefaultSchedulerName { + t.Fatalf("SchedulerName = %q", podSpec.SchedulerName) + } + if podSpec.TerminationGracePeriodSeconds == nil || *podSpec.TerminationGracePeriodSeconds != 30 { + t.Fatalf("TerminationGracePeriodSeconds = %v", podSpec.TerminationGracePeriodSeconds) + } + assertWorkspacePVCVolume(t, podSpec) + assertWorkspaceMount(t, container) +} + +func TestBuildSyncJobClonesIndexesAndWritesResolvedRefFile(t *testing.T) { + repo := workloadRepository() + + job := BuildSyncJob(repo, "ghcr.io/acme/codegraph:default") + + if job.Name != "codegraph-api-service-sync-1" { + t.Fatalf("Name = %q", job.Name) + } + assertOwnedByRepository(t, job.OwnerReferences) + if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 1 { + t.Fatalf("BackoffLimit = %v", job.Spec.BackoffLimit) + } + if job.Spec.Template.Labels[WorkloadLabel] != WorkloadSync { + t.Fatalf("pod workload label = %q", job.Spec.Template.Labels[WorkloadLabel]) + } + + podSpec := job.Spec.Template.Spec + if podSpec.RestartPolicy != corev1.RestartPolicyNever { + t.Fatalf("RestartPolicy = %q", podSpec.RestartPolicy) + } + container := onlyContainer(t, podSpec.Containers) + if container.Command[0] != "/bin/sh" || container.Command[1] != "-c" { + t.Fatalf("Command = %#v", container.Command) + } + script := container.Args[0] + if strings.Contains(script, "rm -rf /workspace/repo\n") { + t.Fatalf("script deletes current repo before replacement:\n%s", script) + } + for _, fragment := range []string{ + "rm -rf /workspace/repo-next", + `git clone "$GIT_URL" /workspace/repo-next`, + `git -C /workspace/repo-next checkout "$GIT_REF"`, + "cd /workspace/repo-next", + "codegraph init", + "git -C /workspace/repo-next rev-parse HEAD > /workspace/.resolved-ref-next", + "rm -rf /workspace/repo-previous", + "if [ -d /workspace/repo ]; then mv /workspace/repo /workspace/repo-previous; fi", + "mv /workspace/repo-next /workspace/repo", + "mv /workspace/.resolved-ref-next /workspace/.resolved-ref", + "if [ -n \"${GIT_USERNAME:-}\" ] && [ -n \"${GIT_PASSWORD:-}\" ]; then", + "export GIT_ASKPASS=/tmp/codegraph-git-askpass", + "if [ -f /git-ssh/ssh-privatekey ]; then", + "cp /git-ssh/ssh-privatekey /tmp/codegraph-ssh-key", + "chmod 600 /tmp/codegraph-ssh-key", + "export GIT_SSH_COMMAND=\"ssh -i /tmp/codegraph-ssh-key -o StrictHostKeyChecking=accept-new\"", + } { + if !strings.Contains(script, fragment) { + t.Fatalf("script missing %q:\n%s", fragment, script) + } + } + if strings.Contains(script, "chmod 600 /git-ssh/ssh-privatekey") { + t.Fatalf("script chmods read-only secret volume key:\n%s", script) + } + if strings.Contains(script, "codegraph index") { + t.Fatalf("script should not run a second full index:\n%s", script) + } + if strings.Count(script, "codegraph init") != 1 { + t.Fatalf("codegraph init count = %d:\n%s", strings.Count(script, "codegraph init"), script) + } + assertEnvValue(t, container.Env, "GIT_URL", "https://github.com/acme/api-service.git") + assertEnvValue(t, container.Env, "GIT_REF", "main") + if len(container.EnvFrom) != 1 || container.EnvFrom[0].SecretRef == nil || container.EnvFrom[0].SecretRef.Name != "api-service-git" { + t.Fatalf("EnvFrom = %#v", container.EnvFrom) + } + assertWorkspacePVCVolume(t, podSpec) + assertWorkspaceMount(t, container) + assertSSHSecretVolume(t, podSpec, "api-service-git") + assertSSHSecretMount(t, container) +} + +func TestBuildSyncJobWithoutAuthOmitsSecretEnvAndMounts(t *testing.T) { + repo := workloadRepository() + repo.Spec.Git.AuthSecretRef = nil + + job := BuildSyncJob(repo, "ghcr.io/acme/codegraph:default") + + podSpec := job.Spec.Template.Spec + container := onlyContainer(t, podSpec.Containers) + if len(container.EnvFrom) != 0 { + t.Fatalf("EnvFrom = %#v", container.EnvFrom) + } + for _, volume := range podSpec.Volumes { + if volume.Name == "git-ssh" { + t.Fatalf("unexpected git ssh volume: %#v", podSpec.Volumes) + } + } + for _, mount := range container.VolumeMounts { + if mount.Name == "git-ssh" || mount.MountPath == "/git-ssh" { + t.Fatalf("unexpected git ssh mount: %#v", container.VolumeMounts) + } + } +} + +func workloadRepository() *codegraphv1alpha1.CodeGraphRepository { + return &codegraphv1alpha1.CodeGraphRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "api-service", Namespace: "default", Generation: 1, UID: types.UID("repo-uid-123")}, + Spec: codegraphv1alpha1.CodeGraphRepositorySpec{ + RepoID: "api-service", + Git: codegraphv1alpha1.GitSpec{ + URL: "https://github.com/acme/api-service.git", + Ref: "main", + AuthSecretRef: &corev1.LocalObjectReference{Name: "api-service-git"}, + }, + MCP: codegraphv1alpha1.MCPSpec{Host: "codegraph.example.com", Path: "/mcp/api-service"}, + Storage: codegraphv1alpha1.StorageSpec{Size: resource.MustParse("20Gi")}, + }, + } +} + +func assertOwnedByRepository(t *testing.T, owners []metav1.OwnerReference) { + t.Helper() + if len(owners) != 1 { + t.Fatalf("len(ownerReferences) = %d", len(owners)) + } + owner := owners[0] + if owner.APIVersion != codegraphv1alpha1.GroupVersion.String() || owner.Kind != "CodeGraphRepository" || owner.Name != "api-service" || owner.UID != types.UID("repo-uid-123") { + t.Fatalf("owner reference = %#v", owner) + } + if owner.Controller == nil || !*owner.Controller { + t.Fatalf("owner controller = %v", owner.Controller) + } +} + +func assertDeploymentSelectorMatchesRepo(t *testing.T, deployment *appsv1.Deployment) { + t.Helper() + if deployment.Spec.Selector == nil { + t.Fatalf("missing selector") + } + if deployment.Spec.Selector.MatchLabels["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("selector = %#v", deployment.Spec.Selector.MatchLabels) + } + if deployment.Spec.Template.Labels["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("pod labels = %#v", deployment.Spec.Template.Labels) + } +} + +func onlyContainer(t *testing.T, containers []corev1.Container) corev1.Container { + t.Helper() + if len(containers) != 1 { + t.Fatalf("len(containers) = %d", len(containers)) + } + return containers[0] +} + +func assertWorkspacePVCVolume(t *testing.T, podSpec corev1.PodSpec) { + t.Helper() + for _, volume := range podSpec.Volumes { + if volume.Name == "workspace" && volume.PersistentVolumeClaim != nil && volume.PersistentVolumeClaim.ClaimName == "codegraph-api-service" { + return + } + } + t.Fatalf("workspace PVC volume not found: %#v", podSpec.Volumes) +} + +func assertWorkspaceMount(t *testing.T, container corev1.Container) { + t.Helper() + for _, mount := range container.VolumeMounts { + if mount.Name == "workspace" && mount.MountPath == "/workspace" { + return + } + } + t.Fatalf("workspace mount not found: %#v", container.VolumeMounts) +} + +func assertSSHSecretVolume(t *testing.T, podSpec corev1.PodSpec, secretName string) { + t.Helper() + for _, volume := range podSpec.Volumes { + if volume.Name == "git-ssh" && volume.Secret != nil && volume.Secret.SecretName == secretName { + return + } + } + t.Fatalf("git ssh secret volume not found: %#v", podSpec.Volumes) +} + +func assertSSHSecretMount(t *testing.T, container corev1.Container) { + t.Helper() + for _, mount := range container.VolumeMounts { + if mount.Name == "git-ssh" && mount.MountPath == "/git-ssh" && mount.ReadOnly { + return + } + } + t.Fatalf("git ssh secret mount not found: %#v", container.VolumeMounts) +} + +func assertEnvValue(t *testing.T, env []corev1.EnvVar, name string, want string) { + t.Helper() + for _, item := range env { + if item.Name == name { + if item.Value != want { + t.Fatalf("env %s = %q", name, item.Value) + } + return + } + } + t.Fatalf("env %s not found: %#v", name, env) +} + +func selectorMatchesLabels(selector map[string]string, labels map[string]string) bool { + for key, value := range selector { + if labels[key] != value { + return false + } + } + return true +} diff --git a/deploy/operator/runtime.Containerfile b/deploy/operator/runtime.Containerfile new file mode 100644 index 000000000..e0d1d505a --- /dev/null +++ b/deploy/operator/runtime.Containerfile @@ -0,0 +1,31 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /src + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git openssh-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/codegraph + +COPY --from=build /src/package.json ./package.json +COPY --from=build /src/node_modules ./node_modules +COPY --from=build /src/dist ./dist + +RUN ln -s /opt/codegraph/dist/bin/codegraph.js /usr/local/bin/codegraph \ + && mkdir -p /workspace \ + && chown -R node:node /workspace /opt/codegraph + +USER node +WORKDIR /workspace + +ENTRYPOINT ["codegraph"] diff --git a/deploy/operator/tools.go b/deploy/operator/tools.go new file mode 100644 index 000000000..e41920133 --- /dev/null +++ b/deploy/operator/tools.go @@ -0,0 +1,5 @@ +//go:build tools + +package tools + +import _ "sigs.k8s.io/controller-tools/cmd/controller-gen" diff --git a/docs/superpowers/plans/2026-06-16-codegraph-cloud-native-crd.md b/docs/superpowers/plans/2026-06-16-codegraph-cloud-native-crd.md new file mode 100644 index 000000000..a93884747 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-codegraph-cloud-native-crd.md @@ -0,0 +1,2161 @@ +# CodeGraph Cloud-Native CRD Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Kubernetes `CodeGraphRepository` CRD and controller that declaratively clones, indexes, serves, and routes repository-scoped CodeGraph HTTP MCP servers behind one shared path-based MCP address. + +**Architecture:** Add a self-contained Go operator under `deploy/operator/` so the existing TypeScript/Node CLI and MCP server remain untouched. The operator reconciles one custom resource into PVC, sync/index Job, Service, and either Gateway API `HTTPRoute` or Kubernetes `Ingress`; it creates or rolls the runtime Deployment only after indexing has succeeded. Resource builder functions are tested without a cluster first; controller reconciliation is then tested with controller-runtime's fake/envtest clients. + +**Tech Stack:** Go 1.23, controller-runtime, controller-tools, Kubernetes API machinery, Gateway API, standard Go tests, existing CodeGraph runtime image and CLI command. + +--- + +## File Structure + +Create a nested Go module so operator dependencies do not affect the npm package: + +- `deploy/operator/go.mod`: Go module and Kubernetes dependencies. +- `deploy/operator/Makefile`: repeatable generate, manifest, test, and build commands. +- `deploy/operator/cmd/manager/main.go`: controller manager shell; Task 6 wires in the repository controller after API and controller packages exist. +- `deploy/operator/api/v1alpha1/groupversion_info.go`: API group registration. +- `deploy/operator/api/v1alpha1/codegraphrepository_types.go`: CRD spec/status types and validation markers. +- `deploy/operator/internal/resources/`: pure resource builders for names, labels, PVC, Job, Deployment, Service, HTTPRoute, and Ingress. +- `deploy/operator/internal/controller/codegraphrepository_controller.go`: reconcile loop and status updates. +- `deploy/operator/config/crd/`: generated CRD YAML. +- `deploy/operator/config/samples/codegraphrepository.yaml`: sample repository declaration. +- `deploy/operator/README.md`: local development and cluster usage notes. + +Do not modify the existing MCP HTTP server in this first pass. The route layer rewrites `/mcp/` to pod-local `/mcp`. + +## Task 1: Scaffold Operator Module + +**Files:** +- Create: `deploy/operator/go.mod` +- Create: `deploy/operator/Makefile` +- Create: `deploy/operator/cmd/manager/main.go` + +- [ ] **Step 1: Create the Go module file** + +Create `deploy/operator/go.mod`: + +```go +module github.com/colbymchenry/codegraph/deploy/operator + +go 1.23 + +require ( + github.com/go-logr/logr v1.4.2 + k8s.io/api v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 + sigs.k8s.io/controller-runtime v0.20.2 + sigs.k8s.io/gateway-api v1.2.1 +) +``` + +- [ ] **Step 2: Create the operator Makefile** + +Create `deploy/operator/Makefile`: + +```makefile +SHELL := /bin/sh + +.PHONY: tidy generate manifests test build + +tidy: + go mod tidy + +generate: + go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.2 object paths="./..." + +manifests: + mkdir -p config/crd + go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.2 crd:allowDangerousTypes=true paths="./api/..." output:crd:artifacts:config=config/crd + +test: + go test ./... + +build: + go build ./cmd/manager +``` + +- [ ] **Step 3: Create the manager entrypoint** + +Create `deploy/operator/cmd/manager/main.go`: + +```go +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) +} + +func main() { + var metricsAddr string + var probeAddr string + var enableLeaderElection bool + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zap.Options{}))) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "codegraph.dev", + }) + if err != nil { + ctrl.Log.Error(err, "unable to start manager") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + ctrl.Log.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + ctrl.Log.Error(err, "unable to set up ready check") + os.Exit(1) + } + + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + ctrl.Log.Error(err, "problem running manager") + os.Exit(1) + } +} +``` + +- [ ] **Step 4: Run tidy and confirm the module resolves** + +Run: + +```bash +cd deploy/operator +go mod tidy +``` + +Expected: command exits with status 0 and creates `deploy/operator/go.sum`. + +- [ ] **Step 5: Run the initial operator tests** + +Run: + +```bash +cd deploy/operator +go test ./... +``` + +Expected: PASS. The repository controller is wired in Task 6 after the API and controller packages exist. + +- [ ] **Step 6: Commit scaffold files** + +Run: + +```bash +git add deploy/operator/go.mod deploy/operator/go.sum deploy/operator/Makefile deploy/operator/cmd/manager/main.go +git commit -m "feat: scaffold codegraph operator module" +``` + +## Task 2: Define CodeGraphRepository API Types + +**Files:** +- Create: `deploy/operator/api/v1alpha1/groupversion_info.go` +- Create: `deploy/operator/api/v1alpha1/codegraphrepository_types.go` +- Create: `deploy/operator/api/v1alpha1/codegraphrepository_types_test.go` + +- [ ] **Step 1: Write API type tests** + +Create `deploy/operator/api/v1alpha1/codegraphrepository_types_test.go`: + +```go +package v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestEndpointBuildsFromHostAndPath(t *testing.T) { + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + MCP: MCPSpec{ + Host: "codegraph.example.com", + Path: "/mcp/api-service", + }, + }, + } + + if got := repo.Endpoint(); got != "https://codegraph.example.com/mcp/api-service" { + t.Fatalf("Endpoint() = %q", got) + } +} + +func TestDefaultImageUsesSpecImageWhenSet(t *testing.T) { + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + Image: "registry.example.com/codegraph:v1", + }, + } + + if got := repo.RuntimeImage("fallback:image"); got != "registry.example.com/codegraph:v1" { + t.Fatalf("RuntimeImage() = %q", got) + } +} + +func TestDefaultImageUsesFallbackWhenSpecImageEmpty(t *testing.T) { + repo := &CodeGraphRepository{} + + if got := repo.RuntimeImage("fallback:image"); got != "fallback:image" { + t.Fatalf("RuntimeImage() = %q", got) + } +} + +func TestSetConditionReplacesSameType(t *testing.T) { + repo := &CodeGraphRepository{} + repo.SetCondition(metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionFalse, + Reason: "Pending", + Message: "runtime is not ready", + }) + repo.SetCondition(metav1.Condition{ + Type: ConditionReady, + Status: metav1.ConditionTrue, + Reason: "RuntimeAvailable", + Message: "runtime is ready", + }) + + if len(repo.Status.Conditions) != 1 { + t.Fatalf("expected 1 condition, got %d", len(repo.Status.Conditions)) + } + if repo.Status.Conditions[0].Status != metav1.ConditionTrue { + t.Fatalf("condition status = %s", repo.Status.Conditions[0].Status) + } +} + +func TestStorageSizeUsesQuantity(t *testing.T) { + size := resource.MustParse("20Gi") + repo := &CodeGraphRepository{ + Spec: CodeGraphRepositorySpec{ + Storage: StorageSpec{Size: size}, + }, + } + + if repo.Spec.Storage.Size.String() != "20Gi" { + t.Fatalf("storage size = %s", repo.Spec.Storage.Size.String()) + } +} +``` + +- [ ] **Step 2: Run tests and verify the API package is missing** + +Run: + +```bash +cd deploy/operator +go test ./api/v1alpha1 +``` + +Expected: FAIL with undefined names such as `CodeGraphRepository`, `MCPSpec`, and `ConditionReady`. + +- [ ] **Step 3: Add group registration** + +Create `deploy/operator/api/v1alpha1/groupversion_info.go`: + +```go +// Package v1alpha1 contains API Schema definitions for the codegraph v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=codegraph.dev +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "codegraph.dev", Version: "v1alpha1"} + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + AddToScheme = SchemeBuilder.AddToScheme +) +``` + +- [ ] **Step 4: Add CodeGraphRepository types** + +Create `deploy/operator/api/v1alpha1/codegraphrepository_types.go`: + +```go +package v1alpha1 + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ConditionReady = "Ready" + ConditionIndexed = "Indexed" + + PhasePending = "Pending" + PhaseSyncing = "Syncing" + PhaseIndexing = "Indexing" + PhaseReady = "Ready" + PhaseDegraded = "Degraded" +) + +type SyncMode string + +const ( + SyncModeManual SyncMode = "manual" +) + +// CodeGraphRepositorySpec defines the desired state of CodeGraphRepository. +type CodeGraphRepositorySpec struct { + // RepoID is the stable path and resource identifier. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=63 + RepoID string `json:"repoId"` + + Git GitSpec `json:"git"` + + MCP MCPSpec `json:"mcp"` + + Storage StorageSpec `json:"storage"` + + Sync SyncSpec `json:"sync,omitempty"` + + // Image overrides the operator default CodeGraph runtime image. + // +optional + Image string `json:"image,omitempty"` + + // Resources applies to both runtime and sync/index containers. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // NodeSelector constrains runtime and sync/index pods. + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // Tolerations apply to runtime and sync/index pods. + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Affinity applies to runtime and sync/index pods. + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` +} + +type GitSpec struct { + // URL is the repository clone URL. + // +kubebuilder:validation:MinLength=1 + URL string `json:"url"` + + // Ref is the branch, tag, or commit to index. + // +kubebuilder:validation:MinLength=1 + Ref string `json:"ref"` + + // AuthSecretRef points to credentials used by git. + // +optional + AuthSecretRef *corev1.LocalObjectReference `json:"authSecretRef,omitempty"` +} + +type MCPSpec struct { + // Host is the shared external MCP host. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // Path is the external path, normally /mcp/. + // +kubebuilder:validation:Pattern=`^/mcp/[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + Path string `json:"path"` +} + +type StorageSpec struct { + // Size is the PVC request for checkout and .codegraph data. + Size resource.Quantity `json:"size"` + + // StorageClassName selects the storage class. + // +optional + StorageClassName *string `json:"storageClassName,omitempty"` +} + +type SyncSpec struct { + // Mode controls repository refresh behavior. + // +kubebuilder:validation:Enum=manual + // +kubebuilder:default=manual + Mode SyncMode `json:"mode,omitempty"` +} + +// CodeGraphRepositoryStatus defines the observed state of CodeGraphRepository. +type CodeGraphRepositoryStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Phase string `json:"phase,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + ResolvedRef string `json:"resolvedRef,omitempty"` + LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + RouteName string `json:"routeName,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Repo",type=string,JSONPath=`.spec.repoId` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type CodeGraphRepository struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CodeGraphRepositorySpec `json:"spec,omitempty"` + Status CodeGraphRepositoryStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type CodeGraphRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CodeGraphRepository `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CodeGraphRepository{}, &CodeGraphRepositoryList{}) +} + +func (r *CodeGraphRepository) Endpoint() string { + host := strings.TrimRight(r.Spec.MCP.Host, "/") + path := r.Spec.MCP.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return "https://" + host + path +} + +func (r *CodeGraphRepository) RuntimeImage(defaultImage string) string { + if r.Spec.Image != "" { + return r.Spec.Image + } + return defaultImage +} + +func (r *CodeGraphRepository) SetCondition(condition metav1.Condition) { + condition.ObservedGeneration = r.Generation + metaSetStatusCondition(&r.Status.Conditions, condition) +} + +``` + +- [ ] **Step 5: Fix the missing condition helper before generation** + +Edit `deploy/operator/api/v1alpha1/codegraphrepository_types.go` and add this import: + +```go +apiMeta "k8s.io/apimachinery/pkg/api/meta" +``` + +Then replace `metaSetStatusCondition` calls with: + +```go +apiMeta.SetStatusCondition(&r.Status.Conditions, condition) +``` + +- [ ] **Step 6: Generate deepcopy methods** + +Run: + +```bash +cd deploy/operator +make generate +``` + +Expected: command exits with status 0 and creates `deploy/operator/api/v1alpha1/zz_generated.deepcopy.go`. + +- [ ] **Step 7: Run API tests** + +Run: + +```bash +cd deploy/operator +go test ./api/v1alpha1 +``` + +Expected: PASS. + +- [ ] **Step 8: Generate CRD manifests** + +Run: + +```bash +cd deploy/operator +make manifests +``` + +Expected: command exits with status 0 and creates `deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml`. + +- [ ] **Step 9: Commit API types** + +Run: + +```bash +git add deploy/operator/api deploy/operator/config/crd deploy/operator/go.mod deploy/operator/go.sum +git commit -m "feat: add codegraph repository crd types" +``` + +## Task 3: Add Resource Naming and Common Helpers + +**Files:** +- Create: `deploy/operator/internal/resources/common.go` +- Create: `deploy/operator/internal/resources/common_test.go` + +- [ ] **Step 1: Write common helper tests** + +Create `deploy/operator/internal/resources/common_test.go`: + +```go +package resources + +import ( + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNamesUseRepoID(t *testing.T) { + repo := repository("api-service") + names := NamesFor(repo) + + if names.Base != "codegraph-api-service" { + t.Fatalf("Base = %q", names.Base) + } + if names.Service != "codegraph-api-service" { + t.Fatalf("Service = %q", names.Service) + } + if names.SyncJob != "codegraph-api-service-sync-7" { + t.Fatalf("SyncJob = %q", names.SyncJob) + } +} + +func TestLabelsIncludeRepoID(t *testing.T) { + repo := repository("api-service") + labels := LabelsFor(repo) + + if labels["app.kubernetes.io/name"] != "codegraph" { + t.Fatalf("missing app label") + } + if labels["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("repo label = %q", labels["codegraph.dev/repo-id"]) + } +} + +func repository(repoID string) *codegraphv1alpha1.CodeGraphRepository { + return &codegraphv1alpha1.CodeGraphRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-service", + Namespace: "default", + Generation: 7, + }, + Spec: codegraphv1alpha1.CodeGraphRepositorySpec{ + RepoID: repoID, + }, + } +} +``` + +- [ ] **Step 2: Run tests and verify package is missing implementation** + +Run: + +```bash +cd deploy/operator +go test ./internal/resources +``` + +Expected: FAIL with undefined `NamesFor` and `LabelsFor`. + +- [ ] **Step 3: Implement common helpers** + +Create `deploy/operator/internal/resources/common.go`: + +```go +package resources + +import ( + "fmt" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + AppName = "codegraph" + ComponentRepositoryMCP = "repository-mcp" +) + +type Names struct { + Base string + PVC string + SyncJob string + Deployment string + Service string + Route string +} + +func NamesFor(repo *codegraphv1alpha1.CodeGraphRepository) Names { + base := "codegraph-" + repo.Spec.RepoID + return Names{ + Base: base, + PVC: base, + SyncJob: fmt.Sprintf("%s-sync-%d", base, repo.Generation), + Deployment: base, + Service: base, + Route: base, + } +} + +func LabelsFor(repo *codegraphv1alpha1.CodeGraphRepository) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": AppName, + "app.kubernetes.io/component": ComponentRepositoryMCP, + "app.kubernetes.io/managed-by": "codegraph-operator", + "codegraph.dev/repo-id": repo.Spec.RepoID, + } +} + +func SelectorFor(repo *codegraphv1alpha1.CodeGraphRepository) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": AppName, + "app.kubernetes.io/component": ComponentRepositoryMCP, + "codegraph.dev/repo-id": repo.Spec.RepoID, + } +} + +func OwnerFor(repo *codegraphv1alpha1.CodeGraphRepository) []metav1.OwnerReference { + return []metav1.OwnerReference{ + *metav1.NewControllerRef(repo, codegraphv1alpha1.GroupVersion.WithKind("CodeGraphRepository")), + } +} +``` + +- [ ] **Step 4: Run resource tests** + +Run: + +```bash +cd deploy/operator +go test ./internal/resources +``` + +Expected: PASS. + +- [ ] **Step 5: Commit common helpers** + +Run: + +```bash +git add deploy/operator/internal/resources +git commit -m "feat: add operator resource helpers" +``` + +## Task 4: Build PVC, Service, Deployment, and Job Resources + +**Files:** +- Create: `deploy/operator/internal/resources/workloads.go` +- Create: `deploy/operator/internal/resources/workloads_test.go` + +- [ ] **Step 1: Write workload builder tests** + +Create `deploy/operator/internal/resources/workloads_test.go`: + +```go +package resources + +import ( + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuildPVCRequestsStorage(t *testing.T) { + repo := workloadRepository() + pvc := BuildPVC(repo) + + if pvc.Name != "codegraph-api-service" { + t.Fatalf("pvc name = %q", pvc.Name) + } + if got := pvc.Spec.Resources.Requests.Storage().String(); got != "20Gi" { + t.Fatalf("storage request = %q", got) + } +} + +func TestBuildServiceTargetsMCPPort(t *testing.T) { + repo := workloadRepository() + service := BuildService(repo) + + if service.Spec.Ports[0].Port != 3000 { + t.Fatalf("service port = %d", service.Spec.Ports[0].Port) + } + if service.Spec.Selector["codegraph.dev/repo-id"] != "api-service" { + t.Fatalf("selector repo id missing") + } +} + +func TestBuildDeploymentRunsHTTPMCPServer(t *testing.T) { + repo := workloadRepository() + deployment := BuildDeployment(repo, "codegraph:test") + container := deployment.Spec.Template.Spec.Containers[0] + + expected := []string{"codegraph", "serve", "--mcp", "--http", "--host", "0.0.0.0", "--port", "3000", "--path", "/workspace/repo"} + for i, want := range expected { + if container.Args[i] != want { + t.Fatalf("args[%d] = %q, want %q", i, container.Args[i], want) + } + } + if container.Image != "codegraph:test" { + t.Fatalf("image = %q", container.Image) + } +} + +func TestBuildSyncJobIncludesGitAndIndexScript(t *testing.T) { + repo := workloadRepository() + job := BuildSyncJob(repo, "codegraph:test") + container := job.Spec.Template.Spec.Containers[0] + script := container.Args[1] + + contains := []string{ + "git clone \"$GIT_URL\" /workspace/repo", + "git -C /workspace/repo checkout \"$GIT_REF\"", + "codegraph init", + "codegraph index", + "git -C /workspace/repo rev-parse HEAD > /workspace/.resolved-ref", + } + for _, want := range contains { + if !stringContains(script, want) { + t.Fatalf("script missing %q:\n%s", want, script) + } + } +} + +func workloadRepository() *codegraphv1alpha1.CodeGraphRepository { + return &codegraphv1alpha1.CodeGraphRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "api-service", Namespace: "default", Generation: 1}, + Spec: codegraphv1alpha1.CodeGraphRepositorySpec{ + RepoID: "api-service", + Git: codegraphv1alpha1.GitSpec{ + URL: "https://github.com/acme/api-service.git", + Ref: "main", + AuthSecretRef: &corev1.LocalObjectReference{Name: "api-service-git"}, + }, + MCP: codegraphv1alpha1.MCPSpec{Host: "codegraph.example.com", Path: "/mcp/api-service"}, + Storage: codegraphv1alpha1.StorageSpec{Size: resource.MustParse("20Gi")}, + }, + } +} + +func stringContains(haystack string, needle string) bool { + return len(needle) == 0 || len(haystack) >= len(needle) && (haystack == needle || stringContains(haystack[1:], needle) || haystack[:len(needle)] == needle) +} +``` + +- [ ] **Step 2: Run tests and verify builders are missing** + +Run: + +```bash +cd deploy/operator +go test ./internal/resources +``` + +Expected: FAIL with undefined `BuildPVC`, `BuildService`, `BuildDeployment`, and `BuildSyncJob`. + +- [ ] **Step 3: Implement workload builders** + +Create `deploy/operator/internal/resources/workloads.go`: + +```go +package resources + +import ( + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + WorkspaceVolume = "workspace" + WorkspaceMountPath = "/workspace" + RepoPath = "/workspace/repo" + MCPPortName = "mcp-http" + MCPPort = int32(3000) +) + +func BuildPVC(repo *codegraphv1alpha1.CodeGraphRepository) *corev1.PersistentVolumeClaim { + names := NamesFor(repo) + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.PVC, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: repo.Spec.Storage.Size, + }, + }, + }, + } + if repo.Spec.Storage.Size.IsZero() { + pvc.Spec.Resources.Requests[corev1.ResourceStorage] = resource.MustParse("10Gi") + } + if repo.Spec.Storage.StorageClassName != nil { + pvc.Spec.StorageClassName = repo.Spec.Storage.StorageClassName + } + return pvc +} + +func BuildService(repo *codegraphv1alpha1.CodeGraphRepository) *corev1.Service { + names := NamesFor(repo) + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Service, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: SelectorFor(repo), + Ports: []corev1.ServicePort{ + {Name: MCPPortName, Port: MCPPort, TargetPort: intstr.FromInt32(MCPPort)}, + }, + }, + } +} + +func BuildDeployment(repo *codegraphv1alpha1.CodeGraphRepository, defaultImage string) *appsv1.Deployment { + names := NamesFor(repo) + replicas := int32(1) + labels := LabelsFor(repo) + selector := SelectorFor(repo) + image := repo.RuntimeImage(defaultImage) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Deployment, + Namespace: repo.Namespace, + Labels: labels, + OwnerReferences: OwnerFor(repo), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: selector}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: selector}, + Spec: podSpec(repo, []corev1.Container{ + { + Name: "codegraph", + Image: image, + Args: []string{"codegraph", "serve", "--mcp", "--http", "--host", "0.0.0.0", "--port", "3000", "--path", RepoPath}, + Ports: []corev1.ContainerPort{{Name: MCPPortName, ContainerPort: MCPPort}}, + Resources: repo.Spec.Resources, + VolumeMounts: []corev1.VolumeMount{{Name: WorkspaceVolume, MountPath: WorkspaceMountPath}}, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Path: "/mcp", Port: intstr.FromString(MCPPortName)}, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 10, + }, + }, + }), + }, + }, + } +} + +func BuildSyncJob(repo *codegraphv1alpha1.CodeGraphRepository, defaultImage string) *batchv1.Job { + names := NamesFor(repo) + backoffLimit := int32(1) + image := repo.RuntimeImage(defaultImage) + spec := podSpec(repo, []corev1.Container{ + { + Name: "sync-index", + Image: image, + Command: []string{"/bin/sh"}, + Args: []string{"-c", syncScript()}, + Env: []corev1.EnvVar{ + {Name: "GIT_URL", Value: repo.Spec.Git.URL}, + {Name: "GIT_REF", Value: repo.Spec.Git.Ref}, + }, + Resources: repo.Spec.Resources, + VolumeMounts: []corev1.VolumeMount{{Name: WorkspaceVolume, MountPath: WorkspaceMountPath}}, + }, + }) + spec.RestartPolicy = corev1.RestartPolicyNever + if repo.Spec.Git.AuthSecretRef != nil { + spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{LocalObjectReference: *repo.Spec.Git.AuthSecretRef}}, + } + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: "git-ssh", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: repo.Spec.Git.AuthSecretRef.Name, + Optional: boolPtr(true), + }, + }, + }) + spec.Containers[0].VolumeMounts = append(spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + Name: "git-ssh", + MountPath: "/git-ssh", + ReadOnly: true, + }) + } + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.SyncJob, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: SelectorFor(repo)}, + Spec: spec, + }, + }, + } +} + +func podSpec(repo *codegraphv1alpha1.CodeGraphRepository, containers []corev1.Container) corev1.PodSpec { + return corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: boolPtr(true), + RunAsUser: int64Ptr(1000), + RunAsGroup: int64Ptr(1000), + FSGroup: int64Ptr(1000), + }, + Containers: containers, + Volumes: []corev1.Volume{ + { + Name: WorkspaceVolume, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: NamesFor(repo).PVC}, + }, + }, + }, + NodeSelector: repo.Spec.NodeSelector, + Tolerations: repo.Spec.Tolerations, + Affinity: repo.Spec.Affinity, + } +} + +func syncScript() string { + return `set -eu +if [ -n "${GIT_USERNAME:-}" ] && [ -n "${GIT_PASSWORD:-}" ]; then + cat >/tmp/git-askpass <<'EOF' +#!/bin/sh +case "$1" in +*Username*) printf '%s\n' "$GIT_USERNAME" ;; +*Password*) printf '%s\n' "$GIT_PASSWORD" ;; +*) printf '\n' ;; +esac +EOF + chmod 0700 /tmp/git-askpass + export GIT_ASKPASS=/tmp/git-askpass +fi +if [ -f /git-ssh/ssh-privatekey ]; then + chmod 0400 /git-ssh/ssh-privatekey + export GIT_SSH_COMMAND="ssh -i /git-ssh/ssh-privatekey -o StrictHostKeyChecking=accept-new" +fi +if [ -d /workspace/repo/.git ]; then + git -C /workspace/repo fetch --all --tags --prune +else + rm -rf /workspace/repo + git clone "$GIT_URL" /workspace/repo +fi +git -C /workspace/repo checkout "$GIT_REF" +cd /workspace/repo +codegraph init +codegraph index +git -C /workspace/repo rev-parse HEAD > /workspace/.resolved-ref +` +} + +func boolPtr(value bool) *bool { return &value } +func int64Ptr(value int64) *int64 { return &value } +``` + +- [ ] **Step 4: Replace recursive test helper with standard library** + +Edit `deploy/operator/internal/resources/workloads_test.go`: add import `strings`, remove `stringContains`, and replace each call to `stringContains(script, want)` with: + +```go +strings.Contains(script, want) +``` + +- [ ] **Step 5: Run workload tests** + +Run: + +```bash +cd deploy/operator +go test ./internal/resources +``` + +Expected: PASS. + +- [ ] **Step 6: Commit workload builders** + +Run: + +```bash +git add deploy/operator/internal/resources +git commit -m "feat: build codegraph repository workloads" +``` + +## Task 5: Build Gateway API and Ingress Routes + +**Files:** +- Create: `deploy/operator/internal/resources/routes.go` +- Create: `deploy/operator/internal/resources/routes_test.go` + +- [ ] **Step 1: Write route builder tests** + +Create `deploy/operator/internal/resources/routes_test.go`: + +```go +package resources + +import ( + "testing" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestBuildHTTPRouteRewritesRepoPathToMCP(t *testing.T) { + repo := workloadRepository() + route := BuildHTTPRoute(repo, RouteConfig{GatewayName: "codegraph", GatewayNamespace: "platform"}) + + if route.Name != "codegraph-api-service" { + t.Fatalf("route name = %q", route.Name) + } + if string(route.Spec.Hostnames[0]) != "codegraph.example.com" { + t.Fatalf("hostname = %q", route.Spec.Hostnames[0]) + } + if string(route.Spec.ParentRefs[0].Name) != "codegraph" { + t.Fatalf("gateway name = %q", route.Spec.ParentRefs[0].Name) + } + if *route.Spec.ParentRefs[0].Namespace != gatewayv1.Namespace("platform") { + t.Fatalf("gateway namespace = %q", *route.Spec.ParentRefs[0].Namespace) + } + filter := route.Spec.Rules[0].Filters[0] + if filter.URLRewrite.Path.ReplacePrefixMatch == nil || *filter.URLRewrite.Path.ReplacePrefixMatch != "/mcp" { + t.Fatalf("route rewrite = %#v", filter.URLRewrite.Path) + } +} + +func TestBuildIngressUsesRepoPathAndService(t *testing.T) { + repo := workloadRepository() + ingress := BuildIngress(repo) + + if ingress.Spec.Rules[0].Host != "codegraph.example.com" { + t.Fatalf("ingress host = %q", ingress.Spec.Rules[0].Host) + } + path := ingress.Spec.Rules[0].HTTP.Paths[0] + if path.Path != "/mcp/api-service" { + t.Fatalf("ingress path = %q", path.Path) + } + if path.Backend.Service.Name != "codegraph-api-service" { + t.Fatalf("backend service = %q", path.Backend.Service.Name) + } + if ingress.Annotations["nginx.ingress.kubernetes.io/rewrite-target"] != "/mcp" { + t.Fatalf("rewrite annotation missing") + } +} +``` + +- [ ] **Step 2: Run tests and verify route builders are missing** + +Run: + +```bash +cd deploy/operator +go test ./internal/resources +``` + +Expected: FAIL with undefined `BuildHTTPRoute`, `RouteConfig`, and `BuildIngress`. + +- [ ] **Step 3: Implement route builders** + +Create `deploy/operator/internal/resources/routes.go`: + +```go +package resources + +import ( + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type RouteConfig struct { + GatewayName string + GatewayNamespace string +} + +func BuildHTTPRoute(repo *codegraphv1alpha1.CodeGraphRepository, config RouteConfig) *gatewayv1.HTTPRoute { + names := NamesFor(repo) + pathType := gatewayv1.PathMatchPathPrefix + path := repo.Spec.MCP.Path + rewriteType := gatewayv1.PrefixMatchHTTPPathModifier + rewritePath := "/mcp" + servicePort := gatewayv1.PortNumber(MCPPort) + parentRef := gatewayv1.ParentReference{Name: gatewayv1.ObjectName(config.GatewayName)} + if config.GatewayNamespace != "" { + namespace := gatewayv1.Namespace(config.GatewayNamespace) + parentRef.Namespace = &namespace + } + + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Route, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + OwnerReferences: OwnerFor(repo), + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{parentRef}, + }, + Hostnames: []gatewayv1.Hostname{gatewayv1.Hostname(repo.Spec.MCP.Host)}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &pathType, Value: &path}}, + }, + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Path: &gatewayv1.HTTPPathModifier{ + Type: rewriteType, + ReplacePrefixMatch: &rewritePath, + }, + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(names.Service), + Port: &servicePort, + }, + }, + }, + }, + }, + }, + }, + } +} + +func BuildIngress(repo *codegraphv1alpha1.CodeGraphRepository) *networkingv1.Ingress { + names := NamesFor(repo) + pathType := networkingv1.PathTypePrefix + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: names.Route, + Namespace: repo.Namespace, + Labels: LabelsFor(repo), + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/rewrite-target": "/mcp", + }, + OwnerReferences: OwnerFor(repo), + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: repo.Spec.MCP.Host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: repo.Spec.MCP.Path, + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: names.Service, + Port: networkingv1.ServiceBackendPort{Number: MCPPort}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func IntOrStringMCPPort() intstr.IntOrString { + return intstr.FromInt32(MCPPort) +} +``` + +- [ ] **Step 4: Remove unused route helper if Go reports it** + +Run: + +```bash +cd deploy/operator +go test ./internal/resources +``` + +If Go reports `IntOrStringMCPPort` unused, delete that function from `routes.go`; otherwise leave the file unchanged. Expected final result: PASS. + +- [ ] **Step 5: Commit route builders** + +Run: + +```bash +git add deploy/operator/internal/resources +git commit -m "feat: build codegraph repository routes" +``` + +## Task 6: Implement Reconciler Resource Creation + +**Files:** +- Create: `deploy/operator/internal/controller/codegraphrepository_controller.go` +- Create: `deploy/operator/internal/controller/codegraphrepository_controller_test.go` +- Modify: `deploy/operator/cmd/manager/main.go` + +Important sequencing constraint: this task must not start the runtime Deployment before the sync/index Job has succeeded. The first reconcile creates PVC, sync/index Job, Service, and route only. Task 7 adds the job-success path that creates or rolls the Deployment, so the MCP runtime opens a completed index instead of holding a stale SQLite handle across repository replacement. + +- [ ] **Step 1: Write reconcile creation test** + +Create `deploy/operator/internal/controller/codegraphrepository_controller_test.go`: + +```go +package controller + +import ( + "context" + "testing" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestReconcileCreatesRepositoryResourcesWithGatewayRoute(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + repo := testRepository() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(repo).WithStatusSubresource(repo).Build() + reconciler := &CodeGraphRepositoryReconciler{ + Client: c, + Scheme: scheme, + Config: Config{ + DefaultImage: "codegraph:test", + RouteMode: "gateway", + GatewayName: "codegraph", + GatewayNamespace: "platform", + }, + } + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + assertExists(t, ctx, c, &corev1.PersistentVolumeClaim{}, "codegraph-api-service") + assertExists(t, ctx, c, &batchv1.Job{}, "codegraph-api-service-sync-1") + assertExists(t, ctx, c, &corev1.Service{}, "codegraph-api-service") + assertExists(t, ctx, c, &gatewayv1.HTTPRoute{}, "codegraph-api-service") + assertNotFound(t, ctx, c, &appsv1.Deployment{}, "codegraph-api-service") +} + +func TestReconcileCreatesIngressWhenConfigured(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + repo := testRepository() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(repo).WithStatusSubresource(repo).Build() + reconciler := &CodeGraphRepositoryReconciler{ + Client: c, + Scheme: scheme, + Config: Config{DefaultImage: "codegraph:test", RouteMode: "ingress"}, + } + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + assertExists(t, ctx, c, &networkingv1.Ingress{}, "codegraph-api-service") +} + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := codegraphv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := gatewayv1.Install(scheme); err != nil { + t.Fatal(err) + } + return scheme +} + +func testRepository() *codegraphv1alpha1.CodeGraphRepository { + return &codegraphv1alpha1.CodeGraphRepository{ + ObjectMeta: metav1.ObjectMeta{Name: "api-service", Namespace: "default", Generation: 1}, + Spec: codegraphv1alpha1.CodeGraphRepositorySpec{ + RepoID: "api-service", + Git: codegraphv1alpha1.GitSpec{URL: "https://github.com/acme/api-service.git", Ref: "main"}, + MCP: codegraphv1alpha1.MCPSpec{Host: "codegraph.example.com", Path: "/mcp/api-service"}, + Storage: codegraphv1alpha1.StorageSpec{Size: resource.MustParse("20Gi")}, + }, + } +} + +func assertExists(t *testing.T, ctx context.Context, c client.Client, obj client.Object, name string) { + t.Helper() + key := types.NamespacedName{Name: name, Namespace: "default"} + if err := c.Get(ctx, key, obj); err != nil { + t.Fatalf("expected %T %s to exist: %v", obj, name, err) + } +} + +func assertNotFound(t *testing.T, ctx context.Context, c client.Client, obj client.Object, name string) { + t.Helper() + key := types.NamespacedName{Name: name, Namespace: "default"} + if err := c.Get(ctx, key, obj); !apierrors.IsNotFound(err) { + t.Fatalf("expected %T %s to be absent, got err=%v", obj, name, err) + } +} +``` + +- [ ] **Step 2: Run controller tests and verify reconciler is missing** + +Run: + +```bash +cd deploy/operator +go test ./internal/controller +``` + +Expected: FAIL with undefined `CodeGraphRepositoryReconciler` and `Config`. + +- [ ] **Step 3: Implement reconciler creation path** + +Create `deploy/operator/internal/controller/codegraphrepository_controller.go`: + +```go +package controller + +import ( + "context" + "fmt" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + "github.com/colbymchenry/codegraph/deploy/operator/internal/resources" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type Config struct { + DefaultImage string + RouteMode string + GatewayName string + GatewayNamespace string +} + +type CodeGraphRepositoryReconciler struct { + client.Client + Scheme *runtime.Scheme + Config Config +} + +func (r *CodeGraphRepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var repo codegraphv1alpha1.CodeGraphRepository + if err := r.Get(ctx, req.NamespacedName, &repo); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if err := r.ensure(ctx, &repo, resources.BuildPVC(&repo)); err != nil { + return r.markDegraded(ctx, &repo, "PVCApplyFailed", err) + } + if err := r.ensure(ctx, &repo, resources.BuildSyncJob(&repo, r.Config.DefaultImage)); err != nil { + return r.markDegraded(ctx, &repo, "SyncJobApplyFailed", err) + } + if err := r.ensure(ctx, &repo, resources.BuildService(&repo)); err != nil { + return r.markDegraded(ctx, &repo, "ServiceApplyFailed", err) + } + if err := r.ensureRoute(ctx, &repo); err != nil { + return r.markDegraded(ctx, &repo, "RouteApplyFailed", err) + } + + repo.Status.ObservedGeneration = repo.Generation + repo.Status.Phase = codegraphv1alpha1.PhasePending + repo.Status.Endpoint = repo.Endpoint() + repo.Status.ServiceName = resources.NamesFor(&repo).Service + repo.Status.RouteName = resources.NamesFor(&repo).Route + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: "ResourcesApplied", + Message: "Repository resources are applied and waiting for workload readiness", + }) + if err := r.Status().Update(ctx, &repo); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *CodeGraphRepositoryReconciler) ensureRoute(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) error { + switch r.Config.RouteMode { + case "", "gateway": + return r.ensure(ctx, repo, resources.BuildHTTPRoute(repo, resources.RouteConfig{ + GatewayName: r.gatewayName(), + GatewayNamespace: r.Config.GatewayNamespace, + })) + case "ingress": + return r.ensure(ctx, repo, resources.BuildIngress(repo)) + default: + return fmt.Errorf("unsupported route mode %q", r.Config.RouteMode) + } +} + +func (r *CodeGraphRepositoryReconciler) gatewayName() string { + if r.Config.GatewayName != "" { + return r.Config.GatewayName + } + return "codegraph" +} + +func (r *CodeGraphRepositoryReconciler) ensure(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, desired client.Object) error { + if err := controllerutil.SetControllerReference(repo, desired, r.Scheme); err != nil { + return err + } + key := client.ObjectKeyFromObject(desired) + current := desired.DeepCopyObject().(client.Object) + err := r.Get(ctx, key, current) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + desired.SetResourceVersion(current.GetResourceVersion()) + return r.Update(ctx, desired) +} + +func (r *CodeGraphRepositoryReconciler) markDegraded(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository, reason string, err error) (ctrl.Result, error) { + repo.Status.ObservedGeneration = repo.Generation + repo.Status.Phase = codegraphv1alpha1.PhaseDegraded + repo.SetCondition(metav1.Condition{ + Type: codegraphv1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) + if updateErr := r.Status().Update(ctx, repo); updateErr != nil { + return ctrl.Result{}, updateErr + } + return ctrl.Result{}, err +} + +func (r *CodeGraphRepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&codegraphv1alpha1.CodeGraphRepository{}). + Owns(&corev1.PersistentVolumeClaim{}). + Owns(&batchv1.Job{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Owns(&networkingv1.Ingress{}). + Owns(&gatewayv1.HTTPRoute{}). + Complete(r) +} +``` + +- [ ] **Step 4: Wire the controller into the manager** + +Replace `deploy/operator/cmd/manager/main.go` with: + +```go +package main + +import ( + "flag" + "os" + + codegraphv1alpha1 "github.com/colbymchenry/codegraph/deploy/operator/api/v1alpha1" + "github.com/colbymchenry/codegraph/deploy/operator/internal/controller" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(codegraphv1alpha1.AddToScheme(scheme)) + utilruntime.Must(gatewayv1.Install(scheme)) +} + +func main() { + var metricsAddr string + var probeAddr string + var enableLeaderElection bool + var gatewayName string + var gatewayNamespace string + var routeMode string + var runtimeImage string + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.StringVar(&gatewayName, "gateway-name", "codegraph", "Gateway name used when route-mode=gateway.") + flag.StringVar(&gatewayNamespace, "gateway-namespace", "", "Gateway namespace used when route-mode=gateway. Defaults to each repository namespace.") + flag.StringVar(&routeMode, "route-mode", "gateway", "Routing mode: gateway or ingress.") + flag.StringVar(&runtimeImage, "runtime-image", "ghcr.io/colbymchenry/codegraph:latest", "Default CodeGraph runtime image.") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zap.Options{}))) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "codegraph.dev", + }) + if err != nil { + ctrl.Log.Error(err, "unable to start manager") + os.Exit(1) + } + + reconciler := &controller.CodeGraphRepositoryReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Config: controller.Config{ + DefaultImage: runtimeImage, + RouteMode: routeMode, + GatewayName: gatewayName, + GatewayNamespace: gatewayNamespace, + }, + } + if err := reconciler.SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to create controller") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + ctrl.Log.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + ctrl.Log.Error(err, "unable to set up ready check") + os.Exit(1) + } + + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + ctrl.Log.Error(err, "problem running manager") + os.Exit(1) + } +} +``` + +- [ ] **Step 5: Run controller tests** + +Run: + +```bash +cd deploy/operator +go test ./internal/controller +``` + +Expected: PASS. + +- [ ] **Step 6: Run all operator tests** + +Run: + +```bash +cd deploy/operator +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 7: Commit reconciler creation** + +Run: + +```bash +git add deploy/operator/internal/controller deploy/operator/cmd/manager/main.go deploy/operator/go.mod deploy/operator/go.sum +git commit -m "feat: reconcile codegraph repository resources" +``` + +## Task 7: Add Status Transitions for Jobs and Runtime Readiness + +**Files:** +- Modify: `deploy/operator/internal/controller/codegraphrepository_controller.go` +- Modify: `deploy/operator/internal/controller/codegraphrepository_controller_test.go` + +- [ ] **Step 1: Add status transition tests** + +Append to `deploy/operator/internal/controller/codegraphrepository_controller_test.go`: + +```go +func TestReconcileMarksReadyWhenJobAndDeploymentAreReady(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + repo := testRepository() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(repo).WithStatusSubresource(repo).Build() + reconciler := &CodeGraphRepositoryReconciler{ + Client: c, + Scheme: scheme, + Config: Config{DefaultImage: "codegraph:test", RouteMode: "ingress"}, + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}); err != nil { + t.Fatalf("first reconcile returned error: %v", err) + } + + var job batchv1.Job + if err := c.Get(ctx, types.NamespacedName{Name: "codegraph-api-service-sync-1", Namespace: "default"}, &job); err != nil { + t.Fatal(err) + } + job.Status.Succeeded = 1 + if err := c.Status().Update(ctx, &job); err != nil { + t.Fatal(err) + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}); err != nil { + t.Fatalf("second reconcile returned error: %v", err) + } + + var deployment appsv1.Deployment + if err := c.Get(ctx, types.NamespacedName{Name: "codegraph-api-service", Namespace: "default"}, &deployment); err != nil { + t.Fatal(err) + } + deployment.Status.AvailableReplicas = 1 + if err := c.Status().Update(ctx, &deployment); err != nil { + t.Fatal(err) + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}); err != nil { + t.Fatalf("third reconcile returned error: %v", err) + } + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}, &updated); err != nil { + t.Fatal(err) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseReady { + t.Fatalf("phase = %q", updated.Status.Phase) + } + if updated.Status.Endpoint != "https://codegraph.example.com/mcp/api-service" { + t.Fatalf("endpoint = %q", updated.Status.Endpoint) + } +} + +func TestReconcileMarksDegradedWhenJobFails(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + repo := testRepository() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(repo).WithStatusSubresource(repo).Build() + reconciler := &CodeGraphRepositoryReconciler{ + Client: c, + Scheme: scheme, + Config: Config{DefaultImage: "codegraph:test", RouteMode: "ingress"}, + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}); err != nil { + t.Fatalf("first reconcile returned error: %v", err) + } + + var job batchv1.Job + if err := c.Get(ctx, types.NamespacedName{Name: "codegraph-api-service-sync-1", Namespace: "default"}, &job); err != nil { + t.Fatal(err) + } + job.Status.Failed = 1 + if err := c.Status().Update(ctx, &job); err != nil { + t.Fatal(err) + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}}); err != nil { + t.Fatalf("second reconcile returned error: %v", err) + } + + var updated codegraphv1alpha1.CodeGraphRepository + if err := c.Get(ctx, types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}, &updated); err != nil { + t.Fatal(err) + } + if updated.Status.Phase != codegraphv1alpha1.PhaseDegraded { + t.Fatalf("phase = %q", updated.Status.Phase) + } +} +``` + +- [ ] **Step 2: Run tests and verify readiness logic is missing** + +Run: + +```bash +cd deploy/operator +go test ./internal/controller +``` + +Expected: FAIL because status remains `Pending`. + +- [ ] **Step 3: Add readiness status logic** + +Edit `deploy/operator/internal/controller/codegraphrepository_controller.go` and replace the status block after `ensureRoute` with: + +```go +phase, readyCondition, indexedCondition, err := r.computeStatus(ctx, &repo) +if err != nil { + return ctrl.Result{}, err +} +repo.Status.ObservedGeneration = repo.Generation +repo.Status.Phase = phase +repo.Status.Endpoint = repo.Endpoint() +repo.Status.ServiceName = resources.NamesFor(&repo).Service +repo.Status.RouteName = resources.NamesFor(&repo).Route +repo.SetCondition(readyCondition) +repo.SetCondition(indexedCondition) +if err := r.Status().Update(ctx, &repo); err != nil { + return ctrl.Result{}, err +} +``` + +Before computing final readiness, add this deployment gate after the sync Job lookup logic: if the sync/index Job has succeeded, ensure `resources.BuildDeployment(&repo, r.Config.DefaultImage)` exists or is updated. If the Deployment was just created or updated, report `Ready=False` with `RuntimeUnavailable` until Kubernetes reports `AvailableReplicas > 0`. Do not create the Deployment while the Job is missing, running, or failed. + +Add these methods to the same file: + +```go +func (r *CodeGraphRepositoryReconciler) computeStatus(ctx context.Context, repo *codegraphv1alpha1.CodeGraphRepository) (string, metav1.Condition, metav1.Condition, error) { + names := resources.NamesFor(repo) + + var job batchv1.Job + if err := r.Get(ctx, client.ObjectKey{Namespace: repo.Namespace, Name: names.SyncJob}, &job); err != nil { + if apierrors.IsNotFound(err) { + return codegraphv1alpha1.PhaseSyncing, readyFalse("WaitingForSyncJob", "sync/index job has not been created"), indexedFalse("WaitingForSyncJob", "sync/index job has not been created"), nil + } + return "", metav1.Condition{}, metav1.Condition{}, err + } + + if job.Status.Failed > 0 { + return codegraphv1alpha1.PhaseDegraded, readyFalse("IndexFailed", "sync/index job failed"), indexedFalse("IndexFailed", "sync/index job failed"), nil + } + if job.Status.Succeeded == 0 { + return codegraphv1alpha1.PhaseIndexing, readyFalse("IndexRunning", "sync/index job is still running"), indexedFalse("IndexRunning", "sync/index job is still running"), nil + } + + var deployment appsv1.Deployment + if err := r.Get(ctx, client.ObjectKey{Namespace: repo.Namespace, Name: names.Deployment}, &deployment); err != nil { + if apierrors.IsNotFound(err) { + return codegraphv1alpha1.PhasePending, readyFalse("WaitingForRuntime", "runtime deployment has not been created"), indexedTrue("IndexSucceeded", "repository indexed successfully"), nil + } + return "", metav1.Condition{}, metav1.Condition{}, err + } + if deployment.Status.AvailableReplicas == 0 { + return codegraphv1alpha1.PhasePending, readyFalse("RuntimeUnavailable", "runtime deployment has no available replicas"), indexedTrue("IndexSucceeded", "repository indexed successfully"), nil + } + + return codegraphv1alpha1.PhaseReady, readyTrue("RuntimeAvailable", "MCP endpoint is serving"), indexedTrue("IndexSucceeded", "repository indexed successfully"), nil +} + +func readyTrue(reason string, message string) metav1.Condition { + return metav1.Condition{Type: codegraphv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: reason, Message: message} +} + +func readyFalse(reason string, message string) metav1.Condition { + return metav1.Condition{Type: codegraphv1alpha1.ConditionReady, Status: metav1.ConditionFalse, Reason: reason, Message: message} +} + +func indexedTrue(reason string, message string) metav1.Condition { + return metav1.Condition{Type: codegraphv1alpha1.ConditionIndexed, Status: metav1.ConditionTrue, Reason: reason, Message: message} +} + +func indexedFalse(reason string, message string) metav1.Condition { + return metav1.Condition{Type: codegraphv1alpha1.ConditionIndexed, Status: metav1.ConditionFalse, Reason: reason, Message: message} +} +``` + +- [ ] **Step 4: Run controller tests** + +Run: + +```bash +cd deploy/operator +go test ./internal/controller +``` + +Expected: PASS. + +- [ ] **Step 5: Run all operator tests** + +Run: + +```bash +cd deploy/operator +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 6: Commit status transitions** + +Run: + +```bash +git add deploy/operator/internal/controller +git commit -m "feat: report codegraph repository readiness" +``` + +## Task 8: Add Sample Manifests and Operator Documentation + +**Files:** +- Create: `deploy/operator/config/samples/codegraphrepository.yaml` +- Create: `deploy/operator/README.md` + +- [ ] **Step 1: Create a sample repository manifest** + +Create `deploy/operator/config/samples/codegraphrepository.yaml`: + +```yaml +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphRepository +metadata: + name: api-service + namespace: codegraph +spec: + repoId: api-service + git: + url: https://github.com/acme/api-service.git + ref: main + authSecretRef: + name: api-service-git + mcp: + host: codegraph.example.com + path: /mcp/api-service + storage: + size: 20Gi + sync: + mode: manual +``` + +- [ ] **Step 2: Create operator README** + +Create `deploy/operator/README.md`: + +```markdown +# CodeGraph Operator + +This operator manages repository-scoped CodeGraph HTTP MCP servers with a `CodeGraphRepository` custom resource. + +## Repository Resource + +Each resource owns one repository checkout, one `.codegraph` index, one runtime Deployment, one Service, and one route. + +```yaml +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphRepository +metadata: + name: api-service +spec: + repoId: api-service + git: + url: https://github.com/acme/api-service.git + ref: main + mcp: + host: codegraph.example.com + path: /mcp/api-service + storage: + size: 20Gi + sync: + mode: manual +``` + +## MCP Address + +Clients connect to the shared host and repository path: + +```text +https://codegraph.example.com/mcp/api-service +``` + +The cluster route rewrites that external path to the pod-local CodeGraph MCP path: + +```text +/mcp +``` + +## Local Development + +```bash +cd deploy/operator +make tidy +make generate +make manifests +make test +``` + +## Git Credentials + +For HTTPS repositories, create a Secret with `GIT_USERNAME` and `GIT_PASSWORD` keys. For SSH repositories, create a Secret containing `ssh-privatekey`. Reference the Secret with `spec.git.authSecretRef.name`. + +## Routing Modes + +The manager supports two route modes: + +```bash +--route-mode=gateway +--route-mode=ingress +``` + +Gateway mode creates Gateway API `HTTPRoute` resources. Ingress mode creates standard Kubernetes `Ingress` resources with an nginx rewrite annotation. +``` + +- [ ] **Step 3: Regenerate manifests and run docs-adjacent tests** + +Run: + +```bash +cd deploy/operator +make manifests +go test ./... +``` + +Expected: both commands exit with status 0. + +- [ ] **Step 4: Commit documentation and samples** + +Run: + +```bash +git add deploy/operator/README.md deploy/operator/config/samples deploy/operator/config/crd +git commit -m "docs: add codegraph operator samples" +``` + +## Task 9: Add Root-Level Validation Entry Point + +**Files:** +- Modify: `package.json` +- Modify: `README.md` or `deploy/operator/README.md` + +- [ ] **Step 1: Add npm script test expectation** + +Before changing `package.json`, run: + +```bash +npm run test:operator +``` + +Expected: FAIL because `test:operator` is not defined. + +- [ ] **Step 2: Add operator test script** + +Edit `package.json` and add this script entry after `test:eval`: + +```json +"test:operator": "cd deploy/operator && go test ./...", +``` + +Keep the existing JSON comma rules valid. + +- [ ] **Step 3: Document root-level validation** + +Append this section to `deploy/operator/README.md`: + +```markdown +## Root Validation + +From the repository root: + +```bash +npm run test:operator +``` + +This runs the Go operator tests without running the full TypeScript test suite. +``` + +- [ ] **Step 4: Run root operator test** + +Run: + +```bash +npm run test:operator +``` + +Expected: PASS with `go test ./...` output from `deploy/operator`. + +- [ ] **Step 5: Run existing TypeScript tests that should remain unaffected** + +Run: + +```bash +npm test -- __tests__/mcp-http.test.ts +``` + +Expected: PASS. This confirms the cloud-native work did not alter the existing HTTP MCP server behavior. + +- [ ] **Step 6: Commit root validation wiring** + +Run: + +```bash +git add package.json deploy/operator/README.md +git commit -m "test: add operator validation script" +``` + +## Task 10: Final Verification + +**Files:** +- Read: `docs/superpowers/specs/2026-06-16-codegraph-cloud-native-crd-design.md` +- Read: `deploy/operator/README.md` +- Read: `deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml` + +- [ ] **Step 1: Run complete operator verification** + +Run: + +```bash +cd deploy/operator +make generate +make manifests +go test ./... +``` + +Expected: all commands exit with status 0 and no generated file diff appears from generation. + +- [ ] **Step 2: Run root validation** + +Run: + +```bash +npm run test:operator +npm test -- __tests__/mcp-http.test.ts +``` + +Expected: both commands pass. + +- [ ] **Step 3: Inspect generated CRD for required fields** + +Run: + +```bash +rg -n "repoId|authSecretRef|resolvedRef|endpoint|conditions|/mcp" deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml +``` + +Expected: output includes schema entries for `repoId`, `authSecretRef`, `endpoint`, and `conditions`. + +- [ ] **Step 4: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: clean except for user-owned files that existed before implementation, such as untracked `AGENTS.md` or `.agents/`. + +- [ ] **Step 5: Prepare final summary** + +Report: + +- Operator module path: `deploy/operator`. +- CRD kind: `CodeGraphRepository`. +- Route modes: Gateway API and Ingress. +- Runtime command: `codegraph serve --mcp --http --host 0.0.0.0 --port 3000 --path /workspace/repo`. +- Verification commands and pass/fail results. + +## Spec Coverage Review + +- Shared path address `https:///mcp/`: covered by API fields, route builders, sample YAML, and README. +- No Python proxy: covered by architecture and no proxy files. +- CRD full lifecycle: covered by API types, PVC, sync/index Job, Deployment, Service, route, and status conditions. +- Per-repository isolation: covered by deterministic per-repo names, labels, owner refs, PVC, and Deployment. +- Existing CodeGraph HTTP MCP server reuse: covered by Deployment command and unchanged TypeScript server. +- Failure handling: covered by degraded status on resource apply failure and failed sync/index Job. +- Testing: covered by API, resource builder, controller, root operator script, and HTTP MCP regression test. diff --git a/docs/superpowers/plans/2026-06-16-codegraph-k8s-gateway.md b/docs/superpowers/plans/2026-06-16-codegraph-k8s-gateway.md new file mode 100644 index 000000000..b1264226b --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-codegraph-k8s-gateway.md @@ -0,0 +1,46 @@ +# CodeGraph Kubernetes MCP Gateway Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Kubernetes-deployed CodeGraph MCP Gateway that lets Codex connect to one URL while reaching at least five repository-scoped MCP runtimes. + +**Architecture:** Add a TypeScript HTTP MCP gateway runtime and wire it into `codegraph serve --mcp --http --gateway-repos `. Add a `CodeGraphGateway` CRD and controller resources that run the gateway with a ConfigMap of backend repo services, then expose exactly one `/mcp` route. + +**Tech Stack:** TypeScript, Node HTTP server, existing MCP JSON-RPC types, Vitest, Go controller-runtime, Kubernetes ConfigMap/Deployment/Service/Ingress, Rancher Desktop K8s. + +--- + +## File Structure + +- Create `src/mcp/gateway.ts`: standalone HTTP MCP gateway implementation and backend forwarding logic. +- Modify `src/bin/codegraph.ts`: add gateway CLI flags and start the gateway server. +- Create `__tests__/mcp-gateway.test.ts`: fake backend servers and gateway protocol tests. +- Create `deploy/operator/api/v1alpha1/codegraphgateway_types.go`: gateway CRD spec/status. +- Modify `deploy/operator/api/v1alpha1/zz_generated.deepcopy.go` and `deploy/operator/config/crd`: generated objects/manifests. +- Create `deploy/operator/internal/resources/gateway.go` and tests: ConfigMap, Deployment, Service, Ingress/HTTPRoute builders. +- Create `deploy/operator/internal/controller/codegraphgateway_controller.go` and tests. +- Modify `deploy/operator/cmd/manager/main.go`: register the gateway controller. +- Modify `deploy/operator/README.md` and samples: single URL and Codex `config.toml`. + +## Tasks + +- [ ] Write failing Vitest coverage for gateway initialize, tools aggregation, dispatch, and unknown prefixes. +- [ ] Implement the minimal TypeScript gateway runtime until the new tests pass. +- [ ] Add CLI flags for `codegraph serve --mcp --http --gateway-repos ` and cover startup through tests or build. +- [ ] Write failing Go tests for `CodeGraphGateway` API helpers and scheme registration. +- [ ] Implement gateway API types, regenerate deepcopy and CRD manifests. +- [ ] Write failing Go tests for gateway resource builders. +- [ ] Implement ConfigMap, Deployment, Service, and route builders for the gateway. +- [ ] Write failing Go tests for gateway reconciliation. +- [ ] Implement the gateway reconciler and wire it into the manager. +- [ ] Update operator docs and samples with the single Codex URL. +- [ ] Run `npm run build`, `npm test -- __tests__/mcp-gateway.test.ts __tests__/mcp-http.test.ts`, and `npm run test:operator`. +- [ ] Rebuild the local runtime image, apply CRDs, run the operator, create five repositories plus one gateway, and verify `http://127.0.0.1/mcp`. + +## Acceptance Criteria + +- Codex needs one config block only. +- `tools/list` from the gateway contains prefixed tools for all five repos. +- `tools/call` through a prefixed tool reaches the matching backend. +- Rancher Desktop verification uses local reachable IP/host `127.0.0.1`. +- Existing per-repository `/mcp/` routes are not required for Codex. diff --git a/docs/superpowers/specs/2026-06-16-codegraph-cloud-native-crd-design.md b/docs/superpowers/specs/2026-06-16-codegraph-cloud-native-crd-design.md new file mode 100644 index 000000000..40a9419a6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-codegraph-cloud-native-crd-design.md @@ -0,0 +1,295 @@ +# CodeGraph Cloud-Native CRD Design + +Date: 2026-06-16 + +## Summary + +The first cloud-native version exposes many repository-scoped CodeGraph MCP servers behind one shared MCP entry point without adding a custom Python proxy. Kubernetes owns repository lifecycle through a custom resource, and standard cloud-native routing maps each repository URL path to its own CodeGraph HTTP MCP service. + +Confirmed scope: + +- Use URL path routing for the shared MCP address: `https:///mcp/`. +- Do not implement a Python proxy in the first version. +- Add a custom Kubernetes resource that manages git checkout, indexing, and serving for each repository. +- Keep each repository isolated at runtime: one repository resource reconciles to its own storage, index job, deployment, service, and route. + +## Goals + +- Let users connect MCP clients through one stable host while selecting repositories by path. +- Let platform teams add or update repositories declaratively through Kubernetes YAML. +- Keep CodeGraph's existing HTTP MCP server as the serving primitive. +- Make repository sync, indexing, health, and route status observable through Kubernetes status fields. +- Keep the first version small enough to implement and test without building a new transport proxy. + +## Non-Goals + +- No Python proxy service in the first version. +- No cross-repository MCP session that queries multiple repositories in one request. +- No user-facing repository picker API. +- No automatic agent installer changes for remote MCP URLs in this first CRD design. +- No multi-tenant authorization policy beyond Kubernetes resource and secret boundaries. + +## Existing Repository Facts + +CodeGraph already has an HTTP MCP serving mode exposed through the CLI: + +```bash +codegraph serve --mcp --http --host 0.0.0.0 --port 3000 --path /workspace/repo +``` + +The HTTP server currently serves one project path per process and exposes a single MCP endpoint, normally `/mcp`. It is a good fit for a per-repository pod model because Kubernetes can place a route in front of each pod instead of requiring the Node process to understand every repository. + +The current server only accepts exact endpoint paths and does not act as a multi-repository router. Therefore the cloud-native design delegates external path matching to Gateway API or Ingress and rewrites `/mcp/` to the pod-local `/mcp` endpoint. + +## Recommended Architecture + +Use a Kubernetes operator with a `CodeGraphRepository` CRD. Each custom resource declares one git repository and its MCP route. The controller reconciles Kubernetes primitives for that repository. + +```mermaid +flowchart LR + Client["MCP client"] --> Gateway["Gateway or Ingress
/mcp/{repoId}"] + Gateway --> Service["repo Service"] + Service --> Runtime["CodeGraph HTTP MCP Pod
codegraph serve --mcp --http"] + Runtime --> PVC["Repository PVC
checkout + .codegraph"] + Controller["CodeGraph controller"] --> CR["CodeGraphRepository CR"] + Controller --> Job["clone/index Job"] + Controller --> Runtime + Job --> PVC +``` + +This keeps the data plane simple: + +- Gateway or Ingress owns the shared host and path routing. +- Each repository gets a normal Kubernetes Service. +- Each runtime pod runs the existing CodeGraph HTTP MCP server against a mounted repository. +- The operator owns lifecycle and status, not request forwarding. + +The preferred implementation is a Go operator using Kubebuilder or controller-runtime. That matches Kubernetes conventions for CRD schema generation, status updates, watches, owner references, and envtest-based tests. A TypeScript controller is possible, but it would be less conventional for this kind of Kubernetes control plane. + +## Custom Resource + +Initial API group and version: + +```yaml +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphRepository +metadata: + name: api-service +spec: + repoId: api-service + git: + url: https://github.com/acme/api-service.git + ref: main + authSecretRef: + name: api-service-git + mcp: + host: codegraph.example.com + path: /mcp/api-service + storage: + size: 20Gi + sync: + mode: manual +``` + +### Spec Fields + +`repoId` is the stable route and resource identifier. It must be DNS-label safe and unique within a namespace. + +`git.url` is the clone URL. HTTPS and SSH are both allowed if credentials are supplied through a Secret. + +`git.ref` is the branch, tag, or commit to index. The controller records the resolved commit in status. + +`git.authSecretRef` points to a Kubernetes Secret. The CRD does not inline credentials. + +`mcp.host` is the shared external host. + +`mcp.path` is the external path. The first version expects `/mcp/`. + +`storage.size` declares the PVC size for checkout and `.codegraph` data. + +`sync.mode` controls refresh behavior. First version supports `manual`; later versions can add `interval` or webhook-triggered sync. + +Optional fields can be added without changing the architecture: + +- `image` for overriding the CodeGraph runtime image. +- `resources` for runtime and job CPU/memory requests. +- `storageClassName` for PVC placement. +- `nodeSelector`, `tolerations`, and `affinity` for scheduling. + +### Status Fields + +The controller writes status that is useful to users and automation: + +```yaml +status: + observedGeneration: 3 + phase: Ready + conditions: + - type: Ready + status: "True" + reason: RuntimeAvailable + message: MCP endpoint is serving + - type: Indexed + status: "True" + reason: IndexSucceeded + message: Repository indexed successfully + resolvedRef: 2f6a2a7 + lastSyncTime: "2026-06-16T10:00:00Z" + endpoint: https://codegraph.example.com/mcp/api-service + serviceName: codegraph-api-service + routeName: codegraph-api-service +``` + +Phases: + +- `Pending`: resource accepted, owned resources not ready yet. +- `Syncing`: clone or fetch job is running. +- `Indexing`: CodeGraph index job is running. +- `Ready`: route, service, runtime pod, and index are ready. +- `Degraded`: last reconcile failed but retry is possible. + +## Reconciled Resources + +For a `CodeGraphRepository` named `api-service`, the controller creates resources with deterministic names: + +- `PersistentVolumeClaim/codegraph-api-service` +- `Job/codegraph-api-service-sync-` +- `Deployment/codegraph-api-service` +- `Service/codegraph-api-service` +- `HTTPRoute/codegraph-api-service` when Gateway API is enabled +- `Ingress/codegraph-api-service` when Ingress mode is configured + +All resources carry labels: + +```yaml +app.kubernetes.io/name: codegraph +app.kubernetes.io/component: repository-mcp +codegraph.dev/repo-id: api-service +``` + +Owner references point back to the `CodeGraphRepository` so garbage collection removes repository-owned resources when the CR is deleted. + +## Data Flow + +1. User applies a `CodeGraphRepository`. +2. Controller validates the route and git settings. +3. Controller creates or updates the PVC. +4. Controller runs a sync/index Job that clones or fetches the repository and runs CodeGraph initialization and indexing into the mounted workspace. +5. Controller starts or rolls the runtime Deployment after the index is ready. +6. Runtime pod serves `POST /mcp` through CodeGraph's existing HTTP MCP server. +7. Gateway API or Ingress receives `POST /mcp/` and forwards the request to that repository Service, rewriting the path to `/mcp`. +8. Controller updates status with resolved commit, endpoint, conditions, and failures. + +## Routing + +Gateway API is the preferred routing API for the first version because it models shared gateways and path-based HTTP routing directly. In clusters without Gateway API, an Ingress mode can provide a compatibility path. + +The route behavior is: + +- External path: `/mcp/` +- Backend service: `codegraph-:3000` +- Backend path: `/mcp` +- Methods: MCP clients primarily use `POST` + +The controller should not assume a specific ingress controller. It should emit either Gateway API `HTTPRoute` resources or standard `Ingress` resources based on operator configuration. + +References: + +- Kubernetes Gateway API: https://kubernetes.io/docs/concepts/services-networking/gateway/ +- Gateway API project documentation: https://gateway-api.sigs.k8s.io/ +- Kubernetes Custom Resources: https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/ + +## Failure Handling + +Git authentication failure: + +- Mark `Indexed=False`. +- Set phase to `Degraded`. +- Keep the previous ready runtime serving if one exists. + +Git ref not found: + +- Mark `Indexed=False`. +- Record the failing ref in the condition message. +- Retry only on spec changes or manual resync. + +Index failure: + +- Mark `Indexed=False`. +- Preserve logs in the failed Job. +- Keep the previous ready runtime serving if one exists. + +Runtime crash: + +- Let Deployment restart policy handle restarts. +- Mark `Ready=False` if pods are unavailable. + +Route conflict: + +- Mark `Ready=False` with a route conflict reason. +- Do not delete another repository's route. + +PVC full: + +- Mark `Indexed=False`. +- Surface a storage-specific condition message. +- Do not automatically resize storage unless Kubernetes storage class and policy allow it in a later version. + +## Security + +Credentials are always read from Kubernetes Secrets. The custom resource only references them. + +Each repository runs in its own workload with its own PVC. This avoids sharing a single process across repositories and limits blast radius. + +The runtime container should run as non-root where the image supports it. The generated Deployment should expose only the MCP HTTP port inside the cluster. + +The first version does not define end-user authorization at the MCP layer. Authentication and authorization for the shared host should be handled by the cluster ingress layer or a future gateway policy design. + +## Testing Strategy + +Controller tests: + +- CRD schema validation for required fields and invalid `repoId` or `mcp.path`. +- Reconcile creates PVC, Job, Deployment, Service, and route resources. +- Status transitions for successful sync/index/ready. +- Status behavior for git failure, index failure, route conflict, and unavailable runtime. +- Owner references and labels are applied consistently. + +Manifest tests: + +- Rendered Gateway API route maps `/mcp/` to the right Service. +- Ingress fallback renders equivalent path routing where supported. +- Generated runtime command uses `codegraph serve --mcp --http --host 0.0.0.0 --port 3000 --path /workspace/repo`. + +End-to-end cluster test: + +- Apply one `CodeGraphRepository`. +- Wait for `Ready=True`. +- Send an MCP initialize request to `https:///mcp/`. +- Verify the response comes from the indexed repository. + +Multi-repository test: + +- Apply two `CodeGraphRepository` resources. +- Confirm each path routes to its own Service and repository workspace. +- Confirm deleting one CR does not disrupt the other. + +## Implementation Boundaries + +This design intentionally keeps CodeGraph server changes minimal. If path rewriting is available at the Gateway or Ingress layer, the Node HTTP server does not need to understand `/mcp/` internally. + +If a target ingress cannot rewrite paths reliably, the smallest CodeGraph change would be adding a configurable HTTP endpoint path to `MCPHttpServer` and CLI flag wiring. That should be treated as a compatibility enhancement, not the default design. + +## Rollout Plan + +1. Add operator project and CRD manifests under a deployment-focused directory. +2. Define `CodeGraphRepository` API types and generated CRD schema. +3. Implement reconciliation for PVC, sync/index Job, runtime Deployment, Service, and Gateway API route. +4. Add status conditions and failure handling. +5. Add unit and envtest coverage. +6. Add a sample manifest for one repository. +7. Document how an MCP client connects to `https:///mcp/`. + +## Open Follow-Up After Spec Approval + +The next step is an implementation plan. That plan should split work into small commits, starting with CRD schema and controller resource rendering before adding sync/index execution details. diff --git a/docs/superpowers/specs/2026-06-16-codegraph-k8s-gateway-design.md b/docs/superpowers/specs/2026-06-16-codegraph-k8s-gateway-design.md new file mode 100644 index 000000000..7b763a425 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-codegraph-k8s-gateway-design.md @@ -0,0 +1,144 @@ +# CodeGraph Kubernetes MCP Gateway Design + +Date: 2026-06-16 + +## Summary + +Add a cloud-native MCP Gateway so Codex connects to one URL only: + +```text +http://127.0.0.1/mcp +``` + +The gateway exposes one MCP server to the client and aggregates multiple repository-scoped CodeGraph HTTP MCP servers behind it. Repository runtimes stay isolated and unchanged: each `CodeGraphRepository` still owns its checkout, index, Deployment, Service, and internal `/mcp` endpoint. The new gateway is the only external MCP address. + +## Goals + +- Codex uses exactly one `config.toml` entry. +- A Kubernetes CRD declares the gateway and the repositories it exposes. +- The gateway is not implemented in Python. +- The gateway works with existing CodeGraph HTTP MCP runtimes. +- Rancher Desktop verification starts at least five repositories and validates one shared `/mcp` endpoint. + +## Non-Goals + +- No authentication policy in the gateway for this version. +- No cross-repository query planner that merges answers from multiple repositories in one tool call. +- No replacement for `CodeGraphRepository`; repository lifecycle stays per-repo. +- No client-side repository selector or multiple Codex MCP entries. + +## Architecture + +The data plane is: + +```mermaid +flowchart LR + Codex["Codex MCP client"] --> Traefik["Rancher Desktop Traefik
127.0.0.1 /mcp"] + Traefik --> Gateway["CodeGraph Gateway
one MCP server"] + Gateway --> Repo1["repo runtime /mcp"] + Gateway --> Repo2["repo runtime /mcp"] + Gateway --> Repo3["repo runtime /mcp"] + Gateway --> Repo4["repo runtime /mcp"] + Gateway --> Repo5["repo runtime /mcp"] +``` + +The control plane adds a `CodeGraphGateway` CRD. A gateway resource reconciles to: + +- a ConfigMap containing repository backend mappings; +- a Deployment running the Node-based CodeGraph MCP gateway; +- a ClusterIP Service; +- an Ingress or Gateway API route exposing exactly `/mcp`. + +## Gateway Runtime + +The gateway speaks the same minimal Streamable HTTP shape as `MCPHttpServer`: JSON-RPC over `POST /mcp`, with `Accept: application/json, text/event-stream`. + +It handles these methods locally: + +- `initialize`: returns gateway server info and MCP tool capability. +- `initialized`: returns HTTP 202 with no body. +- `tools/list`: calls every configured backend `tools/list`, prefixes tool names with a stable repo prefix, and returns one combined tool list. +- `tools/call`: parses the repo prefix, forwards the call to that backend with the original tool name, and returns the backend result. +- `ping`, `resources/list`, `resources/templates/list`, `prompts/list`: returns small successful responses. + +Tool names are namespaced with `__`, for example: + +```text +hello-1__codegraph_explore +hello-2__codegraph_node +``` + +This keeps Codex behavior simple: it sees one MCP server with many tools and does not need multiple server URLs. + +## Gateway CRD + +Example: + +```yaml +apiVersion: codegraph.dev/v1alpha1 +kind: CodeGraphGateway +metadata: + name: local + namespace: codegraph-verify +spec: + host: 127.0.0.1 + path: /mcp + repositories: + - repoId: hello-1 + serviceName: codegraph-hello-1 + - repoId: hello-2 + serviceName: codegraph-hello-2 + - repoId: hello-3 + serviceName: codegraph-hello-3 + - repoId: hello-4 + serviceName: codegraph-hello-4 + - repoId: hello-5 + serviceName: codegraph-hello-5 +``` + +For the first version the repository list is explicit. The operator converts each entry into an in-cluster backend URL: + +```text +http://..svc.cluster.local:3000/mcp +``` + +## Error Handling + +- If a backend `tools/list` fails, the gateway omits that backend from the list and includes a warning tool response only when a routed call targets it. +- If `tools/call` references an unknown prefix, the gateway returns JSON-RPC `InvalidParams`. +- If a backend returns JSON-RPC error, the gateway passes that error through. +- If all backends are unavailable, `tools/list` returns an empty list rather than marking the MCP server broken. + +## Testing + +TypeScript tests cover: + +- `initialize` on the gateway. +- `tools/list` prefixes tools from two fake backend MCP servers. +- `tools/call` strips the prefix and forwards to the selected backend. +- unknown tool prefixes return `InvalidParams`. + +Operator tests cover: + +- `CodeGraphGateway` scheme registration and endpoint helpers. +- gateway ConfigMap, Deployment, Service, and route resource builders. +- gateway reconciler creates resources and reports status. + +End-to-end verification covers Rancher Desktop: + +- five `CodeGraphRepository` resources become ready; +- one `CodeGraphGateway` resource becomes ready; +- `POST http://127.0.0.1/mcp` initializes successfully; +- `tools/list` returns tools for all five repos; +- a namespaced `tools/call` reaches one backend. + +## Codex Configuration + +The target user configuration is: + +```toml +[mcp_servers.codegraph_k8s] +url = "http://127.0.0.1/mcp" +enabled = true +``` + diff --git a/package.json b/package.json index c1ef34d36..6da364817 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,10 @@ "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", "dev": "tsc --watch", "cli": "npm run build && node dist/bin/codegraph.js", - "test": "vitest run", - "test:watch": "vitest", - "test:eval": "vitest run __tests__/evaluation/", + "test": "node --liftoff-only ./node_modules/vitest/vitest.mjs run", + "test:operator": "cd deploy/operator && go test ./...", + "test:watch": "node --liftoff-only ./node_modules/vitest/vitest.mjs", + "test:eval": "node --liftoff-only ./node_modules/vitest/vitest.mjs run __tests__/evaluation/", "eval": "npm run build && npx tsx __tests__/evaluation/runner.ts", "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"" }, diff --git a/site/src/content/docs/getting-started/quickstart.md b/site/src/content/docs/getting-started/quickstart.md index 1d63bb489..28c4c7680 100644 --- a/site/src/content/docs/getting-started/quickstart.md +++ b/site/src/content/docs/getting-started/quickstart.md @@ -22,15 +22,60 @@ npx @colbymchenry/codegraph # zero-install, or: npm i -g @colbymchenry/codegraph ``` -CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro. +CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. -## Initialize Projects +## Pick a connection model + +For a local single-repository workflow, run the interactive installer. It auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro. + +```bash +codegraph install +``` + +For a team or platform workflow, use one shared HTTP MCP server instead. The Kubernetes gateway exposes `/mcp` once, fans out to many repository runtimes, and lets every agent query multiple repositories through the same remote MCP URL. + +## Initialize local projects ```bash cd your-project codegraph init -i ``` -That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists. +For the local MCP path, that's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists. In the Kubernetes gateway path, the operator syncs and indexes each `CodeGraphRepository` instead. + +## Kubernetes: one HTTP MCP URL for many repositories + +For a cloud-native multi-repo setup, use the operator CRDs instead of connecting Codex to each repository runtime separately: + +- Create one `CodeGraphRepository` per Git repository. +- Each repository runs its own checkout, `.codegraph` index, and HTTP MCP runtime in parallel. +- Create one `CodeGraphGateway` to expose the shared `/mcp` endpoint. +- Configure Codex, Cursor, or another MCP client with that single HTTP URL. + +```bash +docker build -f deploy/operator/runtime.Containerfile -t codegraph-runtime:local . + +kubectl apply -f deploy/operator/config/crd/codegraph.dev_codegraphrepositories.yaml +kubectl apply -f deploy/operator/config/crd/codegraph.dev_codegraphgateways.yaml + +cd deploy/operator +go run ./cmd/manager --route-mode=ingress --runtime-image=codegraph-runtime:local +``` + +Apply repository resources and the gateway. For a local five-repo verification gateway: + +```bash +kubectl apply -f deploy/operator/config/samples/codegraphgateway-local-verify.yaml +``` + +Locally, Codex can connect through a single URL: + +```toml +[mcp_servers.codegraph_k8s] +url = "http://127.0.0.1/mcp" +enabled = true +``` + +Gateway tools are prefixed by repository, for example `hello-1__codegraph_explore`, so one agent session can inspect multiple repositories without switching MCP servers. See `deploy/operator/README.md` for runtime images, Git authentication, and Gateway API/Ingress routing options. Next: build [Your First Graph](/codegraph/getting-started/your-first-graph/), or see the full [Installation](/codegraph/getting-started/installation/) options. diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index 63c3e32cc..066b47443 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -3,7 +3,11 @@ title: MCP Server description: The tools CodeGraph exposes to AI agents over MCP. --- -CodeGraph runs as a [Model Context Protocol](https://modelcontextprotocol.io/) server. Start it with: +CodeGraph runs as a [Model Context Protocol](https://modelcontextprotocol.io/) server. + +## Serving modes + +For a local agent connected to the current checkout, start the stdio MCP server with: ```bash codegraph serve --mcp @@ -11,6 +15,20 @@ codegraph serve --mcp Agents configured by the installer launch this automatically. When a `.codegraph/` index exists, the agent uses the tools below. +For a single repository exposed over HTTP, run: + +```bash +codegraph serve --mcp --http --host 0.0.0.0 --port 3000 --path /workspace/repo +``` + +For a cloud-native multi-repo gateway, run the HTTP MCP server with a gateway repository list: + +```bash +codegraph serve --mcp --http --host 0.0.0.0 --port 3000 --gateway-repos /etc/codegraph-gateway/repos.json +``` + +The Kubernetes operator manages that gateway mode for you. Agents connect to one URL, usually `https:///mcp`, and gateway tools are prefixed by repository, such as `api__codegraph_explore`. + ## Tools | Tool | Purpose | @@ -28,4 +46,4 @@ Agents configured by the installer launch this automatically. When a `.codegraph CodeGraph *is* the pre-built search index. For "how does X work?", architecture, trace, or where-is-X questions, an agent should answer in a handful of CodeGraph calls and stop — typically with **zero file reads** — rather than re-deriving the answer with `grep` + `Read`. A direct CodeGraph answer is a handful of calls; a grep/read exploration is dozens. -The installer writes this guidance into each agent's instructions file automatically. +The MCP server delivers this guidance to agents automatically during the MCP `initialize` response. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 1dbbca210..4d59e433f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1206,13 +1206,27 @@ function printFileTree( /** * codegraph serve */ +function parseHttpPort(raw: string | undefined): number { + if (raw === undefined || raw === '') return 3000; + const port = Number(raw); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`Invalid HTTP port: ${raw}`); + } + return port; +} + program .command('serve') .description('Start CodeGraph as an MCP server for AI assistants') .option('-p, --path ', 'Project path (optional for MCP mode, uses rootUri from client)') - .option('--mcp', 'Run as MCP server (stdio transport)') + .option('--mcp', 'Run as MCP server') + .option('--http', 'Run MCP server over Streamable HTTP instead of stdio') + .option('--host ', 'Host for HTTP MCP mode (default: 127.0.0.1)') + .option('--port ', 'Port for HTTP MCP mode (default: 3000, use 0 for a random free port)') + .option('--gateway-repos ', 'Run HTTP MCP mode as a gateway using repository backend JSON or a JSON file path') + .option('--gateway-repo-paths ', 'Run HTTP MCP mode as a repoId router using repository path JSON or a JSON file path') .option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)') - .action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => { + .action(async (options: { path?: string; mcp?: boolean; http?: boolean; host?: string; port?: string; gatewayRepos?: string; gatewayRepoPaths?: string; watch?: boolean }) => { const projectPath = options.path ? resolveProjectPath(options.path) : undefined; // Commander sets watch=false when --no-watch is passed. Route it through @@ -1223,11 +1237,27 @@ program try { if (options.mcp) { - // Start MCP server - it handles initialization lazily based on rootUri from client - const { MCPServer } = await import('../mcp/index'); - const server = new MCPServer(projectPath); - await server.start(); - // Server will run until terminated + if (options.http) { + const parsedPort = parseHttpPort(options.port); + if (options.gatewayRepos && options.gatewayRepoPaths) { + throw new Error('Use either --gateway-repos or --gateway-repo-paths, not both'); + } + const server = options.gatewayRepos + ? await createGatewayHttpServer(options.gatewayRepos, options.host, parsedPort) + : options.gatewayRepoPaths + ? await createRepositoryRouterHttpServer(options.gatewayRepoPaths, options.host, parsedPort) + : await createProjectHttpServer(projectPath, options.host, parsedPort); + const url = await server.start(); + process.stderr.write(`CodeGraph MCP HTTP server listening on ${url}\n`); + process.on('SIGINT', () => server.stop()); + process.on('SIGTERM', () => server.stop()); + } else { + // Start MCP server - it handles initialization lazily based on rootUri from client + const { MCPServer } = await import('../mcp/index'); + const server = new MCPServer(projectPath); + await server.start(); + // Server will run until terminated + } } else { // Default: show info about MCP mode. // Use stderr so stdout stays clean for any piped/stdio usage. @@ -1260,6 +1290,35 @@ program } }); +async function createProjectHttpServer(projectPath: string | undefined, host: string | undefined, port: number): Promise<{ start(): Promise; stop(): void }> { + const { MCPHttpServer } = await import('../mcp/http-server'); + return new MCPHttpServer({ + projectPath, + host, + port, + }); +} + +async function createGatewayHttpServer(reposInput: string, host: string | undefined, port: number): Promise<{ start(): Promise; stop(): void }> { + const { MCPGatewayHttpServer, parseGatewayRepositories } = await import('../mcp/gateway'); + const raw = fs.existsSync(reposInput) ? fs.readFileSync(reposInput, 'utf8') : reposInput; + return new MCPGatewayHttpServer({ + repositories: parseGatewayRepositories(raw), + host, + port, + }); +} + +async function createRepositoryRouterHttpServer(reposInput: string, host: string | undefined, port: number): Promise<{ start(): Promise; stop(): void }> { + const { MCPRepositoryRouterHttpServer, parseRepositoryRoutes } = await import('../mcp/gateway'); + const raw = fs.existsSync(reposInput) ? fs.readFileSync(reposInput, 'utf8') : reposInput; + return new MCPRepositoryRouterHttpServer({ + repositories: parseRepositoryRoutes(raw), + host, + port, + }); +} + /** * codegraph unlock [path] */ diff --git a/src/mcp/gateway.ts b/src/mcp/gateway.ts new file mode 100644 index 000000000..fa7715566 --- /dev/null +++ b/src/mcp/gateway.ts @@ -0,0 +1,628 @@ +import * as http from 'http'; +import type { AddressInfo } from 'net'; +import * as path from 'path'; +import { ErrorCodes, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse } from './transport'; +import { ToolHandler, ToolDefinition, tools as baseTools } from './tools'; +import { CodeGraphPackageVersion } from './version'; +import CodeGraph from '../index'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_ENDPOINT = '/mcp'; +const MAX_BODY_BYTES = 1024 * 1024; +const TOOL_SEPARATOR = '__'; +const REPOSITORIES_TOOL_NAME = 'codegraph_repos'; + +export interface MCPGatewayRepository { + repoId: string; + url: string; +} + +export interface MCPRepositoryRoute { + repoId: string; + path: string; +} + +export interface MCPGatewayHttpServerOptions { + repositories: MCPGatewayRepository[]; + host?: string; + port?: number; + endpoint?: string; +} + +export interface MCPRepositoryRouterHttpServerOptions { + repositories: MCPRepositoryRoute[]; + host?: string; + port?: number; + endpoint?: string; +} + +interface MCPTool { + name: string; + description?: string; + inputSchema: unknown; +} + +interface ToolsListResult { + tools?: MCPTool[]; +} + +export class MCPGatewayHttpServer { + private server: http.Server | null = null; + private endpoint: string; + private host: string; + private port: number; + private repositories: MCPGatewayRepository[]; + + constructor(options: MCPGatewayHttpServerOptions) { + this.repositories = options.repositories; + this.host = options.host ?? DEFAULT_HOST; + this.port = options.port ?? 0; + this.endpoint = normalizeEndpoint(options.endpoint ?? DEFAULT_ENDPOINT); + } + + async start(): Promise { + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + + await new Promise((resolve, reject) => { + const server = this.server!; + const onError = (err: Error) => { + server.off('listening', onListening); + reject(err); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(this.port, this.host); + }); + + const address = this.server.address() as AddressInfo; + return `http://${formatHost(this.host)}:${address.port}${this.endpoint}`; + } + + stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + } + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + if (!this.isEndpoint(req.url)) { + writeText(res, 404, 'Not Found'); + return; + } + + if (!originAllowed(req.headers.origin)) { + writeJson(res, 403, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Forbidden: invalid Origin header')); + return; + } + + if (req.method !== 'POST') { + writeText(res, 405, 'Method Not Allowed', { Allow: 'POST' }); + return; + } + + if (!acceptsStreamableHttp(req.headers.accept)) { + writeJson(res, 406, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Not Acceptable: expected application/json and text/event-stream')); + return; + } + + let body: string; + try { + body = await readBody(req); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + writeJson(res, 413, jsonRpcError(null, ErrorCodes.InvalidRequest, message)); + return; + } + + const parsed = parseJsonRpcMessage(body); + if (!parsed.ok) { + writeJson(res, 400, jsonRpcError(null, parsed.code, parsed.message)); + return; + } + + if (!('method' in parsed.value) || !('id' in parsed.value)) { + writeEmpty(res, 202); + return; + } + + const response = await this.handleMessage(parsed.value); + writeJson(res, 200, response); + } + + private async handleMessage(message: JsonRpcRequest): Promise { + try { + switch (message.method) { + case 'initialize': + return { + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { + name: 'codegraph-gateway', + version: CodeGraphPackageVersion, + }, + instructions: 'Use repository-prefixed CodeGraph tools exposed by this gateway.', + }, + }; + case 'tools/list': + return { + jsonrpc: '2.0', + id: message.id, + result: { tools: await this.listTools() }, + }; + case 'tools/call': + return await this.callTool(message); + case 'ping': + return { jsonrpc: '2.0', id: message.id, result: {} }; + case 'resources/list': + return { jsonrpc: '2.0', id: message.id, result: { resources: [] } }; + case 'resources/templates/list': + return { jsonrpc: '2.0', id: message.id, result: { resourceTemplates: [] } }; + case 'prompts/list': + return { jsonrpc: '2.0', id: message.id, result: { prompts: [] } }; + default: + return jsonRpcError(message.id, ErrorCodes.MethodNotFound, `Method not found: ${message.method}`); + } + } catch (err) { + return jsonRpcError(message.id, ErrorCodes.InternalError, err instanceof Error ? err.message : String(err)); + } + } + + private async listTools(): Promise { + const allTools: MCPTool[] = []; + for (const repo of this.repositories) { + try { + const response = await postJsonRpc(repo.url, { + jsonrpc: '2.0', + id: `tools-list-${repo.repoId}`, + method: 'tools/list', + }); + if (response.error) continue; + const result = response.result as ToolsListResult | undefined; + for (const tool of result?.tools ?? []) { + allTools.push({ + ...tool, + name: gatewayToolName(repo.repoId, tool.name), + description: `[${repo.repoId}] ${tool.description ?? tool.name}`, + }); + } + } catch { + // A down backend should not make the gateway look broken to MCP clients. + } + } + return allTools; + } + + private async callTool(message: JsonRpcRequest): Promise { + const params = message.params as { name?: unknown; arguments?: Record } | undefined; + if (!params || typeof params.name !== 'string') { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, 'Missing tool name'); + } + + const parsed = parseGatewayToolName(params.name); + if (!parsed) { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, `Unknown gateway tool: ${params.name}`); + } + + const repo = this.repositories.find((candidate) => candidate.repoId === parsed.repoId); + if (!repo) { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, `Unknown gateway tool: ${params.name}`); + } + + const response = await postJsonRpc(repo.url, { + jsonrpc: '2.0', + id: message.id, + method: 'tools/call', + params: { + name: parsed.toolName, + arguments: params.arguments ?? {}, + }, + }); + return { ...response, id: message.id }; + } + + private isEndpoint(rawUrl: string | undefined): boolean { + if (!rawUrl) return false; + const path = rawUrl.split('?', 1)[0] || '/'; + return path === this.endpoint; + } +} + +export class MCPRepositoryRouterHttpServer { + private server: http.Server | null = null; + private endpoint: string; + private host: string; + private port: number; + private repositories: Map; + private toolHandler = new ToolHandler(null, (projectRoot) => CodeGraph.openSync(projectRoot)); + + constructor(options: MCPRepositoryRouterHttpServerOptions) { + this.repositories = new Map( + options.repositories.map((repo) => [repo.repoId, path.resolve(repo.path)]) + ); + this.host = options.host ?? DEFAULT_HOST; + this.port = options.port ?? 0; + this.endpoint = normalizeEndpoint(options.endpoint ?? DEFAULT_ENDPOINT); + } + + async start(): Promise { + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + + await new Promise((resolve, reject) => { + const server = this.server!; + const onError = (err: Error) => { + server.off('listening', onListening); + reject(err); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(this.port, this.host); + }); + + const address = this.server.address() as AddressInfo; + return `http://${formatHost(this.host)}:${address.port}${this.endpoint}`; + } + + stop(): void { + this.toolHandler.closeAll(); + if (this.server) { + this.server.close(); + this.server = null; + } + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + if (!this.isEndpoint(req.url)) { + writeText(res, 404, 'Not Found'); + return; + } + + if (!originAllowed(req.headers.origin)) { + writeJson(res, 403, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Forbidden: invalid Origin header')); + return; + } + + if (req.method !== 'POST') { + writeText(res, 405, 'Method Not Allowed', { Allow: 'POST' }); + return; + } + + if (!acceptsStreamableHttp(req.headers.accept)) { + writeJson(res, 406, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Not Acceptable: expected application/json and text/event-stream')); + return; + } + + let body: string; + try { + body = await readBody(req); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + writeJson(res, 413, jsonRpcError(null, ErrorCodes.InvalidRequest, message)); + return; + } + + const parsed = parseJsonRpcMessage(body); + if (!parsed.ok) { + writeJson(res, 400, jsonRpcError(null, parsed.code, parsed.message)); + return; + } + + if (!('method' in parsed.value) || !('id' in parsed.value)) { + writeEmpty(res, 202); + return; + } + + const response = await this.handleMessage(parsed.value); + writeJson(res, 200, response); + } + + private async handleMessage(message: JsonRpcRequest): Promise { + try { + switch (message.method) { + case 'initialize': + return { + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { + name: 'codegraph-repository-router', + version: CodeGraphPackageVersion, + }, + instructions: 'Use CodeGraph tools with the required repoId argument to query a configured repository.', + }, + }; + case 'tools/list': + return { + jsonrpc: '2.0', + id: message.id, + result: { tools: this.listTools() }, + }; + case 'tools/call': + return await this.callTool(message); + case 'ping': + return { jsonrpc: '2.0', id: message.id, result: {} }; + case 'resources/list': + return { jsonrpc: '2.0', id: message.id, result: { resources: [] } }; + case 'resources/templates/list': + return { jsonrpc: '2.0', id: message.id, result: { resourceTemplates: [] } }; + case 'prompts/list': + return { jsonrpc: '2.0', id: message.id, result: { prompts: [] } }; + default: + return jsonRpcError(message.id, ErrorCodes.MethodNotFound, `Method not found: ${message.method}`); + } + } catch (err) { + return jsonRpcError(message.id, ErrorCodes.InternalError, err instanceof Error ? err.message : String(err)); + } + } + + private listTools(): ToolDefinition[] { + const visible = this.toolHandler.getTools(); + const routedTools = visible.map((tool) => ({ + ...tool, + description: `${tool.description} Requires repoId. Use ${REPOSITORIES_TOOL_NAME} to list configured repositories.`, + inputSchema: { + ...tool.inputSchema, + properties: { + repoId: { + type: 'string', + description: 'Configured repository id to query.', + }, + ...withoutProjectPath(tool.inputSchema.properties), + }, + required: [...new Set(['repoId', ...(tool.inputSchema.required ?? [])])], + }, + })); + return [...routedTools, repositoriesToolDefinition()]; + } + + private async callTool(message: JsonRpcRequest): Promise { + const params = message.params as { name?: unknown; arguments?: Record } | undefined; + if (!params || typeof params.name !== 'string') { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, 'Missing tool name'); + } + if (params.name === REPOSITORIES_TOOL_NAME) { + return { + jsonrpc: '2.0', + id: message.id, + result: { + content: [{ + type: 'text', + text: this.formatRepositories(), + }], + }, + }; + } + if (!baseTools.some((tool) => tool.name === params.name)) { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, `Unknown tool: ${params.name}`); + } + + const args = { ...(params.arguments ?? {}) }; + const repoId = args.repoId; + if (typeof repoId !== 'string') { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, 'Missing required repoId argument'); + } + const projectPath = this.repositories.get(repoId); + if (!projectPath) { + return jsonRpcError(message.id, ErrorCodes.InvalidParams, `Unknown repoId: ${repoId}`); + } + + delete args.repoId; + delete args.projectPath; + const result = await this.toolHandler.execute(params.name, { + ...args, + projectPath, + }); + return { jsonrpc: '2.0', id: message.id, result }; + } + + private formatRepositories(): string { + if (this.repositories.size === 0) { + return 'No repositories are configured.'; + } + return [ + 'Configured CodeGraph repositories:', + '', + ...[...this.repositories.entries()].map(([repoId, repoPath]) => `- ${repoId}: ${repoPath}`), + ].join('\n'); + } + + private isEndpoint(rawUrl: string | undefined): boolean { + if (!rawUrl) return false; + const requestPath = rawUrl.split('?', 1)[0] || '/'; + return requestPath === this.endpoint; + } +} + +export function parseGatewayRepositories(raw: string): MCPGatewayRepository[] { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('Gateway repositories must be a JSON array'); + } + return parsed.map((entry, index) => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`Gateway repository at index ${index} must be an object`); + } + const repo = entry as Record; + if (typeof repo.repoId !== 'string' || typeof repo.url !== 'string') { + throw new Error(`Gateway repository at index ${index} must include string repoId and url`); + } + return { repoId: repo.repoId, url: repo.url }; + }); +} + +export function parseRepositoryRoutes(raw: string): MCPRepositoryRoute[] { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('Repository routes must be a JSON array'); + } + return parsed.map((entry, index) => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`Repository route at index ${index} must be an object`); + } + const repo = entry as Record; + if (typeof repo.repoId !== 'string' || typeof repo.path !== 'string') { + throw new Error(`Repository route at index ${index} must include string repoId and path`); + } + return { repoId: repo.repoId, path: repo.path }; + }); +} + +function withoutProjectPath(properties: ToolDefinition['inputSchema']['properties']): ToolDefinition['inputSchema']['properties'] { + const { projectPath: _projectPath, ...rest } = properties; + return rest; +} + +function repositoriesToolDefinition(): ToolDefinition { + return { + name: REPOSITORIES_TOOL_NAME, + description: 'List the repository ids configured for this CodeGraph router.', + inputSchema: { + type: 'object', + properties: {}, + }, + }; +} + +function gatewayToolName(repoId: string, toolName: string): string { + return `${repoId}${TOOL_SEPARATOR}${toolName}`; +} + +function parseGatewayToolName(name: string): { repoId: string; toolName: string } | null { + const index = name.indexOf(TOOL_SEPARATOR); + if (index <= 0 || index + TOOL_SEPARATOR.length >= name.length) return null; + return { + repoId: name.slice(0, index), + toolName: name.slice(index + TOOL_SEPARATOR.length), + }; +} + +async function postJsonRpc(url: string, message: JsonRpcRequest): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify(message), + }); + if (!response.ok) { + throw new Error(`Backend ${url} returned HTTP ${response.status}`); + } + return await response.json() as JsonRpcResponse; +} + +function normalizeEndpoint(endpoint: string): string { + if (!endpoint.startsWith('/')) return `/${endpoint}`; + return endpoint; +} + +function formatHost(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +function originAllowed(origin: string | undefined): boolean { + if (!origin) return true; + try { + const url = new URL(origin); + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname); + } catch { + return false; + } +} + +function acceptsStreamableHttp(accept: string | undefined): boolean { + if (!accept) return false; + const lower = accept.toLowerCase(); + return lower.includes('application/json') && lower.includes('text/event-stream'); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('Request body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +type ParseResult = + | { ok: true; value: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse } + | { ok: false; code: number; message: string }; + +function parseJsonRpcMessage(body: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return { ok: false, code: ErrorCodes.ParseError, message: 'Parse error: invalid JSON' }; + } + + if (typeof parsed !== 'object' || parsed === null) { + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a JSON-RPC object' }; + } + + const obj = parsed as Record; + if (obj.jsonrpc !== '2.0') { + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a valid JSON-RPC 2.0 message' }; + } + + if (typeof obj.method === 'string') { + return { ok: true, value: obj as unknown as JsonRpcRequest | JsonRpcNotification }; + } + + if ('id' in obj && ('result' in obj || 'error' in obj)) { + return { ok: true, value: obj as unknown as JsonRpcResponse }; + } + + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a valid JSON-RPC 2.0 message' }; +} + +function jsonRpcError(id: string | number | null, code: number, message: string): JsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function writeJson(res: http.ServerResponse, status: number, body: JsonRpcResponse): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function writeEmpty(res: http.ServerResponse, status: number): void { + res.writeHead(status); + res.end(); +} + +function writeText( + res: http.ServerResponse, + status: number, + body: string, + headers: Record = {}, +): void { + res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers }); + res.end(body); +} diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts new file mode 100644 index 000000000..204ea44d1 --- /dev/null +++ b/src/mcp/http-server.ts @@ -0,0 +1,320 @@ +import * as http from 'http'; +import type { AddressInfo } from 'net'; +import { MCPEngine } from './engine'; +import { MCPSession } from './session'; +import { + ErrorCodes, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse, + JsonRpcTransport, + MessageHandler, +} from './transport'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_ENDPOINT = '/mcp'; +const MAX_BODY_BYTES = 1024 * 1024; + +interface PendingResult { + status: number; + body: JsonRpcResponse | null; +} + +class SingleRequestHttpTransport implements JsonRpcTransport { + private response: JsonRpcResponse | null = null; + private done: Promise; + private resolveDone!: (result: PendingResult) => void; + private settled = false; + + constructor(private message: JsonRpcRequest | JsonRpcNotification) { + this.done = new Promise((resolve) => { + this.resolveDone = resolve; + }); + } + + start(handler: MessageHandler): void { + void this.run(handler); + } + + stop(): void { + this.finish(); + } + + send(response: JsonRpcResponse): void { + this.response = response; + } + + notify(_method: string, _params?: unknown): void { + // The minimal Streamable HTTP mode does not keep a server-to-client stream. + } + + request(method: string, _params?: unknown, _timeoutMs?: number): Promise { + return Promise.reject(new Error(`Server-initiated request "${method}" is not available over JSON HTTP responses`)); + } + + sendResult(id: string | number, result: unknown): void { + this.send({ jsonrpc: '2.0', id, result }); + } + + sendError(id: string | number | null, code: number, message: string, data?: unknown): void { + this.send({ jsonrpc: '2.0', id, error: { code, message, data } }); + } + + result(): Promise { + return this.done; + } + + private async run(handler: MessageHandler): Promise { + try { + await handler(this.message); + } catch (err) { + if ('id' in this.message) { + this.sendError( + this.message.id, + ErrorCodes.InternalError, + `Internal error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } finally { + this.finish(); + } + } + + private finish(): void { + if (this.settled) return; + this.settled = true; + this.resolveDone({ + status: this.response ? 200 : 202, + body: this.response, + }); + } +} + +export interface MCPHttpServerOptions { + projectPath?: string; + host?: string; + port?: number; + endpoint?: string; +} + +export class MCPHttpServer { + private server: http.Server | null = null; + private engine = new MCPEngine(); + private endpoint: string; + private host: string; + private port: number; + private projectPath: string; + + constructor(options: MCPHttpServerOptions = {}) { + this.host = options.host ?? DEFAULT_HOST; + this.port = options.port ?? 0; + this.endpoint = normalizeEndpoint(options.endpoint ?? DEFAULT_ENDPOINT); + this.projectPath = options.projectPath ?? process.cwd(); + } + + async start(): Promise { + this.engine.setProjectPathHint(this.projectPath); + void this.engine.ensureInitialized(this.projectPath); + + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + + await new Promise((resolve, reject) => { + const server = this.server!; + const onError = (err: Error) => { + server.off('listening', onListening); + reject(err); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(this.port, this.host); + }); + + const address = this.server.address() as AddressInfo; + return `http://${formatHost(this.host)}:${address.port}${this.endpoint}`; + } + + stop(): void { + this.engine.stop(); + if (this.server) { + this.server.close(); + this.server = null; + } + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + if (!this.isEndpoint(req.url)) { + writeText(res, 404, 'Not Found'); + return; + } + + if (!originAllowed(req.headers.origin)) { + writeJson(res, 403, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Forbidden: invalid Origin header')); + return; + } + + if (req.method === 'GET') { + writeText(res, 405, 'Method Not Allowed', { Allow: 'POST' }); + return; + } + + if (req.method !== 'POST') { + writeText(res, 405, 'Method Not Allowed', { Allow: 'POST' }); + return; + } + + if (!acceptsStreamableHttp(req.headers.accept)) { + writeJson(res, 406, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Not Acceptable: expected application/json and text/event-stream')); + return; + } + + let body: string; + try { + body = await readBody(req); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + writeJson(res, 413, jsonRpcError(null, ErrorCodes.InvalidRequest, message)); + return; + } + + const message = parseJsonRpcMessage(body); + if (!message.ok) { + writeJson(res, 400, jsonRpcError(null, message.code, message.message)); + return; + } + + if (!('method' in message.value)) { + writeEmpty(res, 202); + return; + } + + if (!('id' in message.value)) { + const transport = new SingleRequestHttpTransport(message.value); + const session = new MCPSession(transport, this.engine, { explicitProjectPath: this.projectPath }); + session.start(); + await transport.result(); + writeEmpty(res, 202); + return; + } + + const transport = new SingleRequestHttpTransport(message.value); + const session = new MCPSession(transport, this.engine, { explicitProjectPath: this.projectPath }); + session.start(); + const result = await transport.result(); + if (!result.body) { + writeJson(res, 500, jsonRpcError(message.value.id, ErrorCodes.InternalError, 'Request produced no response')); + return; + } + writeJson(res, result.status, result.body); + } + + private isEndpoint(rawUrl: string | undefined): boolean { + if (!rawUrl) return false; + const path = rawUrl.split('?', 1)[0] || '/'; + return path === this.endpoint; + } +} + +function normalizeEndpoint(endpoint: string): string { + if (!endpoint.startsWith('/')) return `/${endpoint}`; + return endpoint; +} + +function formatHost(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +function originAllowed(origin: string | undefined): boolean { + if (!origin) return true; + try { + const url = new URL(origin); + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname); + } catch { + return false; + } +} + +function acceptsStreamableHttp(accept: string | undefined): boolean { + if (!accept) return false; + const lower = accept.toLowerCase(); + return lower.includes('application/json') && lower.includes('text/event-stream'); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('Request body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +type ParseResult = + | { ok: true; value: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse } + | { ok: false; code: number; message: string }; + +function parseJsonRpcMessage(body: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return { ok: false, code: ErrorCodes.ParseError, message: 'Parse error: invalid JSON' }; + } + + if (typeof parsed !== 'object' || parsed === null) { + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a JSON-RPC object' }; + } + + const obj = parsed as Record; + if (obj.jsonrpc !== '2.0') { + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a valid JSON-RPC 2.0 message' }; + } + + if (typeof obj.method === 'string') { + return { ok: true, value: obj as unknown as JsonRpcRequest | JsonRpcNotification }; + } + + if ('id' in obj && ('result' in obj || 'error' in obj)) { + return { ok: true, value: obj as unknown as JsonRpcResponse }; + } + + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a valid JSON-RPC 2.0 message' }; +} + +function jsonRpcError(id: string | number | null, code: number, message: string): JsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function writeJson(res: http.ServerResponse, status: number, body: JsonRpcResponse): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function writeEmpty(res: http.ServerResponse, status: number): void { + res.writeHead(status); + res.end(); +} + +function writeText( + res: http.ServerResponse, + status: number, + body: string, + headers: Record = {}, +): void { + res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers }); + res.end(body); +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9468d8d64..0f2ca76a5 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -664,7 +664,10 @@ export class ToolHandler { // subsequent calls don't pay any cost. private catchUpGate: Promise | null = null; - constructor(private cg: CodeGraph | null) {} + constructor( + private cg: CodeGraph | null, + private openProject: (projectRoot: string) => CodeGraph = (projectRoot) => loadCodeGraph().openSync(projectRoot), + ) {} /** * Update the default CodeGraph instance (e.g. after lazy initialization) @@ -865,7 +868,7 @@ export class ToolHandler { } // Open and cache under both paths - const cg = loadCodeGraph().openSync(resolvedRoot); + const cg = this.openProject(resolvedRoot); this.projectCache.set(resolvedRoot, cg); if (projectPath !== resolvedRoot) { this.projectCache.set(projectPath, cg); diff --git a/vitest.config.ts b/vitest.config.ts index 4a5ad904b..2fae7550d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config'; +const wasmRuntimeFlags = ['--liftoff-only']; + export default defineConfig({ test: { globals: true, @@ -19,6 +21,10 @@ export default defineConfig({ * there, so the variable is a no-op. */ env: { CODEGRAPH_ALLOW_UNSAFE_NODE: '1' }, + poolOptions: { + forks: { execArgv: wasmRuntimeFlags }, + threads: { execArgv: wasmRuntimeFlags }, + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],