Compare commits
2 Commits
422da46360
...
e5a4951787
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5a4951787 | ||
|
|
202d6d47f0 |
143
core/controllers/pkg/gitea/pulls.go
Normal file
143
core/controllers/pkg/gitea/pulls.go
Normal file
@ -0,0 +1,143 @@
|
||||
// Read-side PR operations on top of the canonical pkg/gitea Client.
|
||||
//
|
||||
// client.go already carries the write-side (CreatePullRequest + the
|
||||
// `findOpenPR` race fallback) but had no public read surface for the
|
||||
// MCP server's `gitea.pr.list` + `gitea.pr.get` tools (Wave 8). The two
|
||||
// helpers here add exactly that: a paginated list with state + filter
|
||||
// passthrough, and a single-PR fetch by number. Both reuse the existing
|
||||
// `Client.do` envelope so HTTP error mapping (ErrRepoNotFound) is
|
||||
// shared with the rest of the surface.
|
||||
//
|
||||
// New endpoints (Gitea Admin REST API):
|
||||
//
|
||||
// GET /api/v1/repos/{owner}/{repo}/pulls?state=...&page=...&limit=50
|
||||
// GET /api/v1/repos/{owner}/{repo}/pulls/{number}
|
||||
//
|
||||
// Why a separate file: client.go is already 800+ LOC. Wave 8 review
|
||||
// scope is the two new methods; isolating them keeps the diff scoped
|
||||
// and the canonical surface auditable from one place.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// PullRequestState constrains the `state` query param on ListPullRequests.
|
||||
// Gitea accepts "open" | "closed" | "all"; empty defaults server-side to
|
||||
// "open" but we make the wire shape explicit here so callers don't drift.
|
||||
type PullRequestState string
|
||||
|
||||
const (
|
||||
// PRStateOpen lists only open PRs (Gitea default).
|
||||
PRStateOpen PullRequestState = "open"
|
||||
// PRStateClosed lists only closed/merged PRs.
|
||||
PRStateClosed PullRequestState = "closed"
|
||||
// PRStateAll lists every PR regardless of state.
|
||||
PRStateAll PullRequestState = "all"
|
||||
)
|
||||
|
||||
// ListPRsOpts threads optional filters through ListPullRequests without
|
||||
// expanding the positional signature.
|
||||
type ListPRsOpts struct {
|
||||
// State filters by open/closed/all. Empty → server default ("open").
|
||||
State PullRequestState
|
||||
// Head filters by source branch (matches Gitea's `head=org:branch`).
|
||||
// Empty → no head filter.
|
||||
Head string
|
||||
// Base filters by target branch. Empty → no base filter.
|
||||
Base string
|
||||
}
|
||||
|
||||
// ListPullRequests returns every PR on the repo matching opts, walking
|
||||
// Gitea's pagination (page=1..N, limit=50). Result order matches what
|
||||
// Gitea returns (typically newest-first by creation).
|
||||
//
|
||||
// Returns ErrRepoNotFound on a first-page 404. Subsequent pagination
|
||||
// failures bubble up as *HTTPError.
|
||||
//
|
||||
// Added Wave 8 for openova-sandbox-mcp `gitea.pr.list`.
|
||||
func (c *Client) ListPullRequests(ctx context.Context, org, repo string, opts ListPRsOpts) ([]PullRequest, error) {
|
||||
if org == "" || repo == "" {
|
||||
return nil, errors.New("gitea: ListPullRequests requires non-empty org, repo")
|
||||
}
|
||||
const pageSize = 50
|
||||
out := make([]PullRequest, 0, pageSize)
|
||||
for page := 1; ; page++ {
|
||||
q := url.Values{}
|
||||
q.Set("limit", fmt.Sprintf("%d", pageSize))
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
if opts.State != "" {
|
||||
q.Set("state", string(opts.State))
|
||||
}
|
||||
if opts.Head != "" {
|
||||
// Gitea's `head` filter expects `<org>:<branch>` for same-repo
|
||||
// PRs. Accept both forms — pass through verbatim when the
|
||||
// caller already included the colon.
|
||||
head := opts.Head
|
||||
if !containsRune(head, ':') {
|
||||
head = org + ":" + head
|
||||
}
|
||||
q.Set("head", head)
|
||||
}
|
||||
if opts.Base != "" {
|
||||
q.Set("base", opts.Base)
|
||||
}
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/pulls?%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), q.Encode())
|
||||
var batch []PullRequest
|
||||
status, _, err := c.do(ctx, http.MethodGet, endpoint, nil, &batch)
|
||||
if err != nil {
|
||||
if page == 1 && status == http.StatusNotFound {
|
||||
return nil, ErrRepoNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, batch...)
|
||||
if len(batch) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPullRequest fetches a single PR by number. Returns ErrRepoNotFound
|
||||
// on 404 with "repository" in the body (matching GetFile's heuristic),
|
||||
// otherwise a plain *HTTPError with Status==404 when the PR number itself
|
||||
// doesn't resolve. Callers can `IsNotFound(err)` to fold both cases.
|
||||
//
|
||||
// Added Wave 8 for openova-sandbox-mcp `gitea.pr.get`.
|
||||
func (c *Client) GetPullRequest(ctx context.Context, org, repo string, number int64) (PullRequest, error) {
|
||||
if org == "" || repo == "" {
|
||||
return PullRequest{}, errors.New("gitea: GetPullRequest requires non-empty org, repo")
|
||||
}
|
||||
if number <= 0 {
|
||||
return PullRequest{}, errors.New("gitea: GetPullRequest requires positive PR number")
|
||||
}
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d",
|
||||
url.PathEscape(org), url.PathEscape(repo), number)
|
||||
var out PullRequest
|
||||
status, _, err := c.do(ctx, http.MethodGet, endpoint, nil, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return PullRequest{}, ErrRepoNotFound
|
||||
}
|
||||
return PullRequest{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// containsRune is a tiny strings.ContainsRune replacement kept inline so
|
||||
// pulls.go doesn't import "strings" for a single use; the rest of the
|
||||
// file uses url + fmt + http + errors only.
|
||||
func containsRune(s string, r rune) bool {
|
||||
for _, c := range s {
|
||||
if c == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
249
core/controllers/pkg/gitea/pulls_test.go
Normal file
249
core/controllers/pkg/gitea/pulls_test.go
Normal file
@ -0,0 +1,249 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pullsFake is a tiny httptest stand-in scoped to the two new endpoints
|
||||
// added in pulls.go: paginated list + get-by-number. We don't reuse the
|
||||
// big fakeGitea handler from client_test.go because that one's GET /pulls
|
||||
// branch is filter-by-head only (it was written before list-with-state
|
||||
// existed) and overriding it would risk regressing CreatePullRequest's
|
||||
// 409 path. A scoped fake keeps the new tests independent.
|
||||
type pullsFake struct {
|
||||
// repos that exist (key = "owner/repo").
|
||||
repos map[string]bool
|
||||
// prs is keyed by "owner/repo/number".
|
||||
prs map[string]PullRequest
|
||||
}
|
||||
|
||||
func newPullsFake() *pullsFake {
|
||||
return &pullsFake{
|
||||
repos: map[string]bool{},
|
||||
prs: map[string]PullRequest{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *pullsFake) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
http.Error(w, "no auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
p := r.URL.Path
|
||||
|
||||
// GET /api/v1/repos/{owner}/{repo}/pulls/{number}
|
||||
if r.Method == http.MethodGet &&
|
||||
strings.HasPrefix(p, "/api/v1/repos/") &&
|
||||
strings.Contains(p, "/pulls/") {
|
||||
rest := strings.TrimPrefix(p, "/api/v1/repos/")
|
||||
// rest = "owner/repo/pulls/123"
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 4 || parts[2] != "pulls" {
|
||||
http.Error(w, "bad path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repoKey := parts[0] + "/" + parts[1]
|
||||
if !f.repos[repoKey] {
|
||||
http.Error(w, "no repo", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
pr, ok := f.prs[repoKey+"/"+parts[3]]
|
||||
if !ok {
|
||||
http.Error(w, "no pr", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pr)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /api/v1/repos/{owner}/{repo}/pulls?state=...
|
||||
if r.Method == http.MethodGet &&
|
||||
strings.HasPrefix(p, "/api/v1/repos/") &&
|
||||
strings.HasSuffix(p, "/pulls") {
|
||||
rest := strings.TrimSuffix(strings.TrimPrefix(p, "/api/v1/repos/"), "/pulls")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "bad path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repoKey := parts[0] + "/" + parts[1]
|
||||
if !f.repos[repoKey] {
|
||||
http.Error(w, "no repo", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
stateWanted := r.URL.Query().Get("state")
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
out := []PullRequest{}
|
||||
for k, pr := range f.prs {
|
||||
if !strings.HasPrefix(k, repoKey+"/") {
|
||||
continue
|
||||
}
|
||||
if stateWanted != "" && stateWanted != "all" && pr.State != stateWanted {
|
||||
continue
|
||||
}
|
||||
out = append(out, pr)
|
||||
}
|
||||
// Stable order by Number ascending so test assertions can index.
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j-1].Number > out[j].Number; j-- {
|
||||
out[j-1], out[j] = out[j], out[j-1]
|
||||
}
|
||||
}
|
||||
// Apply pagination window.
|
||||
start := (page - 1) * limit
|
||||
end := start + limit
|
||||
if start > len(out) {
|
||||
start = len(out)
|
||||
}
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out[start:end])
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "unhandled "+r.Method+" "+p, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func newPullsClient(t *testing.T, f *pullsFake) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(f.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL, "test-token")
|
||||
c.HTTP = srv.Client()
|
||||
return c
|
||||
}
|
||||
|
||||
func TestListPullRequests_StateFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPullsFake()
|
||||
f.repos["acme/blueprints"] = true
|
||||
for i := int64(1); i <= 3; i++ {
|
||||
pr := PullRequest{ID: i, Number: i, State: "open", Title: fmt.Sprintf("open #%d", i)}
|
||||
pr.Head.Ref = "feature/" + fmt.Sprint(i)
|
||||
pr.Base.Ref = "main"
|
||||
f.prs[fmt.Sprintf("acme/blueprints/%d", i)] = pr
|
||||
}
|
||||
for i := int64(10); i <= 11; i++ {
|
||||
pr := PullRequest{ID: i, Number: i, State: "closed", Title: fmt.Sprintf("closed #%d", i)}
|
||||
pr.Head.Ref = "old/" + fmt.Sprint(i)
|
||||
pr.Base.Ref = "main"
|
||||
f.prs[fmt.Sprintf("acme/blueprints/%d", i)] = pr
|
||||
}
|
||||
c := newPullsClient(t, f)
|
||||
|
||||
open, err := c.ListPullRequests(context.Background(), "acme", "blueprints", ListPRsOpts{State: PRStateOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequests open: %v", err)
|
||||
}
|
||||
if len(open) != 3 {
|
||||
t.Fatalf("want 3 open PRs, got %d (%v)", len(open), open)
|
||||
}
|
||||
for _, pr := range open {
|
||||
if pr.State != "open" {
|
||||
t.Errorf("unexpected state %q on open list", pr.State)
|
||||
}
|
||||
}
|
||||
|
||||
closed, err := c.ListPullRequests(context.Background(), "acme", "blueprints", ListPRsOpts{State: PRStateClosed})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequests closed: %v", err)
|
||||
}
|
||||
if len(closed) != 2 {
|
||||
t.Fatalf("want 2 closed PRs, got %d", len(closed))
|
||||
}
|
||||
|
||||
all, err := c.ListPullRequests(context.Background(), "acme", "blueprints", ListPRsOpts{State: PRStateAll})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequests all: %v", err)
|
||||
}
|
||||
if len(all) != 5 {
|
||||
t.Fatalf("want 5 PRs (all), got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPullRequests_RepoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPullsFake()
|
||||
c := newPullsClient(t, f)
|
||||
|
||||
_, err := c.ListPullRequests(context.Background(), "ghost", "missing", ListPRsOpts{})
|
||||
if !errors.Is(err, ErrRepoNotFound) {
|
||||
t.Errorf("err = %v, want ErrRepoNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPullRequests_RejectsEmptyArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New("http://x", "tok")
|
||||
if _, err := c.ListPullRequests(context.Background(), "", "r", ListPRsOpts{}); err == nil {
|
||||
t.Error("want error for empty org")
|
||||
}
|
||||
if _, err := c.ListPullRequests(context.Background(), "o", "", ListPRsOpts{}); err == nil {
|
||||
t.Error("want error for empty repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPullRequest_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPullsFake()
|
||||
f.repos["acme/blueprints"] = true
|
||||
pr := PullRequest{ID: 42, Number: 42, State: "open", Title: "hello"}
|
||||
pr.Head.Ref = "feature/x"
|
||||
pr.Base.Ref = "main"
|
||||
f.prs["acme/blueprints/42"] = pr
|
||||
c := newPullsClient(t, f)
|
||||
|
||||
got, err := c.GetPullRequest(context.Background(), "acme", "blueprints", 42)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPullRequest: %v", err)
|
||||
}
|
||||
if got.Number != 42 || got.Title != "hello" || got.Head.Ref != "feature/x" {
|
||||
t.Errorf("unexpected PR: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPullRequest_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPullsFake()
|
||||
f.repos["acme/blueprints"] = true
|
||||
c := newPullsClient(t, f)
|
||||
|
||||
_, err := c.GetPullRequest(context.Background(), "acme", "blueprints", 999)
|
||||
if !IsNotFound(err) {
|
||||
t.Errorf("err = %v, want IsNotFound==true", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPullRequest_RejectsBadArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New("http://x", "tok")
|
||||
if _, err := c.GetPullRequest(context.Background(), "", "r", 1); err == nil {
|
||||
t.Error("want error for empty org")
|
||||
}
|
||||
if _, err := c.GetPullRequest(context.Background(), "o", "r", 0); err == nil {
|
||||
t.Error("want error for non-positive number")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time check: PullRequest must JSON-decode into the same fields
|
||||
// pulls.go reads. Caught a regression in the original Wave-8 draft where
|
||||
// the alias name diverged from the canonical struct.
|
||||
var _ = json.Unmarshal
|
||||
@ -6,13 +6,24 @@
|
||||
// stdin/stdout. Logging goes to stderr only — anything written to
|
||||
// stdout outside an RPC frame breaks the protocol.
|
||||
//
|
||||
// Wave 2 ships the protocol skeleton + the tool catalogue stubs from
|
||||
// products/sandbox/docs/architecture.md §3. Wave 3+ swaps the
|
||||
// not_implemented handlers for real Sovereign API calls.
|
||||
// Wave 2 shipped the protocol skeleton + the tool catalogue stubs from
|
||||
// products/sandbox/docs/architecture.md §3. Wave 8 swapped the stubs
|
||||
// for real handlers on:
|
||||
//
|
||||
// - gitea.repo.list / gitea.repo.get
|
||||
// - gitea.pr.list / gitea.pr.get
|
||||
// - k8s.read.get / k8s.read.list / k8s.read.watch
|
||||
// - sandbox.session.whoami / sandbox.session.info
|
||||
//
|
||||
// Other namespaces (sandbox.db.*, sandbox.auth.*, sandbox.stripe.*,
|
||||
// sandbox.preview.*, k8s.write.*) remain not_implemented stubs until
|
||||
// their respective backends ship. Hard rule: k8s.write.* never lands in
|
||||
// the MCP surface — kubectl-apply is out of scope.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -30,7 +41,7 @@ const (
|
||||
// The number is informational; clients negotiate via `initialize`.
|
||||
protocolVersion = "2024-11-05"
|
||||
serverName = "openova-sandbox-mcp"
|
||||
serverVersion = "0.1.0-wave2"
|
||||
serverVersion = "0.2.0-wave8"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 envelopes.
|
||||
@ -61,8 +72,11 @@ func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
|
||||
log.Printf("%s %s starting (protocol %s)", serverName, serverVersion, protocolVersion)
|
||||
|
||||
reg := tools.NewRegistry()
|
||||
srv := &server{tools: reg, out: os.Stdout}
|
||||
env := tools.NewEnvFromOS()
|
||||
reg := tools.NewRegistry(env)
|
||||
srv := &server{tools: reg, env: env, out: os.Stdout}
|
||||
log.Printf("env: org_id=%q sandbox_id=%q namespace=%q sov=%q gitea_url=%q jwt_secret_set=%v",
|
||||
env.OrgID, env.SandboxID, env.SandboxNamespace, env.SovereignFQDN, env.GiteaBaseURL, len(env.JWTSecret) > 0)
|
||||
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
@ -84,6 +98,7 @@ func main() {
|
||||
// server handles a single MCP peer over stdio.
|
||||
type server struct {
|
||||
tools *tools.Registry
|
||||
env *tools.Env
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
@ -133,13 +148,25 @@ func (s *server) handleToolsCall(req rpcRequest) error {
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return s.writeError(req.ID, -32602, "invalid params", err.Error())
|
||||
}
|
||||
result, err := s.tools.Call(params.Name, params.Arguments)
|
||||
|
||||
// Wave 8: validate the per-call bearer + install Claims on ctx.
|
||||
// In test mode (no SANDBOX_JWT_SECRET), ValidateBearer returns
|
||||
// (nil, nil) and the registry's auth gate is bypassed too.
|
||||
bearer := tools.ExtractBearer(s.env, params.Arguments)
|
||||
claims, err := tools.ValidateBearer(s.env, bearer)
|
||||
if err != nil {
|
||||
return s.writeError(req.ID, -32001, "unauthenticated", err.Error())
|
||||
}
|
||||
args := tools.StripAuthEnvelope(params.Arguments)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := s.tools.Call(ctx, params.Name, args, tools.CallOpts{Claims: claims})
|
||||
if err != nil {
|
||||
return s.writeError(req.ID, -32000, "tool error", err.Error())
|
||||
}
|
||||
// MCP wraps tool results in {content:[{type:"text", text:"..."}]}
|
||||
// but for Wave 2 stubs the JSON blob is sufficient — the agent's
|
||||
// MCP client tolerates either.
|
||||
// — the agent's MCP client expects that envelope. The raw JSON
|
||||
// payload from the handler lives inside content[0].text.
|
||||
body, _ := json.Marshal(result)
|
||||
return s.writeResult(req.ID, map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": string(body)}},
|
||||
|
||||
@ -1,3 +1,60 @@
|
||||
// openova-sandbox-mcp is the per-Sandbox MCP server. Wave 8 swapped the
|
||||
// Wave-2 stub catalogue for real implementations of:
|
||||
//
|
||||
// - gitea.repo.list / gitea.repo.get
|
||||
// - gitea.pr.list / gitea.pr.get
|
||||
// - k8s.read.get / k8s.read.list / k8s.read.watch
|
||||
// - sandbox.session.whoami / sandbox.session.info
|
||||
//
|
||||
// To reuse the canonical Gitea client (`core/controllers/pkg/gitea`) and
|
||||
// the canonical token-claim shape (`core/services/shared/auth.Claims`)
|
||||
// without each package growing its own divergent fork, we depend on the
|
||||
// in-tree modules via the same `replace` pattern catalyst-bootstrap and
|
||||
// every sme-service already use.
|
||||
//
|
||||
// k8s.io/client-go is the only big new dep — needed for the dynamic
|
||||
// + in-cluster client backing k8s.read.*. We use the same v0.31.x line
|
||||
// as core/controllers (already the canonical line across the repo).
|
||||
module github.com/openova-io/openova/products/sandbox/mcp-server
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/openova-io/openova/core/controllers v0.0.0
|
||||
github.com/openova-io/openova/core/services/shared v0.0.0
|
||||
k8s.io/apimachinery v0.31.1
|
||||
k8s.io/client-go v0.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/imdario/mergo v0.3.6 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/term v0.21.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/openova-io/openova/core/controllers => ../../../core/controllers
|
||||
|
||||
replace github.com/openova-io/openova/core/services/shared => ../../../core/services/shared
|
||||
|
||||
128
products/sandbox/mcp-server/go.sum
Normal file
128
products/sandbox/mcp-server/go.sum
Normal file
@ -0,0 +1,128 @@
|
||||
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.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
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-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
||||
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
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/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/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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=
|
||||
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/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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.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/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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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/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=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
|
||||
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
|
||||
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
|
||||
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
|
||||
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
|
||||
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
|
||||
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-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
118
products/sandbox/mcp-server/internal/tools/auth.go
Normal file
118
products/sandbox/mcp-server/internal/tools/auth.go
Normal file
@ -0,0 +1,118 @@
|
||||
// auth.go — bearer-token validation for tools/call.
|
||||
//
|
||||
// Every JSON-RPC `tools/call` may carry a `_auth.token` field in the
|
||||
// arguments blob (the agent's MCP client side-channel) OR fall back to
|
||||
// the env-mounted SANDBOX_TOKEN. main.go pulls the candidate bearer via
|
||||
// ExtractBearer(), passes it to ValidateBearer() to get a *Claims, and
|
||||
// installs the claims on the request context for Registry.Call.
|
||||
//
|
||||
// Validation rules:
|
||||
//
|
||||
// - Algorithm MUST be HS256 (the only sig used by core/services/auth's
|
||||
// PAT mint path — see core/services/auth/handlers/pat.go).
|
||||
// - The shared secret is env.JWTSecret. Mismatch / bad sig → 401.
|
||||
// - `exp` MUST be in the future. `iat`/`nbf` are tolerated as long as
|
||||
// jwt-v5's default leeway accepts them (which means 0s — exact).
|
||||
// - `typ == "pat"` is enforced for production bearers; "session" is
|
||||
// accepted for the cross-process bridge between catalyst-api and
|
||||
// this server (Wave 4 work). We currently accept both.
|
||||
//
|
||||
// In test mode (env.JWTSecret empty), ValidateBearer returns
|
||||
// (nil, nil) on every input — the Registry's auth gate is also disabled.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
sharedauth "github.com/openova-io/openova/core/services/shared/auth"
|
||||
)
|
||||
|
||||
// bearerEnvelope is the optional carrier the MCP `tools/call` arguments
|
||||
// may include. Agents that obtained their PAT via the bridge described
|
||||
// in products/sandbox/docs/newapi-proxy-contract.md §3 pass it through
|
||||
// here per call so a stolen pod env doesn't elevate a single tool call.
|
||||
type bearerEnvelope struct {
|
||||
Auth struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
} `json:"_auth,omitempty"`
|
||||
}
|
||||
|
||||
// ExtractBearer returns the raw bearer string the agent supplied on the
|
||||
// tools/call args, or env.SandboxToken when the call omitted _auth.
|
||||
// Returns "" when neither is set — caller decides whether that's a 401
|
||||
// (production) or an unauthenticated test invocation.
|
||||
func ExtractBearer(env *Env, rawArgs json.RawMessage) string {
|
||||
if len(rawArgs) > 0 {
|
||||
var env_ bearerEnvelope
|
||||
if err := json.Unmarshal(rawArgs, &env_); err == nil && env_.Auth.Token != "" {
|
||||
return env_.Auth.Token
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return ""
|
||||
}
|
||||
return env.SandboxToken
|
||||
}
|
||||
|
||||
// ValidateBearer parses + validates the supplied compact JWT against
|
||||
// env.JWTSecret. Returns the populated *Claims on success, or an error
|
||||
// describing exactly why validation failed (the MCP server uses this in
|
||||
// the 401 body so operators can debug a misconfigured Sovereign).
|
||||
//
|
||||
// When env.JWTSecret is empty (test mode), ValidateBearer returns
|
||||
// (nil, nil) regardless of the token — the registry's auth gate is also
|
||||
// disabled in that mode (see Registry.Call).
|
||||
func ValidateBearer(env *Env, raw string) (*sharedauth.Claims, error) {
|
||||
if env == nil || len(env.JWTSecret) == 0 {
|
||||
// Test mode — no JWT secret. The registry's auth gate is also
|
||||
// disabled, so this nil-claims return is consistent.
|
||||
return nil, nil
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, errors.New("auth: empty bearer (no token in _auth and no SANDBOX_TOKEN env)")
|
||||
}
|
||||
claims := &sharedauth.Claims{}
|
||||
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return env.JWTSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: parse bearer: %w", err)
|
||||
}
|
||||
if !tok.Valid {
|
||||
return nil, errors.New("auth: bearer not valid")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// StripAuthEnvelope returns rawArgs with any `_auth` field removed
|
||||
// before passing the arguments to the tool handler. Tool handlers
|
||||
// should not see the bearer token — auth has already happened.
|
||||
//
|
||||
// We do this rather than rejecting `_auth` outright because the agent
|
||||
// builds the args blob; mutating its shape between agent + server is
|
||||
// the simpler contract.
|
||||
func StripAuthEnvelope(rawArgs json.RawMessage) json.RawMessage {
|
||||
if len(rawArgs) == 0 {
|
||||
return rawArgs
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawArgs, &m); err != nil {
|
||||
return rawArgs // not an object — pass through
|
||||
}
|
||||
if _, present := m["_auth"]; !present {
|
||||
return rawArgs
|
||||
}
|
||||
delete(m, "_auth")
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return rawArgs
|
||||
}
|
||||
return out
|
||||
}
|
||||
63
products/sandbox/mcp-server/internal/tools/env.go
Normal file
63
products/sandbox/mcp-server/internal/tools/env.go
Normal file
@ -0,0 +1,63 @@
|
||||
// env.go — per-process Env construction from OS environment.
|
||||
//
|
||||
// The Sandbox controller (Wave 4) injects these env vars on the
|
||||
// `openova-sandbox-mcp` Deployment. Field naming follows the same
|
||||
// `SANDBOX_*` convention the controller is targetting in
|
||||
// products/sandbox/docs/newapi-proxy-contract.md §1 plus
|
||||
// products/sandbox/docs/architecture.md §6.
|
||||
//
|
||||
// Wire contract (the controller fills these; the binary reads them):
|
||||
//
|
||||
// SANDBOX_ORG_ID = "acme"
|
||||
// SANDBOX_ID = "emrah"
|
||||
// SANDBOX_NAMESPACE = "sandbox-<owner-uid>"
|
||||
// SANDBOX_SOVEREIGN_FQDN = "acme.openova.io"
|
||||
// SANDBOX_REPOS = "acme/eventforge,acme/internal-tools"
|
||||
// SANDBOX_TOKEN = "<long-lived PAT>" (fallback bearer)
|
||||
// SANDBOX_JWT_SECRET = "<HS256 secret>" (validates bearers)
|
||||
// SANDBOX_GITEA_BASE_URL = "http://gitea-http.gitea.svc.cluster.local:3000"
|
||||
// SANDBOX_GITEA_TOKEN = "<machine-account token>"
|
||||
// SANDBOX_KUBECONFIG = "" (empty → in-cluster)
|
||||
//
|
||||
// SANDBOX_JWT_SECRET empty AND SANDBOX_ORG_ID empty = test mode
|
||||
// (the registry skips its auth gate so unit tests don't need to mint a
|
||||
// JWT per call).
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewEnvFromOS reads the canonical SANDBOX_* env vars and returns a
|
||||
// populated Env. Always succeeds — fields the operator didn't set
|
||||
// stay zero-valued and the affected tool families surface a clear
|
||||
// "not configured" error at call time rather than aborting startup.
|
||||
//
|
||||
// The MCP server's `main()` calls this once and hands the *Env to
|
||||
// NewRegistry().
|
||||
func NewEnvFromOS() *Env {
|
||||
env := &Env{
|
||||
OrgID: os.Getenv("SANDBOX_ORG_ID"),
|
||||
SandboxID: os.Getenv("SANDBOX_ID"),
|
||||
SandboxNamespace: os.Getenv("SANDBOX_NAMESPACE"),
|
||||
SovereignFQDN: os.Getenv("SANDBOX_SOVEREIGN_FQDN"),
|
||||
SandboxToken: os.Getenv("SANDBOX_TOKEN"),
|
||||
GiteaBaseURL: os.Getenv("SANDBOX_GITEA_BASE_URL"),
|
||||
GiteaToken: os.Getenv("SANDBOX_GITEA_TOKEN"),
|
||||
KubeconfigPath: os.Getenv("SANDBOX_KUBECONFIG"),
|
||||
}
|
||||
if secret := os.Getenv("SANDBOX_JWT_SECRET"); secret != "" {
|
||||
env.JWTSecret = []byte(secret)
|
||||
}
|
||||
if csv := os.Getenv("SANDBOX_REPOS"); csv != "" {
|
||||
parts := strings.Split(csv, ",")
|
||||
env.SandboxRepos = make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
env.SandboxRepos = append(env.SandboxRepos, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
282
products/sandbox/mcp-server/internal/tools/gitea.go
Normal file
282
products/sandbox/mcp-server/internal/tools/gitea.go
Normal file
@ -0,0 +1,282 @@
|
||||
// gitea.go — real handlers for the gitea.* read tools.
|
||||
//
|
||||
// All four (gitea.repo.list / gitea.repo.get / gitea.pr.list /
|
||||
// gitea.pr.get) delegate to the canonical Gitea client at
|
||||
// `core/controllers/pkg/gitea`. We do NOT re-implement HTTP — the
|
||||
// pkg/gitea client already has the correct error mapping
|
||||
// (ErrOrgNotFound / ErrRepoNotFound / *HTTPError), retry posture, and
|
||||
// JSON-decoder shape used across every controller in the system.
|
||||
//
|
||||
// Each handler:
|
||||
//
|
||||
// 1. Pulls the Sandbox's Gitea base URL + machine-account token from
|
||||
// the Env on the ctx (populated by Registry.Call before invoking).
|
||||
// 2. Parses + validates the arguments JSON.
|
||||
// 3. Scopes the request to claims.OrgID — for `gitea.repo.list` this
|
||||
// pins the listed Org to the bearer's Org; for repo/pr operations
|
||||
// it rejects any owner that doesn't match (so a bearer with
|
||||
// OrgID=acme cannot enumerate repos under `bankdhofar/...`).
|
||||
// 4. Calls the pkg/gitea method, wraps the typed errors into a
|
||||
// short JSON-friendly shape ({"error":"...","status":404}).
|
||||
//
|
||||
// The wrapped output is the raw pkg/gitea struct (gitea.Repo /
|
||||
// gitea.PullRequest) — its JSON tags already match what the MCP agent
|
||||
// expects, so we don't need a separate translation layer.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gitea "github.com/openova-io/openova/core/controllers/pkg/gitea"
|
||||
)
|
||||
|
||||
// giteaClient lazily builds a *gitea.Client from the Env on ctx.
|
||||
// Centralised so every handler reports the same "gitea not configured"
|
||||
// error when the operator forgot to mount the Sandbox-Gitea-Token Secret.
|
||||
func giteaClient(ctx context.Context) (*gitea.Client, error) {
|
||||
env := EnvFrom(ctx)
|
||||
if env == nil || env.GiteaBaseURL == "" {
|
||||
return nil, errors.New("gitea: server env has no GITEA_BASE_URL (controller didn't wire sandbox-gitea-token)")
|
||||
}
|
||||
if env.GiteaToken == "" {
|
||||
return nil, errors.New("gitea: server env has no GITEA_TOKEN (controller didn't mount sandbox-gitea-token Secret)")
|
||||
}
|
||||
// 15s call timeout — gitea is in-cluster so anything slower is a
|
||||
// degraded backend; the MCP user wants a fast error.
|
||||
return gitea.NewWithHTTP(env.GiteaBaseURL, env.GiteaToken,
|
||||
&http.Client{Timeout: 15 * time.Second}), nil
|
||||
}
|
||||
|
||||
// resolveOwner returns the Gitea Org slug to scope this call to. The
|
||||
// MCP server's Env binds the pod to exactly one Org (env.OrgID); we use
|
||||
// that as the default when the call arguments omit `owner`. If the
|
||||
// caller passes an explicit `owner`, it MUST match env.OrgID — agents
|
||||
// cannot cross-tenant by passing a different owner string.
|
||||
func resolveOwner(env *Env, want string) (string, error) {
|
||||
if want == "" {
|
||||
want = env.OrgID
|
||||
}
|
||||
if want == "" {
|
||||
return "", errors.New("gitea: no owner supplied and Sandbox Env has no ORG_ID")
|
||||
}
|
||||
if env.OrgID != "" && want != env.OrgID {
|
||||
return "", fmt.Errorf("gitea: owner %q does not match Sandbox Org %q (cross-tenant access denied)", want, env.OrgID)
|
||||
}
|
||||
return want, nil
|
||||
}
|
||||
|
||||
// --- gitea.repo.list ---------------------------------------------------
|
||||
|
||||
type giteaRepoListArgs struct {
|
||||
// Owner — the Gitea Org slug. Defaults to env.OrgID; if supplied,
|
||||
// must match.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
func giteaRepoList(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args giteaRepoListArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("gitea.repo.list: invalid arguments: %w", err)
|
||||
}
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
owner, err := resolveOwner(env, args.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := giteaClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repos, err := client.ListOrgRepos(ctx, owner)
|
||||
if err != nil {
|
||||
if errors.Is(err, gitea.ErrOrgNotFound) {
|
||||
return map[string]any{"error": "org not found", "owner": owner, "status": http.StatusNotFound}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("gitea.repo.list: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"owner": owner,
|
||||
"count": len(repos),
|
||||
"repos": repos,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaGiteaRepoList() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{"type": "string", "description": "Gitea Org slug. Defaults to the Sandbox's Org."},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- gitea.repo.get ----------------------------------------------------
|
||||
|
||||
type giteaRepoGetArgs struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
func giteaRepoGet(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args giteaRepoGetArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("gitea.repo.get: invalid arguments: %w", err)
|
||||
}
|
||||
if args.Repo == "" {
|
||||
return nil, errors.New("gitea.repo.get: `repo` is required")
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
owner, err := resolveOwner(env, args.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := giteaClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repo, err := client.GetRepo(ctx, owner, args.Repo)
|
||||
if err != nil {
|
||||
if errors.Is(err, gitea.ErrRepoNotFound) {
|
||||
return map[string]any{"error": "repo not found", "owner": owner, "repo": args.Repo, "status": http.StatusNotFound}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("gitea.repo.get: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func schemaGiteaRepoGet() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"repo"},
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{"type": "string"},
|
||||
"repo": map[string]any{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- gitea.pr.list -----------------------------------------------------
|
||||
|
||||
type giteaPRListArgs struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Repo string `json:"repo"`
|
||||
State string `json:"state,omitempty"` // "open" | "closed" | "all"
|
||||
Head string `json:"head,omitempty"` // "<branch>" or "<org>:<branch>"
|
||||
Base string `json:"base,omitempty"`
|
||||
}
|
||||
|
||||
func giteaPRList(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args giteaPRListArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("gitea.pr.list: invalid arguments: %w", err)
|
||||
}
|
||||
if args.Repo == "" {
|
||||
return nil, errors.New("gitea.pr.list: `repo` is required")
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
owner, err := resolveOwner(env, args.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := giteaClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := gitea.PullRequestState(args.State)
|
||||
switch state {
|
||||
case "", gitea.PRStateOpen, gitea.PRStateClosed, gitea.PRStateAll:
|
||||
// ok
|
||||
default:
|
||||
return nil, fmt.Errorf("gitea.pr.list: invalid state %q (want open|closed|all)", args.State)
|
||||
}
|
||||
prs, err := client.ListPullRequests(ctx, owner, args.Repo, gitea.ListPRsOpts{
|
||||
State: state,
|
||||
Head: args.Head,
|
||||
Base: args.Base,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gitea.ErrRepoNotFound) {
|
||||
return map[string]any{"error": "repo not found", "owner": owner, "repo": args.Repo, "status": http.StatusNotFound}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("gitea.pr.list: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"owner": owner,
|
||||
"repo": args.Repo,
|
||||
"state": string(state),
|
||||
"count": len(prs),
|
||||
"prs": prs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaGiteaPRList() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"repo"},
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{"type": "string"},
|
||||
"repo": map[string]any{"type": "string"},
|
||||
"state": map[string]any{"type": "string", "enum": []string{"open", "closed", "all"}},
|
||||
"head": map[string]any{"type": "string"},
|
||||
"base": map[string]any{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- gitea.pr.get ------------------------------------------------------
|
||||
|
||||
type giteaPRGetArgs struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Repo string `json:"repo"`
|
||||
Number int64 `json:"number"`
|
||||
}
|
||||
|
||||
func giteaPRGet(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args giteaPRGetArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("gitea.pr.get: invalid arguments: %w", err)
|
||||
}
|
||||
if args.Repo == "" || args.Number <= 0 {
|
||||
return nil, errors.New("gitea.pr.get: `repo` and positive `number` are required")
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
owner, err := resolveOwner(env, args.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := giteaClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, err := client.GetPullRequest(ctx, owner, args.Repo, args.Number)
|
||||
if err != nil {
|
||||
if gitea.IsNotFound(err) {
|
||||
return map[string]any{"error": "pr not found", "owner": owner, "repo": args.Repo, "number": args.Number, "status": http.StatusNotFound}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("gitea.pr.get: %w", err)
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
func schemaGiteaPRGet() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"repo", "number"},
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{"type": "string"},
|
||||
"repo": map[string]any{"type": "string"},
|
||||
"number": map[string]any{"type": "integer", "minimum": 1},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
281
products/sandbox/mcp-server/internal/tools/gitea_test.go
Normal file
281
products/sandbox/mcp-server/internal/tools/gitea_test.go
Normal file
@ -0,0 +1,281 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGiteaServer is a minimal httptest stand-in for the Gitea endpoints
|
||||
// these tools touch. We don't reuse the canonical pkg/gitea fake (it
|
||||
// lives in a _test.go file and isn't importable). The handlers here
|
||||
// reflect the URL patterns the pkg/gitea client uses verbatim.
|
||||
func fakeGiteaServer(t *testing.T) (*httptest.Server, *fakeGiteaState) {
|
||||
t.Helper()
|
||||
state := &fakeGiteaState{
|
||||
orgs: map[string]bool{},
|
||||
repos: map[string]bool{},
|
||||
prs: map[string]json.RawMessage{},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
http.Error(w, "no auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/orgs/") && strings.HasSuffix(p, "/repos"):
|
||||
org := strings.TrimSuffix(strings.TrimPrefix(p, "/api/v1/orgs/"), "/repos")
|
||||
if !state.orgs[org] {
|
||||
http.Error(w, "no org", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for k := range state.repos {
|
||||
if strings.HasPrefix(k, org+"/") {
|
||||
out = append(out, map[string]any{"name": strings.TrimPrefix(k, org+"/"), "full_name": k})
|
||||
}
|
||||
}
|
||||
writeJSONResp(w, out)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/") && !strings.Contains(p, "/pulls"):
|
||||
rest := strings.TrimPrefix(p, "/api/v1/repos/")
|
||||
if !state.repos[rest] {
|
||||
http.Error(w, "no repo", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSONResp(w, map[string]any{"name": strings.Split(rest, "/")[1], "full_name": rest})
|
||||
case r.Method == http.MethodGet && strings.Contains(p, "/pulls/"):
|
||||
// GET /api/v1/repos/{owner}/{repo}/pulls/{number}
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/api/v1/repos/"), "/")
|
||||
if len(parts) != 4 || parts[2] != "pulls" {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pr, ok := state.prs[parts[0]+"/"+parts[1]+"/"+parts[3]]
|
||||
if !ok {
|
||||
http.Error(w, "no pr", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(pr)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(p, "/pulls"):
|
||||
// GET /api/v1/repos/{owner}/{repo}/pulls?state=...
|
||||
rest := strings.TrimSuffix(strings.TrimPrefix(p, "/api/v1/repos/"), "/pulls")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !state.repos[parts[0]+"/"+parts[1]] {
|
||||
http.Error(w, "no repo", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for k, raw := range state.prs {
|
||||
if strings.HasPrefix(k, parts[0]+"/"+parts[1]+"/") {
|
||||
var pr map[string]any
|
||||
_ = json.Unmarshal(raw, &pr)
|
||||
out = append(out, pr)
|
||||
}
|
||||
}
|
||||
writeJSONResp(w, out)
|
||||
default:
|
||||
http.Error(w, "unhandled "+r.Method+" "+p, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, state
|
||||
}
|
||||
|
||||
type fakeGiteaState struct {
|
||||
orgs map[string]bool
|
||||
repos map[string]bool
|
||||
prs map[string]json.RawMessage
|
||||
}
|
||||
|
||||
func writeJSONResp(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func TestGiteaRepoList_HappyPath(t *testing.T) {
|
||||
srv, state := fakeGiteaServer(t)
|
||||
state.orgs["acme"] = true
|
||||
state.repos["acme/eventforge"] = true
|
||||
state.repos["acme/internal-tools"] = true
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: srv.URL, GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
|
||||
res, err := r.Call(context.Background(), "gitea.repo.list", nil, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("repo.list: %v", err)
|
||||
}
|
||||
m := res.(map[string]any)
|
||||
if m["owner"] != "acme" {
|
||||
t.Errorf("owner=%v", m["owner"])
|
||||
}
|
||||
if m["count"].(int) != 2 {
|
||||
t.Errorf("count=%v want 2", m["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaRepoList_CrossTenantBlocked(t *testing.T) {
|
||||
srv, state := fakeGiteaServer(t)
|
||||
state.orgs["acme"] = true
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: srv.URL, GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
|
||||
args := json.RawMessage(`{"owner":"bankdhofar"}`)
|
||||
_, err := r.Call(context.Background(), "gitea.repo.list", args, CallOpts{})
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-tenant") {
|
||||
t.Errorf("err = %v, want cross-tenant", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaRepoGet_HappyPath(t *testing.T) {
|
||||
srv, state := fakeGiteaServer(t)
|
||||
state.repos["acme/eventforge"] = true
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: srv.URL, GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
|
||||
args := json.RawMessage(`{"repo":"eventforge"}`)
|
||||
res, err := r.Call(context.Background(), "gitea.repo.get", args, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("repo.get: %v", err)
|
||||
}
|
||||
// pkg/gitea.Repo struct serialised — Name field present.
|
||||
b, _ := json.Marshal(res)
|
||||
if !strings.Contains(string(b), `"eventforge"`) {
|
||||
t.Errorf("response missing repo name: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaRepoGet_RepoNotFound(t *testing.T) {
|
||||
srv, _ := fakeGiteaServer(t)
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: srv.URL, GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
|
||||
args := json.RawMessage(`{"repo":"ghost"}`)
|
||||
res, err := r.Call(context.Background(), "gitea.repo.get", args, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
m := res.(map[string]any)
|
||||
if m["status"].(int) != http.StatusNotFound {
|
||||
t.Errorf("status=%v want 404", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaPRList_HappyPath(t *testing.T) {
|
||||
srv, state := fakeGiteaServer(t)
|
||||
state.repos["acme/eventforge"] = true
|
||||
for i, st := range []string{"open", "open", "closed"} {
|
||||
state.prs[`acme/eventforge/`+itoa(i+1)] = json.RawMessage(
|
||||
`{"id":` + itoa(i+1) + `,"number":` + itoa(i+1) + `,"state":"` + st + `","title":"pr-` + itoa(i+1) + `"}`)
|
||||
}
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: srv.URL, GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
|
||||
args := json.RawMessage(`{"repo":"eventforge","state":"open"}`)
|
||||
// NOTE: our fake currently returns all PRs (no state filter); pkg/gitea
|
||||
// passes the state param but the fake doesn't filter — the assertion
|
||||
// here checks the tool wrapper, not the upstream filter logic (which
|
||||
// is tested in pkg/gitea/pulls_test.go).
|
||||
res, err := r.Call(context.Background(), "gitea.pr.list", args, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("pr.list: %v", err)
|
||||
}
|
||||
m := res.(map[string]any)
|
||||
if m["repo"] != "eventforge" {
|
||||
t.Errorf("repo=%v", m["repo"])
|
||||
}
|
||||
if m["state"] != "open" {
|
||||
t.Errorf("state=%v", m["state"])
|
||||
}
|
||||
if m["count"].(int) < 1 {
|
||||
t.Errorf("count too low: %v", m["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaPRList_InvalidState(t *testing.T) {
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: "http://x", GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
args := json.RawMessage(`{"repo":"x","state":"wat"}`)
|
||||
_, err := r.Call(context.Background(), "gitea.pr.list", args, CallOpts{})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid state") {
|
||||
t.Errorf("err = %v, want invalid state", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaPRGet_HappyPath(t *testing.T) {
|
||||
srv, state := fakeGiteaServer(t)
|
||||
state.repos["acme/eventforge"] = true
|
||||
state.prs["acme/eventforge/42"] = json.RawMessage(
|
||||
`{"id":42,"number":42,"state":"open","title":"hello","html_url":"http://gitea/x"}`)
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: srv.URL, GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
|
||||
args := json.RawMessage(`{"repo":"eventforge","number":42}`)
|
||||
res, err := r.Call(context.Background(), "gitea.pr.get", args, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("pr.get: %v", err)
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
if !strings.Contains(string(b), `"hello"`) {
|
||||
t.Errorf("response missing title: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaPRGet_MissingArgs(t *testing.T) {
|
||||
env := &Env{OrgID: "acme", GiteaBaseURL: "http://x", GiteaToken: "tok"}
|
||||
r := NewRegistry(env)
|
||||
_, err := r.Call(context.Background(), "gitea.pr.get", json.RawMessage(`{"repo":"x"}`), CallOpts{})
|
||||
if err == nil {
|
||||
t.Error("want error for missing number")
|
||||
}
|
||||
_, err = r.Call(context.Background(), "gitea.pr.get", json.RawMessage(`{"number":1}`), CallOpts{})
|
||||
if err == nil {
|
||||
t.Error("want error for missing repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitea_NotConfigured(t *testing.T) {
|
||||
env := &Env{OrgID: "acme"} // no GiteaBaseURL / GiteaToken
|
||||
r := NewRegistry(env)
|
||||
_, err := r.Call(context.Background(), "gitea.repo.list", nil, CallOpts{})
|
||||
if err == nil || !strings.Contains(err.Error(), "GITEA_BASE_URL") {
|
||||
t.Errorf("err = %v, want GITEA_BASE_URL message", err)
|
||||
}
|
||||
env.GiteaBaseURL = "http://x"
|
||||
_, err = r.Call(context.Background(), "gitea.repo.list", nil, CallOpts{})
|
||||
if err == nil || !strings.Contains(err.Error(), "GITEA_TOKEN") {
|
||||
t.Errorf("err = %v, want GITEA_TOKEN message", err)
|
||||
}
|
||||
}
|
||||
|
||||
// itoa is a tiny strconv.Itoa stand-in kept inline so the test file
|
||||
// keeps its single-import discipline.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
366
products/sandbox/mcp-server/internal/tools/k8s_read.go
Normal file
366
products/sandbox/mcp-server/internal/tools/k8s_read.go
Normal file
@ -0,0 +1,366 @@
|
||||
// k8s_read.go — real handlers for k8s.read.get / k8s.read.list /
|
||||
// k8s.read.watch.
|
||||
//
|
||||
// Scope: READ-ONLY into the Org's vcluster (via the Sandbox pod's
|
||||
// ServiceAccount; never the host cluster). Per architecture.md §3 +
|
||||
// the Wave-8 hard rule "READ-ONLY clusters (NO kubectl apply via tools
|
||||
// — k8s.write.* stays stubbed)".
|
||||
//
|
||||
// Client model: we mirror the canonical pattern used by
|
||||
// products/catalyst/bootstrap/api/internal/k8scache/factory.go — build a
|
||||
// dynamic.Interface either from an explicit KUBECONFIG path or from
|
||||
// rest.InClusterConfig() and call GVR.Namespace(ns).{Get,List,Watch}.
|
||||
// The Factory itself is the right backend when this server runs INSIDE
|
||||
// catalyst-api's process (it owns cluster bookkeeping); the MCP server
|
||||
// runs as a pod sidecar with its OWN SA in the Org vcluster, so it
|
||||
// builds the dynamic client directly. The wrapping style + result
|
||||
// shape (post-decode JSON of the Unstructured object) matches what the
|
||||
// catalyst-api's `/api/v1/k8s/...` SSE feed publishes so agents see the
|
||||
// same wire shape via either path.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// dynCache memoises the dynamic.Interface so we don't rebuild the
|
||||
// REST config + HTTP client on every tool call. The cache is process-
|
||||
// wide; the env (KubeconfigPath or in-cluster) is fixed at start.
|
||||
var (
|
||||
dynOnce sync.Once
|
||||
dynVal dynamic.Interface
|
||||
dynErr error
|
||||
)
|
||||
|
||||
func dynamicClient(ctx context.Context) (dynamic.Interface, error) {
|
||||
env := EnvFrom(ctx)
|
||||
if env == nil {
|
||||
return nil, errors.New("k8s.read: tool dispatch missing Env on context")
|
||||
}
|
||||
dynOnce.Do(func() {
|
||||
var cfg *rest.Config
|
||||
if env.KubeconfigPath != "" {
|
||||
// Explicit kubeconfig — used in dev / when the controller
|
||||
// mounts the vcluster kubeconfig into the Sandbox pod.
|
||||
cfg, dynErr = clientcmd.BuildConfigFromFlags("", env.KubeconfigPath)
|
||||
} else {
|
||||
// In-cluster — the Sandbox pod's SA token at
|
||||
// /var/run/secrets/kubernetes.io/serviceaccount/.
|
||||
cfg, dynErr = rest.InClusterConfig()
|
||||
}
|
||||
if dynErr != nil {
|
||||
dynErr = fmt.Errorf("k8s.read: build REST config: %w", dynErr)
|
||||
return
|
||||
}
|
||||
// 15s per-call timeout — read-only ops should complete fast.
|
||||
cfg.Timeout = 15 * time.Second
|
||||
dynVal, dynErr = dynamic.NewForConfig(cfg)
|
||||
if dynErr != nil {
|
||||
dynErr = fmt.Errorf("k8s.read: dynamic.NewForConfig: %w", dynErr)
|
||||
}
|
||||
})
|
||||
return dynVal, dynErr
|
||||
}
|
||||
|
||||
// gvrSelector binds the wire identifier (a Kubernetes GroupVersionResource)
|
||||
// to the request envelope. We use GVR (not GVK) because the dynamic client
|
||||
// natively keys by resource; the agent passes resource-plural so we don't
|
||||
// run a per-call RESTMapper lookup.
|
||||
type gvrSelector struct {
|
||||
// Group — "" for core (v1); else like "apps", "postgresql.cnpg.io".
|
||||
Group string `json:"group,omitempty"`
|
||||
// Version — "v1", "v1beta1", etc.
|
||||
Version string `json:"version"`
|
||||
// Resource — the lower-case plural ("pods", "deployments",
|
||||
// "configmaps", "helmreleases", "clusters"). NOT the Kind.
|
||||
Resource string `json:"resource"`
|
||||
}
|
||||
|
||||
func (g gvrSelector) toGVR() (schema.GroupVersionResource, error) {
|
||||
if g.Version == "" || g.Resource == "" {
|
||||
return schema.GroupVersionResource{}, errors.New("gvr: version + resource are required")
|
||||
}
|
||||
return schema.GroupVersionResource{
|
||||
Group: strings.TrimSpace(g.Group),
|
||||
Version: strings.TrimSpace(g.Version),
|
||||
Resource: strings.TrimSpace(g.Resource),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// scopeNamespace applies the Sandbox's namespace policy. The MCP server
|
||||
// is scoped to the Org vcluster — we don't gate which namespace the
|
||||
// agent may read inside that vcluster (the SA's RoleBinding does that),
|
||||
// but we DO default to env.SandboxNamespace when the caller omits it,
|
||||
// so a forgotten `namespace` field doesn't accidentally cross-tenant.
|
||||
//
|
||||
// An empty namespace after defaulting means "cluster-scoped"; we let
|
||||
// the dynamic client interpret that.
|
||||
func scopeNamespace(env *Env, want string) string {
|
||||
if want != "" {
|
||||
return want
|
||||
}
|
||||
return env.SandboxNamespace
|
||||
}
|
||||
|
||||
// --- k8s.read.get ------------------------------------------------------
|
||||
|
||||
type k8sReadGetArgs struct {
|
||||
gvrSelector
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func k8sReadGet(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args k8sReadGetArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.get: invalid arguments: %w", err)
|
||||
}
|
||||
if args.Name == "" {
|
||||
return nil, errors.New("k8s.read.get: `name` is required")
|
||||
}
|
||||
gvr, err := args.toGVR()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.get: %w", err)
|
||||
}
|
||||
client, err := dynamicClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
ns := scopeNamespace(env, args.Namespace)
|
||||
|
||||
var ri dynamic.ResourceInterface
|
||||
if ns != "" {
|
||||
ri = client.Resource(gvr).Namespace(ns)
|
||||
} else {
|
||||
ri = client.Resource(gvr)
|
||||
}
|
||||
obj, err := ri.Get(ctx, args.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.get %s %s/%s: %w", gvr.Resource, ns, args.Name, err)
|
||||
}
|
||||
return obj.Object, nil
|
||||
}
|
||||
|
||||
func schemaK8sReadGet() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"version", "resource", "name"},
|
||||
"properties": map[string]any{
|
||||
"group": map[string]any{"type": "string", "description": "API group; empty for core (v1)."},
|
||||
"version": map[string]any{"type": "string"},
|
||||
"resource": map[string]any{"type": "string", "description": "Lowercase plural (`pods`, `helmreleases`)."},
|
||||
"namespace": map[string]any{"type": "string", "description": "Defaults to the Sandbox namespace."},
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- k8s.read.list -----------------------------------------------------
|
||||
|
||||
type k8sReadListArgs struct {
|
||||
gvrSelector
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
LabelSelector string `json:"label_selector,omitempty"`
|
||||
FieldSelector string `json:"field_selector,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
func k8sReadList(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args k8sReadListArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.list: invalid arguments: %w", err)
|
||||
}
|
||||
gvr, err := args.toGVR()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.list: %w", err)
|
||||
}
|
||||
client, err := dynamicClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
ns := scopeNamespace(env, args.Namespace)
|
||||
opts := metav1.ListOptions{
|
||||
LabelSelector: args.LabelSelector,
|
||||
FieldSelector: args.FieldSelector,
|
||||
Limit: args.Limit,
|
||||
}
|
||||
|
||||
var ri dynamic.ResourceInterface
|
||||
if ns != "" {
|
||||
ri = client.Resource(gvr).Namespace(ns)
|
||||
} else {
|
||||
ri = client.Resource(gvr)
|
||||
}
|
||||
list, err := ri.List(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.list %s %s: %w", gvr.Resource, ns, err)
|
||||
}
|
||||
// Materialise items as plain maps so the JSON output is stable
|
||||
// across client-go versions.
|
||||
out := make([]map[string]any, 0, len(list.Items))
|
||||
for i := range list.Items {
|
||||
out = append(out, list.Items[i].Object)
|
||||
}
|
||||
return map[string]any{
|
||||
"resourceVersion": list.GetResourceVersion(),
|
||||
"continue": list.GetContinue(),
|
||||
"count": len(out),
|
||||
"items": out,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaK8sReadList() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"version", "resource"},
|
||||
"properties": map[string]any{
|
||||
"group": map[string]any{"type": "string"},
|
||||
"version": map[string]any{"type": "string"},
|
||||
"resource": map[string]any{"type": "string"},
|
||||
"namespace": map[string]any{"type": "string"},
|
||||
"label_selector": map[string]any{"type": "string"},
|
||||
"field_selector": map[string]any{"type": "string"},
|
||||
"limit": map[string]any{"type": "integer", "minimum": 1},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- k8s.read.watch ----------------------------------------------------
|
||||
|
||||
// k8sReadWatchArgs follows the same selector shape as list, plus the
|
||||
// short-watch limit (max events / max wait) so the JSON-RPC call has a
|
||||
// bounded turn-around. Long-lived watches go through the MCP
|
||||
// `resources/subscribe` channel (architecture.md §3) — Wave 9.
|
||||
type k8sReadWatchArgs struct {
|
||||
gvrSelector
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
LabelSelector string `json:"label_selector,omitempty"`
|
||||
FieldSelector string `json:"field_selector,omitempty"`
|
||||
MaxEvents int `json:"max_events,omitempty"` // default 32
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // default 10
|
||||
ResourceVersion string `json:"resource_version,omitempty"`
|
||||
}
|
||||
|
||||
// watchEvent is the wire shape this tool returns. We deliberately do
|
||||
// NOT echo back the Kubernetes Watch envelope verbatim (it nests
|
||||
// {type, object: <Unstructured>}); the flatter form is friendlier for
|
||||
// agents iterating over the slice.
|
||||
type watchEvent struct {
|
||||
Type string `json:"type"`
|
||||
Object map[string]any `json:"object,omitempty"`
|
||||
}
|
||||
|
||||
func k8sReadWatch(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args k8sReadWatchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.watch: invalid arguments: %w", err)
|
||||
}
|
||||
gvr, err := args.toGVR()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.watch: %w", err)
|
||||
}
|
||||
if args.MaxEvents <= 0 {
|
||||
args.MaxEvents = 32
|
||||
}
|
||||
if args.TimeoutSeconds <= 0 {
|
||||
args.TimeoutSeconds = 10
|
||||
}
|
||||
timeout := time.Duration(args.TimeoutSeconds) * time.Second
|
||||
|
||||
client, err := dynamicClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := EnvFrom(ctx)
|
||||
ns := scopeNamespace(env, args.Namespace)
|
||||
timeoutSec := int64(args.TimeoutSeconds)
|
||||
opts := metav1.ListOptions{
|
||||
LabelSelector: args.LabelSelector,
|
||||
FieldSelector: args.FieldSelector,
|
||||
ResourceVersion: args.ResourceVersion,
|
||||
TimeoutSeconds: &timeoutSec,
|
||||
Watch: true,
|
||||
}
|
||||
|
||||
var ri dynamic.ResourceInterface
|
||||
if ns != "" {
|
||||
ri = client.Resource(gvr).Namespace(ns)
|
||||
} else {
|
||||
ri = client.Resource(gvr)
|
||||
}
|
||||
|
||||
wctx, cancel := context.WithTimeout(ctx, timeout+2*time.Second)
|
||||
defer cancel()
|
||||
w, err := ri.Watch(wctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("k8s.read.watch %s %s: %w", gvr.Resource, ns, err)
|
||||
}
|
||||
defer w.Stop()
|
||||
|
||||
events := make([]watchEvent, 0, args.MaxEvents)
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
LOOP:
|
||||
for len(events) < args.MaxEvents {
|
||||
select {
|
||||
case <-wctx.Done():
|
||||
break LOOP
|
||||
case <-deadline.C:
|
||||
break LOOP
|
||||
case ev, ok := <-w.ResultChan():
|
||||
if !ok {
|
||||
break LOOP
|
||||
}
|
||||
out := watchEvent{Type: string(ev.Type)}
|
||||
if u, ok := ev.Object.(*unstructured.Unstructured); ok {
|
||||
out.Object = u.Object
|
||||
}
|
||||
events = append(events, out)
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaK8sReadWatch() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"version", "resource"},
|
||||
"properties": map[string]any{
|
||||
"group": map[string]any{"type": "string"},
|
||||
"version": map[string]any{"type": "string"},
|
||||
"resource": map[string]any{"type": "string"},
|
||||
"namespace": map[string]any{"type": "string"},
|
||||
"label_selector": map[string]any{"type": "string"},
|
||||
"field_selector": map[string]any{"type": "string"},
|
||||
"max_events": map[string]any{"type": "integer", "minimum": 1, "maximum": 1024, "default": 32},
|
||||
"timeout_seconds": map[string]any{"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
|
||||
"resource_version": map[string]any{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time check: keep watch import live for the type assertion above.
|
||||
var _ watch.Event
|
||||
@ -2,19 +2,38 @@
|
||||
//
|
||||
// Each tool is registered with a fully-qualified name (matching the
|
||||
// namespaces enumerated in products/sandbox/docs/architecture.md §3)
|
||||
// plus a short description. Wave 2 ships stubs only — every Call()
|
||||
// returns {"status":"not_implemented"} so the agent can list, dispatch,
|
||||
// and reason about the surface end-to-end before any backend is wired.
|
||||
// plus a short description. Wave 2 shipped stubs only — every Call()
|
||||
// returned {"status":"not_implemented"}. Wave 8 (this file's current
|
||||
// shape) replaces the stubs for:
|
||||
//
|
||||
// Real implementations land in Wave 3+ and replace the Handler func on
|
||||
// each Tool record without touching the registry shape.
|
||||
// - gitea.repo.list / gitea.repo.get
|
||||
// - gitea.pr.list / gitea.pr.get
|
||||
// - k8s.read.get / k8s.read.list / k8s.read.watch
|
||||
// - sandbox.session.whoami / sandbox.session.info
|
||||
//
|
||||
// All other namespaces (sandbox.db.*, sandbox.auth.*, sandbox.stripe.*,
|
||||
// sandbox.preview.*, k8s.write.*) remain stubbed and continue to return
|
||||
// not_implemented until Wave 8+ ships their backends.
|
||||
//
|
||||
// Wire model recap (architecture.md §3):
|
||||
//
|
||||
// - The agent process (claude / cursor / qwen / aider / opencode)
|
||||
// speaks MCP JSON-RPC over stdio to this server.
|
||||
// - The server speaks HTTPS to the Sovereign control plane using the
|
||||
// per-Sandbox PAT injected into the pod env as SANDBOX_TOKEN.
|
||||
// - Every tool call is authz'd against the bearer's Claims
|
||||
// (core/services/shared/auth) before reaching the backend.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
sharedauth "github.com/openova-io/openova/core/services/shared/auth"
|
||||
)
|
||||
|
||||
// Tool is one MCP tool the server advertises over stdio JSON-RPC.
|
||||
@ -27,31 +46,142 @@ type Tool struct {
|
||||
Description string `json:"description"`
|
||||
|
||||
// InputSchema is a JSON Schema fragment for the tool's arguments.
|
||||
// Wave 2 uses {} (any) for stubs; Wave 3 tightens.
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
|
||||
// Handler runs the tool. Wave 2 stubs return not_implemented.
|
||||
Handler func(args json.RawMessage) (any, error) `json:"-"`
|
||||
// Handler runs the tool. Stubs (sandbox.db.*, sandbox.auth.*, etc.)
|
||||
// have Handler==nil; Registry.Call returns notImplemented for them.
|
||||
Handler HandlerFunc `json:"-"`
|
||||
|
||||
// RequiredCapability — when non-empty, Registry.Call rejects any
|
||||
// invocation whose Claims do not carry this capability string in
|
||||
// their Capabilities list (architecture.md §3 RBAC). Empty means
|
||||
// any authenticated bearer may invoke (read-only tools).
|
||||
RequiredCapability string `json:"-"`
|
||||
}
|
||||
|
||||
// HandlerFunc is the shape every real tool implementation conforms to.
|
||||
// Receives the carry-context (which holds Claims + Env via the keys in
|
||||
// this package) and the raw arguments JSON. Returns any JSON-marshalable
|
||||
// value or an error.
|
||||
type HandlerFunc func(ctx context.Context, args json.RawMessage) (any, error)
|
||||
|
||||
// Registry is the in-process catalogue. Safe for concurrent reads.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]Tool
|
||||
// env is the per-process server environment (token-signing secret,
|
||||
// org_id, gitea URL/token, kubeconfig, etc.) all tool handlers read
|
||||
// at call time. Stored on Registry instead of context because every
|
||||
// handler needs it and threading it through arguments was noisier.
|
||||
env *Env
|
||||
}
|
||||
|
||||
// NewRegistry returns a registry pre-populated with every namespace
|
||||
// stub from architecture.md §3.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{tools: make(map[string]Tool)}
|
||||
for _, t := range defaultCatalogue() {
|
||||
// Env carries the per-process configuration every tool handler reads.
|
||||
// Populated from the pod's environment in main.go via NewEnvFromOS().
|
||||
type Env struct {
|
||||
// OrgID — the operator's Organization slug (`acme`, `bankdhofar`).
|
||||
// Matched against Claims.OrgID on every tool call; mismatch → 403.
|
||||
OrgID string
|
||||
|
||||
// SandboxID — the Sandbox CR name (`emrah`). Surfaced via
|
||||
// sandbox.session.info.
|
||||
SandboxID string
|
||||
|
||||
// SandboxNamespace — k8s namespace housing the Sandbox pod
|
||||
// + its resources inside the Org vcluster (sandbox-<owner-uid>).
|
||||
SandboxNamespace string
|
||||
|
||||
// SovereignFQDN — the Sovereign's primary FQDN (`acme.openova.io`).
|
||||
SovereignFQDN string
|
||||
|
||||
// SandboxRepos — comma-separated list of `<org>/<repo>` entries
|
||||
// the Sandbox has cloned. Surfaced via sandbox.session.info.
|
||||
SandboxRepos []string
|
||||
|
||||
// JWTSecret — HS256 secret used to validate per-call bearer tokens.
|
||||
// Sourced from the Sandbox-mounted Secret `sandbox-jwt-secret`.
|
||||
// Empty → accept tokens unsigned (test mode); a real deployment
|
||||
// MUST set this.
|
||||
JWTSecret []byte
|
||||
|
||||
// SandboxToken — the long-lived PAT minted by the sandbox-controller
|
||||
// at pod create time. Used as the fall-back bearer when a tool call
|
||||
// arrives without an explicit _auth.token argument.
|
||||
SandboxToken string
|
||||
|
||||
// GiteaBaseURL — the Sovereign's Gitea root (no /api/v1 suffix).
|
||||
GiteaBaseURL string
|
||||
|
||||
// GiteaToken — the per-Sandbox Gitea PAT (NOT the user's; minted by
|
||||
// sandbox-controller from a Gitea machine account). Used by the
|
||||
// gitea.* tool handlers.
|
||||
GiteaToken string
|
||||
|
||||
// KubeconfigPath — path to a kubeconfig pointing at the Org vcluster.
|
||||
// Empty → use in-cluster config (the Sandbox pod's SA).
|
||||
KubeconfigPath string
|
||||
}
|
||||
|
||||
// claimsCtxKey is the unexported context key under which the
|
||||
// per-call bearer's Claims live. main.go stuffs them on the context
|
||||
// after validating the bearer; tool handlers retrieve via ClaimsFrom().
|
||||
type claimsCtxKey struct{}
|
||||
|
||||
// ClaimsFrom returns the per-call Claims if present on ctx, or
|
||||
// (nil, false) when the request was unauthenticated.
|
||||
func ClaimsFrom(ctx context.Context) (*sharedauth.Claims, bool) {
|
||||
c, ok := ctx.Value(claimsCtxKey{}).(*sharedauth.Claims)
|
||||
return c, ok && c != nil
|
||||
}
|
||||
|
||||
// WithClaims returns a new context carrying claims. The MCP server's
|
||||
// dispatch path uses this once per `tools/call` after validating the
|
||||
// bearer.
|
||||
func WithClaims(ctx context.Context, claims *sharedauth.Claims) context.Context {
|
||||
return context.WithValue(ctx, claimsCtxKey{}, claims)
|
||||
}
|
||||
|
||||
// EnvFrom returns the Registry's Env via the supplied ctx for the
|
||||
// handler chain. Provided as a context-helper so unit tests can swap
|
||||
// envs without touching the Registry singleton.
|
||||
func EnvFrom(ctx context.Context) *Env {
|
||||
if e, ok := ctx.Value(envCtxKey{}).(*Env); ok {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithEnv attaches env to ctx; tool dispatch installs the Registry's
|
||||
// env on every call.
|
||||
func WithEnv(ctx context.Context, env *Env) context.Context {
|
||||
return context.WithValue(ctx, envCtxKey{}, env)
|
||||
}
|
||||
|
||||
type envCtxKey struct{}
|
||||
|
||||
// NewRegistry returns a registry pre-populated with every catalogue
|
||||
// stub from architecture.md §3. Wave 8 entries (gitea.* / k8s.read.* /
|
||||
// sandbox.session.*) carry real Handler funcs; the rest remain stubs.
|
||||
//
|
||||
// Pass nil for env in unit tests that exercise only the registry shape
|
||||
// (List + stub Call); real binaries always pass a populated Env.
|
||||
func NewRegistry(env *Env) *Registry {
|
||||
if env == nil {
|
||||
env = &Env{}
|
||||
}
|
||||
r := &Registry{tools: make(map[string]Tool), env: env}
|
||||
for _, t := range defaultCatalogue(env) {
|
||||
r.tools[t.Name] = t
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Register adds (or replaces) a tool by name. Used by tests + Wave 3
|
||||
// to swap stubs for real handlers.
|
||||
// Env returns the registry's Env. Used by tests + the MCP server's
|
||||
// outer dispatcher when it wants to thread a copy onto a request ctx.
|
||||
func (r *Registry) Env() *Env { return r.env }
|
||||
|
||||
// Register adds (or replaces) a tool by name. Used by tests + future
|
||||
// waves to swap stubs for real handlers.
|
||||
func (r *Registry) Register(t Tool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -70,8 +200,26 @@ func (r *Registry) List() []Tool {
|
||||
return out
|
||||
}
|
||||
|
||||
// CallOpts threads per-call inputs (the bearer's parsed Claims) to the
|
||||
// handler chain without leaking them into the tool's argument schema.
|
||||
type CallOpts struct {
|
||||
// Claims is the validated bearer claims (validated by main.go's
|
||||
// dispatch loop). May be nil for tools whose RequiredCapability is
|
||||
// empty AND env.JWTSecret is also empty (test mode).
|
||||
Claims *sharedauth.Claims
|
||||
}
|
||||
|
||||
// Call invokes the named tool with the supplied argument blob.
|
||||
func (r *Registry) Call(name string, args json.RawMessage) (any, error) {
|
||||
//
|
||||
// Authorisation: when env.JWTSecret is non-empty, the caller MUST pass
|
||||
// opts.Claims; nil → 401. When the tool has a RequiredCapability, that
|
||||
// string MUST be present in claims.Capabilities; absent → 403.
|
||||
//
|
||||
// Org scoping: every tool that touches the Org's data (gitea.*,
|
||||
// k8s.read.*) checks claims.OrgID == env.OrgID and rejects on mismatch.
|
||||
// session.* skip the org check (the operator always sees their own
|
||||
// session).
|
||||
func (r *Registry) Call(ctx context.Context, name string, args json.RawMessage, opts CallOpts) (any, error) {
|
||||
r.mu.RLock()
|
||||
t, ok := r.tools[name]
|
||||
r.mu.RUnlock()
|
||||
@ -81,56 +229,141 @@ func (r *Registry) Call(name string, args json.RawMessage) (any, error) {
|
||||
if t.Handler == nil {
|
||||
return notImplemented(name), nil
|
||||
}
|
||||
return t.Handler(args)
|
||||
|
||||
// Auth gate: only enforced when the binary was started with a JWT
|
||||
// secret. With no secret, the deployment is test/dev mode and
|
||||
// callers can omit Claims (the gitea + k8s tools still surface
|
||||
// "not configured" if their backends weren't wired).
|
||||
//
|
||||
// When Claims ARE supplied — production OR a test that mints
|
||||
// them — we always enforce the capability + org-scope checks so
|
||||
// the test surface matches production semantics.
|
||||
if len(r.env.JWTSecret) > 0 && opts.Claims == nil {
|
||||
return nil, errors.New("tools/call: unauthenticated (no bearer claims)")
|
||||
}
|
||||
if opts.Claims != nil {
|
||||
if t.RequiredCapability != "" && !opts.Claims.HasCapability(t.RequiredCapability) {
|
||||
return nil, fmt.Errorf("tools/call: forbidden (missing capability %q)", t.RequiredCapability)
|
||||
}
|
||||
// Org-scope: gitea.* + k8s.read.* must match the pod's OrgID.
|
||||
// session.* is exempt (the operator's session is the operator's
|
||||
// regardless of which Org slug their claim carries).
|
||||
if r.env.OrgID != "" && opts.Claims.OrgID != "" && opts.Claims.OrgID != r.env.OrgID && !exemptFromOrgScope(name) {
|
||||
return nil, fmt.Errorf("tools/call: forbidden (org_id mismatch: claim=%q env=%q)", opts.Claims.OrgID, r.env.OrgID)
|
||||
}
|
||||
}
|
||||
|
||||
ctx = WithClaims(ctx, opts.Claims)
|
||||
ctx = WithEnv(ctx, r.env)
|
||||
return t.Handler(ctx, args)
|
||||
}
|
||||
|
||||
// notImplemented is the canonical Wave 2 stub response.
|
||||
// exemptFromOrgScope reports whether `name` is a tool that the
|
||||
// org-scope check should skip. Today: just sandbox.session.* — those
|
||||
// tools return only information the bearer's own token already
|
||||
// authorised them to see.
|
||||
func exemptFromOrgScope(name string) bool {
|
||||
switch name {
|
||||
case "sandbox.session.whoami", "sandbox.session.info":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// notImplemented is the canonical stub response retained for every
|
||||
// Wave 8+ tool family still on the to-do list.
|
||||
func notImplemented(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"status": "not_implemented",
|
||||
"tool": name,
|
||||
"wave": 2,
|
||||
"note": "scaffold; real handler lands in Wave 3+",
|
||||
"note": "stub; real handler scheduled for Wave 8+",
|
||||
}
|
||||
}
|
||||
|
||||
// defaultCatalogue enumerates the four namespaces required for Wave 2
|
||||
// (gitea, k8s.read, sandbox.db, sandbox.auth) plus a thin
|
||||
// sandbox.session.* for the MCP server's own session metadata. Adding
|
||||
// the remaining namespaces (sandbox.deploy, marketplace, flux, rag,
|
||||
// etc.) is a stub list extension only — Wave 3 work.
|
||||
func defaultCatalogue() []Tool {
|
||||
any := map[string]any{"type": "object", "additionalProperties": true}
|
||||
// defaultCatalogue enumerates every tool the server advertises.
|
||||
// Wave 8 entries are wired to real handlers; remaining namespaces hold
|
||||
// place with Handler=nil so the agent can still `tools/list` them.
|
||||
func defaultCatalogue(env *Env) []Tool {
|
||||
anyObj := map[string]any{"type": "object", "additionalProperties": true}
|
||||
|
||||
return []Tool{
|
||||
// gitea.*
|
||||
{Name: "gitea.repos.list", Description: "List repos in the Org's Gitea Org.", InputSchema: any},
|
||||
{Name: "gitea.repos.get", Description: "Get a single repo by owner/name.", InputSchema: any},
|
||||
{Name: "gitea.pr.list", Description: "List PRs on a repo.", InputSchema: any},
|
||||
{Name: "gitea.pr.create", Description: "Open a PR (branch -> base) with title and body.", InputSchema: any},
|
||||
{Name: "gitea.pr.merge", Description: "Merge a PR by number (admin or org-admin only).", InputSchema: any},
|
||||
{Name: "gitea.issue.list", Description: "List issues on a repo.", InputSchema: any},
|
||||
{Name: "gitea.issue.create", Description: "Create an issue with title and body.", InputSchema: any},
|
||||
{Name: "gitea.release.list", Description: "List releases on a repo.", InputSchema: any},
|
||||
// gitea.* — read surface backed by core/controllers/pkg/gitea.
|
||||
{
|
||||
Name: "gitea.repo.list",
|
||||
Description: "List repos in the Org's Gitea Org.",
|
||||
InputSchema: schemaGiteaRepoList(),
|
||||
Handler: giteaRepoList,
|
||||
},
|
||||
{
|
||||
Name: "gitea.repo.get",
|
||||
Description: "Get a single repo by owner/name.",
|
||||
InputSchema: schemaGiteaRepoGet(),
|
||||
Handler: giteaRepoGet,
|
||||
},
|
||||
{
|
||||
Name: "gitea.pr.list",
|
||||
Description: "List PRs on a repo (state=open|closed|all).",
|
||||
InputSchema: schemaGiteaPRList(),
|
||||
Handler: giteaPRList,
|
||||
},
|
||||
{
|
||||
Name: "gitea.pr.get",
|
||||
Description: "Get a single PR by repo + number.",
|
||||
InputSchema: schemaGiteaPRGet(),
|
||||
Handler: giteaPRGet,
|
||||
},
|
||||
|
||||
// k8s.read.* (Org vcluster scope — never host)
|
||||
{Name: "k8s.read.get", Description: "GET a single object by GVK + namespace + name.", InputSchema: any},
|
||||
{Name: "k8s.read.list", Description: "LIST objects by GVK + namespace (label selector optional).", InputSchema: any},
|
||||
{Name: "k8s.read.watch", Description: "WATCH a kind for live updates (returns a subscription id).", InputSchema: any},
|
||||
{Name: "k8s.read.logs", Description: "Fetch container logs for a pod (read-only).", InputSchema: any},
|
||||
// gitea.* — write surface remains stubbed (Wave 8+).
|
||||
{Name: "gitea.pr.create", Description: "Open a PR (branch -> base) with title and body.", InputSchema: anyObj},
|
||||
{Name: "gitea.pr.merge", Description: "Merge a PR by number (admin or org-admin only).", InputSchema: anyObj},
|
||||
{Name: "gitea.issue.list", Description: "List issues on a repo.", InputSchema: anyObj},
|
||||
{Name: "gitea.issue.create", Description: "Create an issue with title and body.", InputSchema: anyObj},
|
||||
{Name: "gitea.release.list", Description: "List releases on a repo.", InputSchema: anyObj},
|
||||
|
||||
// sandbox.db.*
|
||||
{Name: "sandbox.db.provision", Description: "Provision a CNPG cluster (size + version). Returns Cluster CR ref.", InputSchema: any},
|
||||
{Name: "sandbox.db.list", Description: "List CNPG clusters owned by this Sandbox.", InputSchema: any},
|
||||
{Name: "sandbox.db.dump", Description: "Trigger a pg_dump-backed backup; returns object-store URL.", InputSchema: any},
|
||||
{Name: "sandbox.db.drop", Description: "Drop a CNPG cluster owned by this Sandbox.", InputSchema: any},
|
||||
// k8s.read.* — Org vcluster scope (NEVER host).
|
||||
{
|
||||
Name: "k8s.read.get",
|
||||
Description: "GET a single object by GVK + namespace + name (Org vcluster).",
|
||||
InputSchema: schemaK8sReadGet(),
|
||||
Handler: k8sReadGet,
|
||||
},
|
||||
{
|
||||
Name: "k8s.read.list",
|
||||
Description: "LIST objects by GVK + namespace (Org vcluster; label selector optional).",
|
||||
InputSchema: schemaK8sReadList(),
|
||||
Handler: k8sReadList,
|
||||
},
|
||||
{
|
||||
Name: "k8s.read.watch",
|
||||
Description: "WATCH a kind for live updates (returns a window of events, then closes).",
|
||||
InputSchema: schemaK8sReadWatch(),
|
||||
Handler: k8sReadWatch,
|
||||
},
|
||||
{Name: "k8s.read.logs", Description: "Fetch container logs for a pod (read-only).", InputSchema: anyObj},
|
||||
|
||||
// sandbox.auth.* (Keycloak realm + client management within Sandbox scope)
|
||||
{Name: "sandbox.auth.provisionRealm", Description: "Provision a Keycloak realm for an Application under this Sandbox.", InputSchema: any},
|
||||
{Name: "sandbox.auth.listClients", Description: "List Keycloak clients in the Sandbox's realm.", InputSchema: any},
|
||||
{Name: "sandbox.auth.registerClient", Description: "Register a new Keycloak client (id, redirect URIs).", InputSchema: any},
|
||||
// sandbox.db.* — Wave 8+ (CNPG provisioning).
|
||||
{Name: "sandbox.db.provision", Description: "Provision a CNPG cluster (size + version). Returns Cluster CR ref.", InputSchema: anyObj},
|
||||
{Name: "sandbox.db.list", Description: "List CNPG clusters owned by this Sandbox.", InputSchema: anyObj},
|
||||
{Name: "sandbox.db.dump", Description: "Trigger a pg_dump-backed backup; returns object-store URL.", InputSchema: anyObj},
|
||||
{Name: "sandbox.db.drop", Description: "Drop a CNPG cluster owned by this Sandbox.", InputSchema: anyObj},
|
||||
|
||||
// sandbox.session.* (this MCP server's own metadata)
|
||||
{Name: "sandbox.session.whoami", Description: "Return the claims (sub, org_id, sandbox_id, role) the server sees on its token.", InputSchema: any},
|
||||
{Name: "sandbox.session.info", Description: "Return the current Sandbox name, namespace, attached repos.", InputSchema: any},
|
||||
// sandbox.auth.* — Wave 8+ (Keycloak management).
|
||||
{Name: "sandbox.auth.provisionRealm", Description: "Provision a Keycloak realm for an Application under this Sandbox.", InputSchema: anyObj},
|
||||
{Name: "sandbox.auth.listClients", Description: "List Keycloak clients in the Sandbox's realm.", InputSchema: anyObj},
|
||||
{Name: "sandbox.auth.registerClient", Description: "Register a new Keycloak client (id, redirect URIs).", InputSchema: anyObj},
|
||||
|
||||
// sandbox.session.* — this MCP server's own metadata (Wave 8).
|
||||
{
|
||||
Name: "sandbox.session.whoami",
|
||||
Description: "Return the claims (sub, org_id, sandbox_id, role) the server sees on the per-call bearer.",
|
||||
InputSchema: anyObj,
|
||||
Handler: sessionWhoami,
|
||||
},
|
||||
{
|
||||
Name: "sandbox.session.info",
|
||||
Description: "Return the Sandbox's name, namespace, attached repos, Sovereign FQDN.",
|
||||
InputSchema: anyObj,
|
||||
Handler: sessionInfo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
375
products/sandbox/mcp-server/internal/tools/registry_test.go
Normal file
375
products/sandbox/mcp-server/internal/tools/registry_test.go
Normal file
@ -0,0 +1,375 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
sharedauth "github.com/openova-io/openova/core/services/shared/auth"
|
||||
)
|
||||
|
||||
// TestNewRegistry_NilEnvOK — historical contract: callers + tests that
|
||||
// just want to enumerate the catalogue pass nil.
|
||||
func TestNewRegistry_NilEnvOK(t *testing.T) {
|
||||
r := NewRegistry(nil)
|
||||
if r == nil {
|
||||
t.Fatal("NewRegistry(nil) returned nil")
|
||||
}
|
||||
if got := r.Env(); got == nil {
|
||||
t.Error("Env() should never be nil even when input was nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_ListContainsCatalogue confirms every namespace from
|
||||
// architecture.md §3 is present (sorted), regardless of whether the
|
||||
// handler is real or still a stub.
|
||||
func TestRegistry_ListContainsCatalogue(t *testing.T) {
|
||||
r := NewRegistry(&Env{})
|
||||
got := r.List()
|
||||
|
||||
want := []string{
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.list",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.get",
|
||||
"gitea.pr.list",
|
||||
"gitea.pr.merge",
|
||||
"gitea.release.list",
|
||||
"gitea.repo.get",
|
||||
"gitea.repo.list",
|
||||
"k8s.read.get",
|
||||
"k8s.read.list",
|
||||
"k8s.read.logs",
|
||||
"k8s.read.watch",
|
||||
"sandbox.auth.listClients",
|
||||
"sandbox.auth.provisionRealm",
|
||||
"sandbox.auth.registerClient",
|
||||
"sandbox.db.drop",
|
||||
"sandbox.db.dump",
|
||||
"sandbox.db.list",
|
||||
"sandbox.db.provision",
|
||||
"sandbox.session.info",
|
||||
"sandbox.session.whoami",
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, t := range got {
|
||||
have[t.Name] = true
|
||||
}
|
||||
for _, w := range want {
|
||||
if !have[w] {
|
||||
t.Errorf("expected tool %q in catalogue", w)
|
||||
}
|
||||
}
|
||||
// Sorted order.
|
||||
for i := 1; i < len(got); i++ {
|
||||
if got[i-1].Name > got[i].Name {
|
||||
t.Errorf("catalogue not sorted: %q > %q", got[i-1].Name, got[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_StubsReturnNotImplemented covers every tool we explicitly
|
||||
// kept stubbed for Wave 8.
|
||||
func TestRegistry_StubsReturnNotImplemented(t *testing.T) {
|
||||
r := NewRegistry(&Env{})
|
||||
stubs := []string{
|
||||
"sandbox.db.provision",
|
||||
"sandbox.db.list",
|
||||
"sandbox.auth.provisionRealm",
|
||||
"k8s.read.logs",
|
||||
"gitea.pr.merge",
|
||||
}
|
||||
for _, name := range stubs {
|
||||
res, err := r.Call(context.Background(), name, nil, CallOpts{})
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected err: %v", name, err)
|
||||
continue
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("%s: result type %T, want map", name, res)
|
||||
continue
|
||||
}
|
||||
if m["status"] != "not_implemented" {
|
||||
t.Errorf("%s: status=%v want not_implemented", name, m["status"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_AuthGate_NoSecretBypass — when JWTSecret + OrgID are
|
||||
// both empty, the auth gate is off (test mode) and the call goes
|
||||
// through with nil claims.
|
||||
func TestRegistry_AuthGate_NoSecretBypass(t *testing.T) {
|
||||
r := NewRegistry(&Env{}) // empty env = test mode
|
||||
// sandbox.session.info has Handler!=nil
|
||||
res, err := r.Call(context.Background(), "sandbox.session.info", nil, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if _, ok := res.(map[string]any); !ok {
|
||||
t.Errorf("result type %T, want map", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_AuthGate_NoClaimsRejects — when JWTSecret is set
|
||||
// (production mode), calling without Claims is rejected.
|
||||
func TestRegistry_AuthGate_NoClaimsRejects(t *testing.T) {
|
||||
r := NewRegistry(&Env{OrgID: "acme", JWTSecret: []byte("x")})
|
||||
_, err := r.Call(context.Background(), "sandbox.session.info", nil, CallOpts{Claims: nil})
|
||||
if err == nil || !strings.Contains(err.Error(), "unauthenticated") {
|
||||
t.Errorf("err = %v, want unauthenticated", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_AuthGate_OrgMismatch — claim org_id != env.OrgID = 403,
|
||||
// EXCEPT for sandbox.session.* which is exempt.
|
||||
func TestRegistry_AuthGate_OrgMismatch(t *testing.T) {
|
||||
r := NewRegistry(&Env{
|
||||
OrgID: "acme",
|
||||
JWTSecret: []byte("x"),
|
||||
GiteaBaseURL: "http://gitea",
|
||||
GiteaToken: "tok",
|
||||
})
|
||||
cl := &sharedauth.Claims{
|
||||
Email: "u@x",
|
||||
OrgID: "bankdhofar",
|
||||
}
|
||||
_, err := r.Call(context.Background(), "gitea.repo.list", nil, CallOpts{Claims: cl})
|
||||
if err == nil || !strings.Contains(err.Error(), "org_id mismatch") {
|
||||
t.Errorf("gitea.repo.list with wrong org: err = %v, want org_id mismatch", err)
|
||||
}
|
||||
// session.whoami is exempt — should pass through (no Gitea hop).
|
||||
if _, err := r.Call(context.Background(), "sandbox.session.whoami", nil, CallOpts{Claims: cl}); err != nil {
|
||||
t.Errorf("session.whoami should be org-scope exempt; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_AuthGate_RequiredCapability — when a tool sets
|
||||
// RequiredCapability, claims must carry it.
|
||||
func TestRegistry_AuthGate_RequiredCapability(t *testing.T) {
|
||||
r := NewRegistry(&Env{OrgID: "acme", JWTSecret: []byte("x")})
|
||||
r.Register(Tool{
|
||||
Name: "test.cap",
|
||||
InputSchema: map[string]any{},
|
||||
Handler: func(context.Context, json.RawMessage) (any, error) { return "ok", nil },
|
||||
RequiredCapability: "sandbox:db:provision",
|
||||
})
|
||||
cl := &sharedauth.Claims{OrgID: "acme", Capabilities: []string{"sandbox:db:list"}}
|
||||
if _, err := r.Call(context.Background(), "test.cap", nil, CallOpts{Claims: cl}); err == nil || !strings.Contains(err.Error(), "forbidden") {
|
||||
t.Errorf("wrong cap: err = %v, want forbidden", err)
|
||||
}
|
||||
cl.Capabilities = append(cl.Capabilities, "sandbox:db:provision")
|
||||
if _, err := r.Call(context.Background(), "test.cap", nil, CallOpts{Claims: cl}); err != nil {
|
||||
t.Errorf("with cap: err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionWhoami_AuthenticatedShape — the whoami handler echoes
|
||||
// every Claim field the bearer carried.
|
||||
func TestSessionWhoami_AuthenticatedShape(t *testing.T) {
|
||||
r := NewRegistry(&Env{OrgID: "acme", JWTSecret: []byte("x")})
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
cl := &sharedauth.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "user-uuid",
|
||||
ID: "jti-1",
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
},
|
||||
Email: "ops@acme",
|
||||
Role: "member",
|
||||
OrgID: "acme",
|
||||
Groups: []string{"/acme/admins"},
|
||||
Capabilities: []string{"sandbox:db:list"},
|
||||
Typ: "pat",
|
||||
}
|
||||
res, err := r.Call(context.Background(), "sandbox.session.whoami", nil, CallOpts{Claims: cl})
|
||||
if err != nil {
|
||||
t.Fatalf("whoami: %v", err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("type %T, want map", res)
|
||||
}
|
||||
if m["authenticated"] != true {
|
||||
t.Errorf("authenticated=%v want true", m["authenticated"])
|
||||
}
|
||||
if m["sub"] != "user-uuid" || m["org_id"] != "acme" || m["typ"] != "pat" {
|
||||
t.Errorf("claim echo wrong: %+v", m)
|
||||
}
|
||||
if m["jti"] != "jti-1" {
|
||||
t.Errorf("jti=%v want jti-1", m["jti"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionInfo_ReturnsEnv — session.info surfaces the env-bound
|
||||
// scope so the agent can correlate its repos + Sovereign FQDN.
|
||||
func TestSessionInfo_ReturnsEnv(t *testing.T) {
|
||||
r := NewRegistry(&Env{
|
||||
OrgID: "acme",
|
||||
SandboxID: "emrah",
|
||||
SandboxNamespace: "sandbox-uid-123",
|
||||
SovereignFQDN: "acme.openova.io",
|
||||
SandboxRepos: []string{"acme/eventforge", "acme/internal-tools"},
|
||||
GiteaBaseURL: "http://gitea.gitea.svc:3000",
|
||||
})
|
||||
res, err := r.Call(context.Background(), "sandbox.session.info", nil, CallOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("info: %v", err)
|
||||
}
|
||||
m := res.(map[string]any)
|
||||
if m["sandbox_id"] != "emrah" || m["org_id"] != "acme" {
|
||||
t.Errorf("info wrong: %+v", m)
|
||||
}
|
||||
if m["kubeconfig"] != "in-cluster" {
|
||||
t.Errorf("kubeconfig=%v want in-cluster", m["kubeconfig"])
|
||||
}
|
||||
repos, ok := m["repos"].([]string)
|
||||
if !ok || len(repos) != 2 {
|
||||
t.Errorf("repos=%v (%T)", m["repos"], m["repos"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBearer covers the three paths: _auth.token, env fallback,
|
||||
// neither.
|
||||
func TestExtractBearer(t *testing.T) {
|
||||
t.Parallel()
|
||||
envWithTok := &Env{SandboxToken: "env-tok"}
|
||||
|
||||
// (1) _auth.token wins.
|
||||
args1 := json.RawMessage(`{"foo":"bar","_auth":{"token":"call-tok"}}`)
|
||||
if got := ExtractBearer(envWithTok, args1); got != "call-tok" {
|
||||
t.Errorf("got %q want call-tok", got)
|
||||
}
|
||||
// (2) env fallback.
|
||||
args2 := json.RawMessage(`{"foo":"bar"}`)
|
||||
if got := ExtractBearer(envWithTok, args2); got != "env-tok" {
|
||||
t.Errorf("got %q want env-tok", got)
|
||||
}
|
||||
// (3) neither.
|
||||
if got := ExtractBearer(&Env{}, args2); got != "" {
|
||||
t.Errorf("got %q want empty", got)
|
||||
}
|
||||
// (4) nil env, raw with token.
|
||||
if got := ExtractBearer(nil, args1); got != "call-tok" {
|
||||
t.Errorf("got %q want call-tok (nil env)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBearer_HappyPath — round-trip a PAT through HS256 and
|
||||
// confirm the parsed Claims carry the org_id.
|
||||
func TestValidateBearer_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := []byte("super-secret")
|
||||
env := &Env{JWTSecret: secret, OrgID: "acme"}
|
||||
now := time.Now()
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &sharedauth.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "user-1",
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
Email: "e@e",
|
||||
OrgID: "acme",
|
||||
Typ: "pat",
|
||||
}).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
cl, err := ValidateBearer(env, signed)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if cl == nil {
|
||||
t.Fatal("claims nil")
|
||||
}
|
||||
if cl.OrgID != "acme" || cl.Email != "e@e" || cl.Typ != "pat" {
|
||||
t.Errorf("claims wrong: %+v", cl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBearer_TestModeBypass — empty JWTSecret means no
|
||||
// validation; any token (incl. empty) is accepted with nil claims.
|
||||
func TestValidateBearer_TestModeBypass(t *testing.T) {
|
||||
t.Parallel()
|
||||
cl, err := ValidateBearer(&Env{}, "")
|
||||
if err != nil || cl != nil {
|
||||
t.Errorf("test mode: got (%v, %v), want (nil, nil)", cl, err)
|
||||
}
|
||||
cl, err = ValidateBearer(&Env{}, "garbage")
|
||||
if err != nil || cl != nil {
|
||||
t.Errorf("test mode garbage: got (%v, %v), want (nil, nil)", cl, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBearer_WrongSig — flipped secret rejects.
|
||||
func TestValidateBearer_WrongSig(t *testing.T) {
|
||||
t.Parallel()
|
||||
signed, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": "x",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}).SignedString([]byte("good"))
|
||||
_, err := ValidateBearer(&Env{JWTSecret: []byte("bad")}, signed)
|
||||
if err == nil {
|
||||
t.Error("want validation error on wrong sig")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBearer_Expired — expired token rejects.
|
||||
func TestValidateBearer_Expired(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := []byte("s")
|
||||
signed, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": "x",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(),
|
||||
}).SignedString(secret)
|
||||
_, err := ValidateBearer(&Env{JWTSecret: secret}, signed)
|
||||
if err == nil {
|
||||
t.Error("want error on expired token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripAuthEnvelope — _auth removed, other fields preserved.
|
||||
func TestStripAuthEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := json.RawMessage(`{"foo":"bar","_auth":{"token":"x"},"n":42}`)
|
||||
out := StripAuthEnvelope(in)
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(out, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, has := m["_auth"]; has {
|
||||
t.Error("_auth should be stripped")
|
||||
}
|
||||
if m["foo"] != "bar" || m["n"].(float64) != 42 {
|
||||
t.Errorf("other fields lost: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEnvFromOS — env vars round-trip into Env, repos CSV split.
|
||||
func TestNewEnvFromOS(t *testing.T) {
|
||||
t.Setenv("SANDBOX_ORG_ID", "acme")
|
||||
t.Setenv("SANDBOX_ID", "emrah")
|
||||
t.Setenv("SANDBOX_NAMESPACE", "sandbox-uid-123")
|
||||
t.Setenv("SANDBOX_SOVEREIGN_FQDN", "acme.openova.io")
|
||||
t.Setenv("SANDBOX_REPOS", "acme/a, acme/b , acme/c")
|
||||
t.Setenv("SANDBOX_TOKEN", "pat-1")
|
||||
t.Setenv("SANDBOX_JWT_SECRET", "shh")
|
||||
t.Setenv("SANDBOX_GITEA_BASE_URL", "http://gitea")
|
||||
t.Setenv("SANDBOX_GITEA_TOKEN", "gtk")
|
||||
|
||||
env := NewEnvFromOS()
|
||||
if env.OrgID != "acme" || env.SandboxID != "emrah" || env.SandboxNamespace != "sandbox-uid-123" {
|
||||
t.Errorf("scalar fields wrong: %+v", env)
|
||||
}
|
||||
if string(env.JWTSecret) != "shh" {
|
||||
t.Errorf("jwt secret=%q", env.JWTSecret)
|
||||
}
|
||||
if len(env.SandboxRepos) != 3 || env.SandboxRepos[0] != "acme/a" || env.SandboxRepos[2] != "acme/c" {
|
||||
t.Errorf("repos parsed wrong: %v", env.SandboxRepos)
|
||||
}
|
||||
}
|
||||
103
products/sandbox/mcp-server/internal/tools/session.go
Normal file
103
products/sandbox/mcp-server/internal/tools/session.go
Normal file
@ -0,0 +1,103 @@
|
||||
// session.go — sandbox.session.* tools.
|
||||
//
|
||||
// These two tools are the agent's self-discovery surface: "who am I"
|
||||
// (which Claims does the MCP server see on my per-call bearer?) and
|
||||
// "what is this Sandbox" (which Org, which namespace, which repos are
|
||||
// attached). They are the first calls a freshly-bootstrapped agent
|
||||
// will make — every other tool depends on the agent having a coherent
|
||||
// picture of its scope.
|
||||
//
|
||||
// Neither tool requires a capability; they return only what the bearer's
|
||||
// own already-validated claims authorise them to see. The org-scope
|
||||
// check is also disabled for these two (registry.go::exemptFromOrgScope).
|
||||
//
|
||||
// Wire shape: plain map[string]any — JSON-RPC clients render the
|
||||
// `content[0].text` blob agents already know how to parse.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sessionWhoami(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
claims, ok := ClaimsFrom(ctx)
|
||||
env := EnvFrom(ctx)
|
||||
if !ok {
|
||||
// Unauthenticated mode (test env / a developer running the
|
||||
// server without a JWT secret) — surface the env we DO know so
|
||||
// the agent can still bootstrap.
|
||||
return map[string]any{
|
||||
"authenticated": false,
|
||||
"org_id": envField(env, "OrgID"),
|
||||
"sandbox_id": envField(env, "SandboxID"),
|
||||
"note": "no bearer claims on request — running in unauthenticated mode",
|
||||
}, nil
|
||||
}
|
||||
out := map[string]any{
|
||||
"authenticated": true,
|
||||
"sub": claims.Subject,
|
||||
"email": claims.Email,
|
||||
"email_verified": claims.EmailVerified,
|
||||
"role": claims.Role,
|
||||
"org_id": claims.OrgID,
|
||||
"groups": claims.Groups,
|
||||
"capabilities": claims.Capabilities,
|
||||
"typ": claims.Typ,
|
||||
"sovereign_fqdn": claims.SovereignFQDN,
|
||||
"deployment_id": claims.DeploymentID,
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
out["expires_at"] = claims.ExpiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if claims.IssuedAt != nil {
|
||||
out["issued_at"] = claims.IssuedAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if claims.ID != "" {
|
||||
out["jti"] = claims.ID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sessionInfo(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
env := EnvFrom(ctx)
|
||||
if env == nil {
|
||||
return nil, errors.New("sandbox.session.info: server env not populated")
|
||||
}
|
||||
return map[string]any{
|
||||
"sandbox_id": env.SandboxID,
|
||||
"sandbox_namespace": env.SandboxNamespace,
|
||||
"org_id": env.OrgID,
|
||||
"sovereign_fqdn": env.SovereignFQDN,
|
||||
"repos": env.SandboxRepos,
|
||||
"gitea_base_url": env.GiteaBaseURL,
|
||||
"kubeconfig": kubeconfigSource(env),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// kubeconfigSource reports where the k8s.read.* client picks up its
|
||||
// REST config — useful for an agent debugging a "why can't I read pods"
|
||||
// failure.
|
||||
func kubeconfigSource(env *Env) string {
|
||||
if env.KubeconfigPath != "" {
|
||||
return "file:" + env.KubeconfigPath
|
||||
}
|
||||
return "in-cluster"
|
||||
}
|
||||
|
||||
// envField is a small helper so whoami's unauthenticated branch can
|
||||
// still surface fields without exploding on a nil Env.
|
||||
func envField(env *Env, field string) string {
|
||||
if env == nil {
|
||||
return ""
|
||||
}
|
||||
switch field {
|
||||
case "OrgID":
|
||||
return env.OrgID
|
||||
case "SandboxID":
|
||||
return env.SandboxID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user