From 5c3f926fe5dc9e3e90000fda33a18d84dbe52db6 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Wed, 11 Nov 2026 15:20:49 +0700 Subject: [PATCH 1/2] feat(mcp): Serve compute-mcp over TLS when given a cert compute-mcp only served plain http, and the assistant now refuses to send a caller's bearer token to an http endpoint, so its tools stopped loading. With --tls-cert-file and --tls-key-file it serves https and reloads the certificate when the files are renewed. With neither it serves http as before, and with only one it refuses to start. --- cmd/compute-mcp/docs.go | 2 +- cmd/compute-mcp/main.go | 38 +++-- cmd/compute-mcp/serve.go | 127 ++++++++++++++ cmd/compute-mcp/serve_test.go | 305 ++++++++++++++++++++++++++++++++++ 4 files changed, 458 insertions(+), 14 deletions(-) create mode 100644 cmd/compute-mcp/serve.go create mode 100644 cmd/compute-mcp/serve_test.go diff --git a/cmd/compute-mcp/docs.go b/cmd/compute-mcp/docs.go index 6cdd6ad2..ed0dd54c 100644 --- a/cmd/compute-mcp/docs.go +++ b/cmd/compute-mcp/docs.go @@ -34,7 +34,7 @@ type document struct { contentType string } -// knowledgeHandler serves compute's knowledge and skills over plain HTTP. +// knowledgeHandler serves compute's knowledge and skills to plain GET requests. // // Routing is an exact-match lookup in a table enumerated at startup, never a // path join against a directory, so "..", an absolute path, or an escaped diff --git a/cmd/compute-mcp/main.go b/cmd/compute-mcp/main.go index 1d1b86d5..f373b06c 100644 --- a/cmd/compute-mcp/main.go +++ b/cmd/compute-mcp/main.go @@ -8,6 +8,11 @@ // GET /runbooks/.md Skills: triage procedures // GET /healthz liveness // +// Served over https when given a certificate (--tls-cert-file, --tls-key-file), +// which is how it is deployed: a caller sends a bearer token to /mcp and the +// assistant will not send one over plain http. Without a certificate it serves +// plain http, for local development. +// // One process, because compute's capability document names all of those URLs. // Only /mcp takes a credential; see docs.go for why the documents do not. // @@ -25,7 +30,6 @@ package main import ( "context" - "errors" "flag" "fmt" "net" @@ -88,10 +92,17 @@ func init() { } func main() { - var addr string + var ( + addr string + files tlsFiles + ) flag.StringVar(&addr, "addr", envOr("COMPUTE_MCP_ADDR", ":8080"), "address to serve MCP on") + flag.StringVar(&files.certFile, "tls-cert-file", os.Getenv("COMPUTE_MCP_TLS_CERT_FILE"), + "serving certificate; set with --tls-key-file to serve https instead of http") + flag.StringVar(&files.keyFile, "tls-key-file", os.Getenv("COMPUTE_MCP_TLS_KEY_FILE"), + "key for --tls-cert-file") opts := zap.Options{Development: true} opts.BindFlags(flag.CommandLine) @@ -99,6 +110,11 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + if err := files.validate(); err != nil { + setupLog.Error(err, "refusing to start") + os.Exit(1) + } + // GetConfig resolves the --kubeconfig flag that controller-runtime // registers, then KUBECONFIG, then in-cluster config, then ~/.kube/config. // Only the endpoint and CA are used; see clientForToken. @@ -113,7 +129,7 @@ func main() { os.Exit(1) } - if err := run(addr, baseConfig); err != nil { + if err := run(ctrl.SetupSignalHandler(), addr, files, baseConfig); err != nil { setupLog.Error(err, "server failed") os.Exit(1) } @@ -161,7 +177,7 @@ func sameEndpoint(a, b string) bool { return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/") } -func run(addr string, baseConfig *rest.Config) error { +func run(ctx context.Context, addr string, files tlsFiles, baseConfig *rest.Config) error { exec, err := newExecRuntime() if err != nil { return err @@ -217,17 +233,13 @@ func run(addr string, baseConfig *rest.Config) error { _, _ = fmt.Fprintln(w, "ok") }) - server := &http.Server{ - Addr: addr, - Handler: mux, - ReadHeaderTimeout: readHeaderTimeout, + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listening on %s: %w", addr, err) } - setupLog.Info("listening", "addr", addr, "mcp", "/mcp", "docs", docs.paths()) - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("serving on %s: %w", addr, err) - } - return nil + setupLog.Info("listening", "scheme", files.scheme(), "addr", addr, "mcp", "/mcp", "docs", docs.paths()) + return serve(ctx, ln, mux, files) } // depsFromRequest returns a DepsFor that resolves the caller's identity and diff --git a/cmd/compute-mcp/serve.go b/cmd/compute-mcp/serve.go new file mode 100644 index 00000000..ea941480 --- /dev/null +++ b/cmd/compute-mcp/serve.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "time" + + "sigs.k8s.io/controller-runtime/pkg/certwatcher" +) + +const ( + schemeHTTP = "http" + schemeHTTPS = "https" + + // shutdownTimeout bounds how long in-flight requests get to finish once the + // process is asked to stop. It sits inside the pod's termination grace + // period, so the kubelet never has to kill a server that is still draining. + shutdownTimeout = 20 * time.Second +) + +// tlsFiles names the serving certificate and its key. Both empty means plain +// http, which is what local development and the e2e environment run. +// +// The assistant refuses to send a caller's bearer token over anything but +// https, so a deployment the assistant calls must set both. +type tlsFiles struct { + certFile string + keyFile string +} + +// validate rejects a half-configured pair. One file without the other is a +// deployment mistake, and serving plain http in answer to it would put bearer +// tokens on the wire in the clear while looking healthy. +func (f tlsFiles) validate() error { + switch { + case f.certFile != "" && f.keyFile == "": + return errors.New("--tls-cert-file is set but --tls-key-file is not: " + + "set both to serve https, or neither to serve http") + case f.certFile == "" && f.keyFile != "": + return errors.New("--tls-key-file is set but --tls-cert-file is not: " + + "set both to serve https, or neither to serve http") + } + return nil +} + +func (f tlsFiles) enabled() bool { + return f.certFile != "" && f.keyFile != "" +} + +// scheme is the URL scheme the server answers on. +func (f tlsFiles) scheme() string { + if f.enabled() { + return schemeHTTPS + } + return schemeHTTP +} + +// serve answers on ln until ctx is cancelled, then drains in-flight requests. +// A clean shutdown returns nil. +// +// With a certificate it serves https and nothing else. The certificate is read +// through a watcher rather than once at startup: the cert-manager CSI driver +// rewrites the files in place on renewal, and a server that loaded them once +// would present an expired certificate until its next restart. +func serve(ctx context.Context, ln net.Listener, handler http.Handler, files tlsFiles) error { + if err := files.validate(); err != nil { + return err + } + + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: readHeaderTimeout, + } + + // Cancelled on every return path, so the watcher never outlives the server. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Buffered for both goroutines: whichever loses the race to report must + // not block forever on a channel nobody reads any more. + failed := make(chan error, 2) + + if files.enabled() { + watcher, err := certwatcher.New(files.certFile, files.keyFile) + if err != nil { + return fmt.Errorf("loading TLS certificate %s and key %s: %w", + files.certFile, files.keyFile, err) + } + server.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: watcher.GetCertificate, + } + go func() { + if err := watcher.Start(ctx); err != nil { + failed <- fmt.Errorf("watching TLS certificate: %w", err) + } + }() + // The certificate comes from GetCertificate, so no files are named here. + go func() { failed <- server.ServeTLS(ln, "", "") }() + } else { + go func() { failed <- server.Serve(ln) }() + } + + select { + case err := <-failed: + _ = server.Close() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serving %s on %s: %w", files.scheme(), ln.Addr(), err) + case <-ctx.Done(): + } + + // The parent context is already cancelled, so the drain needs its own. + shutdownCtx, stop := context.WithTimeout(context.Background(), shutdownTimeout) + defer stop() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutting down: %w", err) + } + return nil +} diff --git a/cmd/compute-mcp/serve_test.go b/cmd/compute-mcp/serve_test.go new file mode 100644 index 00000000..814bf89a --- /dev/null +++ b/cmd/compute-mcp/serve_test.go @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// File names the tests write the throwaway certificate and key under. +const ( + testCertFile = "tls.crt" + testKeyFile = "tls.key" +) + +const ( + // serveTimeout bounds every wait on the server under test. + serveTimeout = 10 * time.Second + + // rotationTimeout covers certwatcher's polling fallback, which re-reads the + // files every ten seconds when a filesystem event is missed. + rotationTimeout = 30 * time.Second +) + +// writeSelfSignedCert writes a throwaway serving certificate for 127.0.0.1 into +// dir and returns the file pair plus the certificate, which is its own CA. +func writeSelfSignedCert(t *testing.T, dir string, serial int64) (tlsFiles, *x509.Certificate) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: "compute-mcp"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("creating certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing certificate: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshalling key: %v", err) + } + + files := tlsFiles{ + certFile: filepath.Join(dir, testCertFile), + keyFile: filepath.Join(dir, testKeyFile), + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(files.certFile, certPEM, 0o600); err != nil { + t.Fatalf("writing certificate: %v", err) + } + if err := os.WriteFile(files.keyFile, keyPEM, 0o600); err != nil { + t.Fatalf("writing key: %v", err) + } + return files, cert +} + +// startServer runs serve on a loopback port and returns its address, the +// channel serve's result arrives on, and the cancel that shuts it down. +func startServer(t *testing.T, files tlsFiles) (string, <-chan error, context.CancelFunc) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listening: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintln(w, "ok") + }) + go func() { done <- serve(ctx, ln, handler, files) }() + + // Runs after the test body, so a test that already drained done is not + // waited on twice: cancel is idempotent and the server is gone by then. + t.Cleanup(cancel) + return ln.Addr().String(), done, cancel +} + +// clientTrusting returns a client that trusts only the given certificates and +// opens a fresh connection per request, so each request sees the certificate +// the server presents now. +func clientTrusting(certs ...*x509.Certificate) *http.Client { + pool := x509.NewCertPool() + for _, c := range certs { + pool.AddCert(c) + } + return &http.Client{ + Timeout: serveTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + DisableKeepAlives: true, + }, + } +} + +func TestTLSFilesValidate(t *testing.T) { + cases := []struct { + name string + files tlsFiles + wantErr string + wantScheme string + }{ + {name: "neither set serves http", files: tlsFiles{}, wantScheme: "http"}, + { + name: "both set serves https", + files: tlsFiles{certFile: testCertFile, keyFile: testKeyFile}, + wantScheme: "https", + }, + { + name: "certificate without key", + files: tlsFiles{certFile: testCertFile}, + wantErr: "--tls-key-file", + }, + { + name: "key without certificate", + files: tlsFiles{keyFile: testKeyFile}, + wantErr: "--tls-cert-file", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.files.validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("validate: %v", err) + } + if got := tc.files.scheme(); got != tc.wantScheme { + t.Errorf("scheme = %q, want %q", got, tc.wantScheme) + } + return + } + if err == nil { + t.Fatal("validate accepted a half-configured certificate") + } + // The error must name the flag that is missing, so the operator + // does not have to guess which half they left out. + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not name the missing %s", err, tc.wantErr) + } + }) + } +} + +// TestServeRejectsHalfConfiguredTLS guards the fallback: a server given only +// one file must not quietly serve plain http. +func TestServeRejectsHalfConfiguredTLS(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listening: %v", err) + } + defer func() { _ = ln.Close() }() + + err = serve(context.Background(), ln, http.NotFoundHandler(), tlsFiles{certFile: testCertFile}) + if err == nil { + t.Fatal("serve started with a certificate and no key") + } +} + +func TestServeWithoutCertificateServesPlainHTTP(t *testing.T) { + addr, _, _ := startServer(t, tlsFiles{}) + + resp, err := (&http.Client{Timeout: serveTimeout}).Get("http://" + addr + "/healthz") + if err != nil { + t.Fatalf("GET over http: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func TestServeWithCertificateServesTLS(t *testing.T) { + files, cert := writeSelfSignedCert(t, t.TempDir(), 1) + addr, _, _ := startServer(t, files) + + resp, err := clientTrusting(cert).Get("https://" + addr + "/healthz") + if err != nil { + t.Fatalf("GET over https: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if resp.TLS == nil || resp.TLS.Version < tls.VersionTLS12 { + t.Errorf("connection state = %+v, want TLS 1.2 or newer", resp.TLS) + } + + // A plain http request must not be answered as if it were fine: the caller + // would be sending a bearer token in the clear. + plain, err := (&http.Client{Timeout: serveTimeout}).Get("http://" + addr + "/healthz") + if err == nil { + defer func() { _ = plain.Body.Close() }() + if plain.StatusCode == http.StatusOK { + t.Error("TLS server answered a plain http request with 200") + } + } +} + +// TestServePicksUpARotatedCertificate is why the server watches the files: the +// cert-manager CSI driver rewrites them in place on renewal, and a server that +// read them once would serve an expired certificate until its next restart. +func TestServePicksUpARotatedCertificate(t *testing.T) { + dir := t.TempDir() + files, first := writeSelfSignedCert(t, dir, 1) + addr, _, _ := startServer(t, files) + + if _, err := servedSerial(addr, first); err != nil { + t.Fatalf("before rotation: %v", err) + } + + _, second := writeSelfSignedCert(t, dir, 2) + + deadline := time.Now().Add(rotationTimeout) + for { + serial, err := servedSerial(addr, first, second) + if err == nil && serial.Cmp(second.SerialNumber) == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("server still presents the old certificate (serial %v, err %v)", serial, err) + } + time.Sleep(100 * time.Millisecond) + } +} + +// servedSerial returns the serial of the certificate the server presents. +func servedSerial(addr string, trusted ...*x509.Certificate) (*big.Int, error) { + resp, err := clientTrusting(trusted...).Get("https://" + addr + "/healthz") + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + return resp.TLS.PeerCertificates[0].SerialNumber, nil +} + +func TestServeFailsOnUnreadableCertificate(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listening: %v", err) + } + defer func() { _ = ln.Close() }() + + missing := filepath.Join(t.TempDir(), "absent") + err = serve(context.Background(), ln, http.NotFoundHandler(), + tlsFiles{certFile: missing + ".crt", keyFile: missing + ".key"}) + if err == nil { + t.Fatal("serve started without a readable certificate") + } +} + +func TestServeStopsWhenContextIsCancelled(t *testing.T) { + for _, tc := range []struct { + name string + tls bool + }{{name: "http"}, {name: "https", tls: true}} { + t.Run(tc.name, func(t *testing.T) { + files := tlsFiles{} + if tc.tls { + files, _ = writeSelfSignedCert(t, t.TempDir(), 1) + } + _, done, cancel := startServer(t, files) + + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("serve returned %v on a clean shutdown, want nil", err) + } + case <-time.After(serveTimeout): + t.Fatal("serve did not return after its context was cancelled") + } + }) + } +} From d47813c5bf6817d4a7be73782203fb2d02f851c8 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Wed, 11 Nov 2026 15:20:49 +0700 Subject: [PATCH 2/2] fix(mcp): Deploy compute-mcp with a cert and https URLs The deployment served plain http and the capability documents advertised http URLs, which the assistant will not send a bearer token to. The pod now mounts a certificate from the datum-control-plane issuer and serves https on the same port, with https probes. Every compute-mcp URL in the capability documents is https. --- .../service-agent-configuration-v2.yaml | 20 +++++------ .../service-agent-configuration.yaml | 18 +++++----- .../service-agent-configuration-v3.yaml | 20 +++++------ config/components/compute-mcp/deployment.yaml | 33 +++++++++++++++++-- config/components/compute-mcp/service.yaml | 8 +++-- 5 files changed, 65 insertions(+), 34 deletions(-) diff --git a/config/components/assistant-capability/service-agent-configuration-v2.yaml b/config/components/assistant-capability/service-agent-configuration-v2.yaml index 6c6e6074..bb1ccbb8 100644 --- a/config/components/assistant-capability/service-agent-configuration-v2.yaml +++ b/config/components/assistant-capability/service-agent-configuration-v2.yaml @@ -30,7 +30,7 @@ spec: sources: - type: LLMDocs title: Compute resource model and condition semantics - url: http://compute-mcp.compute-system.svc.cluster.local:8080/llms-full.txt + url: https://compute-mcp.compute-system.svc.cluster.local:8080/llms-full.txt # Short explanations of compute's main resources, so basic questions can be # answered without fetching anything. concepts: @@ -57,7 +57,7 @@ spec: # Production needs this changed to the AI gateway address. Going direct # there means the call is not billed, does not carry the customer's # identity, and will simply be refused. - endpoint: http://compute-mcp.compute-system.svc.cluster.local:8080/mcp + endpoint: https://compute-mcp.compute-system.svc.cluster.local:8080/mcp toolSelector: # The approved list. Anything not named here is never offered to the # assistant, even if compute-mcp serves it. @@ -82,28 +82,28 @@ spec: skills: - name: workload-not-available description: Triage a compute Workload that is not available, from symptom to root cause and owner - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-not-available.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-not-available.md - name: quota-triage description: Distinguish QuotaExceeded, QuotaNoBudget, PendingEvaluation and quota backend faults, and act on each - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/quota-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/quota-triage.md - name: instance-not-ready description: Diagnose ImageUnavailable, InstanceCrashing, ConfigurationError and stuck Provisioning on compute Instances - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-not-ready.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-not-ready.md - name: referenced-data-triage description: 'Resolve ReferencedDataReady failures: missing, unauthorized, or oversized ConfigMaps and Secrets' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/referenced-data-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/referenced-data-triage.md - name: placement-triage description: Handle NoMatchingLocation, AmbiguousServingLocation and CityCodeMismatch — all platform faults - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/placement-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/placement-triage.md - name: stalled-transient description: Recognize a provider job wedged in transient and act on the stalled actionability state rather than waiting it out - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/stalled-transient.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/stalled-transient.md - name: instance-command-check description: 'Suggest one exact, bounded command a person can run to inspect their Instance, without running it for them' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-command-check.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-command-check.md - name: workload-create description: 'Deploy something new: prerequisites, the choices that are final at create, and render, plan, show, confirm, then apply' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-create.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-create.md authority: # Reads run as the person asking, so this grants no access they do not diff --git a/config/components/assistant-capability/service-agent-configuration.yaml b/config/components/assistant-capability/service-agent-configuration.yaml index 19101ee9..d73f3b8e 100644 --- a/config/components/assistant-capability/service-agent-configuration.yaml +++ b/config/components/assistant-capability/service-agent-configuration.yaml @@ -30,7 +30,7 @@ spec: sources: - type: LLMDocs title: Compute resource model and condition semantics - url: http://compute-mcp.compute-system.svc.cluster.local:8080/llms-full.txt + url: https://compute-mcp.compute-system.svc.cluster.local:8080/llms-full.txt # Short explanations of compute's main resources, so basic questions can be # answered without fetching anything. concepts: @@ -57,7 +57,7 @@ spec: # Production needs this changed to the AI gateway address. Going direct # there means the call is not billed, does not carry the customer's # identity, and will simply be refused. - endpoint: http://compute-mcp.compute-system.svc.cluster.local:8080/mcp + endpoint: https://compute-mcp.compute-system.svc.cluster.local:8080/mcp toolSelector: # The approved list. Anything not named here is never offered to the # assistant, even if compute-mcp serves it. @@ -80,25 +80,25 @@ spec: skills: - name: workload-not-available description: Triage a compute Workload that is not available, from symptom to root cause and owner - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-not-available.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-not-available.md - name: quota-triage description: Distinguish QuotaExceeded, QuotaNoBudget, PendingEvaluation and quota backend faults, and act on each - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/quota-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/quota-triage.md - name: instance-not-ready description: Diagnose ImageUnavailable, InstanceCrashing, ConfigurationError and stuck Provisioning on compute Instances - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-not-ready.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-not-ready.md - name: referenced-data-triage description: 'Resolve ReferencedDataReady failures: missing, unauthorized, or oversized ConfigMaps and Secrets' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/referenced-data-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/referenced-data-triage.md - name: placement-triage description: Handle NoMatchingLocation, AmbiguousServingLocation and CityCodeMismatch — all platform faults - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/placement-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/placement-triage.md - name: stalled-transient description: Recognize a provider job wedged in transient and act on the stalled actionability state rather than waiting it out - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/stalled-transient.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/stalled-transient.md - name: workload-create description: 'Deploy something new: prerequisites, the choices that are final at create, and render, plan, show, confirm, then apply' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-create.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-create.md authority: # Reads run as the person asking, so this grants no access they do not diff --git a/config/components/assistant-exec-capability/service-agent-configuration-v3.yaml b/config/components/assistant-exec-capability/service-agent-configuration-v3.yaml index 7b562781..72292e78 100644 --- a/config/components/assistant-exec-capability/service-agent-configuration-v3.yaml +++ b/config/components/assistant-exec-capability/service-agent-configuration-v3.yaml @@ -32,7 +32,7 @@ spec: sources: - type: LLMDocs title: Compute resource model and condition semantics - url: http://compute-mcp.compute-system.svc.cluster.local:8080/llms-full.txt + url: https://compute-mcp.compute-system.svc.cluster.local:8080/llms-full.txt # Short explanations of compute's main resources, so basic questions can be # answered without fetching anything. concepts: @@ -59,7 +59,7 @@ spec: # Production needs this changed to the AI gateway address. Going direct # there means the call is not billed, does not carry the customer's # identity, and will simply be refused. - endpoint: http://compute-mcp.compute-system.svc.cluster.local:8080/mcp + endpoint: https://compute-mcp.compute-system.svc.cluster.local:8080/mcp toolSelector: # The approved list. Anything not named here is never offered to the # assistant, even if compute-mcp serves it. @@ -88,28 +88,28 @@ spec: skills: - name: workload-not-available description: Triage a compute Workload that is not available, from symptom to root cause and owner - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-not-available.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-not-available.md - name: quota-triage description: Distinguish QuotaExceeded, QuotaNoBudget, PendingEvaluation and quota backend faults, and act on each - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/quota-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/quota-triage.md - name: instance-not-ready description: Diagnose ImageUnavailable, InstanceCrashing, ConfigurationError and stuck Provisioning on compute Instances - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-not-ready.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-not-ready.md - name: referenced-data-triage description: 'Resolve ReferencedDataReady failures: missing, unauthorized, or oversized ConfigMaps and Secrets' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/referenced-data-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/referenced-data-triage.md - name: placement-triage description: Handle NoMatchingLocation, AmbiguousServingLocation and CityCodeMismatch — all platform faults - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/placement-triage.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/placement-triage.md - name: stalled-transient description: Recognize a provider job wedged in transient and act on the stalled actionability state rather than waiting it out - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/stalled-transient.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/stalled-transient.md - name: instance-command-check description: 'Plan one exact Instance command, show its target, arguments, TTL and approval text, then run only after a fresh exact approval message' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-command-check-v3.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/instance-command-check-v3.md - name: workload-create description: 'Deploy something new: prerequisites, the choices that are final at create, and render, plan, show, confirm, then apply' - source: http://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-create.md + source: https://compute-mcp.compute-system.svc.cluster.local:8080/runbooks/workload-create.md authority: # Reads run as the person asking, so this grants no access they do not diff --git a/config/components/compute-mcp/deployment.yaml b/config/components/compute-mcp/deployment.yaml index b69dc639..088fc64f 100644 --- a/config/components/compute-mcp/deployment.yaml +++ b/config/components/compute-mcp/deployment.yaml @@ -53,6 +53,8 @@ spec: - /compute-mcp args: - --addr=:8080 + - --tls-cert-file=/etc/compute-mcp/tls/tls.crt + - --tls-key-file=/etc/compute-mcp/tls/tls.key env: - name: POD_NAMESPACE valueFrom: @@ -66,10 +68,16 @@ spec: name: compute-assistant-exec-key key: key optional: true + # The port keeps its number and its name although it now carries + # https: NetworkPolicies select it by number and the Service by name. ports: - containerPort: 8080 name: http protocol: TCP + volumeMounts: + - name: compute-mcp-certs + mountPath: /etc/compute-mcp/tls + readOnly: true securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -87,18 +95,39 @@ spec: httpGet: path: /healthz port: http - scheme: HTTP + scheme: HTTPS initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: http - scheme: HTTP + scheme: HTTPS initialDelaySeconds: 5 periodSeconds: 10 # No writable volume: the binary is static, the docs it serves are # compiled in, and nothing here writes to disk. + volumes: + # The serving certificate. The assistant sends the caller's bearer token + # to /mcp and refuses to do so over plain http, and it trusts this + # issuer. The driver renews the files in place and the server reloads + # them without a restart. + # + # dns-names must cover the host in the capability documents' URLs + # (config/components/assistant-capability), so they follow the Service + # name and the namespace this component is deployed to. + - name: compute-mcp-certs + csi: + driver: csi.cert-manager.io + readOnly: true + volumeAttributes: + csi.cert-manager.io/issuer-name: datum-control-plane + csi.cert-manager.io/issuer-kind: ClusterIssuer + csi.cert-manager.io/common-name: compute-mcp + csi.cert-manager.io/dns-names: compute-mcp,compute-mcp.compute-system.svc,compute-mcp.compute-system.svc.cluster.local + csi.cert-manager.io/key-usages: digital signature,key encipherment,server auth + # The container's group, so the non-root process can read the key. + csi.cert-manager.io/fs-group: "65532" dnsPolicy: ClusterFirst restartPolicy: Always terminationGracePeriodSeconds: 30 diff --git a/config/components/compute-mcp/service.yaml b/config/components/compute-mcp/service.yaml index 1be8ffb6..52eb372b 100644 --- a/config/components/compute-mcp/service.yaml +++ b/config/components/compute-mcp/service.yaml @@ -1,7 +1,9 @@ # Cluster-internal only: the assistant reaches /mcp over this Service's DNS -# name, and no Gateway or HTTPRoute fronts it. Unlike the UI plugins there is no -# https:// requirement forcing a cert-manager CSI volume here, so the pod serves -# plain HTTP and the mesh/network layer owns transport security. +# name, and no Gateway or HTTPRoute fronts it. The pod serves https on this +# port with a certificate for this Service's names, because the assistant sends +# the caller's bearer token here and will not send one over plain http. The +# port is still named `http`; renaming it would break whatever selects it by +# name. apiVersion: v1 kind: Service metadata: