From ad05054c4dbe8b26a546ebc29dc04e8712e97724 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Fri, 1 May 2026 17:03:01 +0530 Subject: [PATCH 1/4] fix(cli): move top-level command routing into folders Keep CLI behavior unchanged while moving top-level Cobra routing into the foldered internal/cli command packages where the shared boundary is clean. Also document that agents must work from branches and PRs instead of pushing directly to main. --- AGENTS.md | 1 + docs/internals/go-package-reference.md | 103 +++++++++++++++ internal/cli/access.go | 5 + internal/cli/access/access.go | 123 +++++++++++++++++- internal/cli/bootstrap.go | 12 +- internal/cli/bootstrap/bootstrap.go | 59 ++++++++- internal/cli/build.go | 6 +- internal/cli/client.go | 5 + internal/cli/pipeline/pipeline.go | 46 ++++++- internal/cli/sentinel.go | 6 +- internal/cli/sentinel/sentinel.go | 81 +++++++++++- internal/cli/server.go | 10 ++ internal/cli/server/server.go | 167 ++++++++++++++++++++++++- internal/cli/setup.go | 28 ++++- internal/cli/setup/setup.go | 112 ++++++++++++++++- internal/cli/status.go | 6 +- internal/cli/status/status.go | 9 +- 17 files changed, 757 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44f1c9a..2889169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ Do not hand-wave command behavior from memory when the docs are meant to reflect - **Scope:** Change only what the task needs; do not “clean up” unrelated files. Match naming and patterns in the nearest similar code. - **Tests:** Add or adjust tests in the same package when behavior changes. For CLI output, expect golden file updates. - **Branch names:** Use `component/feature_name` for task branches. Pick the component from the same scope list used for commit messages, and write the feature name in lowercase snake_case, for example `doc/commit_message_guidance`, `cli/registry_status`, or `operator/ingress_defaults`. +- **Agent branch / PR flow:** Agents must create and push changes on a new branch, and open or update a PR from that branch. Agents must not push directly to `main`. - **Commit messages:** Use `fix(): ...` for bug fixes and `feat(): ...` for user-facing behavior. Use `doc: ...` for README / AGENTS / docs-only edits, and `website: ...` for `website/` changes. Prefer components that match repo areas, such as `cli`, `operator`, `api`, `crd`, `access`, `policy`, `k8sclient`, `manifest`, `metadata`, `sentinel`, `registry`, `ingress`, `services-api`, `ui`, `ingest`, `processor`, `mcp-proxy`, `traefik-plugin`, `config`, `examples`, `test`, or `ci`. Keep the subject concise and imperative; add a body only when the reason, risk, or verification needs context. - **Docs you were not asked to edit:** Avoid adding new top-level docs unless the task needs them; this file, `README`, and existing doc trees are the defaults for agents. - **Secrets and prod:** This repo is **alpha**; do not hardcode real credentials. Use the existing secret and env patterns documented below. diff --git a/docs/internals/go-package-reference.md b/docs/internals/go-package-reference.md index 8653cb7..931f288 100644 --- a/docs/internals/go-package-reference.md +++ b/docs/internals/go-package-reference.md @@ -2069,8 +2069,11 @@ _No package overview is documented._ - [`Constants`](#cli-internals-constants) - [`Variables`](#cli-internals-variables) +- [`func BootstrapApplyK3s(kubectl KubectlRunner) error`](#cli-internals-func-bootstrapapplyk3s-kubectl-kubectlrunner-error) +- [`func BuildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string`](#cli-internals-func-buildoperatorargs-metricsaddr-probeaddr-string-leaderelect-leaderelectchanged-bool-string) - [`func ClusterIssuerNameForACME(staging bool) string`](#cli-internals-func-clusterissuernameforacme-staging-bool-string) - [`func Cyan(msg string) string`](#cli-internals-func-cyan-msg-string-string) +- [`func DetectProvider(kubectl KubectlRunner) (string, error)`](#cli-internals-func-detectprovider-kubectl-kubectlrunner-string-error) - [`func Error(msg string)`](#cli-internals-func-error-msg-string) - [`func GetAnalyticsIngestURLOverride() string`](#cli-internals-func-getanalyticsingesturloverride-string) - [`func GetCertTimeout() time.Duration`](#cli-internals-func-getcerttimeout-time-duration) @@ -2095,6 +2098,7 @@ _No package overview is documented._ - [`func NewAccessCmdWithManager(mgr *AccessManager) *cobra.Command`](#cli-internals-func-newaccesscmdwithmanager-mgr-accessmanager-cobra-command) - [`func NewAuthCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newauthcmd-logger-zap-logger-cobra-command) - [`func NewBootstrapCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbootstrapcmd-logger-zap-logger-cobra-command) +- [`func NewBuildImageCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbuildimagecmd-logger-zap-logger-cobra-command) - [`func NewClusterCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newclustercmd-logger-zap-logger-cobra-command) - [`func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclustercmdwithmanager-mgr-clustermanager-cobra-command) - [`func NewPipelineCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newpipelinecmd-logger-zap-logger-cobra-command) @@ -2109,19 +2113,26 @@ _No package overview is documented._ - [`func NewStatusCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newstatuscmd-logger-zap-logger-cobra-command) - [`func PrintDoctorReport(r DoctorReport)`](#cli-internals-func-printdoctorreport-r-doctorreport) - [`func Red(msg string) string`](#cli-internals-func-red-msg-string-string) +- [`func RunBootstrapPreflight(kubectl KubectlRunner) error`](#cli-internals-func-runbootstrappreflight-kubectl-kubectlrunner-error) - [`func Section(title string)`](#cli-internals-func-section-title-string) +- [`func SentinelComponentKeys() []string`](#cli-internals-func-sentinelcomponentkeys-string) - [`func SetDebugMode(enabled bool)`](#cli-internals-func-setdebugmode-enabled-bool) +- [`func SetupPlatform(logger *zap.Logger, plan SetupPlan) error`](#cli-internals-func-setupplatform-logger-zap-logger-plan-setupplan-error) +- [`func ShowPlatformStatus(logger *zap.Logger) error`](#cli-internals-func-showplatformstatus-logger-zap-logger-error) - [`func SpinnerStart(msg string) func(success bool, finalMsg string)`](#cli-internals-func-spinnerstart-msg-string-func-success-bool-finalmsg-string) - [`func Step(title string)`](#cli-internals-func-step-title-string) - [`func Success(msg string)`](#cli-internals-func-success-msg-string) - [`func Table(data [][]string)`](#cli-internals-func-table-data-string) - [`func TableBoxed(data [][]string)`](#cli-internals-func-tableboxed-data-string) +- [`func ValidateStorageMode(mode string) error`](#cli-internals-func-validatestoragemode-mode-string-error) +- [`func ValidateTLSSetupCLIFlags(`](#cli-internals-func-validatetlssetupcliflags) - [`func Warn(msg string)`](#cli-internals-func-warn-msg-string) - [`func Yellow(msg string) string`](#cli-internals-func-yellow-msg-string-string) - [`type AccessManager struct`](#cli-internals-type-accessmanager-struct) - [`func DefaultAccessManager(logger *zap.Logger) *AccessManager`](#cli-internals-func-defaultaccessmanager-logger-zap-logger-accessmanager) - [`func NewAccessManager(kubectl *KubectlClient, logger *zap.Logger) *AccessManager`](#cli-internals-func-newaccessmanager-kubectl-kubectlclient-logger-zap-logger-accessmanager) - [`func (m *AccessManager) ApplyAccessResource(file string) error`](#cli-internals-func-m-accessmanager-applyaccessresource-file-string-error) +- [`func (m *AccessManager) BindUseKubeFlag(cmd *cobra.Command)`](#cli-internals-func-m-accessmanager-bindusekubeflag-cmd-cobra-command) - [`func (m *AccessManager) DeleteAccessResource(resource, name, namespace string) error`](#cli-internals-func-m-accessmanager-deleteaccessresource-resource-name-namespace-string-error) - [`func (m *AccessManager) GetAccessResource(resource, name, namespace string) error`](#cli-internals-func-m-accessmanager-getaccessresource-resource-name-namespace-string-error) - [`func (m *AccessManager) ListAccessResources(resource, namespace string, allNamespaces bool) error`](#cli-internals-func-m-accessmanager-listaccessresources-resource-namespace-string-allnamespaces-bool-error) @@ -2172,6 +2183,7 @@ _No package overview is documented._ - [`func (c *KubectlClient) Run(args []string) error`](#cli-internals-func-c-kubectlclient-run-args-string-error) - [`func (c *KubectlClient) RunWithOutput(args []string, stdout, stderr io.Writer) error`](#cli-internals-func-c-kubectlclient-runwithoutput-args-string-stdout-stderr-io-writer-error) - [`type KubectlRunner interface`](#cli-internals-type-kubectlrunner-interface) +- [`func DefaultKubectlRunner() KubectlRunner`](#cli-internals-func-defaultkubectlrunner-kubectlrunner) - [`type MockCommand struct`](#cli-internals-type-mockcommand-struct) - [`func (m *MockCommand) CombinedOutput() ([]byte, error)`](#cli-internals-func-m-mockcommand-combinedoutput-byte-error) - [`func (m *MockCommand) Output() ([]byte, error)`](#cli-internals-func-m-mockcommand-output-byte-error) @@ -2227,6 +2239,7 @@ _No package overview is documented._ - [`func DefaultServerManager(logger *zap.Logger) *ServerManager`](#cli-internals-func-defaultservermanager-logger-zap-logger-servermanager) - [`func NewServerManager(kubectl *KubectlClient, logger *zap.Logger) *ServerManager`](#cli-internals-func-newservermanager-kubectl-kubectlclient-logger-zap-logger-servermanager) - [`func (m *ServerManager) ApplyServerFromFile(file string) error`](#cli-internals-func-m-servermanager-applyserverfromfile-file-string-error) +- [`func (m *ServerManager) BindUseKubeFlag(cmd *cobra.Command)`](#cli-internals-func-m-servermanager-bindusekubeflag-cmd-cobra-command) - [`func (m *ServerManager) CreateServer(name, namespace, image, imageTag string) error`](#cli-internals-func-m-servermanager-createserver-name-namespace-image-imagetag-string-error) - [`func (m *ServerManager) CreateServerFromFile(file string) error`](#cli-internals-func-m-servermanager-createserverfromfile-file-string-error) - [`func (m *ServerManager) DeleteServer(name, namespace string) error`](#cli-internals-func-m-servermanager-deleteserver-name-namespace-string-error) @@ -2234,6 +2247,7 @@ _No package overview is documented._ - [`func (m *ServerManager) GetServer(name, namespace string) error`](#cli-internals-func-m-servermanager-getserver-name-namespace-string-error) - [`func (m *ServerManager) InspectServerPolicy(name, namespace string) error`](#cli-internals-func-m-servermanager-inspectserverpolicy-name-namespace-string-error) - [`func (m *ServerManager) ListServers(namespace string) error`](#cli-internals-func-m-servermanager-listservers-namespace-string-error) +- [`func (m *ServerManager) Logger() *zap.Logger`](#cli-internals-func-m-servermanager-logger-zap-logger) - [`func (m *ServerManager) PatchServer(name, namespace, patchType, patch, patchFile string) error`](#cli-internals-func-m-servermanager-patchserver-name-namespace-patchtype-patch-patchfile-string-error) - [`func (m *ServerManager) ServerStatus(namespace string) error`](#cli-internals-func-m-servermanager-serverstatus-namespace-string-error) - [`func (m *ServerManager) ViewServerLogs(name, namespace string, follow bool) error`](#cli-internals-func-m-servermanager-viewserverlogs-name-namespace-string-follow-bool-error) @@ -2481,6 +2495,19 @@ var DefaultPrinter = &Printer{} ### Functions + +```text +func BootstrapApplyK3s(kubectl KubectlRunner) error +``` + + +```text +func BuildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string + buildOperatorArgs constructs operator command-line arguments from flags. + Only includes flags that were explicitly set. + +``` + ```text func ClusterIssuerNameForACME(staging bool) string @@ -2496,6 +2523,11 @@ func Cyan(msg string) string ``` + +```text +func DetectProvider(kubectl KubectlRunner) (string, error) +``` + ```text func Error(msg string) @@ -2675,6 +2707,11 @@ func NewBootstrapCmd(logger *zap.Logger) *cobra.Command ``` + +```text +func NewBuildImageCmd(logger *zap.Logger) *cobra.Command +``` + ```text func NewClusterCmd(logger *zap.Logger) *cobra.Command @@ -2775,6 +2812,11 @@ func Red(msg string) string ``` + +```text +func RunBootstrapPreflight(kubectl KubectlRunner) error +``` + ```text func Section(title string) @@ -2782,6 +2824,11 @@ func Section(title string) ``` + +```text +func SentinelComponentKeys() []string +``` + ```text func SetDebugMode(enabled bool) @@ -2790,6 +2837,16 @@ func SetDebugMode(enabled bool) ``` + +```text +func SetupPlatform(logger *zap.Logger, plan SetupPlan) error +``` + + +```text +func ShowPlatformStatus(logger *zap.Logger) error +``` + ```text func SpinnerStart(msg string) func(success bool, finalMsg string) @@ -2825,6 +2882,24 @@ func TableBoxed(data [][]string) ``` + +```text +func ValidateStorageMode(mode string) error +``` + + +```text +func ValidateTLSSetupCLIFlags( + tlsEnabled bool, + acmeEmailResolved, tlsCIResolved string, + acmeStagingResolved, skipCertManagerInstall bool, +) error + validateTLSSetupCLIFlags enforces ACME / internal-issuer mutual exclusion + and requires --with-tls when any TLS or cert-manager-related options are + set. + +``` + ```text func Warn(msg string) @@ -2867,6 +2942,13 @@ func (m *AccessManager) ApplyAccessResource(file string) error ``` + +```text +func (m *AccessManager) BindUseKubeFlag(cmd *cobra.Command) + BindUseKubeFlag wires the shared --use-kube flag onto the command. + +``` + ```text func (m *AccessManager) DeleteAccessResource(resource, name, namespace string) error @@ -3312,6 +3394,13 @@ type KubectlRunner interface { ``` + +```text +func DefaultKubectlRunner() KubectlRunner + DefaultKubectlRunner returns the shared kubectl runner used by CLI commands. + +``` + ```text type MockCommand struct { @@ -3724,6 +3813,13 @@ func (m *ServerManager) ApplyServerFromFile(file string) error ``` + +```text +func (m *ServerManager) BindUseKubeFlag(cmd *cobra.Command) + BindUseKubeFlag wires the shared --use-kube flag onto the command. + +``` + ```text func (m *ServerManager) CreateServer(name, namespace, image, imageTag string) error @@ -3774,6 +3870,13 @@ func (m *ServerManager) ListServers(namespace string) error ``` + +```text +func (m *ServerManager) Logger() *zap.Logger + Logger exposes the manager logger to foldered command packages. + +``` + ```text func (m *ServerManager) PatchServer(name, namespace, patchType, patch, patchFile string) error diff --git a/internal/cli/access.go b/internal/cli/access.go index 0609b01..06ef18a 100644 --- a/internal/cli/access.go +++ b/internal/cli/access.go @@ -54,6 +54,11 @@ to target the cluster with kubectl and a kubeconfig (cluster admin path).`, return cmd } +// BindUseKubeFlag wires the shared --use-kube flag onto the command. +func (m *AccessManager) BindUseKubeFlag(cmd *cobra.Command) { + cmd.PersistentFlags().BoolVar(&m.useKube, "use-kube", false, "Use kubectl and local kubeconfig instead of the platform API for supported commands") +} + func (m *AccessManager) newAccessGrantCmd() *cobra.Command { cmd := &cobra.Command{ Use: "grant", diff --git a/internal/cli/access/access.go b/internal/cli/access/access.go index c30db26..ae6c1fd 100644 --- a/internal/cli/access/access.go +++ b/internal/cli/access/access.go @@ -8,7 +8,128 @@ import ( "mcp-runtime/internal/cli" ) +const ( + grantResource = "mcpaccessgrant" + sessionResource = "mcpagentsession" +) + // New returns the access command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewAccessCmd(logger) + return NewWithManager(cli.DefaultAccessManager(logger)) +} + +// NewWithManager returns the access command using the provided manager. +func NewWithManager(mgr *cli.AccessManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "access", + Short: "Manage grants and agent sessions", + Long: `Commands for managing MCPAccessGrant and MCPAgentSession resources that feed the gateway policy layer. + +With mcp-runtime auth login, commands use the platform API by default. Use --use-kube +to target the cluster with kubectl and a kubeconfig (cluster admin path).`, + } + + mgr.BindUseKubeFlag(cmd) + + cmd.AddCommand(newGrantCmd(mgr), newSessionCmd(mgr)) + return cmd +} + +func newGrantCmd(mgr *cli.AccessManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "grant", + Short: "Manage MCPAccessGrant resources", + } + cmd.AddCommand(newListCmd(mgr, grantResource, "grants")) + cmd.AddCommand(newGetCmd(mgr, grantResource, "grant")) + cmd.AddCommand(newApplyCmd(mgr, "grant")) + cmd.AddCommand(newDeleteCmd(mgr, grantResource, "grant")) + cmd.AddCommand(newToggleCmd(mgr, grantResource, "disable", "Disable a grant", true)) + cmd.AddCommand(newToggleCmd(mgr, grantResource, "enable", "Enable a grant", false)) + return cmd +} + +func newSessionCmd(mgr *cli.AccessManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "session", + Short: "Manage MCPAgentSession resources", + } + cmd.AddCommand(newListCmd(mgr, sessionResource, "sessions")) + cmd.AddCommand(newGetCmd(mgr, sessionResource, "session")) + cmd.AddCommand(newApplyCmd(mgr, "session")) + cmd.AddCommand(newDeleteCmd(mgr, sessionResource, "session")) + cmd.AddCommand(newToggleCmd(mgr, sessionResource, "revoke", "Revoke an agent session", true)) + cmd.AddCommand(newToggleCmd(mgr, sessionResource, "unrevoke", "Clear the revoked flag on an agent session", false)) + return cmd +} + +func newListCmd(mgr *cli.AccessManager, resource, label string) *cobra.Command { + var namespace string + var allNamespaces bool + cmd := &cobra.Command{ + Use: "list", + Short: "List access " + label, + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ListAccessResources(resource, namespace, allNamespaces) + }, + } + cmd.Flags().StringVar(&namespace, "namespace", "", "Namespace to inspect") + cmd.Flags().BoolVar(&allNamespaces, "all-namespaces", true, "List resources across all namespaces when no namespace is specified") + return cmd +} + +func newGetCmd(mgr *cli.AccessManager, resource, label string) *cobra.Command { + var namespace string + cmd := &cobra.Command{ + Use: "get [name]", + Short: "Get an access " + label, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.GetAccessResource(resource, args[0], namespace) + }, + } + cmd.Flags().StringVar(&namespace, "namespace", cli.NamespaceMCPServers, "Namespace") + return cmd +} + +func newApplyCmd(mgr *cli.AccessManager, label string) *cobra.Command { + var file string + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a " + label + " manifest", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ApplyAccessResource(file) + }, + } + cmd.Flags().StringVar(&file, "file", "", "Manifest file to apply") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +func newDeleteCmd(mgr *cli.AccessManager, resource, label string) *cobra.Command { + var namespace string + cmd := &cobra.Command{ + Use: "delete [name]", + Short: "Delete an access " + label, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.DeleteAccessResource(resource, args[0], namespace) + }, + } + cmd.Flags().StringVar(&namespace, "namespace", cli.NamespaceMCPServers, "Namespace") + return cmd +} + +func newToggleCmd(mgr *cli.AccessManager, resource, use, short string, value bool) *cobra.Command { + var namespace string + cmd := &cobra.Command{ + Use: use + " [name]", + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ToggleAccessResource(resource, args[0], namespace, value) + }, + } + cmd.Flags().StringVar(&namespace, "namespace", cli.NamespaceMCPServers, "Namespace") + return cmd } diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 9d230c7..00c7716 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -31,7 +31,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 chosenProvider := provider if chosenProvider == "" || chosenProvider == "auto" { - detectedProvider, err := detectProvider(kubectlClient) + detectedProvider, err := DetectProvider(kubectlClient) if err != nil { return err } @@ -39,7 +39,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 } Info(fmt.Sprintf("Provider: %s", chosenProvider)) - if err := runBootstrapPreflight(kubectlClient); err != nil { + if err := RunBootstrapPreflight(kubectlClient); err != nil { return err } @@ -51,7 +51,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 switch chosenProvider { case "k3s": - if err := bootstrapApplyK3s(kubectlClient); err != nil { + if err := BootstrapApplyK3s(kubectlClient); err != nil { return err } case "rke2", "kubeadm", "generic": @@ -71,7 +71,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 return cmd } -func detectProvider(kubectl KubectlRunner) (string, error) { +func DetectProvider(kubectl KubectlRunner) (string, error) { out, err := kubectlOutput(kubectl, []string{"get", "nodes", "-o", "jsonpath={range .items[*]}{.status.nodeInfo.kubeletVersion}{\"\\n\"}{end}"}) if err != nil { return "", wrapWithSentinel(ErrClusterNotAccessible, err, fmt.Sprintf("kubectl get nodes failed: %v", err)) @@ -87,7 +87,7 @@ func detectProvider(kubectl KubectlRunner) (string, error) { } } -func runBootstrapPreflight(kubectl KubectlRunner) error { +func RunBootstrapPreflight(kubectl KubectlRunner) error { Info("Preflight: kubectl connectivity") if err := kubectl.Run([]string{"version", "--client=true"}); err != nil { return wrapWithSentinel(ErrClusterNotAccessible, err, fmt.Sprintf("kubectl not available: %v", err)) @@ -137,7 +137,7 @@ func checkHasDefaultStorageClass(kubectl KubectlRunner) error { return fmt.Errorf("no StorageClass annotated with storageclass.kubernetes.io/is-default-class=true") } -func bootstrapApplyK3s(kubectl KubectlRunner) error { +func BootstrapApplyK3s(kubectl KubectlRunner) error { Info("Applying k3s addons: CoreDNS + local-path provisioner (if missing)") // Apply only when the manifests exist on disk (k3s server). diff --git a/internal/cli/bootstrap/bootstrap.go b/internal/cli/bootstrap/bootstrap.go index 108b283..2319ce4 100644 --- a/internal/cli/bootstrap/bootstrap.go +++ b/internal/cli/bootstrap/bootstrap.go @@ -2,6 +2,8 @@ package bootstrap import ( + "fmt" + "github.com/spf13/cobra" "go.uber.org/zap" @@ -10,5 +12,60 @@ import ( // New returns the bootstrap command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewBootstrapCmd(logger) + var apply bool + var provider string + + cmd := &cobra.Command{ + Use: "bootstrap", + Short: "Bootstrap cluster prerequisites (on-prem focused)", + Long: `Bootstrap validates and (optionally) installs cluster prerequisites needed by mcp-runtime setup. + +By design, this does not provision Kubernetes clusters end-to-end across all distributions. +Use this to prepare an existing cluster for running 'mcp-runtime setup'. + +Note: bootstrap --apply is automated for k3s only and must be executed on the k3s server node (it expects local manifests under /var/lib/rancher/k3s/server/manifests).`, + RunE: func(cmd *cobra.Command, args []string) error { + cli.Section("MCP Runtime Bootstrap") + + kubectl := cli.DefaultKubectlRunner() + chosenProvider := provider + if chosenProvider == "" || chosenProvider == "auto" { + detectedProvider, err := cli.DetectProvider(kubectl) + if err != nil { + return err + } + chosenProvider = detectedProvider + } + cli.Info(fmt.Sprintf("Provider: %s", chosenProvider)) + + if err := cli.RunBootstrapPreflight(kubectl); err != nil { + return err + } + + if !apply { + cli.Success("Bootstrap preflight complete (no changes applied)") + cli.Info("Next: run `./bin/mcp-runtime setup` (or `./bin/mcp-runtime setup --storage-mode hostpath` for single-node dev)") + return nil + } + + switch chosenProvider { + case "k3s": + if err := cli.BootstrapApplyK3s(kubectl); err != nil { + return err + } + case "rke2", "kubeadm", "generic": + cli.Warn("Apply mode is currently only automated for k3s. For other distributions, use the preflight output and install DNS/storage/ingress/load-balancer via your standard platform tooling.") + default: + cli.Warn(fmt.Sprintf("Unknown provider %q; skipping apply", chosenProvider)) + } + + cli.Success("Bootstrap complete") + cli.Info("Next: run `./bin/mcp-runtime setup`") + return nil + }, + } + + cmd.Flags().BoolVar(&apply, "apply", false, "Apply safe bootstrap fixes when possible (k3s only today; run on the k3s server node)") + cmd.Flags().StringVar(&provider, "provider", "auto", "Cluster provider hint (auto|k3s|rke2|kubeadm|generic)") + return cmd } diff --git a/internal/cli/build.go b/internal/cli/build.go index 9f8a064..6f54e3e 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -24,7 +24,7 @@ import ( // yamlMarshal is a test seam for yaml.Marshal. var yamlMarshal = yaml.Marshal -func newBuildImageCmd(logger *zap.Logger) *cobra.Command { +func NewBuildImageCmd(logger *zap.Logger) *cobra.Command { var dockerfile string var metadataFile string var metadataDir string @@ -52,6 +52,10 @@ func newBuildImageCmd(logger *zap.Logger) *cobra.Command { return cmd } +func newBuildImageCmd(logger *zap.Logger) *cobra.Command { + return NewBuildImageCmd(logger) +} + func buildImage(logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, context string) error { // Get registry URL if registryURL == "" { diff --git a/internal/cli/client.go b/internal/cli/client.go index 2b2f74b..a6c9cdf 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -76,6 +76,11 @@ func (c *KubectlClient) RunWithOutput(args []string, stdout, stderr io.Writer) e var kubectlClient = mustNewKubectlClient() +// DefaultKubectlRunner returns the shared kubectl runner used by CLI commands. +func DefaultKubectlRunner() KubectlRunner { + return kubectlClient +} + func mustNewKubectlClient() *KubectlClient { client, err := NewKubectlClient(execExecutor) if err != nil { diff --git a/internal/cli/pipeline/pipeline.go b/internal/cli/pipeline/pipeline.go index 22824f7..b7dd8c9 100644 --- a/internal/cli/pipeline/pipeline.go +++ b/internal/cli/pipeline/pipeline.go @@ -10,5 +10,49 @@ import ( // New returns the pipeline command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewPipelineCmd(logger) + return NewWithManager(cli.DefaultPipelineManager(logger)) +} + +// NewWithManager returns the pipeline command using the provided manager. +func NewWithManager(mgr *cli.PipelineManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "pipeline", + Short: "Pipeline integration commands", + Long: "Commands for CI/CD pipeline integration to generate and deploy CRDs", + } + + var metadataFile string + var metadataDir string + var outputDir string + generateCmd := &cobra.Command{ + Use: "generate", + Short: "Generate CRD files from metadata", + Long: `Generate Kubernetes CRD files from metadata/registry files. +This command reads server definitions and creates CRD YAML files that +the operator will use to deploy MCP servers.`, + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.GenerateCRDsFromMetadata(metadataFile, metadataDir, outputDir) + }, + } + generateCmd.Flags().StringVar(&metadataFile, "file", "", "Path to metadata file (YAML)") + generateCmd.Flags().StringVar(&metadataDir, "dir", ".mcp", "Directory containing metadata files") + generateCmd.Flags().StringVar(&outputDir, "output", "manifests", "Output directory for CRD files") + + var manifestsDir string + var namespace string + deployCmd := &cobra.Command{ + Use: "deploy", + Short: "Deploy CRD files to cluster", + Long: `Deploy generated CRD files to the Kubernetes cluster. +This applies all CRD manifests to the cluster, which triggers +the operator to create the necessary Kubernetes resources.`, + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.DeployCRDs(manifestsDir, namespace) + }, + } + deployCmd.Flags().StringVar(&manifestsDir, "dir", "manifests", "Directory containing CRD files") + deployCmd.Flags().StringVar(&namespace, "namespace", "", "Namespace to deploy to (overrides metadata)") + + cmd.AddCommand(generateCmd, deployCmd) + return cmd } diff --git a/internal/cli/sentinel.go b/internal/cli/sentinel.go index 5774e63..f5256b0 100644 --- a/internal/cli/sentinel.go +++ b/internal/cli/sentinel.go @@ -133,7 +133,7 @@ func NewSentinelCmdWithManager(mgr *SentinelManager) *cobra.Command { return cmd } -func sentinelComponentKeys() []string { +func SentinelComponentKeys() []string { keys := make([]string, 0, len(sentinelComponents)) for _, component := range sentinelComponents { keys = append(keys, component.Key) @@ -156,7 +156,7 @@ func findSentinelComponent(name string) (*sentinelComponent, error) { } } - return nil, newWithSentinel(nil, fmt.Sprintf("unknown sentinel component %q (use one of: %s)", name, strings.Join(sentinelComponentKeys(), ", "))) + return nil, newWithSentinel(nil, fmt.Sprintf("unknown sentinel component %q (use one of: %s)", name, strings.Join(SentinelComponentKeys(), ", "))) } func findSentinelPortTarget(name string) (*sentinelPortTarget, error) { @@ -190,7 +190,7 @@ func (m *SentinelManager) newSentinelLogsCmd() *cobra.Command { Use: "logs [component]", Short: "View logs for a mcp-sentinel component", Args: cobra.ExactArgs(1), - ValidArgs: sentinelComponentKeys(), + ValidArgs: SentinelComponentKeys(), RunE: func(cmd *cobra.Command, args []string) error { return m.ViewSentinelLogs(args[0], follow, previous, tail, since) }, diff --git a/internal/cli/sentinel/sentinel.go b/internal/cli/sentinel/sentinel.go index ac4a946..152077a 100644 --- a/internal/cli/sentinel/sentinel.go +++ b/internal/cli/sentinel/sentinel.go @@ -10,5 +10,84 @@ import ( // New returns the sentinel command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewSentinelCmd(logger) + return NewWithManager(cli.DefaultSentinelManager(logger)) +} + +// NewWithManager returns the sentinel command using the provided manager. +func NewWithManager(mgr *cli.SentinelManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "sentinel", + Short: "Operate the bundled mcp-sentinel stack", + Long: "Commands for inspecting and operating the bundled mcp-sentinel analytics, gateway, and observability stack.", + } + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show mcp-sentinel stack status", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ShowSentinelStatus() + }, + } + + var follow bool + var previous bool + var tail int + var since string + logsCmd := &cobra.Command{ + Use: "logs [component]", + Short: "View logs for a mcp-sentinel component", + Args: cobra.ExactArgs(1), + ValidArgs: cli.SentinelComponentKeys(), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ViewSentinelLogs(args[0], follow, previous, tail, since) + }, + } + logsCmd.Flags().BoolVar(&follow, "follow", false, "Follow log output") + logsCmd.Flags().BoolVar(&previous, "previous", false, "Show logs from the previous container instance") + logsCmd.Flags().IntVar(&tail, "tail", 200, "Number of recent log lines to show (-1 for all)") + logsCmd.Flags().StringVar(&since, "since", "", "Only return logs newer than a relative duration like 5m or 1h") + + eventsCmd := &cobra.Command{ + Use: "events", + Short: "Show recent Kubernetes events for mcp-sentinel", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ShowSentinelEvents() + }, + } + + var localPort int + var address string + portForwardCmd := &cobra.Command{ + Use: "port-forward [target]", + Short: "Port-forward a common mcp-sentinel service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.PortForwardSentinelTarget(args[0], localPort, address) + }, + } + portForwardCmd.Flags().IntVar(&localPort, "port", 0, "Local port to bind (defaults to the target service port)") + portForwardCmd.Flags().StringVar(&address, "address", "127.0.0.1", "Addresses to listen on") + + var restartAll bool + restartCmd := &cobra.Command{ + Use: "restart [component]", + Short: "Restart one or all mcp-sentinel workloads", + Args: func(cmd *cobra.Command, args []string) error { + if restartAll && len(args) == 0 { + return nil + } + return cobra.ExactArgs(1)(cmd, args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + component := "" + if len(args) > 0 { + component = args[0] + } + return mgr.RestartSentinel(component, restartAll) + }, + } + restartCmd.Flags().BoolVar(&restartAll, "all", false, "Restart every mcp-sentinel workload") + + cmd.AddCommand(statusCmd, logsCmd, eventsCmd, portForwardCmd, restartCmd) + return cmd } diff --git a/internal/cli/server.go b/internal/cli/server.go index 2f7a943..e46bea6 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -98,6 +98,16 @@ For pushing images, use 'registry push'.`, return cmd } +// BindUseKubeFlag wires the shared --use-kube flag onto the command. +func (m *ServerManager) BindUseKubeFlag(cmd *cobra.Command) { + cmd.PersistentFlags().BoolVar(&m.useKube, "use-kube", false, "Use kubectl and local kubeconfig instead of the platform API for supported commands") +} + +// Logger exposes the manager logger to foldered command packages. +func (m *ServerManager) Logger() *zap.Logger { + return m.logger +} + func newServerBuildCmd(logger *zap.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "build", diff --git a/internal/cli/server/server.go b/internal/cli/server/server.go index 162da08..4554610 100644 --- a/internal/cli/server/server.go +++ b/internal/cli/server/server.go @@ -10,5 +10,170 @@ import ( // New returns the server command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewServerCmd(logger) + return NewWithManager(cli.DefaultServerManager(logger)) +} + +// NewWithManager returns the server command using the provided manager. +func NewWithManager(mgr *cli.ServerManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Manage MCP servers", + Long: `Commands for managing MCP server deployments. + +With mcp-runtime auth login, list, status, and policy use the platform API when +--use-kube is not set. Create, apply, delete, patch, and logs require kubectl +and a cluster kubeconfig (or --use-kube for those operations). + +For building images from source, use 'server build'. +For pushing images, use 'registry push'.`, + } + + mgr.BindUseKubeFlag(cmd) + + var namespace string + listCmd := &cobra.Command{ + Use: "list", + Short: "List MCP servers", + Long: "List all MCP server deployments", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ListServers(namespace) + }, + } + listCmd.Flags().StringVar(&namespace, "namespace", cli.NamespaceMCPServers, "Namespace to list servers from") + + var getNamespace string + getCmd := &cobra.Command{ + Use: "get [name]", + Short: "Get MCP server details", + Long: "Get detailed information about an MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.GetServer(args[0], getNamespace) + }, + } + getCmd.Flags().StringVar(&getNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + + var createNamespace string + var image string + var imageTag string + var file string + createCmd := &cobra.Command{ + Use: "create [name]", + Short: "Create an MCP server", + Long: "Create a new MCP server deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if file != "" { + return mgr.CreateServerFromFile(file) + } + return mgr.CreateServer(args[0], createNamespace, image, imageTag) + }, + } + createCmd.Flags().StringVar(&createNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + createCmd.Flags().StringVar(&image, "image", "", "Container image") + createCmd.Flags().StringVar(&imageTag, "tag", "latest", "Image tag") + createCmd.Flags().StringVar(&file, "file", "", "YAML file with server spec") + + var applyFile string + applyCmd := &cobra.Command{ + Use: "apply", + Short: "Apply an MCP server manifest", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ApplyServerFromFile(applyFile) + }, + } + applyCmd.Flags().StringVar(&applyFile, "file", "", "YAML file with MCPServer manifest") + _ = applyCmd.MarkFlagRequired("file") + + var exportNamespace string + var exportFile string + exportCmd := &cobra.Command{ + Use: "export [name]", + Short: "Export an MCP server manifest", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ExportServer(args[0], exportNamespace, exportFile) + }, + } + exportCmd.Flags().StringVar(&exportNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + exportCmd.Flags().StringVar(&exportFile, "file", "", "Write the manifest to a file instead of stdout") + + var patchNamespace string + var patchType string + var patch string + var patchFile string + patchCmd := &cobra.Command{ + Use: "patch [name]", + Short: "Patch an MCP server manifest", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.PatchServer(args[0], patchNamespace, patchType, patch, patchFile) + }, + } + patchCmd.Flags().StringVar(&patchNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + patchCmd.Flags().StringVar(&patchType, "type", "merge", "Patch type (merge|json|strategic)") + patchCmd.Flags().StringVar(&patch, "patch", "", "Inline JSON/YAML patch document") + patchCmd.Flags().StringVar(&patchFile, "patch-file", "", "Path to a JSON/YAML patch document") + + var deleteNamespace string + deleteCmd := &cobra.Command{ + Use: "delete [name]", + Short: "Delete an MCP server", + Long: "Delete an MCP server deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.DeleteServer(args[0], deleteNamespace) + }, + } + deleteCmd.Flags().StringVar(&deleteNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + + var logsNamespace string + var follow bool + logsCmd := &cobra.Command{ + Use: "logs [name]", + Short: "View server logs", + Long: "View logs from an MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ViewServerLogs(args[0], logsNamespace, follow) + }, + } + logsCmd.Flags().StringVar(&logsNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + logsCmd.Flags().BoolVar(&follow, "follow", false, "Follow log output") + + var statusNamespace string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show MCP server runtime status (pods, images, pull secrets)", + Long: "List MCPServer resources with their Deployment/pod status, image, and pull secrets.", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ServerStatus(statusNamespace) + }, + } + statusCmd.Flags().StringVar(&statusNamespace, "namespace", cli.NamespaceMCPServers, "Namespace to inspect") + + var policyNamespace string + policyCmd := &cobra.Command{ + Use: "policy", + Short: "Inspect rendered gateway policy for an MCP server", + } + inspectCmd := &cobra.Command{ + Use: "inspect [name]", + Short: "Show the rendered gateway policy document for a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.InspectServerPolicy(args[0], policyNamespace) + }, + } + inspectCmd.Flags().StringVar(&policyNamespace, "namespace", cli.NamespaceMCPServers, "Namespace") + policyCmd.AddCommand(inspectCmd) + + buildCmd := &cobra.Command{ + Use: "build", + Short: "Build MCP server images (push via `registry push`)", + } + buildCmd.AddCommand(cli.NewBuildImageCmd(mgr.Logger())) + + cmd.AddCommand(listCmd, getCmd, createCmd, applyCmd, exportCmd, patchCmd, deleteCmd, logsCmd, statusCmd, policyCmd, buildCmd) + return cmd } diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 9fe578a..0646a64 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -219,7 +219,7 @@ func (d SetupDeps) withDefaults(logger *zap.Logger) SetupDeps { // validateTLSSetupCLIFlags enforces ACME / internal-issuer mutual exclusion and // requires --with-tls when any TLS or cert-manager-related options are set. -func validateTLSSetupCLIFlags( +func ValidateTLSSetupCLIFlags( tlsEnabled bool, acmeEmailResolved, tlsCIResolved string, acmeStagingResolved, skipCertManagerInstall bool, @@ -347,7 +347,7 @@ will use to push and pull container images.`, // buildOperatorArgs constructs operator command-line arguments from flags. // Only includes flags that were explicitly set. -func buildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string { +func BuildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string { var args []string if metricsAddr != "" { @@ -363,7 +363,7 @@ func buildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectCh return args } -func validateStorageMode(mode string) error { +func ValidateStorageMode(mode string) error { switch mode { case StorageModeDynamic, StorageModeHostpath: return nil @@ -372,10 +372,30 @@ func validateStorageMode(mode string) error { } } -func setupPlatform(logger *zap.Logger, plan SetupPlan) error { +func SetupPlatform(logger *zap.Logger, plan SetupPlan) error { return setupPlatformWithDeps(logger, plan, SetupDeps{}.withDefaults(logger)) } +func validateTLSSetupCLIFlags( + tlsEnabled bool, + acmeEmailResolved, tlsCIResolved string, + acmeStagingResolved, skipCertManagerInstall bool, +) error { + return ValidateTLSSetupCLIFlags(tlsEnabled, acmeEmailResolved, tlsCIResolved, acmeStagingResolved, skipCertManagerInstall) +} + +func buildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string { + return BuildOperatorArgs(metricsAddr, probeAddr, leaderElect, leaderElectChanged) +} + +func validateStorageMode(mode string) error { + return ValidateStorageMode(mode) +} + +func setupPlatform(logger *zap.Logger, plan SetupPlan) error { + return SetupPlatform(logger, plan) +} + func setupPlatformWithDeps(logger *zap.Logger, plan SetupPlan, deps SetupDeps) error { deps = deps.withDefaults(logger) Section("MCP Runtime Setup") diff --git a/internal/cli/setup/setup.go b/internal/cli/setup/setup.go index 7f4d746..7887554 100644 --- a/internal/cli/setup/setup.go +++ b/internal/cli/setup/setup.go @@ -2,6 +2,9 @@ package setup import ( + "os" + "strings" + "github.com/spf13/cobra" "go.uber.org/zap" @@ -10,5 +13,112 @@ import ( // New returns the setup command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewSetupCmd(logger) + var registryType string + var registryStorageSize string + var storageMode string + var kubeconfig string + var kubeContext string + var ingressMode string + var ingressManifest string + var forceIngressInstall bool + var tlsEnabled bool + var testMode bool + var strictProd bool + var withoutAnalytics bool + var operatorMetricsAddr string + var operatorProbeAddr string + var operatorLeaderElect bool + var acmeEmail string + var acmeStaging bool + var tlsClusterIssuer string + var skipCertManagerInstall bool + + cmd := &cobra.Command{ + Use: "setup", + Short: "Setup the complete MCP platform", + Long: `Setup the complete MCP platform including: +- Kubernetes cluster initialization +- Internal container registry deployment (Docker Registry) +- Operator deployment +- Ingress controller configuration + +The platform deploys an internal Docker registry by default, which teams +will use to push and pull container images.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cli.ValidateStorageMode(storageMode); err != nil { + return err + } + + operatorArgs := cli.BuildOperatorArgs( + operatorMetricsAddr, + operatorProbeAddr, + operatorLeaderElect, + cmd.Flags().Changed("operator-leader-elect"), + ) + + acmeEmailResolved := strings.TrimSpace(acmeEmail) + if acmeEmailResolved == "" { + acmeEmailResolved = strings.TrimSpace(os.Getenv("MCP_ACME_EMAIL")) + } + acmeStagingResolved := acmeStaging + if v := strings.TrimSpace(os.Getenv("MCP_ACME_STAGING")); v == "1" || strings.EqualFold(v, "true") { + acmeStagingResolved = true + } + tlsCIResolved := strings.TrimSpace(tlsClusterIssuer) + if tlsCIResolved == "" { + tlsCIResolved = strings.TrimSpace(os.Getenv("MCP_TLS_CLUSTER_ISSUER")) + } + if err := cli.ValidateTLSSetupCLIFlags(tlsEnabled, acmeEmailResolved, tlsCIResolved, acmeStagingResolved, skipCertManagerInstall); err != nil { + return err + } + + plan := cli.BuildSetupPlan(cli.SetupPlanInput{ + Kubeconfig: kubeconfig, + Context: kubeContext, + RegistryType: registryType, + RegistryStorageSize: registryStorageSize, + StorageMode: storageMode, + IngressMode: ingressMode, + IngressManifest: ingressManifest, + IngressManifestChanged: cmd.Flags().Changed("ingress-manifest"), + ForceIngressInstall: forceIngressInstall, + TLSEnabled: tlsEnabled, + TestMode: testMode, + StrictProd: strictProd, + DeployAnalytics: !withoutAnalytics, + OperatorArgs: operatorArgs, + ACMEmail: acmeEmailResolved, + ACMEStaging: acmeStagingResolved, + TLSClusterIssuer: tlsCIResolved, + InstallCertManager: !skipCertManagerInstall, + }) + + return cli.SetupPlatform(logger, plan) + }, + } + + cmd.Flags().StringVar(®istryType, "registry-type", "docker", "Registry type (docker; harbor coming soon)") + cmd.Flags().StringVar(®istryStorageSize, "registry-storage", "20Gi", "Registry storage size (default: 20Gi)") + cmd.Flags().StringVar(&storageMode, "storage-mode", "dynamic", "Storage mode for local/dev clusters (dynamic|hostpath). Use hostpath for single-node k3s/minikube/kind without a provisioner.") + cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") + cmd.Flags().StringVar(&kubeContext, "context", "", "Kubernetes context to use") + cmd.Flags().StringVar(&ingressMode, "ingress", "traefik", "Ingress controller to install automatically during setup (traefik|none)") + cmd.Flags().StringVar(&ingressManifest, "ingress-manifest", "config/ingress/overlays/http", "Manifest to apply when installing the ingress controller") + cmd.Flags().BoolVar(&forceIngressInstall, "force-ingress-install", false, "Force ingress install even if an ingress class already exists") + cmd.Flags().BoolVar(&tlsEnabled, "with-tls", false, "Enable TLS overlays (ingress/registry). Use --acme-email for public Let's Encrypt, --tls-cluster-issuer for an org ClusterIssuer, or the bundled mcp-runtime-ca private CA (no ACME) when neither is set") + cmd.Flags().StringVar(&acmeEmail, "acme-email", "", "Contact email for Let's Encrypt (HTTP-01 via cert-manager). Mutually exclusive with --tls-cluster-issuer. Overrides env MCP_ACME_EMAIL") + cmd.Flags().StringVar(&tlsClusterIssuer, "tls-cluster-issuer", "", "Use an existing cert-manager ClusterIssuer (e.g. internal CA; setup does not create it). Mutually exclusive with --acme-email. Overrides env MCP_TLS_CLUSTER_ISSUER") + cmd.Flags().BoolVar(&acmeStaging, "acme-staging", false, "Use Let's Encrypt staging CA (also set MCP_ACME_STAGING=1)") + cmd.Flags().BoolVar(&skipCertManagerInstall, "skip-cert-manager-install", false, "Do not install cert-manager; require CRDs to already exist") + cmd.Flags().BoolVar(&testMode, "test-mode", false, "Test mode for local Kind/dev installs; builds and pushes latest-tag runtime images while relaxing production guardrails") + cmd.Flags().BoolVar(&strictProd, "strict-prod", false, "Require production-style registry and TLS validation for non-test setup") + cmd.Flags().BoolVar(&withoutAnalytics, "without-sentinel", false, "Skip deploying the bundled mcp-sentinel stack") + cmd.Flags().BoolVar(&withoutAnalytics, "without-analytics", false, "Deprecated alias for --without-sentinel") + _ = cmd.Flags().MarkDeprecated("without-analytics", "use --without-sentinel") + _ = cmd.Flags().MarkHidden("without-analytics") + cmd.Flags().StringVar(&operatorMetricsAddr, "operator-metrics-addr", "", "Operator metrics bind address (default: :8080 from manager.yaml)") + cmd.Flags().StringVar(&operatorProbeAddr, "operator-probe-addr", "", "Operator health probe bind address (default: :8081 from manager.yaml)") + cmd.Flags().BoolVar(&operatorLeaderElect, "operator-leader-elect", false, "Override operator leader election when set") + + return cmd } diff --git a/internal/cli/status.go b/internal/cli/status.go index 5432b2e..46894c0 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -50,7 +50,7 @@ func NewStatusCmd(logger *zap.Logger) *cobra.Command { return cmd } -func showPlatformStatus(logger *zap.Logger) error { +func ShowPlatformStatus(logger *zap.Logger) error { Header("MCP Platform Status") DefaultPrinter.Println() @@ -151,6 +151,10 @@ func showPlatformStatus(logger *zap.Logger) error { return nil } +func showPlatformStatus(logger *zap.Logger) error { + return ShowPlatformStatus(logger) +} + func checkClusterStatusQuiet() error { output, err := runKubectlCombinedOutput([]string{"cluster-info"}) if err == nil { diff --git a/internal/cli/status/status.go b/internal/cli/status/status.go index c2628b9..25b44c9 100644 --- a/internal/cli/status/status.go +++ b/internal/cli/status/status.go @@ -10,5 +10,12 @@ import ( // New returns the status command. func New(logger *zap.Logger) *cobra.Command { - return cli.NewStatusCmd(logger) + return &cobra.Command{ + Use: "status", + Short: "Show platform status", + Long: "Show the overall status of the MCP platform", + RunE: func(cmd *cobra.Command, args []string) error { + return cli.ShowPlatformStatus(logger) + }, + } } From 39f11c4f4fb0e3295dff6d17173ccfe09cc4259a Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Fri, 1 May 2026 17:15:06 +0530 Subject: [PATCH 2/4] refactor(cli): add shared runtime facade for command folders Build the shared CLI dependencies once in internal/cli and pass a runtime facade into the foldered command packages. Keep Cobra trees and command-local orchestration in the folders while shared managers and helpers stay in internal/cli. --- docs/internals/go-package-reference.md | 191 +++++++++++++++++++++++++ internal/cli/access/access.go | 5 +- internal/cli/auth.go | 37 +++-- internal/cli/auth/auth.go | 26 +++- internal/cli/bootstrap/bootstrap.go | 20 ++- internal/cli/cluster.go | 19 +++ internal/cli/cluster/cluster.go | 102 ++++++++++++- internal/cli/pipeline/pipeline.go | 5 +- internal/cli/registry.go | 103 +++++++++++++ internal/cli/registry/registry.go | 72 +++++++++- internal/cli/root/commands.go | 23 +-- internal/cli/runtime.go | 59 ++++++++ internal/cli/sentinel/sentinel.go | 5 +- internal/cli/server/server.go | 5 +- internal/cli/setup/setup.go | 13 +- internal/cli/status/status.go | 13 +- 16 files changed, 647 insertions(+), 51 deletions(-) create mode 100644 internal/cli/runtime.go diff --git a/docs/internals/go-package-reference.md b/docs/internals/go-package-reference.md index 931f288..08d1d06 100644 --- a/docs/internals/go-package-reference.md +++ b/docs/internals/go-package-reference.md @@ -2099,8 +2099,10 @@ _No package overview is documented._ - [`func NewAuthCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newauthcmd-logger-zap-logger-cobra-command) - [`func NewBootstrapCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbootstrapcmd-logger-zap-logger-cobra-command) - [`func NewBuildImageCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbuildimagecmd-logger-zap-logger-cobra-command) +- [`func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclustercertcmdwithmanager-mgr-clustermanager-cobra-command) - [`func NewClusterCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newclustercmd-logger-zap-logger-cobra-command) - [`func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclustercmdwithmanager-mgr-clustermanager-cobra-command) +- [`func NewClusterDoctorCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclusterdoctorcmdwithmanager-mgr-clustermanager-cobra-command) - [`func NewPipelineCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newpipelinecmd-logger-zap-logger-cobra-command) - [`func NewPipelineCmdWithManager(mgr *PipelineManager) *cobra.Command`](#cli-internals-func-newpipelinecmdwithmanager-mgr-pipelinemanager-cobra-command) - [`func NewRegistryCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newregistrycmd-logger-zap-logger-cobra-command) @@ -2114,6 +2116,8 @@ _No package overview is documented._ - [`func PrintDoctorReport(r DoctorReport)`](#cli-internals-func-printdoctorreport-r-doctorreport) - [`func Red(msg string) string`](#cli-internals-func-red-msg-string-string) - [`func RunBootstrapPreflight(kubectl KubectlRunner) error`](#cli-internals-func-runbootstrappreflight-kubectl-kubectlrunner-error) +- [`func RunRegistryProvision(mgr *RegistryManager, url, username, password, operatorImage string) error`](#cli-internals-func-runregistryprovision-mgr-registrymanager-url-username-password-operatorimage-string-error) +- [`func RunRegistryPush(mgr *RegistryManager, image, registryURL, name, mode, helperNamespace string) error`](#cli-internals-func-runregistrypush-mgr-registrymanager-image-registryurl-name-mode-helpernamespace-string-error) - [`func Section(title string)`](#cli-internals-func-section-title-string) - [`func SentinelComponentKeys() []string`](#cli-internals-func-sentinelcomponentkeys-string) - [`func SetDebugMode(enabled bool)`](#cli-internals-func-setdebugmode-enabled-bool) @@ -2138,6 +2142,12 @@ _No package overview is documented._ - [`func (m *AccessManager) ListAccessResources(resource, namespace string, allNamespaces bool) error`](#cli-internals-func-m-accessmanager-listaccessresources-resource-namespace-string-allnamespaces-bool-error) - [`func (m *AccessManager) ToggleAccessResource(resource, name, namespace string, value bool) error`](#cli-internals-func-m-accessmanager-toggleaccessresource-resource-name-namespace-string-value-bool-error) - [`type AnalyticsImageSet struct`](#cli-internals-type-analyticsimageset-struct) +- [`type AuthManager struct`](#cli-internals-type-authmanager-struct) +- [`func NewAuthManager(logger *zap.Logger) *AuthManager`](#cli-internals-func-newauthmanager-logger-zap-logger-authmanager) +- [`func (m *AuthManager) NewLoginCmd() *cobra.Command`](#cli-internals-func-m-authmanager-newlogincmd-cobra-command) +- [`func (m *AuthManager) NewLogoutCmd() *cobra.Command`](#cli-internals-func-m-authmanager-newlogoutcmd-cobra-command) +- [`func (m *AuthManager) NewStatusCmd() *cobra.Command`](#cli-internals-func-m-authmanager-newstatuscmd-cobra-command) +- [`func (m *AuthManager) RunAuthLogin(cmd *cobra.Command, f LoginFlags) error`](#cli-internals-func-m-authmanager-runauthlogin-cmd-cobra-command-f-loginflags-error) - [`type CLIConfig struct`](#cli-internals-type-cliconfig-struct) - [`func LoadCLIConfig() *CLIConfig`](#cli-internals-func-loadcliconfig-cliconfig) - [`type CertManager struct`](#cli-internals-type-certmanager-struct) @@ -2150,6 +2160,7 @@ _No package overview is documented._ - [`func NewClusterManager(kubectl *KubectlClient, exec Executor, logger *zap.Logger) *ClusterManager`](#cli-internals-func-newclustermanager-kubectl-kubectlclient-exec-executor-logger-zap-logger-clustermanager) - [`func (m *ClusterManager) CheckClusterStatus() error`](#cli-internals-func-m-clustermanager-checkclusterstatus-error) - [`func (m *ClusterManager) ConfigureCluster(ingress ingressOptions) error`](#cli-internals-func-m-clustermanager-configurecluster-ingress-ingressoptions-error) +- [`func (m *ClusterManager) ConfigureClusterWithValues(mode, manifest string, force bool) error`](#cli-internals-func-m-clustermanager-configureclusterwithvalues-mode-manifest-string-force-bool-error) - [`func (m *ClusterManager) ConfigureKubeconfig(kubeconfig, context string) error`](#cli-internals-func-m-clustermanager-configurekubeconfig-kubeconfig-context-string-error) - [`func (m *ClusterManager) ConfigureKubeconfigFromProvider(provider, region, clusterName, resourceGroup, project, zone, kubeconfig string) error`](#cli-internals-func-m-clustermanager-configurekubeconfigfromprovider-provider-region-clustername-resourcegroup-project-zone-kubeconfig-string-error) - [`func (m *ClusterManager) EnsureNamespace(name string) error`](#cli-internals-func-m-clustermanager-ensurenamespace-name-string-error) @@ -2184,6 +2195,7 @@ _No package overview is documented._ - [`func (c *KubectlClient) RunWithOutput(args []string, stdout, stderr io.Writer) error`](#cli-internals-func-c-kubectlclient-runwithoutput-args-string-stdout-stderr-io-writer-error) - [`type KubectlRunner interface`](#cli-internals-type-kubectlrunner-interface) - [`func DefaultKubectlRunner() KubectlRunner`](#cli-internals-func-defaultkubectlrunner-kubectlrunner) +- [`type LoginFlags struct`](#cli-internals-type-loginflags-struct) - [`type MockCommand struct`](#cli-internals-type-mockcommand-struct) - [`func (m *MockCommand) CombinedOutput() ([]byte, error)`](#cli-internals-func-m-mockcommand-combinedoutput-byte-error) - [`func (m *MockCommand) Output() ([]byte, error)`](#cli-internals-func-m-mockcommand-output-byte-error) @@ -2227,6 +2239,17 @@ _No package overview is documented._ - [`func (m *RegistryManager) PushInCluster(source, target, helperNS string) error`](#cli-internals-func-m-registrymanager-pushincluster-source-target-helperns-string-error) - [`func (m *RegistryManager) ShowRegistryInfo() error`](#cli-internals-func-m-registrymanager-showregistryinfo-error) - [`type RegistryManagerAPI interface`](#cli-internals-type-registrymanagerapi-interface) +- [`type Runtime struct`](#cli-internals-type-runtime-struct) +- [`func NewRuntime(logger *zap.Logger) *Runtime`](#cli-internals-func-newruntime-logger-zap-logger-runtime) +- [`func (r *Runtime) AccessManager() *AccessManager`](#cli-internals-func-r-runtime-accessmanager-accessmanager) +- [`func (r *Runtime) AuthManager() *AuthManager`](#cli-internals-func-r-runtime-authmanager-authmanager) +- [`func (r *Runtime) ClusterManager() *ClusterManager`](#cli-internals-func-r-runtime-clustermanager-clustermanager) +- [`func (r *Runtime) KubectlRunner() KubectlRunner`](#cli-internals-func-r-runtime-kubectlrunner-kubectlrunner) +- [`func (r *Runtime) Logger() *zap.Logger`](#cli-internals-func-r-runtime-logger-zap-logger) +- [`func (r *Runtime) PipelineManager() *PipelineManager`](#cli-internals-func-r-runtime-pipelinemanager-pipelinemanager) +- [`func (r *Runtime) RegistryManager() *RegistryManager`](#cli-internals-func-r-runtime-registrymanager-registrymanager) +- [`func (r *Runtime) SentinelManager() *SentinelManager`](#cli-internals-func-r-runtime-sentinelmanager-sentinelmanager) +- [`func (r *Runtime) ServerManager() *ServerManager`](#cli-internals-func-r-runtime-servermanager-servermanager) - [`type SentinelManager struct`](#cli-internals-type-sentinelmanager-struct) - [`func DefaultSentinelManager(logger *zap.Logger) *SentinelManager`](#cli-internals-func-defaultsentinelmanager-logger-zap-logger-sentinelmanager) - [`func NewSentinelManager(kubectl *KubectlClient, logger *zap.Logger) *SentinelManager`](#cli-internals-func-newsentinelmanager-kubectl-kubectlclient-logger-zap-logger-sentinelmanager) @@ -2712,6 +2735,14 @@ func NewBootstrapCmd(logger *zap.Logger) *cobra.Command func NewBuildImageCmd(logger *zap.Logger) *cobra.Command ``` + +```text +func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command + NewClusterCertCmdWithManager exposes the cert subcommand builder for folder + packages. + +``` + ```text func NewClusterCmd(logger *zap.Logger) *cobra.Command @@ -2727,6 +2758,14 @@ func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command ``` + +```text +func NewClusterDoctorCmdWithManager(mgr *ClusterManager) *cobra.Command + NewClusterDoctorCmdWithManager exposes the doctor subcommand builder for + folder packages. + +``` + ```text func NewPipelineCmd(logger *zap.Logger) *cobra.Command @@ -2817,6 +2856,21 @@ func Red(msg string) string func RunBootstrapPreflight(kubectl KubectlRunner) error ``` + +```text +func RunRegistryProvision(mgr *RegistryManager, url, username, password, operatorImage string) error + RunRegistryProvision contains the registry provision command flow for folder + packages. + +``` + + +```text +func RunRegistryPush(mgr *RegistryManager, image, registryURL, name, mode, helperNamespace string) error + RunRegistryPush contains the registry push command flow for folder packages. + +``` + ```text func Section(title string) @@ -2996,6 +3050,47 @@ type AnalyticsImageSet struct { ``` + +```text +type AuthManager struct { + // Has unexported fields. +} + +``` + + +```text +func NewAuthManager(logger *zap.Logger) *AuthManager + +``` + + +```text +func (m *AuthManager) NewLoginCmd() *cobra.Command + NewLoginCmd exposes the login subcommand builder for folder packages. + +``` + + +```text +func (m *AuthManager) NewLogoutCmd() *cobra.Command + NewLogoutCmd exposes the logout subcommand builder for folder packages. + +``` + + +```text +func (m *AuthManager) NewStatusCmd() *cobra.Command + NewStatusCmd exposes the status subcommand builder for folder packages. + +``` + + +```text +func (m *AuthManager) RunAuthLogin(cmd *cobra.Command, f LoginFlags) error + +``` + ```text type CLIConfig struct { @@ -3118,6 +3213,14 @@ func (m *ClusterManager) ConfigureCluster(ingress ingressOptions) error ``` + +```text +func (m *ClusterManager) ConfigureClusterWithValues(mode, manifest string, force bool) error + ConfigureClusterWithValues adapts exported flag values into the internal + ingress options shape. + +``` + ```text func (m *ClusterManager) ConfigureKubeconfig(kubeconfig, context string) error @@ -3401,6 +3504,14 @@ func DefaultKubectlRunner() KubectlRunner ``` + +```text +type LoginFlags struct { + // Has unexported fields. +} + +``` + ```text type MockCommand struct { @@ -3732,6 +3843,86 @@ type RegistryManagerAPI interface { ``` + +```text +type Runtime struct { + // Has unexported fields. +} + Runtime is the shared CLI facade for wiring common dependencies once and + handing typed managers to the foldered command packages. + +``` + + +```text +func NewRuntime(logger *zap.Logger) *Runtime + NewRuntime builds the shared CLI runtime facade. + +``` + + +```text +func (r *Runtime) AccessManager() *AccessManager + AccessManager returns the access command manager. + +``` + + +```text +func (r *Runtime) AuthManager() *AuthManager + AuthManager returns the auth command manager. + +``` + + +```text +func (r *Runtime) ClusterManager() *ClusterManager + ClusterManager returns the cluster command manager. + +``` + + +```text +func (r *Runtime) KubectlRunner() KubectlRunner + KubectlRunner returns the shared kubectl runner. + +``` + + +```text +func (r *Runtime) Logger() *zap.Logger + Logger returns the shared logger. + +``` + + +```text +func (r *Runtime) PipelineManager() *PipelineManager + PipelineManager returns the pipeline command manager. + +``` + + +```text +func (r *Runtime) RegistryManager() *RegistryManager + RegistryManager returns the registry command manager. + +``` + + +```text +func (r *Runtime) SentinelManager() *SentinelManager + SentinelManager returns the sentinel command manager. + +``` + + +```text +func (r *Runtime) ServerManager() *ServerManager + ServerManager returns the server command manager. + +``` + ```text type SentinelManager struct { diff --git a/internal/cli/access/access.go b/internal/cli/access/access.go index ae6c1fd..a8e70e5 100644 --- a/internal/cli/access/access.go +++ b/internal/cli/access/access.go @@ -3,7 +3,6 @@ package access import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) @@ -14,8 +13,8 @@ const ( ) // New returns the access command. -func New(logger *zap.Logger) *cobra.Command { - return NewWithManager(cli.DefaultAccessManager(logger)) +func New(runtime *cli.Runtime) *cobra.Command { + return NewWithManager(runtime.AccessManager()) } // NewWithManager returns the access command using the provided manager. diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 2149195..ea75661 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -31,7 +31,7 @@ var authHTTPDoHook func(req *http.Request) (*http.Response, error) // NewAuthCmd is the `auth` command (login, logout, status) for platform credentials. func NewAuthCmd(logger *zap.Logger) *cobra.Command { - m := &authManager{logger: logger} + m := NewAuthManager(logger) cmd := &cobra.Command{ Use: "auth", Short: "Log in to the platform API and manage saved credentials", @@ -55,11 +55,11 @@ Optional environment: return cmd } -type authManager struct { +type AuthManager struct { logger *zap.Logger } -type loginFlags struct { +type LoginFlags struct { apiURL string email string password string @@ -69,13 +69,17 @@ type loginFlags struct { skipVerify bool } -func (m *authManager) newAuthLoginCmd() *cobra.Command { - var f loginFlags +func NewAuthManager(logger *zap.Logger) *AuthManager { + return &AuthManager{logger: logger} +} + +func (m *AuthManager) newAuthLoginCmd() *cobra.Command { + var f LoginFlags cmd := &cobra.Command{ Use: "login", Short: "Save a platform API token and optional registry host", RunE: func(cmd *cobra.Command, _ []string) error { - return m.runAuthLogin(cmd, f) + return m.RunAuthLogin(cmd, f) }, } @@ -90,7 +94,12 @@ func (m *authManager) newAuthLoginCmd() *cobra.Command { return cmd } -func (m *authManager) runAuthLogin(cmd *cobra.Command, f loginFlags) error { +// NewLoginCmd exposes the login subcommand builder for folder packages. +func (m *AuthManager) NewLoginCmd() *cobra.Command { + return m.newAuthLoginCmd() +} + +func (m *AuthManager) RunAuthLogin(cmd *cobra.Command, f LoginFlags) error { stdout := io.Writer(os.Stdout) stderr := io.Writer(os.Stderr) if cmd != nil { @@ -229,7 +238,7 @@ func terminalFD(fd uintptr) (int, error) { return int(fd), nil } -func (m *authManager) newAuthLogoutCmd() *cobra.Command { +func (m *AuthManager) newAuthLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout", Short: "Delete saved platform credentials on this machine", @@ -247,7 +256,12 @@ func (m *authManager) newAuthLogoutCmd() *cobra.Command { } } -func (m *authManager) newAuthStatusCmd() *cobra.Command { +// NewLogoutCmd exposes the logout subcommand builder for folder packages. +func (m *AuthManager) NewLogoutCmd() *cobra.Command { + return m.newAuthLogoutCmd() +} + +func (m *AuthManager) newAuthStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show whether platform API credentials are configured", @@ -298,6 +312,11 @@ func (m *authManager) newAuthStatusCmd() *cobra.Command { } } +// NewStatusCmd exposes the status subcommand builder for folder packages. +func (m *AuthManager) NewStatusCmd() *cobra.Command { + return m.newAuthStatusCmd() +} + // fileCredentialsIfRelevant returns saved-file credentials when not using the env override. func fileCredentialsIfRelevant() (*authfile.Credentials, error) { if strings.TrimSpace(os.Getenv(authfile.EnvAPIToken)) != "" { diff --git a/internal/cli/auth/auth.go b/internal/cli/auth/auth.go index 73338fe..7151109 100644 --- a/internal/cli/auth/auth.go +++ b/internal/cli/auth/auth.go @@ -3,12 +3,32 @@ package auth import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" + "mcp-runtime/pkg/authfile" ) // New returns the auth command. -func New(logger *zap.Logger) *cobra.Command { - return cli.NewAuthCmd(logger) +func New(runtime *cli.Runtime) *cobra.Command { + m := runtime.AuthManager() + cmd := &cobra.Command{ + Use: "auth", + Short: "Log in to the platform API and manage saved credentials", + Long: `Authenticate to the Sentinel platform using email/password or an API token (not Kubernetes). + +Use this for day-to-day deploy and registry-related flows. Cluster install and admin work +use Kubernetes and the cluster commands, not this command. + +The token is stored in a local file (mode 0600) under the user config directory, unless you set ` + authfile.EnvAPIToken + `. + +Optional environment: + ` + authfile.EnvAPIURL + ` default API base for login, e.g. https://platform.example.com + ` + authfile.EnvAPIToken + ` use this token for API calls; overrides a saved file + MCP_RUNTIME_CONFIG_DIR override the config directory (mainly for tests)`, + } + + cmd.AddCommand(m.NewLoginCmd()) + cmd.AddCommand(m.NewLogoutCmd()) + cmd.AddCommand(m.NewStatusCmd()) + return cmd } diff --git a/internal/cli/bootstrap/bootstrap.go b/internal/cli/bootstrap/bootstrap.go index 2319ce4..16a2a5b 100644 --- a/internal/cli/bootstrap/bootstrap.go +++ b/internal/cli/bootstrap/bootstrap.go @@ -5,15 +5,23 @@ import ( "fmt" "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) +type manager struct { + kubectl cli.KubectlRunner +} + +func newManager(runtime *cli.Runtime) *manager { + return &manager{kubectl: runtime.KubectlRunner()} +} + // New returns the bootstrap command. -func New(logger *zap.Logger) *cobra.Command { +func New(runtime *cli.Runtime) *cobra.Command { var apply bool var provider string + mgr := newManager(runtime) cmd := &cobra.Command{ Use: "bootstrap", @@ -26,11 +34,9 @@ Use this to prepare an existing cluster for running 'mcp-runtime setup'. Note: bootstrap --apply is automated for k3s only and must be executed on the k3s server node (it expects local manifests under /var/lib/rancher/k3s/server/manifests).`, RunE: func(cmd *cobra.Command, args []string) error { cli.Section("MCP Runtime Bootstrap") - - kubectl := cli.DefaultKubectlRunner() chosenProvider := provider if chosenProvider == "" || chosenProvider == "auto" { - detectedProvider, err := cli.DetectProvider(kubectl) + detectedProvider, err := cli.DetectProvider(mgr.kubectl) if err != nil { return err } @@ -38,7 +44,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 } cli.Info(fmt.Sprintf("Provider: %s", chosenProvider)) - if err := cli.RunBootstrapPreflight(kubectl); err != nil { + if err := cli.RunBootstrapPreflight(mgr.kubectl); err != nil { return err } @@ -50,7 +56,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 switch chosenProvider { case "k3s": - if err := cli.BootstrapApplyK3s(kubectl); err != nil { + if err := cli.BootstrapApplyK3s(mgr.kubectl); err != nil { return err } case "rke2", "kubeadm", "generic": diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 3413e0c..34b8fd3 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -67,6 +67,16 @@ func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command { return cmd } +// NewClusterCertCmdWithManager exposes the cert subcommand builder for folder packages. +func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command { + return mgr.newClusterCertCmd() +} + +// NewClusterDoctorCmdWithManager exposes the doctor subcommand builder for folder packages. +func NewClusterDoctorCmdWithManager(mgr *ClusterManager) *cobra.Command { + return mgr.newClusterDoctorCmd() +} + func (m *ClusterManager) newClusterInitCmd() *cobra.Command { var kubeconfig string var context string @@ -445,6 +455,15 @@ func (m *ClusterManager) ConfigureCluster(ingress ingressOptions) error { return nil } +// ConfigureClusterWithValues adapts exported flag values into the internal ingress options shape. +func (m *ClusterManager) ConfigureClusterWithValues(mode, manifest string, force bool) error { + return m.ConfigureCluster(ingressOptions{ + mode: mode, + manifest: manifest, + force: force, + }) +} + // ProvisionCluster provisions a new Kubernetes cluster. func (m *ClusterManager) ProvisionCluster(provider, region string, nodeCount int, clusterName string) error { m.logger.Info("Provisioning cluster", zap.String("provider", provider), zap.String("region", region), zap.String("name", clusterName)) diff --git a/internal/cli/cluster/cluster.go b/internal/cli/cluster/cluster.go index 0cea079..2f85876 100644 --- a/internal/cli/cluster/cluster.go +++ b/internal/cli/cluster/cluster.go @@ -3,12 +3,108 @@ package cluster import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) // New returns the cluster command. -func New(logger *zap.Logger) *cobra.Command { - return cli.NewClusterCmd(logger) +func New(runtime *cli.Runtime) *cobra.Command { + return NewWithManager(runtime.ClusterManager()) +} + +// NewWithManager returns the cluster command using the provided manager. +func NewWithManager(mgr *cli.ClusterManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster", + Short: "Manage Kubernetes cluster", + Long: "Commands for managing the Kubernetes cluster", + } + + var kubeconfig string + var context string + initCmd := &cobra.Command{ + Use: "init", + Short: "Initialize cluster configuration", + Long: "Initialize and configure the Kubernetes cluster for MCP platform", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.InitCluster(kubeconfig, context) + }, + } + initCmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") + initCmd.Flags().StringVar(&context, "context", "", "Kubernetes context to use") + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Check cluster status", + Long: "Check the status of the Kubernetes cluster", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.CheckClusterStatus() + }, + } + + var ingressMode string + var ingressManifest string + var forceIngressInstall bool + var configKubeconfig string + var configContext string + var provider string + var region string + var clusterName string + var resourceGroup string + var project string + var zone string + configCmd := &cobra.Command{ + Use: "config", + Short: "Configure cluster settings", + Long: "Configure cluster settings like ingress and kubeconfig context", + RunE: func(cmd *cobra.Command, args []string) error { + if provider != "" { + if err := mgr.ConfigureKubeconfigFromProvider(provider, region, clusterName, resourceGroup, project, zone, configKubeconfig); err != nil { + return err + } + } + if configKubeconfig != "" || configContext != "" || provider != "" { + if err := mgr.ConfigureKubeconfig(configKubeconfig, configContext); err != nil { + return err + } + } + return mgr.ConfigureClusterWithValues(ingressMode, ingressManifest, forceIngressInstall) + }, + } + configCmd.Flags().StringVar(&ingressMode, "ingress", "traefik", "Ingress controller to install (traefik|none)") + configCmd.Flags().StringVar(&ingressManifest, "ingress-manifest", "config/ingress/overlays/prod", "Manifest to apply when installing the ingress controller") + configCmd.Flags().BoolVar(&forceIngressInstall, "force-ingress-install", false, "Force ingress install even if an ingress class already exists") + configCmd.Flags().StringVar(&configKubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") + configCmd.Flags().StringVar(&configContext, "context", "", "Kubernetes context to use") + configCmd.Flags().StringVar(&provider, "provider", "", "Cloud provider for kubeconfig (eks; aks/gke planned)") + configCmd.Flags().StringVar(®ion, "region", "us-west-1", "Region for cloud provider kubeconfig") + configCmd.Flags().StringVar(&clusterName, "name", "mcp-runtime", "Cluster name for cloud provider kubeconfig") + configCmd.Flags().StringVar(&resourceGroup, "resource-group", "", "Resource group (AKS, planned)") + configCmd.Flags().StringVar(&project, "project", "", "Project ID (GKE, planned)") + configCmd.Flags().StringVar(&zone, "zone", "", "Zone (GKE, planned)") + + var provisionProvider string + var provisionRegion string + var nodeCount int + var provisionClusterName string + provisionCmd := &cobra.Command{ + Use: "provision", + Short: "Provision a new cluster", + Long: "Provision a new Kubernetes cluster (requires cloud provider credentials)", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ProvisionCluster(provisionProvider, provisionRegion, nodeCount, provisionClusterName) + }, + } + provisionCmd.Flags().StringVar(&provisionProvider, "provider", "kind", "Cloud provider (kind, gke, eks, aks)") + provisionCmd.Flags().StringVar(&provisionRegion, "region", "us-west-1", "Region for cluster") + provisionCmd.Flags().IntVar(&nodeCount, "nodes", 3, "Number of nodes") + provisionCmd.Flags().StringVar(&provisionClusterName, "name", "mcp-runtime", "Cluster name (used by supported providers)") + + cmd.AddCommand(initCmd) + cmd.AddCommand(statusCmd) + cmd.AddCommand(configCmd) + cmd.AddCommand(provisionCmd) + cmd.AddCommand(cli.NewClusterCertCmdWithManager(mgr)) + cmd.AddCommand(cli.NewClusterDoctorCmdWithManager(mgr)) + return cmd } diff --git a/internal/cli/pipeline/pipeline.go b/internal/cli/pipeline/pipeline.go index b7dd8c9..6234d76 100644 --- a/internal/cli/pipeline/pipeline.go +++ b/internal/cli/pipeline/pipeline.go @@ -3,14 +3,13 @@ package pipeline import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) // New returns the pipeline command. -func New(logger *zap.Logger) *cobra.Command { - return NewWithManager(cli.DefaultPipelineManager(logger)) +func New(runtime *cli.Runtime) *cobra.Command { + return NewWithManager(runtime.PipelineManager()) } // NewWithManager returns the pipeline command using the provided manager. diff --git a/internal/cli/registry.go b/internal/cli/registry.go index d02b08e..0051e70 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -63,6 +63,109 @@ func NewRegistryCmdWithManager(mgr *RegistryManager) *cobra.Command { return cmd } +// RunRegistryProvision contains the registry provision command flow for folder packages. +func RunRegistryProvision(mgr *RegistryManager, url, username, password, operatorImage string) error { + flagCfg := &ExternalRegistryConfig{ + URL: url, + Username: username, + Password: password, + } + cfg, err := resolveExternalRegistryConfig(flagCfg) + if err != nil { + return err + } + if cfg == nil || cfg.URL == "" { + err := newWithSentinel(ErrRegistryURLRequired, "registry url is required (flag, env PROVISIONED_REGISTRY_URL, or config file)") + Error("Registry URL required") + logStructuredError(mgr.logger, err, "Registry URL required") + return err + } + if err := saveExternalRegistryConfig(cfg); err != nil { + wrappedErr := wrapWithSentinel(ErrSaveRegistryConfigFailed, err, fmt.Sprintf("failed to save registry config: %v", err)) + Error("Failed to save registry config") + logStructuredError(mgr.logger, wrappedErr, "Failed to save registry config") + return wrappedErr + } + if cfg.Username != "" && cfg.Password != "" { + mgr.logger.Info("Performing docker login to external registry", zap.String("url", cfg.URL)) + if err := mgr.LoginRegistry(cfg.URL, cfg.Username, cfg.Password); err != nil { + return err + } + } + if operatorImage != "" { + mgr.logger.Info("Building and pushing operator image to external registry", zap.String("image", operatorImage)) + if err := buildOperatorImage(operatorImage); err != nil { + wrappedErr := wrapWithSentinelAndContext( + ErrBuildOperatorImageFailed, + err, + fmt.Sprintf("failed to build operator image: %v", err), + map[string]any{"image": operatorImage, "component": "registry"}, + ) + Error("Failed to build operator image") + logStructuredError(mgr.logger, wrappedErr, "Failed to build operator image") + return wrappedErr + } + if err := pushOperatorImage(operatorImage); err != nil { + wrappedErr := wrapWithSentinelAndContext( + ErrPushOperatorImageFailed, + err, + fmt.Sprintf("failed to push operator image: %v", err), + map[string]any{"image": operatorImage, "component": "registry"}, + ) + Error("Failed to push operator image") + logStructuredError(mgr.logger, wrappedErr, "Failed to push operator image") + return wrappedErr + } + } + mgr.logger.Info("External registry configured", zap.String("url", cfg.URL)) + fmt.Printf("External registry configured: %s\n", cfg.URL) + return nil +} + +// RunRegistryPush contains the registry push command flow for folder packages. +func RunRegistryPush(mgr *RegistryManager, image, registryURL, name, mode, helperNamespace string) error { + if image == "" { + err := newWithSentinel(ErrImageRequired, "image is required (use --image)") + Error("Image required") + logStructuredError(mgr.logger, err, "Image required") + return err + } + targetRegistry := registryURL + if targetRegistry == "" { + if ext, err := resolveExternalRegistryConfig(nil); err == nil && ext != nil && ext.URL != "" { + targetRegistry = strings.TrimSuffix(ext.URL, "/") + } + } + if targetRegistry == "" { + targetRegistry = getPlatformRegistryURL(mgr.logger) + } + + repo, tag := splitImage(image) + if name != "" { + repo = name + } else { + repo = dropRegistryPrefix(repo) + } + target := targetRegistry + "/" + repo + if tag != "" { + target = target + ":" + tag + } + + mgr.logger.Info("Pushing image", zap.String("source", image), zap.String("target", target)) + + switch mode { + case "direct": + return mgr.PushDirect(image, target) + case "in-cluster": + return mgr.PushInCluster(image, target, helperNamespace) + default: + err := newWithSentinel(ErrUnknownRegistryMode, fmt.Sprintf("unknown mode %q (use direct|in-cluster)", mode)) + Error("Unknown registry mode") + logStructuredError(mgr.logger, err, "Unknown registry mode") + return err + } +} + func (m *RegistryManager) newRegistryStatusCmd() *cobra.Command { var namespace string diff --git a/internal/cli/registry/registry.go b/internal/cli/registry/registry.go index 3cfc72d..d90b9a1 100644 --- a/internal/cli/registry/registry.go +++ b/internal/cli/registry/registry.go @@ -3,12 +3,78 @@ package registry import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) // New returns the registry command. -func New(logger *zap.Logger) *cobra.Command { - return cli.NewRegistryCmd(logger) +func New(runtime *cli.Runtime) *cobra.Command { + return NewWithManager(runtime.RegistryManager()) +} + +// NewWithManager returns the registry command using the provided manager. +func NewWithManager(mgr *cli.RegistryManager) *cobra.Command { + cmd := &cobra.Command{ + Use: "registry", + Short: "Manage container registry", + Long: "Commands for managing the container registry", + } + + var namespace string + statusCmd := &cobra.Command{ + Use: "status", + Short: "Check registry status", + Long: "Check the status of the container registry", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.CheckRegistryStatus(namespace) + }, + } + statusCmd.Flags().StringVar(&namespace, "namespace", cli.NamespaceRegistry, "Registry namespace") + + infoCmd := &cobra.Command{ + Use: "info", + Short: "Show registry information", + Long: "Show registry URL and connection information", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ShowRegistryInfo() + }, + } + + var url string + var username string + var password string + var operatorImage string + provisionCmd := &cobra.Command{ + Use: "provision", + Short: "Configure an external registry", + Long: "Configure an external registry to be used for operator/runtime images", + RunE: func(cmd *cobra.Command, args []string) error { + return cli.RunRegistryProvision(mgr, url, username, password, operatorImage) + }, + } + provisionCmd.Flags().StringVar(&url, "url", "", "External registry URL (e.g., registry.mcpruntime.com)") + provisionCmd.Flags().StringVar(&username, "username", "", "Registry username (optional)") + provisionCmd.Flags().StringVar(&password, "password", "", "Registry password (optional)") + provisionCmd.Flags().StringVar(&operatorImage, "operator-image", "", "Optional: build and push operator image to this external registry (e.g., /mcp-runtime-operator:latest)") + + var image string + var registryURL string + var name string + var mode string + var helperNamespace string + pushCmd := &cobra.Command{ + Use: "push", + Short: "Retag and push an image to the platform or provisioned registry", + RunE: func(cmd *cobra.Command, args []string) error { + return cli.RunRegistryPush(mgr, image, registryURL, name, mode, helperNamespace) + }, + } + pushCmd.Flags().StringVar(&image, "image", "", "Local image to push (required)") + pushCmd.Flags().StringVar(®istryURL, "registry", "", "Target registry (defaults to provisioned or internal)") + pushCmd.Flags().StringVar(&name, "name", "", "Override target repo/name (default: source name without registry)") + pushCmd.Flags().StringVar(&mode, "mode", "in-cluster", "Push mode: in-cluster (default, uses skopeo helper) or direct (docker push)") + pushCmd.Flags().StringVar(&helperNamespace, "namespace", cli.NamespaceRegistry, "Namespace to run the in-cluster helper pod") + + cmd.AddCommand(statusCmd, infoCmd, provisionCmd, pushCmd) + return cmd } diff --git a/internal/cli/root/commands.go b/internal/cli/root/commands.go index 913030f..b35bb14 100644 --- a/internal/cli/root/commands.go +++ b/internal/cli/root/commands.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" + "mcp-runtime/internal/cli" "mcp-runtime/internal/cli/access" "mcp-runtime/internal/cli/auth" "mcp-runtime/internal/cli/bootstrap" @@ -18,14 +19,16 @@ import ( // AddCommands registers every top-level mcp-runtime command on root. func AddCommands(root *cobra.Command, logger *zap.Logger) { - root.AddCommand(cluster.New(logger)) - root.AddCommand(registry.New(logger)) - root.AddCommand(server.New(logger)) - root.AddCommand(access.New(logger)) - root.AddCommand(auth.New(logger)) - root.AddCommand(bootstrap.New(logger)) - root.AddCommand(setup.New(logger)) - root.AddCommand(status.New(logger)) - root.AddCommand(sentinel.New(logger)) - root.AddCommand(pipeline.New(logger)) + runtime := cli.NewRuntime(logger) + + root.AddCommand(cluster.New(runtime)) + root.AddCommand(registry.New(runtime)) + root.AddCommand(server.New(runtime)) + root.AddCommand(access.New(runtime)) + root.AddCommand(auth.New(runtime)) + root.AddCommand(bootstrap.New(runtime)) + root.AddCommand(setup.New(runtime)) + root.AddCommand(status.New(runtime)) + root.AddCommand(sentinel.New(runtime)) + root.AddCommand(pipeline.New(runtime)) } diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go new file mode 100644 index 0000000..36b5f4f --- /dev/null +++ b/internal/cli/runtime.go @@ -0,0 +1,59 @@ +package cli + +import "go.uber.org/zap" + +// Runtime is the shared CLI facade for wiring common dependencies once and +// handing typed managers to the foldered command packages. +type Runtime struct { + logger *zap.Logger +} + +// NewRuntime builds the shared CLI runtime facade. +func NewRuntime(logger *zap.Logger) *Runtime { + return &Runtime{logger: logger} +} + +// Logger returns the shared logger. +func (r *Runtime) Logger() *zap.Logger { + return r.logger +} + +// KubectlRunner returns the shared kubectl runner. +func (r *Runtime) KubectlRunner() KubectlRunner { + return DefaultKubectlRunner() +} + +// AccessManager returns the access command manager. +func (r *Runtime) AccessManager() *AccessManager { + return DefaultAccessManager(r.logger) +} + +// AuthManager returns the auth command manager. +func (r *Runtime) AuthManager() *AuthManager { + return NewAuthManager(r.logger) +} + +// ClusterManager returns the cluster command manager. +func (r *Runtime) ClusterManager() *ClusterManager { + return DefaultClusterManager(r.logger) +} + +// PipelineManager returns the pipeline command manager. +func (r *Runtime) PipelineManager() *PipelineManager { + return DefaultPipelineManager(r.logger) +} + +// RegistryManager returns the registry command manager. +func (r *Runtime) RegistryManager() *RegistryManager { + return DefaultRegistryManager(r.logger) +} + +// SentinelManager returns the sentinel command manager. +func (r *Runtime) SentinelManager() *SentinelManager { + return DefaultSentinelManager(r.logger) +} + +// ServerManager returns the server command manager. +func (r *Runtime) ServerManager() *ServerManager { + return DefaultServerManager(r.logger) +} diff --git a/internal/cli/sentinel/sentinel.go b/internal/cli/sentinel/sentinel.go index 152077a..8a59580 100644 --- a/internal/cli/sentinel/sentinel.go +++ b/internal/cli/sentinel/sentinel.go @@ -3,14 +3,13 @@ package sentinel import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) // New returns the sentinel command. -func New(logger *zap.Logger) *cobra.Command { - return NewWithManager(cli.DefaultSentinelManager(logger)) +func New(runtime *cli.Runtime) *cobra.Command { + return NewWithManager(runtime.SentinelManager()) } // NewWithManager returns the sentinel command using the provided manager. diff --git a/internal/cli/server/server.go b/internal/cli/server/server.go index 4554610..8d17032 100644 --- a/internal/cli/server/server.go +++ b/internal/cli/server/server.go @@ -3,14 +3,13 @@ package server import ( "github.com/spf13/cobra" - "go.uber.org/zap" "mcp-runtime/internal/cli" ) // New returns the server command. -func New(logger *zap.Logger) *cobra.Command { - return NewWithManager(cli.DefaultServerManager(logger)) +func New(runtime *cli.Runtime) *cobra.Command { + return NewWithManager(runtime.ServerManager()) } // NewWithManager returns the server command using the provided manager. diff --git a/internal/cli/setup/setup.go b/internal/cli/setup/setup.go index 7887554..a22415a 100644 --- a/internal/cli/setup/setup.go +++ b/internal/cli/setup/setup.go @@ -11,8 +11,16 @@ import ( "mcp-runtime/internal/cli" ) +type manager struct { + logger *zap.Logger +} + +func newManager(runtime *cli.Runtime) *manager { + return &manager{logger: runtime.Logger()} +} + // New returns the setup command. -func New(logger *zap.Logger) *cobra.Command { +func New(runtime *cli.Runtime) *cobra.Command { var registryType string var registryStorageSize string var storageMode string @@ -32,6 +40,7 @@ func New(logger *zap.Logger) *cobra.Command { var acmeStaging bool var tlsClusterIssuer string var skipCertManagerInstall bool + mgr := newManager(runtime) cmd := &cobra.Command{ Use: "setup", @@ -93,7 +102,7 @@ will use to push and pull container images.`, InstallCertManager: !skipCertManagerInstall, }) - return cli.SetupPlatform(logger, plan) + return cli.SetupPlatform(mgr.logger, plan) }, } diff --git a/internal/cli/status/status.go b/internal/cli/status/status.go index 25b44c9..c80ca07 100644 --- a/internal/cli/status/status.go +++ b/internal/cli/status/status.go @@ -8,14 +8,23 @@ import ( "mcp-runtime/internal/cli" ) +type manager struct { + logger *zap.Logger +} + +func newManager(runtime *cli.Runtime) *manager { + return &manager{logger: runtime.Logger()} +} + // New returns the status command. -func New(logger *zap.Logger) *cobra.Command { +func New(runtime *cli.Runtime) *cobra.Command { + mgr := newManager(runtime) return &cobra.Command{ Use: "status", Short: "Show platform status", Long: "Show the overall status of the MCP platform", RunE: func(cmd *cobra.Command, args []string) error { - return cli.ShowPlatformStatus(logger) + return cli.ShowPlatformStatus(mgr.logger) }, } } From fa11c8031e6e177cb64cf5c3c1d44c824e7bb647 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Fri, 1 May 2026 17:22:50 +0530 Subject: [PATCH 3/4] refactor(auth): move auth command logic into folder Move the auth command manager, login flow, verification helpers, and tests into internal/cli/auth so the subcommand folder owns its command-specific behavior. Keep only the shared platform base-URL normalization helper in internal/cli for reuse by platform_client. --- docs/internals/go-package-reference.md | 82 +----- internal/cli/auth.go | 381 ------------------------- internal/cli/auth/auth.go | 319 ++++++++++++++++++++- internal/cli/{ => auth}/auth_test.go | 33 +-- internal/cli/platform_client.go | 2 +- internal/cli/platform_url.go | 15 + internal/cli/runtime.go | 5 - 7 files changed, 360 insertions(+), 477 deletions(-) delete mode 100644 internal/cli/auth.go rename internal/cli/{ => auth}/auth_test.go (81%) create mode 100644 internal/cli/platform_url.go diff --git a/docs/internals/go-package-reference.md b/docs/internals/go-package-reference.md index 08d1d06..a6763c7 100644 --- a/docs/internals/go-package-reference.md +++ b/docs/internals/go-package-reference.md @@ -2096,7 +2096,6 @@ _No package overview is documented._ - [`func IsDebugMode() bool`](#cli-internals-func-isdebugmode-bool) - [`func NewAccessCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newaccesscmd-logger-zap-logger-cobra-command) - [`func NewAccessCmdWithManager(mgr *AccessManager) *cobra.Command`](#cli-internals-func-newaccesscmdwithmanager-mgr-accessmanager-cobra-command) -- [`func NewAuthCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newauthcmd-logger-zap-logger-cobra-command) - [`func NewBootstrapCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbootstrapcmd-logger-zap-logger-cobra-command) - [`func NewBuildImageCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbuildimagecmd-logger-zap-logger-cobra-command) - [`func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclustercertcmdwithmanager-mgr-clustermanager-cobra-command) @@ -2113,6 +2112,7 @@ _No package overview is documented._ - [`func NewServerCmdWithManager(mgr *ServerManager) *cobra.Command`](#cli-internals-func-newservercmdwithmanager-mgr-servermanager-cobra-command) - [`func NewSetupCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newsetupcmd-logger-zap-logger-cobra-command) - [`func NewStatusCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newstatuscmd-logger-zap-logger-cobra-command) +- [`func NormalizePlatformAPIBaseURL(raw string) string`](#cli-internals-func-normalizeplatformapibaseurl-raw-string-string) - [`func PrintDoctorReport(r DoctorReport)`](#cli-internals-func-printdoctorreport-r-doctorreport) - [`func Red(msg string) string`](#cli-internals-func-red-msg-string-string) - [`func RunBootstrapPreflight(kubectl KubectlRunner) error`](#cli-internals-func-runbootstrappreflight-kubectl-kubectlrunner-error) @@ -2142,12 +2142,6 @@ _No package overview is documented._ - [`func (m *AccessManager) ListAccessResources(resource, namespace string, allNamespaces bool) error`](#cli-internals-func-m-accessmanager-listaccessresources-resource-namespace-string-allnamespaces-bool-error) - [`func (m *AccessManager) ToggleAccessResource(resource, name, namespace string, value bool) error`](#cli-internals-func-m-accessmanager-toggleaccessresource-resource-name-namespace-string-value-bool-error) - [`type AnalyticsImageSet struct`](#cli-internals-type-analyticsimageset-struct) -- [`type AuthManager struct`](#cli-internals-type-authmanager-struct) -- [`func NewAuthManager(logger *zap.Logger) *AuthManager`](#cli-internals-func-newauthmanager-logger-zap-logger-authmanager) -- [`func (m *AuthManager) NewLoginCmd() *cobra.Command`](#cli-internals-func-m-authmanager-newlogincmd-cobra-command) -- [`func (m *AuthManager) NewLogoutCmd() *cobra.Command`](#cli-internals-func-m-authmanager-newlogoutcmd-cobra-command) -- [`func (m *AuthManager) NewStatusCmd() *cobra.Command`](#cli-internals-func-m-authmanager-newstatuscmd-cobra-command) -- [`func (m *AuthManager) RunAuthLogin(cmd *cobra.Command, f LoginFlags) error`](#cli-internals-func-m-authmanager-runauthlogin-cmd-cobra-command-f-loginflags-error) - [`type CLIConfig struct`](#cli-internals-type-cliconfig-struct) - [`func LoadCLIConfig() *CLIConfig`](#cli-internals-func-loadcliconfig-cliconfig) - [`type CertManager struct`](#cli-internals-type-certmanager-struct) @@ -2195,7 +2189,6 @@ _No package overview is documented._ - [`func (c *KubectlClient) RunWithOutput(args []string, stdout, stderr io.Writer) error`](#cli-internals-func-c-kubectlclient-runwithoutput-args-string-stdout-stderr-io-writer-error) - [`type KubectlRunner interface`](#cli-internals-type-kubectlrunner-interface) - [`func DefaultKubectlRunner() KubectlRunner`](#cli-internals-func-defaultkubectlrunner-kubectlrunner) -- [`type LoginFlags struct`](#cli-internals-type-loginflags-struct) - [`type MockCommand struct`](#cli-internals-type-mockcommand-struct) - [`func (m *MockCommand) CombinedOutput() ([]byte, error)`](#cli-internals-func-m-mockcommand-combinedoutput-byte-error) - [`func (m *MockCommand) Output() ([]byte, error)`](#cli-internals-func-m-mockcommand-output-byte-error) @@ -2242,7 +2235,6 @@ _No package overview is documented._ - [`type Runtime struct`](#cli-internals-type-runtime-struct) - [`func NewRuntime(logger *zap.Logger) *Runtime`](#cli-internals-func-newruntime-logger-zap-logger-runtime) - [`func (r *Runtime) AccessManager() *AccessManager`](#cli-internals-func-r-runtime-accessmanager-accessmanager) -- [`func (r *Runtime) AuthManager() *AuthManager`](#cli-internals-func-r-runtime-authmanager-authmanager) - [`func (r *Runtime) ClusterManager() *ClusterManager`](#cli-internals-func-r-runtime-clustermanager-clustermanager) - [`func (r *Runtime) KubectlRunner() KubectlRunner`](#cli-internals-func-r-runtime-kubectlrunner-kubectlrunner) - [`func (r *Runtime) Logger() *zap.Logger`](#cli-internals-func-r-runtime-logger-zap-logger) @@ -2711,14 +2703,6 @@ func NewAccessCmd(logger *zap.Logger) *cobra.Command func NewAccessCmdWithManager(mgr *AccessManager) *cobra.Command ``` - -```text -func NewAuthCmd(logger *zap.Logger) *cobra.Command - NewAuthCmd is the `auth` command (login, logout, status) for platform - credentials. - -``` - ```text func NewBootstrapCmd(logger *zap.Logger) *cobra.Command @@ -2837,6 +2821,14 @@ func NewStatusCmd(logger *zap.Logger) *cobra.Command ``` + +```text +func NormalizePlatformAPIBaseURL(raw string) string + NormalizePlatformAPIBaseURL trims whitespace, trailing slashes, and an + optional trailing /api suffix from a platform base URL. + +``` + ```text func PrintDoctorReport(r DoctorReport) @@ -3050,47 +3042,6 @@ type AnalyticsImageSet struct { ``` - -```text -type AuthManager struct { - // Has unexported fields. -} - -``` - - -```text -func NewAuthManager(logger *zap.Logger) *AuthManager - -``` - - -```text -func (m *AuthManager) NewLoginCmd() *cobra.Command - NewLoginCmd exposes the login subcommand builder for folder packages. - -``` - - -```text -func (m *AuthManager) NewLogoutCmd() *cobra.Command - NewLogoutCmd exposes the logout subcommand builder for folder packages. - -``` - - -```text -func (m *AuthManager) NewStatusCmd() *cobra.Command - NewStatusCmd exposes the status subcommand builder for folder packages. - -``` - - -```text -func (m *AuthManager) RunAuthLogin(cmd *cobra.Command, f LoginFlags) error - -``` - ```text type CLIConfig struct { @@ -3504,14 +3455,6 @@ func DefaultKubectlRunner() KubectlRunner ``` - -```text -type LoginFlags struct { - // Has unexported fields. -} - -``` - ```text type MockCommand struct { @@ -3867,13 +3810,6 @@ func (r *Runtime) AccessManager() *AccessManager ``` - -```text -func (r *Runtime) AuthManager() *AuthManager - AuthManager returns the auth command manager. - -``` - ```text func (r *Runtime) ClusterManager() *ClusterManager diff --git a/internal/cli/auth.go b/internal/cli/auth.go deleted file mode 100644 index ea75661..0000000 --- a/internal/cli/auth.go +++ /dev/null @@ -1,381 +0,0 @@ -// This file implements `mcp-runtime auth` for platform (non-kubeconfig) identity: -// store API base URL and token for Sentinel API and optional registry host. - -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "net/http" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - "go.uber.org/zap" - "golang.org/x/term" - - "mcp-runtime/pkg/authfile" -) - -// authAPITestHook, if set, runs instead of the default API probe (unit tests only). -var authAPITestHook func(ctx context.Context, apiBaseURL, token string) error - -// authHTTPDoHook, if set, runs HTTP requests instead of the default client (unit tests only). -var authHTTPDoHook func(req *http.Request) (*http.Response, error) - -// NewAuthCmd is the `auth` command (login, logout, status) for platform credentials. -func NewAuthCmd(logger *zap.Logger) *cobra.Command { - m := NewAuthManager(logger) - cmd := &cobra.Command{ - Use: "auth", - Short: "Log in to the platform API and manage saved credentials", - Long: `Authenticate to the Sentinel platform using email/password or an API token (not Kubernetes). - -Use this for day-to-day deploy and registry-related flows. Cluster install and admin work -use Kubernetes and the cluster commands, not this command. - -The token is stored in a local file (mode 0600) under the user config directory, unless you set ` + authfile.EnvAPIToken + `. - -Optional environment: - ` + authfile.EnvAPIURL + ` default API base for login, e.g. https://platform.example.com - ` + authfile.EnvAPIToken + ` use this token for API calls; overrides a saved file - MCP_RUNTIME_CONFIG_DIR override the config directory (mainly for tests)`, - } - - cmd.AddCommand(m.newAuthLoginCmd()) - cmd.AddCommand(m.newAuthLogoutCmd()) - cmd.AddCommand(m.newAuthStatusCmd()) - - return cmd -} - -type AuthManager struct { - logger *zap.Logger -} - -type LoginFlags struct { - apiURL string - email string - password string - token string - tokenFromStdin bool - registryHost string - skipVerify bool -} - -func NewAuthManager(logger *zap.Logger) *AuthManager { - return &AuthManager{logger: logger} -} - -func (m *AuthManager) newAuthLoginCmd() *cobra.Command { - var f LoginFlags - cmd := &cobra.Command{ - Use: "login", - Short: "Save a platform API token and optional registry host", - RunE: func(cmd *cobra.Command, _ []string) error { - return m.RunAuthLogin(cmd, f) - }, - } - - cmd.Flags().StringVar(&f.apiURL, "api-url", os.Getenv(authfile.EnvAPIURL), "Sentinel API base URL (scheme and host, no /api path)") - cmd.Flags().StringVar(&f.email, "email", "", "Platform account email for password login") - cmd.Flags().StringVar(&f.password, "password", "", "Platform account password (prefer interactive prompt or token auth in shared shells)") - cmd.Flags().StringVar(&f.token, "token", "", "API token (or use --token-stdin, or the interactive prompt)") - cmd.Flags().BoolVar(&f.tokenFromStdin, "token-stdin", false, "Read the token from stdin (non-interactive)") - cmd.Flags().StringVar(&f.registryHost, "registry-host", "", "Optional host:port for the platform image registry for later use with docker") - cmd.Flags().BoolVar(&f.skipVerify, "skip-verify", false, "Store credentials without calling the API to validate the token") - - return cmd -} - -// NewLoginCmd exposes the login subcommand builder for folder packages. -func (m *AuthManager) NewLoginCmd() *cobra.Command { - return m.newAuthLoginCmd() -} - -func (m *AuthManager) RunAuthLogin(cmd *cobra.Command, f LoginFlags) error { - stdout := io.Writer(os.Stdout) - stderr := io.Writer(os.Stderr) - if cmd != nil { - stdout = cmd.OutOrStdout() - stderr = cmd.ErrOrStderr() - } - - apiURL := strings.TrimSpace(f.apiURL) - if apiURL == "" { - return newWithSentinel(ErrFieldRequired, "api URL is required (set --api-url or "+authfile.EnvAPIURL+")") - } - apiURL = normalizePlatformAPIBaseURL(apiURL) - if apiURL == "" { - return newWithSentinel(ErrFieldRequired, "api URL must include scheme and host") - } - - var token, loginRole string - if strings.TrimSpace(f.email) != "" || strings.TrimSpace(f.password) != "" { - if strings.TrimSpace(f.email) == "" || strings.TrimSpace(f.password) == "" { - return newWithSentinel(ErrFieldRequired, "email and password are both required for password login") - } - tok, role, err := loginPlatformPassword(context.Background(), apiURL, f.email, f.password) - if err != nil { - return newWithSentinel(nil, fmt.Sprintf("platform login failed: %v", err)) - } - token = tok - loginRole = role - f.skipVerify = true - } else if f.tokenFromStdin { - b, err := io.ReadAll(os.Stdin) - if err != nil { - return newWithSentinel(nil, fmt.Sprintf("read stdin: %v", err)) - } - token = strings.TrimSpace(string(b)) - } else if strings.TrimSpace(f.token) != "" { - token = strings.TrimSpace(f.token) - } else { - stdinFD, err := terminalFD(os.Stdin.Fd()) - if err != nil || !term.IsTerminal(stdinFD) { - return newWithSentinel(ErrFieldRequired, "not a TTY: pass --token, --token-stdin, or run in an interactive terminal") - } - fmt.Fprint(stderr, "Enter platform API token: ") - tok, err := term.ReadPassword(stdinFD) - fmt.Fprintln(stderr) - if err != nil { - return newWithSentinel(nil, fmt.Sprintf("read token: %v", err)) - } - token = strings.TrimSpace(string(tok)) - } - if token == "" { - return newWithSentinel(ErrFieldRequired, "token is required") - } - - if !f.skipVerify { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - var err error - if authAPITestHook != nil { - err = authAPITestHook(ctx, apiURL, token) - } else { - err = verifyPlatformAPIToken(ctx, apiURL, token) - } - if err != nil { - return newWithSentinel(nil, fmt.Sprintf("API token could not be verified: %v", err)) - } - } - - path, err := authfile.FilePath() - if err != nil { - return err - } - c := &authfile.Credentials{ - APIBaseURL: apiURL, - Token: token, - Role: loginRole, - RegistryHost: strings.TrimSpace(f.registryHost), - } - if err := authfile.Save(path, c); err != nil { - return err - } - if m.logger != nil { - m.logger.Info("saved platform credentials", zap.String("api", apiURL), zap.String("path", path)) - } - fmt.Fprintf(stdout, "Platform credentials saved to %s\n", path) - if c.RegistryHost != "" { - fmt.Fprintf(stdout, "Registry host recorded: %s\n", c.RegistryHost) - } - return nil -} - -func loginPlatformPassword(ctx context.Context, apiBaseURL, email, password string) (token, role string, err error) { - body, err := json.Marshal(map[string]string{"email": strings.TrimSpace(email), "password": password}) - if err != nil { - return "", "", err - } - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - u := normalizePlatformAPIBaseURL(apiBaseURL) + "/api/auth/login" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) - if err != nil { - return "", "", err - } - req.Header.Set("content-type", "application/json") - var resp *http.Response - if authHTTPDoHook != nil { - resp, err = authHTTPDoHook(req) - } else { - resp, err = (&http.Client{Timeout: 30 * time.Second}).Do(req) - } - if err != nil { - return "", "", err - } - defer drainAndCloseBody(resp.Body) - var out struct { - AccessToken string `json:"access_token"` - User struct { - Role string `json:"role"` - } `json:"user"` - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", "", fmt.Errorf("HTTP %d", resp.StatusCode) - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", "", err - } - if strings.TrimSpace(out.AccessToken) == "" { - return "", "", errors.New("login response did not include access_token") - } - return strings.TrimSpace(out.AccessToken), strings.TrimSpace(out.User.Role), nil -} - -func terminalFD(fd uintptr) (int, error) { - if fd > uintptr(math.MaxInt) { - return 0, errors.New("file descriptor out of range") - } - return int(fd), nil -} - -func (m *AuthManager) newAuthLogoutCmd() *cobra.Command { - return &cobra.Command{ - Use: "logout", - Short: "Delete saved platform credentials on this machine", - RunE: func(cmd *cobra.Command, _ []string) error { - path, err := authfile.FilePath() - if err != nil { - return err - } - if err := authfile.Remove(path); err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), "Logged out from the platform (local credentials removed).") - return nil - }, - } -} - -// NewLogoutCmd exposes the logout subcommand builder for folder packages. -func (m *AuthManager) NewLogoutCmd() *cobra.Command { - return m.newAuthLogoutCmd() -} - -func (m *AuthManager) newAuthStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show whether platform API credentials are configured", - RunE: func(cmd *cobra.Command, _ []string) error { - stdout := cmd.OutOrStdout() - stderr := cmd.ErrOrStderr() - if t := strings.TrimSpace(os.Getenv(authfile.EnvAPIToken)); t != "" { - fmt.Fprintln(stdout, "A platform API token is set in "+authfile.EnvAPIToken+" and overrides any saved file.") - if b := strings.TrimSpace(os.Getenv(authfile.EnvAPIURL)); b == "" { - fmt.Fprintln(stderr, "Note: "+authfile.EnvAPIURL+" is not set. Commands that need a base URL require it (or a saved `mcp-runtime auth login`).") - } - } else { - p, perr := authfile.FilePath() - if perr == nil { - if _, fErr := os.Stat(p); fErr == nil { - fmt.Fprintln(stdout, "Credentials file: "+p) - } else { - fmt.Fprintln(stdout, "Credentials file: "+p+" (not present)") - } - } - } - tok, api, src, rerr := authfile.ResolveToken() - if rerr != nil { - if errors.Is(rerr, authfile.ErrNotFound) { - fmt.Fprintln(stdout, "Not logged in. Run `mcp-runtime auth login` or set "+authfile.EnvAPIToken+".") - return nil - } - return rerr - } - fmt.Fprintln(stdout, "Status: have platform API token") - fmt.Fprintln(stdout, " source:", src) - if api != "" { - fmt.Fprintln(stdout, " API base URL:", api) - } else { - fmt.Fprintln(stdout, " API base URL: (set --api-url on login or "+authfile.EnvAPIURL+" if using "+authfile.EnvAPIToken+" only)") - } - if c, cErr := fileCredentialsIfRelevant(); cErr == nil && c != nil { - if c.RegistryHost != "" { - fmt.Fprintln(stdout, " saved registry host:", c.RegistryHost) - } - if c.Role != "" { - fmt.Fprintln(stdout, " role (from saved file):", c.Role) - } - } - fmt.Fprintln(stdout, " token (masked):", authfile.MaskToken(tok)) - return nil - }, - } -} - -// NewStatusCmd exposes the status subcommand builder for folder packages. -func (m *AuthManager) NewStatusCmd() *cobra.Command { - return m.newAuthStatusCmd() -} - -// fileCredentialsIfRelevant returns saved-file credentials when not using the env override. -func fileCredentialsIfRelevant() (*authfile.Credentials, error) { - if strings.TrimSpace(os.Getenv(authfile.EnvAPIToken)) != "" { - return nil, nil - } - path, err := authfile.FilePath() - if err != nil { - return nil, err - } - return authfile.Load(path) -} - -// verifyPlatformAPIToken issues GET /api/auth/me to confirm the key is accepted. -func verifyPlatformAPIToken(ctx context.Context, apiBaseURL, token string) error { - s := normalizePlatformAPIBaseURL(apiBaseURL) - u := s + "/api/auth/me" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return err - } - req.Header.Set("x-api-key", token) - req.Header.Set("authorization", "Bearer "+token) - var resp *http.Response - if authHTTPDoHook != nil { - resp, err = authHTTPDoHook(req) - } else { - client := &http.Client{Timeout: 30 * time.Second} - resp, err = client.Do(req) - } - if err != nil { - return err - } - defer drainAndCloseBody(resp.Body) - switch resp.StatusCode { - case http.StatusUnauthorized, http.StatusForbidden: - return fmt.Errorf("server rejected the token (HTTP %d)", resp.StatusCode) - case http.StatusNotFound: - return fmt.Errorf("API URL may be wrong (path returned HTTP 404, expected %q)", u) - } - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - return fmt.Errorf("verify request failed: HTTP %d", resp.StatusCode) -} - -func normalizePlatformAPIBaseURL(raw string) string { - s := strings.TrimSpace(raw) - s = strings.TrimRight(s, "/") - if strings.HasSuffix(strings.ToLower(s), "/api") { - s = strings.TrimSpace(s[:len(s)-len("/api")]) - s = strings.TrimRight(s, "/") - } - return s -} - -func drainAndCloseBody(body io.ReadCloser) { - if body == nil { - return - } - _, _ = io.Copy(io.Discard, body) - _ = body.Close() -} diff --git a/internal/cli/auth/auth.go b/internal/cli/auth/auth.go index 7151109..bf2b761 100644 --- a/internal/cli/auth/auth.go +++ b/internal/cli/auth/auth.go @@ -2,15 +2,53 @@ package auth import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "strings" + "time" + "github.com/spf13/cobra" + "go.uber.org/zap" + "golang.org/x/term" "mcp-runtime/internal/cli" "mcp-runtime/pkg/authfile" ) +// apiTestHook, if set, runs instead of the default API probe (unit tests only). +var apiTestHook func(ctx context.Context, apiBaseURL, token string) error + +// httpDoHook, if set, runs HTTP requests instead of the default client (unit tests only). +var httpDoHook func(req *http.Request) (*http.Response, error) + +type manager struct { + logger *zap.Logger +} + +type loginFlags struct { + apiURL string + email string + password string + token string + tokenFromStdin bool + registryHost string + skipVerify bool +} + +func newManager(runtime *cli.Runtime) *manager { + return &manager{logger: runtime.Logger()} +} + // New returns the auth command. func New(runtime *cli.Runtime) *cobra.Command { - m := runtime.AuthManager() + m := newManager(runtime) cmd := &cobra.Command{ Use: "auth", Short: "Log in to the platform API and manage saved credentials", @@ -32,3 +70,282 @@ Optional environment: cmd.AddCommand(m.NewStatusCmd()) return cmd } + +func (m *manager) NewLoginCmd() *cobra.Command { + var f loginFlags + cmd := &cobra.Command{ + Use: "login", + Short: "Save a platform API token and optional registry host", + RunE: func(cmd *cobra.Command, _ []string) error { + return m.runLogin(cmd, f) + }, + } + + cmd.Flags().StringVar(&f.apiURL, "api-url", os.Getenv(authfile.EnvAPIURL), "Sentinel API base URL (scheme and host, no /api path)") + cmd.Flags().StringVar(&f.email, "email", "", "Platform account email for password login") + cmd.Flags().StringVar(&f.password, "password", "", "Platform account password (prefer interactive prompt or token auth in shared shells)") + cmd.Flags().StringVar(&f.token, "token", "", "API token (or use --token-stdin, or the interactive prompt)") + cmd.Flags().BoolVar(&f.tokenFromStdin, "token-stdin", false, "Read the token from stdin (non-interactive)") + cmd.Flags().StringVar(&f.registryHost, "registry-host", "", "Optional host:port for the platform image registry for later use with docker") + cmd.Flags().BoolVar(&f.skipVerify, "skip-verify", false, "Store credentials without calling the API to validate the token") + + return cmd +} + +func (m *manager) runLogin(cmd *cobra.Command, f loginFlags) error { + stdout := io.Writer(os.Stdout) + stderr := io.Writer(os.Stderr) + if cmd != nil { + stdout = cmd.OutOrStdout() + stderr = cmd.ErrOrStderr() + } + + apiURL := strings.TrimSpace(f.apiURL) + if apiURL == "" { + return fmt.Errorf("api URL is required (set --api-url or %s)", authfile.EnvAPIURL) + } + apiURL = cli.NormalizePlatformAPIBaseURL(apiURL) + if apiURL == "" { + return errors.New("api URL must include scheme and host") + } + + var token, loginRole string + if strings.TrimSpace(f.email) != "" || strings.TrimSpace(f.password) != "" { + if strings.TrimSpace(f.email) == "" || strings.TrimSpace(f.password) == "" { + return errors.New("email and password are both required for password login") + } + tok, role, err := loginPlatformPassword(context.Background(), apiURL, f.email, f.password) + if err != nil { + return fmt.Errorf("platform login failed: %w", err) + } + token = tok + loginRole = role + f.skipVerify = true + } else if f.tokenFromStdin { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("read stdin: %w", err) + } + token = strings.TrimSpace(string(b)) + } else if strings.TrimSpace(f.token) != "" { + token = strings.TrimSpace(f.token) + } else { + stdinFD, err := terminalFD(os.Stdin.Fd()) + if err != nil || !term.IsTerminal(stdinFD) { + return errors.New("not a TTY: pass --token, --token-stdin, or run in an interactive terminal") + } + fmt.Fprint(stderr, "Enter platform API token: ") + tok, err := term.ReadPassword(stdinFD) + fmt.Fprintln(stderr) + if err != nil { + return fmt.Errorf("read token: %w", err) + } + token = strings.TrimSpace(string(tok)) + } + if token == "" { + return errors.New("token is required") + } + + if !f.skipVerify { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var err error + if apiTestHook != nil { + err = apiTestHook(ctx, apiURL, token) + } else { + err = verifyPlatformAPIToken(ctx, apiURL, token) + } + if err != nil { + return fmt.Errorf("API token could not be verified: %w", err) + } + } + + path, err := authfile.FilePath() + if err != nil { + return err + } + c := &authfile.Credentials{ + APIBaseURL: apiURL, + Token: token, + Role: loginRole, + RegistryHost: strings.TrimSpace(f.registryHost), + } + if err := authfile.Save(path, c); err != nil { + return err + } + if m.logger != nil { + m.logger.Info("saved platform credentials", zap.String("api", apiURL), zap.String("path", path)) + } + fmt.Fprintf(stdout, "Platform credentials saved to %s\n", path) + if c.RegistryHost != "" { + fmt.Fprintf(stdout, "Registry host recorded: %s\n", c.RegistryHost) + } + return nil +} + +func (m *manager) NewLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Delete saved platform credentials on this machine", + RunE: func(cmd *cobra.Command, _ []string) error { + path, err := authfile.FilePath() + if err != nil { + return err + } + if err := authfile.Remove(path); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Logged out from the platform (local credentials removed).") + return nil + }, + } +} + +func (m *manager) NewStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether platform API credentials are configured", + RunE: func(cmd *cobra.Command, _ []string) error { + stdout := cmd.OutOrStdout() + stderr := cmd.ErrOrStderr() + if t := strings.TrimSpace(os.Getenv(authfile.EnvAPIToken)); t != "" { + fmt.Fprintln(stdout, "A platform API token is set in "+authfile.EnvAPIToken+" and overrides any saved file.") + if b := strings.TrimSpace(os.Getenv(authfile.EnvAPIURL)); b == "" { + fmt.Fprintln(stderr, "Note: "+authfile.EnvAPIURL+" is not set. Commands that need a base URL require it (or a saved `mcp-runtime auth login`).") + } + } else { + p, perr := authfile.FilePath() + if perr == nil { + if _, fErr := os.Stat(p); fErr == nil { + fmt.Fprintln(stdout, "Credentials file: "+p) + } else { + fmt.Fprintln(stdout, "Credentials file: "+p+" (not present)") + } + } + } + tok, api, src, rerr := authfile.ResolveToken() + if rerr != nil { + if errors.Is(rerr, authfile.ErrNotFound) { + fmt.Fprintln(stdout, "Not logged in. Run `mcp-runtime auth login` or set "+authfile.EnvAPIToken+".") + return nil + } + return rerr + } + fmt.Fprintln(stdout, "Status: have platform API token") + fmt.Fprintln(stdout, " source:", src) + if api != "" { + fmt.Fprintln(stdout, " API base URL:", api) + } else { + fmt.Fprintln(stdout, " API base URL: (set --api-url on login or "+authfile.EnvAPIURL+" if using "+authfile.EnvAPIToken+" only)") + } + if c, cErr := fileCredentialsIfRelevant(); cErr == nil && c != nil { + if c.RegistryHost != "" { + fmt.Fprintln(stdout, " saved registry host:", c.RegistryHost) + } + if c.Role != "" { + fmt.Fprintln(stdout, " role (from saved file):", c.Role) + } + } + fmt.Fprintln(stdout, " token (masked):", authfile.MaskToken(tok)) + return nil + }, + } +} + +func loginPlatformPassword(ctx context.Context, apiBaseURL, email, password string) (token, role string, err error) { + body, err := json.Marshal(map[string]string{"email": strings.TrimSpace(email), "password": password}) + if err != nil { + return "", "", err + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + u := cli.NormalizePlatformAPIBaseURL(apiBaseURL) + "/api/auth/login" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return "", "", err + } + req.Header.Set("content-type", "application/json") + var resp *http.Response + if httpDoHook != nil { + resp, err = httpDoHook(req) + } else { + resp, err = (&http.Client{Timeout: 30 * time.Second}).Do(req) + } + if err != nil { + return "", "", err + } + defer drainAndCloseBody(resp.Body) + var out struct { + AccessToken string `json:"access_token"` + User struct { + Role string `json:"role"` + } `json:"user"` + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", "", err + } + if strings.TrimSpace(out.AccessToken) == "" { + return "", "", errors.New("login response did not include access_token") + } + return strings.TrimSpace(out.AccessToken), strings.TrimSpace(out.User.Role), nil +} + +func fileCredentialsIfRelevant() (*authfile.Credentials, error) { + if strings.TrimSpace(os.Getenv(authfile.EnvAPIToken)) != "" { + return nil, nil + } + path, err := authfile.FilePath() + if err != nil { + return nil, err + } + return authfile.Load(path) +} + +func verifyPlatformAPIToken(ctx context.Context, apiBaseURL, token string) error { + u := cli.NormalizePlatformAPIBaseURL(apiBaseURL) + "/api/auth/me" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("x-api-key", token) + req.Header.Set("authorization", "Bearer "+token) + var resp *http.Response + if httpDoHook != nil { + resp, err = httpDoHook(req) + } else { + client := &http.Client{Timeout: 30 * time.Second} + resp, err = client.Do(req) + } + if err != nil { + return err + } + defer drainAndCloseBody(resp.Body) + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("server rejected the token (HTTP %d)", resp.StatusCode) + case http.StatusNotFound: + return fmt.Errorf("API URL may be wrong (path returned HTTP 404, expected %q)", u) + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("verify request failed: HTTP %d", resp.StatusCode) +} + +func terminalFD(fd uintptr) (int, error) { + if fd > uintptr(math.MaxInt) { + return 0, errors.New("file descriptor out of range") + } + return int(fd), nil +} + +func drainAndCloseBody(body io.ReadCloser) { + if body == nil { + return + } + _, _ = io.Copy(io.Discard, body) + _ = body.Close() +} diff --git a/internal/cli/auth_test.go b/internal/cli/auth/auth_test.go similarity index 81% rename from internal/cli/auth_test.go rename to internal/cli/auth/auth_test.go index 960e94c..6166521 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth/auth_test.go @@ -1,4 +1,4 @@ -package cli +package auth import ( "bytes" @@ -12,12 +12,13 @@ import ( "time" "go.uber.org/zap" + "mcp-runtime/internal/cli" "mcp-runtime/pkg/authfile" ) func TestVerifyPlatformAPIToken(t *testing.T) { - prevHook := authHTTPDoHook - authHTTPDoHook = func(r *http.Request) (*http.Response, error) { + prevHook := httpDoHook + httpDoHook = func(r *http.Request) (*http.Response, error) { if r.URL.Path != "/api/auth/me" { t.Errorf("path: %q", r.URL.Path) return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil))}, nil @@ -28,7 +29,7 @@ func TestVerifyPlatformAPIToken(t *testing.T) { } return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte("[]")))}, nil } - defer func() { authHTTPDoHook = prevHook }() + defer func() { httpDoHook = prevHook }() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := verifyPlatformAPIToken(ctx, "https://platform.example.com", "k"); err != nil { @@ -36,12 +37,12 @@ func TestVerifyPlatformAPIToken(t *testing.T) { } } -func TestVerifyPlatformAPIToken_Unauthorized(t *testing.T) { - prevHook := authHTTPDoHook - authHTTPDoHook = func(_ *http.Request) (*http.Response, error) { +func TestVerifyPlatformAPITokenUnauthorized(t *testing.T) { + prevHook := httpDoHook + httpDoHook = func(_ *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(bytes.NewReader(nil))}, nil } - defer func() { authHTTPDoHook = prevHook }() + defer func() { httpDoHook = prevHook }() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := verifyPlatformAPIToken(ctx, "https://platform.example.com", "k"); err == nil { @@ -53,8 +54,8 @@ func TestAuthLoginSavesAndVerifies(t *testing.T) { d := t.TempDir() t.Setenv("MCP_RUNTIME_CONFIG_DIR", d) - prevHTTPHook := authHTTPDoHook - authHTTPDoHook = func(r *http.Request) (*http.Response, error) { + prevHTTPHook := httpDoHook + httpDoHook = func(r *http.Request) (*http.Response, error) { if r.URL.Path != "/api/auth/me" { t.Errorf("path: %q", r.URL.Path) } @@ -63,9 +64,9 @@ func TestAuthLoginSavesAndVerifies(t *testing.T) { } return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte("[]")))}, nil } - defer func() { authHTTPDoHook = prevHTTPHook }() + defer func() { httpDoHook = prevHTTPHook }() - cmd := NewAuthCmd(zap.NewNop()) + cmd := New(cli.NewRuntime(zap.NewNop())) var out, errb bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&errb) @@ -93,8 +94,8 @@ func TestAuthLoginSavesAndVerifies(t *testing.T) { func TestAuthLoginNormalizesTrailingAPIPath(t *testing.T) { d := t.TempDir() t.Setenv("MCP_RUNTIME_CONFIG_DIR", d) - previousHook := authAPITestHook - authAPITestHook = func(_ context.Context, apiBaseURL, token string) error { + previousHook := apiTestHook + apiTestHook = func(_ context.Context, apiBaseURL, token string) error { if apiBaseURL != "https://platform.example.com" { t.Fatalf("apiBaseURL = %q, want https://platform.example.com", apiBaseURL) } @@ -103,9 +104,9 @@ func TestAuthLoginNormalizesTrailingAPIPath(t *testing.T) { } return nil } - defer func() { authAPITestHook = previousHook }() + defer func() { apiTestHook = previousHook }() - cmd := NewAuthCmd(zap.NewNop()) + cmd := New(cli.NewRuntime(zap.NewNop())) cmd.SetArgs([]string{"login", "--api-url", "https://platform.example.com/api/", "--token", "good"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute: %v", err) diff --git a/internal/cli/platform_client.go b/internal/cli/platform_client.go index 4c05953..4094796 100644 --- a/internal/cli/platform_client.go +++ b/internal/cli/platform_client.go @@ -49,7 +49,7 @@ func newPlatformClient() (*platformClient, error) { return nil, authfile.ErrNotFound } return &platformClient{ - baseURL: normalizePlatformAPIBaseURL(base), + baseURL: NormalizePlatformAPIBaseURL(base), token: tok, http: &http.Client{Timeout: 2 * time.Minute}, apiPrefix: "/api", diff --git a/internal/cli/platform_url.go b/internal/cli/platform_url.go new file mode 100644 index 0000000..748508c --- /dev/null +++ b/internal/cli/platform_url.go @@ -0,0 +1,15 @@ +package cli + +import "strings" + +// NormalizePlatformAPIBaseURL trims whitespace, trailing slashes, and an +// optional trailing /api suffix from a platform base URL. +func NormalizePlatformAPIBaseURL(raw string) string { + s := strings.TrimSpace(raw) + s = strings.TrimRight(s, "/") + if strings.HasSuffix(strings.ToLower(s), "/api") { + s = strings.TrimSpace(s[:len(s)-len("/api")]) + s = strings.TrimRight(s, "/") + } + return s +} diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index 36b5f4f..ca8df4f 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -28,11 +28,6 @@ func (r *Runtime) AccessManager() *AccessManager { return DefaultAccessManager(r.logger) } -// AuthManager returns the auth command manager. -func (r *Runtime) AuthManager() *AuthManager { - return NewAuthManager(r.logger) -} - // ClusterManager returns the cluster command manager. func (r *Runtime) ClusterManager() *ClusterManager { return DefaultClusterManager(r.logger) From 2eef67a3999bf31623d690a37a214c1438b98dcb Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Fri, 1 May 2026 17:58:45 +0530 Subject: [PATCH 4/4] refactor(cli): move command logic into folder packages --- docs/internals/go-package-reference.md | 277 ++++---------- internal/cli/access.go | 141 ------- internal/cli/access_test.go | 22 -- internal/cli/bootstrap.go | 189 --------- internal/cli/bootstrap/bootstrap.go | 123 +++++- internal/cli/build.go | 37 +- internal/cli/build_test.go | 72 ---- internal/cli/cert.go | 60 --- internal/cli/cert_test.go | 136 ------- internal/cli/cluster.go | 144 +------ internal/cli/cluster/cert.go | 64 ++++ internal/cli/cluster/cluster.go | 4 +- internal/cli/cluster/doctor.go | 24 ++ internal/cli/cluster_doctor.go | 19 - internal/cli/cluster_test.go | 143 +++---- internal/cli/errors.go | 20 + internal/cli/pipeline.go | 251 ------------ internal/cli/pipeline/pipeline.go | 166 +++++++- internal/cli/pipeline/pipeline_test.go | 168 ++++++++ internal/cli/pipeline_test.go | 506 ------------------------- internal/cli/platform_client.go | 5 + internal/cli/registry.go | 193 ---------- internal/cli/registry_test.go | 268 +------------ internal/cli/resource_helpers.go | 8 + internal/cli/runtime.go | 15 +- internal/cli/sentinel.go | 111 ------ internal/cli/sentinel_test.go | 30 -- internal/cli/server.go | 255 ------------- internal/cli/server/build.go | 36 ++ internal/cli/server/server.go | 2 +- internal/cli/server_test.go | 125 ------ internal/cli/setup.go | 125 +----- internal/cli/setup_steps_test.go | 18 - internal/cli/status.go | 19 - internal/cli/status_test.go | 27 +- 35 files changed, 766 insertions(+), 3037 deletions(-) delete mode 100644 internal/cli/bootstrap.go create mode 100644 internal/cli/cluster/cert.go create mode 100644 internal/cli/cluster/doctor.go delete mode 100644 internal/cli/pipeline.go create mode 100644 internal/cli/pipeline/pipeline_test.go delete mode 100644 internal/cli/pipeline_test.go create mode 100644 internal/cli/server/build.go diff --git a/docs/internals/go-package-reference.md b/docs/internals/go-package-reference.md index a6763c7..d935a2e 100644 --- a/docs/internals/go-package-reference.md +++ b/docs/internals/go-package-reference.md @@ -2069,11 +2069,11 @@ _No package overview is documented._ - [`Constants`](#cli-internals-constants) - [`Variables`](#cli-internals-variables) -- [`func BootstrapApplyK3s(kubectl KubectlRunner) error`](#cli-internals-func-bootstrapapplyk3s-kubectl-kubectlrunner-error) +- [`func ApplyManifestContentWithNamespace(kubectl KubectlRunner, manifest, namespace string) error`](#cli-internals-func-applymanifestcontentwithnamespace-kubectl-kubectlrunner-manifest-namespace-string-error) +- [`func BuildImage(logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, context string) error`](#cli-internals-func-buildimage-logger-zap-logger-servername-dockerfile-metadatafile-metadatadir-registryurl-tag-context-string-error) - [`func BuildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string`](#cli-internals-func-buildoperatorargs-metricsaddr-probeaddr-string-leaderelect-leaderelectchanged-bool-string) - [`func ClusterIssuerNameForACME(staging bool) string`](#cli-internals-func-clusterissuernameforacme-staging-bool-string) - [`func Cyan(msg string) string`](#cli-internals-func-cyan-msg-string-string) -- [`func DetectProvider(kubectl KubectlRunner) (string, error)`](#cli-internals-func-detectprovider-kubectl-kubectlrunner-string-error) - [`func Error(msg string)`](#cli-internals-func-error-msg-string) - [`func GetAnalyticsIngestURLOverride() string`](#cli-internals-func-getanalyticsingesturloverride-string) - [`func GetCertTimeout() time.Duration`](#cli-internals-func-getcerttimeout-time-duration) @@ -2091,31 +2091,18 @@ _No package overview is documented._ - [`func GetRegistryPort() int`](#cli-internals-func-getregistryport-int) - [`func GetSkopeoImage() string`](#cli-internals-func-getskopeoimage-string) - [`func Green(msg string) string`](#cli-internals-func-green-msg-string-string) +- [`func HasPlatformClient() bool`](#cli-internals-func-hasplatformclient-bool) - [`func Header(title string)`](#cli-internals-func-header-title-string) - [`func Info(msg string)`](#cli-internals-func-info-msg-string) - [`func IsDebugMode() bool`](#cli-internals-func-isdebugmode-bool) -- [`func NewAccessCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newaccesscmd-logger-zap-logger-cobra-command) -- [`func NewAccessCmdWithManager(mgr *AccessManager) *cobra.Command`](#cli-internals-func-newaccesscmdwithmanager-mgr-accessmanager-cobra-command) -- [`func NewBootstrapCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbootstrapcmd-logger-zap-logger-cobra-command) -- [`func NewBuildImageCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newbuildimagecmd-logger-zap-logger-cobra-command) -- [`func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclustercertcmdwithmanager-mgr-clustermanager-cobra-command) -- [`func NewClusterCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newclustercmd-logger-zap-logger-cobra-command) -- [`func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclustercmdwithmanager-mgr-clustermanager-cobra-command) -- [`func NewClusterDoctorCmdWithManager(mgr *ClusterManager) *cobra.Command`](#cli-internals-func-newclusterdoctorcmdwithmanager-mgr-clustermanager-cobra-command) -- [`func NewPipelineCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newpipelinecmd-logger-zap-logger-cobra-command) -- [`func NewPipelineCmdWithManager(mgr *PipelineManager) *cobra.Command`](#cli-internals-func-newpipelinecmdwithmanager-mgr-pipelinemanager-cobra-command) -- [`func NewRegistryCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newregistrycmd-logger-zap-logger-cobra-command) -- [`func NewRegistryCmdWithManager(mgr *RegistryManager) *cobra.Command`](#cli-internals-func-newregistrycmdwithmanager-mgr-registrymanager-cobra-command) -- [`func NewSentinelCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newsentinelcmd-logger-zap-logger-cobra-command) -- [`func NewSentinelCmdWithManager(mgr *SentinelManager) *cobra.Command`](#cli-internals-func-newsentinelcmdwithmanager-mgr-sentinelmanager-cobra-command) -- [`func NewServerCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newservercmd-logger-zap-logger-cobra-command) -- [`func NewServerCmdWithManager(mgr *ServerManager) *cobra.Command`](#cli-internals-func-newservercmdwithmanager-mgr-servermanager-cobra-command) -- [`func NewSetupCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newsetupcmd-logger-zap-logger-cobra-command) -- [`func NewStatusCmd(logger *zap.Logger) *cobra.Command`](#cli-internals-func-newstatuscmd-logger-zap-logger-cobra-command) +- [`func LogStructuredError(logger *zap.Logger, err error, msg string)`](#cli-internals-func-logstructurederror-logger-zap-logger-err-error-msg-string) +- [`func NewSetupStepFailedError() error`](#cli-internals-func-newsetupstepfailederror-error) +- [`func NewWithSentinel(base error, msg string) error`](#cli-internals-func-newwithsentinel-base-error-msg-string-error) - [`func NormalizePlatformAPIBaseURL(raw string) string`](#cli-internals-func-normalizeplatformapibaseurl-raw-string-string) - [`func PrintDoctorReport(r DoctorReport)`](#cli-internals-func-printdoctorreport-r-doctorreport) +- [`func ReadFileAtPath(path string) ([]byte, error)`](#cli-internals-func-readfileatpath-path-string-byte-error) - [`func Red(msg string) string`](#cli-internals-func-red-msg-string-string) -- [`func RunBootstrapPreflight(kubectl KubectlRunner) error`](#cli-internals-func-runbootstrappreflight-kubectl-kubectlrunner-error) +- [`func ResolveRegularFilePath(file string) (string, error)`](#cli-internals-func-resolveregularfilepath-file-string-string-error) - [`func RunRegistryProvision(mgr *RegistryManager, url, username, password, operatorImage string) error`](#cli-internals-func-runregistryprovision-mgr-registrymanager-url-username-password-operatorimage-string-error) - [`func RunRegistryPush(mgr *RegistryManager, image, registryURL, name, mode, helperNamespace string) error`](#cli-internals-func-runregistrypush-mgr-registrymanager-image-registryurl-name-mode-helpernamespace-string-error) - [`func Section(title string)`](#cli-internals-func-section-title-string) @@ -2131,6 +2118,8 @@ _No package overview is documented._ - [`func ValidateStorageMode(mode string) error`](#cli-internals-func-validatestoragemode-mode-string-error) - [`func ValidateTLSSetupCLIFlags(`](#cli-internals-func-validatetlssetupcliflags) - [`func Warn(msg string)`](#cli-internals-func-warn-msg-string) +- [`func WrapWithSentinel(base, cause error, msg string) error`](#cli-internals-func-wrapwithsentinel-base-cause-error-msg-string-error) +- [`func WrapWithSentinelAndContext(base, cause error, msg string, context map[string]any) error`](#cli-internals-func-wrapwithsentinelandcontext-base-cause-error-msg-string-context-map-string-any-error) - [`func Yellow(msg string) string`](#cli-internals-func-yellow-msg-string-string) - [`type AccessManager struct`](#cli-internals-type-accessmanager-struct) - [`func DefaultAccessManager(logger *zap.Logger) *AccessManager`](#cli-internals-func-defaultaccessmanager-logger-zap-logger-accessmanager) @@ -2159,6 +2148,8 @@ _No package overview is documented._ - [`func (m *ClusterManager) ConfigureKubeconfigFromProvider(provider, region, clusterName, resourceGroup, project, zone, kubeconfig string) error`](#cli-internals-func-m-clustermanager-configurekubeconfigfromprovider-provider-region-clustername-resourcegroup-project-zone-kubeconfig-string-error) - [`func (m *ClusterManager) EnsureNamespace(name string) error`](#cli-internals-func-m-clustermanager-ensurenamespace-name-string-error) - [`func (m *ClusterManager) InitCluster(kubeconfig, context string) error`](#cli-internals-func-m-clustermanager-initcluster-kubeconfig-context-string-error) +- [`func (m *ClusterManager) KubectlRunner() KubectlRunner`](#cli-internals-func-m-clustermanager-kubectlrunner-kubectlrunner) +- [`func (m *ClusterManager) Logger() *zap.Logger`](#cli-internals-func-m-clustermanager-logger-zap-logger) - [`func (m *ClusterManager) ProvisionCluster(provider, region string, nodeCount int, clusterName string) error`](#cli-internals-func-m-clustermanager-provisioncluster-provider-region-string-nodecount-int-clustername-string-error) - [`type ClusterManagerAPI interface`](#cli-internals-type-clustermanagerapi-interface) - [`type Command interface`](#cli-internals-type-command-interface) @@ -2201,11 +2192,6 @@ _No package overview is documented._ - [`func (m *MockExecutor) HasCommand(name string) bool`](#cli-internals-func-m-mockexecutor-hascommand-name-string-bool) - [`func (m *MockExecutor) LastCommand() ExecSpec`](#cli-internals-func-m-mockexecutor-lastcommand-execspec) - [`func (m *MockExecutor) Reset()`](#cli-internals-func-m-mockexecutor-reset) -- [`type PipelineManager struct`](#cli-internals-type-pipelinemanager-struct) -- [`func DefaultPipelineManager(logger *zap.Logger) *PipelineManager`](#cli-internals-func-defaultpipelinemanager-logger-zap-logger-pipelinemanager) -- [`func NewPipelineManager(kubectl *KubectlClient, logger *zap.Logger) *PipelineManager`](#cli-internals-func-newpipelinemanager-kubectl-kubectlclient-logger-zap-logger-pipelinemanager) -- [`func (m *PipelineManager) DeployCRDs(manifestsDir, namespace string) error`](#cli-internals-func-m-pipelinemanager-deploycrds-manifestsdir-namespace-string-error) -- [`func (m *PipelineManager) GenerateCRDsFromMetadata(metadataFile, metadataDir, outputDir string) error`](#cli-internals-func-m-pipelinemanager-generatecrdsfrommetadata-metadatafile-metadatadir-outputdir-string-error) - [`type Printer struct`](#cli-internals-type-printer-struct) - [`func (p *Printer) Cyan(msg string) string`](#cli-internals-func-p-printer-cyan-msg-string-string) - [`func (p *Printer) Error(msg string)`](#cli-internals-func-p-printer-error-msg-string) @@ -2236,9 +2222,10 @@ _No package overview is documented._ - [`func NewRuntime(logger *zap.Logger) *Runtime`](#cli-internals-func-newruntime-logger-zap-logger-runtime) - [`func (r *Runtime) AccessManager() *AccessManager`](#cli-internals-func-r-runtime-accessmanager-accessmanager) - [`func (r *Runtime) ClusterManager() *ClusterManager`](#cli-internals-func-r-runtime-clustermanager-clustermanager) +- [`func (r *Runtime) Executor() Executor`](#cli-internals-func-r-runtime-executor-executor) +- [`func (r *Runtime) KubectlClient() *KubectlClient`](#cli-internals-func-r-runtime-kubectlclient-kubectlclient) - [`func (r *Runtime) KubectlRunner() KubectlRunner`](#cli-internals-func-r-runtime-kubectlrunner-kubectlrunner) - [`func (r *Runtime) Logger() *zap.Logger`](#cli-internals-func-r-runtime-logger-zap-logger) -- [`func (r *Runtime) PipelineManager() *PipelineManager`](#cli-internals-func-r-runtime-pipelinemanager-pipelinemanager) - [`func (r *Runtime) RegistryManager() *RegistryManager`](#cli-internals-func-r-runtime-registrymanager-registrymanager) - [`func (r *Runtime) SentinelManager() *SentinelManager`](#cli-internals-func-r-runtime-sentinelmanager-sentinelmanager) - [`func (r *Runtime) ServerManager() *ServerManager`](#cli-internals-func-r-runtime-servermanager-servermanager) @@ -2510,9 +2497,14 @@ var DefaultPrinter = &Printer{} ### Functions - + ```text -func BootstrapApplyK3s(kubectl KubectlRunner) error +func ApplyManifestContentWithNamespace(kubectl KubectlRunner, manifest, namespace string) error +``` + + +```text +func BuildImage(logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, context string) error ``` @@ -2538,11 +2530,6 @@ func Cyan(msg string) string ``` - -```text -func DetectProvider(kubectl KubectlRunner) (string, error) -``` - ```text func Error(msg string) @@ -2672,6 +2659,11 @@ func Green(msg string) string ``` + +```text +func HasPlatformClient() bool +``` + ```text func Header(title string) @@ -2693,132 +2685,19 @@ func IsDebugMode() bool ``` - -```text -func NewAccessCmd(logger *zap.Logger) *cobra.Command -``` - - -```text -func NewAccessCmdWithManager(mgr *AccessManager) *cobra.Command -``` - - -```text -func NewBootstrapCmd(logger *zap.Logger) *cobra.Command - NewBootstrapCmd provides an on-prem focused bootstrap workflow. - - Production note: this intentionally does not attempt to provision clusters - across all distributions. It performs preflights and (optionally) applies a - small set of safe, local-distro-specific fixes. - -``` - - -```text -func NewBuildImageCmd(logger *zap.Logger) *cobra.Command -``` - - -```text -func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command - NewClusterCertCmdWithManager exposes the cert subcommand builder for folder - packages. - -``` - - -```text -func NewClusterCmd(logger *zap.Logger) *cobra.Command - NewClusterCmd returns the root cluster subcommand (status/init/provision). - -``` - - -```text -func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command - NewClusterCmdWithManager returns the cluster subcommand using the provided - manager. - -``` - - -```text -func NewClusterDoctorCmdWithManager(mgr *ClusterManager) *cobra.Command - NewClusterDoctorCmdWithManager exposes the doctor subcommand builder for - folder packages. - -``` - - -```text -func NewPipelineCmd(logger *zap.Logger) *cobra.Command - NewPipelineCmd returns the pipeline subcommand for generate/deploy flows. - -``` - - -```text -func NewPipelineCmdWithManager(mgr *PipelineManager) *cobra.Command - NewPipelineCmdWithManager returns the pipeline subcommand using the provided - manager. - -``` - - -```text -func NewRegistryCmd(logger *zap.Logger) *cobra.Command - NewRegistryCmd builds the registry subcommand for managing registry - lifecycle. - -``` - - -```text -func NewRegistryCmdWithManager(mgr *RegistryManager) *cobra.Command - NewRegistryCmdWithManager returns the registry subcommand using the provided - manager. - -``` - - -```text -func NewSentinelCmd(logger *zap.Logger) *cobra.Command -``` - - -```text -func NewSentinelCmdWithManager(mgr *SentinelManager) *cobra.Command -``` - - + ```text -func NewServerCmd(logger *zap.Logger) *cobra.Command - NewServerCmd returns the server subcommand (build/deploy helpers). - +func LogStructuredError(logger *zap.Logger, err error, msg string) ``` - + ```text -func NewServerCmdWithManager(mgr *ServerManager) *cobra.Command - NewServerCmdWithManager returns the server subcommand using the provided - manager. This is useful for testing with mock dependencies. - +func NewSetupStepFailedError() error ``` - + ```text -func NewSetupCmd(logger *zap.Logger) *cobra.Command - NewSetupCmd constructs the top-level setup command for installing the - platform. - -``` - - -```text -func NewStatusCmd(logger *zap.Logger) *cobra.Command - NewStatusCmd returns the status subcommand for platform health checks. - +func NewWithSentinel(base error, msg string) error ``` @@ -2836,6 +2715,11 @@ func PrintDoctorReport(r DoctorReport) ``` + +```text +func ReadFileAtPath(path string) ([]byte, error) +``` + ```text func Red(msg string) string @@ -2843,9 +2727,9 @@ func Red(msg string) string ``` - + ```text -func RunBootstrapPreflight(kubectl KubectlRunner) error +func ResolveRegularFilePath(file string) (string, error) ``` @@ -2953,6 +2837,16 @@ func Warn(msg string) ``` + +```text +func WrapWithSentinel(base, cause error, msg string) error +``` + + +```text +func WrapWithSentinelAndContext(base, cause error, msg string, context map[string]any) error +``` + ```text func Yellow(msg string) string @@ -3201,6 +3095,21 @@ func (m *ClusterManager) InitCluster(kubeconfig, context string) error ``` + +```text +func (m *ClusterManager) KubectlRunner() KubectlRunner + KubectlRunner exposes the shared kubectl runner for foldered command + routing. + +``` + + +```text +func (m *ClusterManager) Logger() *zap.Logger + Logger exposes the shared logger for foldered command routing. + +``` + ```text func (m *ClusterManager) ProvisionCluster(provider, region string, nodeCount int, clusterName string) error @@ -3552,43 +3461,6 @@ func (m *MockExecutor) Reset() ``` - -```text -type PipelineManager struct { - // Has unexported fields. -} - PipelineManager handles pipeline operations with injected dependencies. - -``` - - -```text -func DefaultPipelineManager(logger *zap.Logger) *PipelineManager - DefaultPipelineManager returns a PipelineManager using default clients. - -``` - - -```text -func NewPipelineManager(kubectl *KubectlClient, logger *zap.Logger) *PipelineManager - NewPipelineManager creates a PipelineManager with the given dependencies. - -``` - - -```text -func (m *PipelineManager) DeployCRDs(manifestsDir, namespace string) error - DeployCRDs deploys CRD files to the cluster. - -``` - - -```text -func (m *PipelineManager) GenerateCRDsFromMetadata(metadataFile, metadataDir, outputDir string) error - GenerateCRDsFromMetadata generates CRD files from metadata. - -``` - ```text type Printer struct { @@ -3817,6 +3689,20 @@ func (r *Runtime) ClusterManager() *ClusterManager ``` + +```text +func (r *Runtime) Executor() Executor + Executor returns the shared process executor. + +``` + + +```text +func (r *Runtime) KubectlClient() *KubectlClient + KubectlClient returns the shared kubectl client. + +``` + ```text func (r *Runtime) KubectlRunner() KubectlRunner @@ -3831,13 +3717,6 @@ func (r *Runtime) Logger() *zap.Logger ``` - -```text -func (r *Runtime) PipelineManager() *PipelineManager - PipelineManager returns the pipeline command manager. - -``` - ```text func (r *Runtime) RegistryManager() *RegistryManager diff --git a/internal/cli/access.go b/internal/cli/access.go index 06ef18a..0e6d651 100644 --- a/internal/cli/access.go +++ b/internal/cli/access.go @@ -31,152 +31,11 @@ func DefaultAccessManager(logger *zap.Logger) *AccessManager { return NewAccessManager(kubectlClient, logger) } -func NewAccessCmd(logger *zap.Logger) *cobra.Command { - mgr := DefaultAccessManager(logger) - return NewAccessCmdWithManager(mgr) -} - -func NewAccessCmdWithManager(mgr *AccessManager) *cobra.Command { - cmd := &cobra.Command{ - Use: "access", - Short: "Manage grants and agent sessions", - Long: `Commands for managing MCPAccessGrant and MCPAgentSession resources that feed the gateway policy layer. - -With mcp-runtime auth login, commands use the platform API by default. Use --use-kube -to target the cluster with kubectl and a kubeconfig (cluster admin path).`, - } - - cmd.PersistentFlags().BoolVar(&mgr.useKube, "use-kube", false, "Use kubectl and local kubeconfig instead of the platform API for supported commands") - - cmd.AddCommand(mgr.newAccessGrantCmd()) - cmd.AddCommand(mgr.newAccessSessionCmd()) - - return cmd -} - // BindUseKubeFlag wires the shared --use-kube flag onto the command. func (m *AccessManager) BindUseKubeFlag(cmd *cobra.Command) { cmd.PersistentFlags().BoolVar(&m.useKube, "use-kube", false, "Use kubectl and local kubeconfig instead of the platform API for supported commands") } -func (m *AccessManager) newAccessGrantCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "grant", - Short: "Manage MCPAccessGrant resources", - } - - cmd.AddCommand(m.newAccessListCmd(accessGrantResource, "grants")) - cmd.AddCommand(m.newAccessGetCmd(accessGrantResource, "grant")) - cmd.AddCommand(m.newAccessApplyCmd(accessGrantResource, "grant")) - cmd.AddCommand(m.newAccessDeleteCmd(accessGrantResource, "grant")) - cmd.AddCommand(m.newAccessToggleCmd(accessGrantResource, "disable", "Disable a grant", true)) - cmd.AddCommand(m.newAccessToggleCmd(accessGrantResource, "enable", "Enable a grant", false)) - - return cmd -} - -func (m *AccessManager) newAccessSessionCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "session", - Short: "Manage MCPAgentSession resources", - } - - cmd.AddCommand(m.newAccessListCmd(accessSessionResource, "sessions")) - cmd.AddCommand(m.newAccessGetCmd(accessSessionResource, "session")) - cmd.AddCommand(m.newAccessApplyCmd(accessSessionResource, "session")) - cmd.AddCommand(m.newAccessDeleteCmd(accessSessionResource, "session")) - cmd.AddCommand(m.newAccessToggleCmd(accessSessionResource, "revoke", "Revoke an agent session", true)) - cmd.AddCommand(m.newAccessToggleCmd(accessSessionResource, "unrevoke", "Clear the revoked flag on an agent session", false)) - - return cmd -} - -func (m *AccessManager) newAccessListCmd(resource, label string) *cobra.Command { - var namespace string - var allNamespaces bool - - cmd := &cobra.Command{ - Use: "list", - Short: fmt.Sprintf("List access %s", label), - RunE: func(cmd *cobra.Command, args []string) error { - return m.ListAccessResources(resource, namespace, allNamespaces) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", "", "Namespace to inspect") - cmd.Flags().BoolVar(&allNamespaces, "all-namespaces", true, "List resources across all namespaces when no namespace is specified") - - return cmd -} - -func (m *AccessManager) newAccessGetCmd(resource, label string) *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "get [name]", - Short: fmt.Sprintf("Get an access %s", label), - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.GetAccessResource(resource, args[0], namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - - return cmd -} - -func (m *AccessManager) newAccessApplyCmd(resource, label string) *cobra.Command { - var file string - - cmd := &cobra.Command{ - Use: "apply", - Short: fmt.Sprintf("Apply a %s manifest", label), - RunE: func(cmd *cobra.Command, args []string) error { - return m.ApplyAccessResource(file) - }, - } - - cmd.Flags().StringVar(&file, "file", "", "Manifest file to apply") - _ = cmd.MarkFlagRequired("file") - - return cmd -} - -func (m *AccessManager) newAccessDeleteCmd(resource, label string) *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "delete [name]", - Short: fmt.Sprintf("Delete an access %s", label), - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.DeleteAccessResource(resource, args[0], namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - - return cmd -} - -func (m *AccessManager) newAccessToggleCmd(resource, use, short string, value bool) *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: use + " [name]", - Short: short, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.ToggleAccessResource(resource, args[0], namespace, value) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - - return cmd -} - func (m *AccessManager) accessListQueryNamespace(namespace string, allNamespaces bool) string { switch { case namespace != "": diff --git a/internal/cli/access_test.go b/internal/cli/access_test.go index bfe6758..91d8990 100644 --- a/internal/cli/access_test.go +++ b/internal/cli/access_test.go @@ -8,28 +8,6 @@ import ( "go.uber.org/zap" ) -func TestNewAccessCmd(t *testing.T) { - cmd := NewAccessCmd(zap.NewNop()) - if cmd == nil { - t.Fatal("NewAccessCmd should not return nil") - } - if cmd.Use != "access" { - t.Fatalf("expected Use='access', got %q", cmd.Use) - } - - expected := map[string]bool{"grant": false, "session": false} - for _, sub := range cmd.Commands() { - if _, ok := expected[sub.Use]; ok { - expected[sub.Use] = true - } - } - for name, found := range expected { - if !found { - t.Fatalf("expected subcommand %q not found", name) - } - } -} - func TestAccessManager_ListAccessResources(t *testing.T) { mock := &MockExecutor{} kubectl := &KubectlClient{exec: mock, validators: nil} diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go deleted file mode 100644 index 00c7716..0000000 --- a/internal/cli/bootstrap.go +++ /dev/null @@ -1,189 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "go.uber.org/zap" -) - -// NewBootstrapCmd provides an on-prem focused bootstrap workflow. -// -// Production note: this intentionally does not attempt to provision clusters across all distributions. -// It performs preflights and (optionally) applies a small set of safe, local-distro-specific fixes. -func NewBootstrapCmd(logger *zap.Logger) *cobra.Command { - var apply bool - var provider string - - cmd := &cobra.Command{ - Use: "bootstrap", - Short: "Bootstrap cluster prerequisites (on-prem focused)", - Long: `Bootstrap validates and (optionally) installs cluster prerequisites needed by mcp-runtime setup. - -By design, this does not provision Kubernetes clusters end-to-end across all distributions. -Use this to prepare an existing cluster for running 'mcp-runtime setup'. - -Note: bootstrap --apply is automated for k3s only and must be executed on the k3s server node (it expects local manifests under /var/lib/rancher/k3s/server/manifests).`, - RunE: func(cmd *cobra.Command, args []string) error { - Section("MCP Runtime Bootstrap") - - chosenProvider := provider - if chosenProvider == "" || chosenProvider == "auto" { - detectedProvider, err := DetectProvider(kubectlClient) - if err != nil { - return err - } - chosenProvider = detectedProvider - } - Info(fmt.Sprintf("Provider: %s", chosenProvider)) - - if err := RunBootstrapPreflight(kubectlClient); err != nil { - return err - } - - if !apply { - Success("Bootstrap preflight complete (no changes applied)") - Info("Next: run `./bin/mcp-runtime setup` (or `./bin/mcp-runtime setup --storage-mode hostpath` for single-node dev)") - return nil - } - - switch chosenProvider { - case "k3s": - if err := BootstrapApplyK3s(kubectlClient); err != nil { - return err - } - case "rke2", "kubeadm", "generic": - Warn("Apply mode is currently only automated for k3s. For other distributions, use the preflight output and install DNS/storage/ingress/load-balancer via your standard platform tooling.") - default: - Warn(fmt.Sprintf("Unknown provider %q; skipping apply", chosenProvider)) - } - - Success("Bootstrap complete") - Info("Next: run `./bin/mcp-runtime setup`") - return nil - }, - } - - cmd.Flags().BoolVar(&apply, "apply", false, "Apply safe bootstrap fixes when possible (k3s only today; run on the k3s server node)") - cmd.Flags().StringVar(&provider, "provider", "auto", "Cluster provider hint (auto|k3s|rke2|kubeadm|generic)") - return cmd -} - -func DetectProvider(kubectl KubectlRunner) (string, error) { - out, err := kubectlOutput(kubectl, []string{"get", "nodes", "-o", "jsonpath={range .items[*]}{.status.nodeInfo.kubeletVersion}{\"\\n\"}{end}"}) - if err != nil { - return "", wrapWithSentinel(ErrClusterNotAccessible, err, fmt.Sprintf("kubectl get nodes failed: %v", err)) - } - lower := strings.ToLower(string(out)) - switch { - case strings.Contains(lower, "k3s"): - return "k3s", nil - case strings.Contains(lower, "rke2"): - return "rke2", nil - default: - return "generic", nil - } -} - -func RunBootstrapPreflight(kubectl KubectlRunner) error { - Info("Preflight: kubectl connectivity") - if err := kubectl.Run([]string{"version", "--client=true"}); err != nil { - return wrapWithSentinel(ErrClusterNotAccessible, err, fmt.Sprintf("kubectl not available: %v", err)) - } - if err := kubectl.Run([]string{"get", "nodes"}); err != nil { - return wrapWithSentinel(ErrClusterNotAccessible, err, fmt.Sprintf("kubectl cannot reach cluster: %v", err)) - } - - Info("Preflight: CoreDNS") - if err := checkDeploymentExists(kubectl, "kube-system", "coredns"); err != nil { - Warn("CoreDNS not detected (kube-system/deployment coredns). Cluster DNS must be installed for in-cluster service discovery.") - } - - Info("Preflight: Default StorageClass") - if err := checkHasDefaultStorageClass(kubectl); err != nil { - Warn(fmt.Sprintf("No default StorageClass detected: %v", err)) - } - - Info("Preflight: IngressClass traefik") - if err := kubectl.Run([]string{"get", "ingressclass", "traefik"}); err != nil { - Warn("IngressClass traefik not found. If you plan to use Traefik, install it before running setup (or let setup install it when configured).") - } - - Info("Preflight: MetalLB") - if err := kubectl.Run([]string{"get", "ns", "metallb-system"}); err != nil { - Warn("MetalLB not detected (namespace metallb-system). If you need LoadBalancer services on bare metal, install MetalLB.") - } - - return nil -} - -func checkDeploymentExists(kubectl KubectlRunner, namespace, name string) error { - return kubectl.Run([]string{"get", "deployment", name, "-n", namespace}) -} - -func checkHasDefaultStorageClass(kubectl KubectlRunner) error { - out, err := kubectlOutput(kubectl, []string{"get", "storageclass", "-o", "jsonpath={range .items[*]}{.metadata.name}{\" \"}{.metadata.annotations.storageclass\\.kubernetes\\.io/is-default-class}{\"\\n\"}{end}"}) - if err != nil { - return err - } - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 && fields[1] == "true" { - return nil - } - } - return fmt.Errorf("no StorageClass annotated with storageclass.kubernetes.io/is-default-class=true") -} - -func BootstrapApplyK3s(kubectl KubectlRunner) error { - Info("Applying k3s addons: CoreDNS + local-path provisioner (if missing)") - - // Apply only when the manifests exist on disk (k3s server). - paths := []string{ - "/var/lib/rancher/k3s/server/manifests/coredns.yaml", - "/var/lib/rancher/k3s/server/manifests/local-storage.yaml", - } - var missing []string - for _, p := range paths { - if _, err := os.Stat(p); err != nil { - missing = append(missing, p) - } - } - if len(missing) > 0 { - msg := fmt.Sprintf("k3s manifests missing on disk (%s); bootstrap --apply expects to run on the k3s server node", strings.Join(missing, ", ")) - return wrapWithSentinel(ErrClusterConfigFailed, fmt.Errorf("missing manifests"), msg) - } - - for _, p := range paths { - if err := kubectl.Run([]string{"apply", "-f", p}); err != nil { - return wrapWithSentinel(ErrClusterConfigFailed, err, fmt.Sprintf("failed to apply %s: %v", p, err)) - } - } - - Info("Waiting for kube-system addons to be ready") - if err := kubectl.Run([]string{"rollout", "status", "deployment/coredns", "-n", "kube-system", "--timeout=180s"}); err != nil { - return wrapWithSentinel(ErrDeploymentTimeout, err, fmt.Sprintf("coredns rollout failed: %v", err)) - } - if err := kubectl.Run([]string{"rollout", "status", "deployment/local-path-provisioner", "-n", "kube-system", "--timeout=180s"}); err != nil { - return wrapWithSentinel(ErrDeploymentTimeout, err, fmt.Sprintf("local-path-provisioner rollout failed: %v", err)) - } - - // Best-effort: show disk-pressure so users don't get surprised by evictions. - Info("Node disk-pressure check") - cond, err := kubectlOutput(kubectl, []string{"get", "nodes", "-o", "jsonpath={range .items[*]}{.metadata.name}{\" \"}{range .status.conditions[?(@.type==\"DiskPressure\")]}{.status}{end}{\"\\n\"}{end}"}) - if err == nil { - Info(strings.TrimSpace(string(cond))) - } - - return nil -} - -func kubectlOutput(kubectl KubectlRunner, args []string) ([]byte, error) { - cmd, err := kubectl.CommandArgs(args) - if err != nil { - return nil, err - } - return cmd.Output() -} diff --git a/internal/cli/bootstrap/bootstrap.go b/internal/cli/bootstrap/bootstrap.go index 16a2a5b..218c832 100644 --- a/internal/cli/bootstrap/bootstrap.go +++ b/internal/cli/bootstrap/bootstrap.go @@ -3,6 +3,8 @@ package bootstrap import ( "fmt" + "os" + "strings" "github.com/spf13/cobra" @@ -17,6 +19,121 @@ func newManager(runtime *cli.Runtime) *manager { return &manager{kubectl: runtime.KubectlRunner()} } +func detectProvider(kubectl cli.KubectlRunner) (string, error) { + out, err := kubectlOutput(kubectl, []string{"get", "nodes", "-o", "jsonpath={range .items[*]}{.status.nodeInfo.kubeletVersion}{\"\\n\"}{end}"}) + if err != nil { + return "", cli.WrapWithSentinel(cli.ErrClusterNotAccessible, err, fmt.Sprintf("kubectl get nodes failed: %v", err)) + } + lower := strings.ToLower(string(out)) + switch { + case strings.Contains(lower, "k3s"): + return "k3s", nil + case strings.Contains(lower, "rke2"): + return "rke2", nil + default: + return "generic", nil + } +} + +func runBootstrapPreflight(kubectl cli.KubectlRunner) error { + cli.Info("Preflight: kubectl connectivity") + if err := kubectl.Run([]string{"version", "--client=true"}); err != nil { + return cli.WrapWithSentinel(cli.ErrClusterNotAccessible, err, fmt.Sprintf("kubectl not available: %v", err)) + } + if err := kubectl.Run([]string{"get", "nodes"}); err != nil { + return cli.WrapWithSentinel(cli.ErrClusterNotAccessible, err, fmt.Sprintf("kubectl cannot reach cluster: %v", err)) + } + + cli.Info("Preflight: CoreDNS") + if err := checkDeploymentExists(kubectl, "kube-system", "coredns"); err != nil { + cli.Warn("CoreDNS not detected (kube-system/deployment coredns). Cluster DNS must be installed for in-cluster service discovery.") + } + + cli.Info("Preflight: Default StorageClass") + if err := checkHasDefaultStorageClass(kubectl); err != nil { + cli.Warn(fmt.Sprintf("No default StorageClass detected: %v", err)) + } + + cli.Info("Preflight: IngressClass traefik") + if err := kubectl.Run([]string{"get", "ingressclass", "traefik"}); err != nil { + cli.Warn("IngressClass traefik not found. If you plan to use Traefik, install it before running setup (or let setup install it when configured).") + } + + cli.Info("Preflight: MetalLB") + if err := kubectl.Run([]string{"get", "ns", "metallb-system"}); err != nil { + cli.Warn("MetalLB not detected (namespace metallb-system). If you need LoadBalancer services on bare metal, install MetalLB.") + } + + return nil +} + +func bootstrapApplyK3s(kubectl cli.KubectlRunner) error { + cli.Info("Applying k3s addons: CoreDNS + local-path provisioner (if missing)") + + paths := []string{ + "/var/lib/rancher/k3s/server/manifests/coredns.yaml", + "/var/lib/rancher/k3s/server/manifests/local-storage.yaml", + } + var missing []string + for _, p := range paths { + if _, err := os.Stat(p); err != nil { + missing = append(missing, p) + } + } + if len(missing) > 0 { + msg := fmt.Sprintf("k3s manifests missing on disk (%s); bootstrap --apply expects to run on the k3s server node", strings.Join(missing, ", ")) + return cli.WrapWithSentinel(cli.ErrClusterConfigFailed, fmt.Errorf("missing manifests"), msg) + } + + for _, p := range paths { + if err := kubectl.Run([]string{"apply", "-f", p}); err != nil { + return cli.WrapWithSentinel(cli.ErrClusterConfigFailed, err, fmt.Sprintf("failed to apply %s: %v", p, err)) + } + } + + cli.Info("Waiting for kube-system addons to be ready") + if err := kubectl.Run([]string{"rollout", "status", "deployment/coredns", "-n", "kube-system", "--timeout=180s"}); err != nil { + return cli.WrapWithSentinel(cli.ErrDeploymentTimeout, err, fmt.Sprintf("coredns rollout failed: %v", err)) + } + if err := kubectl.Run([]string{"rollout", "status", "deployment/local-path-provisioner", "-n", "kube-system", "--timeout=180s"}); err != nil { + return cli.WrapWithSentinel(cli.ErrDeploymentTimeout, err, fmt.Sprintf("local-path-provisioner rollout failed: %v", err)) + } + + cli.Info("Node disk-pressure check") + cond, err := kubectlOutput(kubectl, []string{"get", "nodes", "-o", "jsonpath={range .items[*]}{.metadata.name}{\" \"}{range .status.conditions[?(@.type==\"DiskPressure\")]}{.status}{end}{\"\\n\"}{end}"}) + if err == nil { + cli.Info(strings.TrimSpace(string(cond))) + } + + return nil +} + +func checkDeploymentExists(kubectl cli.KubectlRunner, namespace, name string) error { + return kubectl.Run([]string{"get", "deployment", name, "-n", namespace}) +} + +func checkHasDefaultStorageClass(kubectl cli.KubectlRunner) error { + out, err := kubectlOutput(kubectl, []string{"get", "storageclass", "-o", "jsonpath={range .items[*]}{.metadata.name}{\" \"}{.metadata.annotations.storageclass\\.kubernetes\\.io/is-default-class}{\"\\n\"}{end}"}) + if err != nil { + return err + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == "true" { + return nil + } + } + return fmt.Errorf("no StorageClass annotated with storageclass.kubernetes.io/is-default-class=true") +} + +func kubectlOutput(kubectl cli.KubectlRunner, args []string) ([]byte, error) { + cmd, err := kubectl.CommandArgs(args) + if err != nil { + return nil, err + } + return cmd.CombinedOutput() +} + // New returns the bootstrap command. func New(runtime *cli.Runtime) *cobra.Command { var apply bool @@ -36,7 +153,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 cli.Section("MCP Runtime Bootstrap") chosenProvider := provider if chosenProvider == "" || chosenProvider == "auto" { - detectedProvider, err := cli.DetectProvider(mgr.kubectl) + detectedProvider, err := detectProvider(mgr.kubectl) if err != nil { return err } @@ -44,7 +161,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 } cli.Info(fmt.Sprintf("Provider: %s", chosenProvider)) - if err := cli.RunBootstrapPreflight(mgr.kubectl); err != nil { + if err := runBootstrapPreflight(mgr.kubectl); err != nil { return err } @@ -56,7 +173,7 @@ Note: bootstrap --apply is automated for k3s only and must be executed on the k3 switch chosenProvider { case "k3s": - if err := cli.BootstrapApplyK3s(mgr.kubectl); err != nil { + if err := bootstrapApplyK3s(mgr.kubectl); err != nil { return err } case "rke2", "kubeadm", "generic": diff --git a/internal/cli/build.go b/internal/cli/build.go index 6f54e3e..697304d 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -13,7 +13,6 @@ import ( "path/filepath" "strings" - "github.com/spf13/cobra" "go.uber.org/zap" "mcp-runtime/pkg/metadata" @@ -24,38 +23,6 @@ import ( // yamlMarshal is a test seam for yaml.Marshal. var yamlMarshal = yaml.Marshal -func NewBuildImageCmd(logger *zap.Logger) *cobra.Command { - var dockerfile string - var metadataFile string - var metadataDir string - var registryURL string - var tag string - var context string - - cmd := &cobra.Command{ - Use: "image ", - Short: "Build Docker image for an MCP server", - Long: `Build a Docker image from Dockerfile and update metadata file.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return buildImage(logger, args[0], dockerfile, metadataFile, metadataDir, registryURL, tag, context) - }, - } - - cmd.Flags().StringVar(&dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") - cmd.Flags().StringVar(&metadataFile, "metadata-file", "", "Path to metadata file") - cmd.Flags().StringVar(&metadataDir, "metadata-dir", ".mcp", "Directory containing metadata files") - cmd.Flags().StringVar(®istryURL, "registry", "", "Registry URL (defaults to platform registry)") - cmd.Flags().StringVar(&tag, "tag", "", "Image tag (defaults to git SHA or 'latest')") - cmd.Flags().StringVar(&context, "context", ".", "Build context directory") - - return cmd -} - -func newBuildImageCmd(logger *zap.Logger) *cobra.Command { - return NewBuildImageCmd(logger) -} - func buildImage(logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, context string) error { // Get registry URL if registryURL == "" { @@ -110,6 +77,10 @@ func buildImage(logger *zap.Logger, serverName, dockerfile, metadataFile, metada return nil } +func BuildImage(logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, context string) error { + return buildImage(logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, context) +} + func updateMetadataImage(serverName, imageName, tag, metadataFile, metadataDir string) error { // Find the metadata file containing this server var targetFile string diff --git a/internal/cli/build_test.go b/internal/cli/build_test.go index c4cf161..ae5cfab 100644 --- a/internal/cli/build_test.go +++ b/internal/cli/build_test.go @@ -10,35 +10,6 @@ import ( "go.uber.org/zap" ) -func TestNewBuildImageCmd(t *testing.T) { - logger := zap.NewNop() - cmd := newBuildImageCmd(logger) - - t.Run("command-created", func(t *testing.T) { - if cmd == nil { - t.Fatal("newBuildImageCmd should not return nil") - } - // Use includes the argument pattern (required arg uses <>) - if cmd.Use != "image " { - t.Errorf("expected Use='image ', got %q", cmd.Use) - } - }) - - t.Run("has-flags", func(t *testing.T) { - flags := cmd.Flags() - if flags == nil { - t.Fatal("newBuildImageCmd should have flags") - } - - expectedFlags := []string{"dockerfile", "metadata-file", "metadata-dir", "registry", "tag", "context"} - for _, name := range expectedFlags { - if flags.Lookup(name) == nil { - t.Errorf("expected flag %q not found", name) - } - } - }) -} - func TestGetGitTag(t *testing.T) { // This test runs in a git repo, so it should return a valid SHA or "latest" tag := getGitTag() @@ -280,49 +251,6 @@ func (v *validatorFailingExecutor) Command(name string, args []string, validator return nil, v.err } -func TestNewBuildImageCmdRunE(t *testing.T) { - logger := zap.NewNop() - - t.Run("executes_build_image", func(t *testing.T) { - originalExecutor := execExecutor - defer func() { execExecutor = originalExecutor }() - - mock := &MockExecutor{} - execExecutor = mock - - tmp := t.TempDir() - metadataFile := filepath.Join(tmp, "servers.yaml") - if err := os.WriteFile(metadataFile, []byte(`version: v1 -servers: - - name: my-server -`), 0o600); err != nil { - t.Fatalf("write metadata: %v", err) - } - - cmd := newBuildImageCmd(logger) - cmd.SetArgs([]string{"my-server", "--registry", "test-registry", "--tag", "v1.0", "--metadata-file", metadataFile}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !mock.HasCommand("docker") { - t.Error("expected docker command to be executed") - } - }) - - t.Run("fails_without_server_name", func(t *testing.T) { - cmd := newBuildImageCmd(logger) - cmd.SetArgs([]string{}) - - err := cmd.Execute() - if err == nil { - t.Error("expected error when server name is missing") - } - }) -} - func TestGetGitTagWithMock(t *testing.T) { t.Run("returns_latest_when_git_fails", func(t *testing.T) { originalExecutor := execExecutor diff --git a/internal/cli/cert.go b/internal/cli/cert.go index e794357..07486c4 100644 --- a/internal/cli/cert.go +++ b/internal/cli/cert.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/spf13/cobra" "go.uber.org/zap" ) @@ -34,65 +33,6 @@ func NewCertManager(kubectl KubectlRunner, logger *zap.Logger) *CertManager { return &CertManager{kubectl: kubectl, logger: logger} } -func (m *ClusterManager) newClusterCertCmd() *cobra.Command { - certMgr := NewCertManager(m.kubectl, m.logger) - cmd := &cobra.Command{ - Use: "cert", - Short: "Manage cert-manager resources", - Long: "Manage cert-manager resources required for TLS in the MCP platform", - } - - cmd.AddCommand(certMgr.newCertStatusCmd()) - cmd.AddCommand(certMgr.newCertApplyCmd()) - cmd.AddCommand(certMgr.newCertWaitCmd()) - - return cmd -} - -func (m *CertManager) newCertStatusCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "status", - Short: "Check cert-manager resources", - Long: "Check cert-manager installation, CA secret, issuer, and registry certificate", - RunE: func(cmd *cobra.Command, args []string) error { - return m.Status() - }, - } - - return cmd -} - -func (m *CertManager) newCertApplyCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "apply", - Short: "Apply cert-manager resources", - Long: "Apply ClusterIssuer and registry Certificate manifests", - RunE: func(cmd *cobra.Command, args []string) error { - return m.Apply() - }, - } - - return cmd -} - -func (m *CertManager) newCertWaitCmd() *cobra.Command { - var timeout time.Duration - cmd := &cobra.Command{ - Use: "wait", - Short: "Wait for registry certificate readiness", - Long: "Wait for the registry certificate to reach Ready state", - RunE: func(cmd *cobra.Command, args []string) error { - if timeout == 0 { - timeout = GetCertTimeout() - } - return m.Wait(timeout) - }, - } - - cmd.Flags().DurationVar(&timeout, "timeout", 0, "Timeout for certificate readiness (default from MCP_CERT_TIMEOUT)") - return cmd -} - // Status verifies cert-manager installation and required resources. func (m *CertManager) Status() error { Info("Checking cert-manager installation") diff --git a/internal/cli/cert_test.go b/internal/cli/cert_test.go index bcc517f..90b5b65 100644 --- a/internal/cli/cert_test.go +++ b/internal/cli/cert_test.go @@ -3,7 +3,6 @@ package cli import ( "bytes" "errors" - "fmt" "io" "os" "strings" @@ -271,141 +270,6 @@ func TestCertManagerWaitFailure(t *testing.T) { } } -func TestCertWaitCmdUsesDefaultTimeout(t *testing.T) { - var waitArgs []string - mock := &MockExecutor{ - CommandFunc: func(spec ExecSpec) *MockCommand { - if commandHasArgs(spec, "wait", "--for=condition=Ready", "certificate/"+registryCertificateName, "-n", NamespaceRegistry) { - waitArgs = spec.Args - } - return &MockCommand{Args: spec.Args} - }, - } - kubectl := &KubectlClient{exec: mock, validators: nil} - manager := NewCertManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := manager.newCertWaitCmd() - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if waitArgs == nil { - t.Fatal("expected wait command to be invoked") - } - wantTimeout := fmt.Sprintf("--timeout=%s", GetCertTimeout()) - if !contains(waitArgs, wantTimeout) { - t.Fatalf("expected timeout %q, got args: %v", wantTimeout, waitArgs) - } -} - -func TestCertWaitCmdUsesFlagTimeout(t *testing.T) { - var waitArgs []string - mock := &MockExecutor{ - CommandFunc: func(spec ExecSpec) *MockCommand { - if commandHasArgs(spec, "wait", "--for=condition=Ready", "certificate/"+registryCertificateName, "-n", NamespaceRegistry) { - waitArgs = spec.Args - } - return &MockCommand{Args: spec.Args} - }, - } - kubectl := &KubectlClient{exec: mock, validators: nil} - manager := NewCertManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := manager.newCertWaitCmd() - if err := cmd.Flags().Set("timeout", "5s"); err != nil { - t.Fatalf("set timeout flag: %v", err) - } - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if waitArgs == nil { - t.Fatal("expected wait command to be invoked") - } - if !contains(waitArgs, "--timeout=5s") { - t.Fatalf("expected timeout flag to be used, got args: %v", waitArgs) - } -} - -func TestCertApplyCmdInvokesApply(t *testing.T) { - origKubectl := kubectlClient - t.Cleanup(func() { kubectlClient = origKubectl }) - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - kubectlClient = kubectl - manager := NewCertManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := manager.newCertApplyCmd() - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(mock.Commands) == 0 { - t.Fatal("expected kubectl commands to be invoked") - } -} - -func TestCertStatusCmdInvokesStatus(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - manager := NewCertManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := manager.newCertStatusCmd() - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(mock.Commands) == 0 { - t.Fatal("expected kubectl commands to be invoked") - } -} - -func TestNewClusterCertCmd(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - clusterMgr := NewClusterManager(kubectl, mock, zap.NewNop()) - - cmd := clusterMgr.newClusterCertCmd() - - t.Run("command_created", func(t *testing.T) { - if cmd == nil { - t.Fatal("newClusterCertCmd should not return nil") - } - if cmd.Use != "cert" { - t.Errorf("expected Use='cert', got %q", cmd.Use) - } - }) - - t.Run("has_subcommands", func(t *testing.T) { - subcommands := cmd.Commands() - if len(subcommands) != 3 { - t.Errorf("expected 3 subcommands (status, apply, wait), got %d", len(subcommands)) - } - - expectedSubs := map[string]bool{"status": false, "apply": false, "wait": false} - for _, sub := range subcommands { - if _, ok := expectedSubs[sub.Use]; ok { - expectedSubs[sub.Use] = true - } - } - - for name, found := range expectedSubs { - if !found { - t.Errorf("expected subcommand %q not found", name) - } - } - }) -} - func TestCertManagerStatusMissingCertManager(t *testing.T) { mock := &MockExecutor{ CommandFunc: func(spec ExecSpec) *MockCommand { diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 34b8fd3..308fc9e 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -10,7 +10,6 @@ import ( "path/filepath" "strings" - "github.com/spf13/cobra" "go.uber.org/zap" ) @@ -43,145 +42,14 @@ func DefaultClusterManager(logger *zap.Logger) *ClusterManager { return NewClusterManager(kubectlClient, execExecutor, logger) } -// NewClusterCmd returns the root cluster subcommand (status/init/provision). -func NewClusterCmd(logger *zap.Logger) *cobra.Command { - mgr := DefaultClusterManager(logger) - return NewClusterCmdWithManager(mgr) +// KubectlRunner exposes the shared kubectl runner for foldered command routing. +func (m *ClusterManager) KubectlRunner() KubectlRunner { + return m.kubectl } -// NewClusterCmdWithManager returns the cluster subcommand using the provided manager. -func NewClusterCmdWithManager(mgr *ClusterManager) *cobra.Command { - cmd := &cobra.Command{ - Use: "cluster", - Short: "Manage Kubernetes cluster", - Long: "Commands for managing the Kubernetes cluster", - } - - cmd.AddCommand(mgr.newClusterInitCmd()) - cmd.AddCommand(mgr.newClusterStatusCmd()) - cmd.AddCommand(mgr.newClusterConfigCmd()) - cmd.AddCommand(mgr.newClusterProvisionCmd()) - cmd.AddCommand(mgr.newClusterCertCmd()) - cmd.AddCommand(mgr.newClusterDoctorCmd()) - - return cmd -} - -// NewClusterCertCmdWithManager exposes the cert subcommand builder for folder packages. -func NewClusterCertCmdWithManager(mgr *ClusterManager) *cobra.Command { - return mgr.newClusterCertCmd() -} - -// NewClusterDoctorCmdWithManager exposes the doctor subcommand builder for folder packages. -func NewClusterDoctorCmdWithManager(mgr *ClusterManager) *cobra.Command { - return mgr.newClusterDoctorCmd() -} - -func (m *ClusterManager) newClusterInitCmd() *cobra.Command { - var kubeconfig string - var context string - - cmd := &cobra.Command{ - Use: "init", - Short: "Initialize cluster configuration", - Long: "Initialize and configure the Kubernetes cluster for MCP platform", - RunE: func(cmd *cobra.Command, args []string) error { - return m.InitCluster(kubeconfig, context) - }, - } - - cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") - cmd.Flags().StringVar(&context, "context", "", "Kubernetes context to use") - - return cmd -} - -func (m *ClusterManager) newClusterStatusCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "status", - Short: "Check cluster status", - Long: "Check the status of the Kubernetes cluster", - RunE: func(cmd *cobra.Command, args []string) error { - return m.CheckClusterStatus() - }, - } - - return cmd -} - -func (m *ClusterManager) newClusterConfigCmd() *cobra.Command { - var ingressMode string - var ingressManifest string - var forceIngressInstall bool - var kubeconfig string - var context string - var provider string - var region string - var clusterName string - var resourceGroup string - var project string - var zone string - - cmd := &cobra.Command{ - Use: "config", - Short: "Configure cluster settings", - Long: "Configure cluster settings like ingress and kubeconfig context", - RunE: func(cmd *cobra.Command, args []string) error { - if provider != "" { - if err := m.ConfigureKubeconfigFromProvider(provider, region, clusterName, resourceGroup, project, zone, kubeconfig); err != nil { - return err - } - } - if kubeconfig != "" || context != "" || provider != "" { - if err := m.ConfigureKubeconfig(kubeconfig, context); err != nil { - return err - } - } - opts := ingressOptions{ - mode: ingressMode, - manifest: ingressManifest, - force: forceIngressInstall, - } - return m.ConfigureCluster(opts) - }, - } - - cmd.Flags().StringVar(&ingressMode, "ingress", "traefik", "Ingress controller to install (traefik|none)") - cmd.Flags().StringVar(&ingressManifest, "ingress-manifest", "config/ingress/overlays/prod", "Manifest to apply when installing the ingress controller") - cmd.Flags().BoolVar(&forceIngressInstall, "force-ingress-install", false, "Force ingress install even if an ingress class already exists") - cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") - cmd.Flags().StringVar(&context, "context", "", "Kubernetes context to use") - cmd.Flags().StringVar(&provider, "provider", "", "Cloud provider for kubeconfig (eks; aks/gke planned)") - cmd.Flags().StringVar(®ion, "region", "us-west-1", "Region for cloud provider kubeconfig") - cmd.Flags().StringVar(&clusterName, "name", defaultClusterName, "Cluster name for cloud provider kubeconfig") - cmd.Flags().StringVar(&resourceGroup, "resource-group", "", "Resource group (AKS, planned)") - cmd.Flags().StringVar(&project, "project", "", "Project ID (GKE, planned)") - cmd.Flags().StringVar(&zone, "zone", "", "Zone (GKE, planned)") - - return cmd -} - -func (m *ClusterManager) newClusterProvisionCmd() *cobra.Command { - var provider string - var region string - var nodeCount int - var clusterName string - - cmd := &cobra.Command{ - Use: "provision", - Short: "Provision a new cluster", - Long: "Provision a new Kubernetes cluster (requires cloud provider credentials)", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ProvisionCluster(provider, region, nodeCount, clusterName) - }, - } - - cmd.Flags().StringVar(&provider, "provider", "kind", "Cloud provider (kind, gke, eks, aks)") - cmd.Flags().StringVar(®ion, "region", "us-west-1", "Region for cluster") - cmd.Flags().IntVar(&nodeCount, "nodes", 3, "Number of nodes") - cmd.Flags().StringVar(&clusterName, "name", defaultClusterName, "Cluster name (used by supported providers)") - - return cmd +// Logger exposes the shared logger for foldered command routing. +func (m *ClusterManager) Logger() *zap.Logger { + return m.logger } // InitCluster initializes cluster configuration. diff --git a/internal/cli/cluster/cert.go b/internal/cli/cluster/cert.go new file mode 100644 index 0000000..2f8af9e --- /dev/null +++ b/internal/cli/cluster/cert.go @@ -0,0 +1,64 @@ +package cluster + +import ( + "time" + + "github.com/spf13/cobra" + + "mcp-runtime/internal/cli" +) + +func newClusterCertCmd(mgr *cli.ClusterManager) *cobra.Command { + certMgr := cli.NewCertManager(mgr.KubectlRunner(), mgr.Logger()) + cmd := &cobra.Command{ + Use: "cert", + Short: "Manage cert-manager resources", + Long: "Manage cert-manager resources required for TLS in the MCP platform", + } + + cmd.AddCommand(certMgrStatusCmd(certMgr)) + cmd.AddCommand(certMgrApplyCmd(certMgr)) + cmd.AddCommand(certMgrWaitCmd(certMgr)) + + return cmd +} + +func certMgrStatusCmd(mgr *cli.CertManager) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Check cert-manager resources", + Long: "Check cert-manager installation, CA secret, issuer, and registry certificate", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.Status() + }, + } +} + +func certMgrApplyCmd(mgr *cli.CertManager) *cobra.Command { + return &cobra.Command{ + Use: "apply", + Short: "Apply cert-manager resources", + Long: "Apply ClusterIssuer and registry Certificate manifests", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.Apply() + }, + } +} + +func certMgrWaitCmd(mgr *cli.CertManager) *cobra.Command { + var timeoutDuration time.Duration + cmd := &cobra.Command{ + Use: "wait", + Short: "Wait for registry certificate readiness", + Long: "Wait for the registry certificate to reach Ready state", + RunE: func(cmd *cobra.Command, args []string) error { + if timeoutDuration == 0 { + timeoutDuration = cli.GetCertTimeout() + } + return mgr.Wait(timeoutDuration) + }, + } + + cmd.Flags().DurationVar(&timeoutDuration, "timeout", 0, "Timeout for certificate readiness (default from MCP_CERT_TIMEOUT)") + return cmd +} diff --git a/internal/cli/cluster/cluster.go b/internal/cli/cluster/cluster.go index 2f85876..e1025dd 100644 --- a/internal/cli/cluster/cluster.go +++ b/internal/cli/cluster/cluster.go @@ -104,7 +104,7 @@ func NewWithManager(mgr *cli.ClusterManager) *cobra.Command { cmd.AddCommand(statusCmd) cmd.AddCommand(configCmd) cmd.AddCommand(provisionCmd) - cmd.AddCommand(cli.NewClusterCertCmdWithManager(mgr)) - cmd.AddCommand(cli.NewClusterDoctorCmdWithManager(mgr)) + cmd.AddCommand(newClusterCertCmd(mgr)) + cmd.AddCommand(newClusterDoctorCmd(mgr)) return cmd } diff --git a/internal/cli/cluster/doctor.go b/internal/cli/cluster/doctor.go new file mode 100644 index 0000000..2606ecc --- /dev/null +++ b/internal/cli/cluster/doctor.go @@ -0,0 +1,24 @@ +package cluster + +import ( + "github.com/spf13/cobra" + + "mcp-runtime/internal/cli" +) + +func newClusterDoctorCmd(mgr *cli.ClusterManager) *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Diagnose MCP Runtime cluster readiness and installed components", + Long: "Detect the Kubernetes distribution and check that the registry service, cluster DNS, " + + "operator/CRD prerequisites, ingress (Traefik) wiring, image pulls, Sentinel, and MCPServer reconciliation are healthy. Prints remediation steps for your distribution " + + "when something is missing. See docs/cluster-readiness.md for the full per-distribution checklist.", + RunE: func(cmd *cobra.Command, args []string) error { + report := cli.RunDoctorAndPrint(mgr.KubectlRunner()) + if !report.AllOK() { + return cli.NewSetupStepFailedError() + } + return nil + }, + } +} diff --git a/internal/cli/cluster_doctor.go b/internal/cli/cluster_doctor.go index 52177ef..598acfe 100644 --- a/internal/cli/cluster_doctor.go +++ b/internal/cli/cluster_doctor.go @@ -14,8 +14,6 @@ import ( "strconv" "strings" "time" - - "github.com/spf13/cobra" ) // Distribution identifies a Kubernetes flavor for remediation messaging. @@ -108,23 +106,6 @@ func (r DoctorReport) AllOK() bool { return true } -func (m *ClusterManager) newClusterDoctorCmd() *cobra.Command { - return &cobra.Command{ - Use: "doctor", - Short: "Diagnose MCP Runtime cluster readiness and installed components", - Long: "Detect the Kubernetes distribution and check that the registry service, cluster DNS, " + - "operator/CRD prerequisites, ingress (Traefik) wiring, image pulls, Sentinel, and MCPServer reconciliation are healthy. Prints remediation steps for your distribution " + - "when something is missing. See docs/cluster-readiness.md for the full per-distribution checklist.", - RunE: func(cmd *cobra.Command, args []string) error { - report := RunDoctorAndPrint(m.kubectl) - if !report.AllOK() { - return newWithSentinel(ErrSetupStepFailed, "cluster doctor found unmet prerequisites; see docs/cluster-readiness.md") - } - return nil - }, - } -} - // RunDoctor executes cluster diagnostics and returns a report. func RunDoctor(kubectl KubectlRunner) DoctorReport { distro := DetectDistribution(kubectl) diff --git a/internal/cli/cluster_test.go b/internal/cli/cluster_test.go index 15ae02f..37ec45c 100644 --- a/internal/cli/cluster_test.go +++ b/internal/cli/cluster_test.go @@ -1,7 +1,6 @@ package cli import ( - "bytes" "errors" "os" "path/filepath" @@ -12,45 +11,6 @@ import ( "go.uber.org/zap" ) -func TestNewClusterCmd(t *testing.T) { - logger := zap.NewNop() - cmd := NewClusterCmd(logger) - - t.Run("command-created", func(t *testing.T) { - if cmd == nil { - t.Fatal("NewClusterCmd should not return nil") - } - if cmd.Use != "cluster" { - t.Errorf("expected Use='cluster', got %q", cmd.Use) - } - }) - - t.Run("has-subcommands", func(t *testing.T) { - subcommands := cmd.Commands() - if len(subcommands) < 4 { - t.Errorf("expected at least 4 subcommands (init, status, config, provision), got %d", len(subcommands)) - } - - expectedSubs := map[string]bool{ - "init": false, - "status": false, - "config": false, - "provision": false, - } - for _, sub := range subcommands { - if _, ok := expectedSubs[sub.Use]; ok { - expectedSubs[sub.Use] = true - } - } - - for name, found := range expectedSubs { - if !found { - t.Errorf("expected subcommand %q not found", name) - } - } - }) -} - func TestClusterManager_CheckClusterStatus(t *testing.T) { t.Run("calls kubectl cluster-info", func(t *testing.T) { mock := &MockExecutor{ @@ -88,7 +48,7 @@ func TestClusterConfigRunE_WithProviderAndContext(t *testing.T) { kubectl := &KubectlClient{exec: mockKubectl, validators: nil} mgr := NewClusterManager(kubectl, mockExec, zap.NewNop()) - configCmd := findClusterSubcommand(t, NewClusterCmdWithManager(mgr), "config") + configCmd := findClusterSubcommand(t, newTestClusterCommand(mgr), "config") tempDir := t.TempDir() kubeconfigPath := filepath.Join(tempDir, "config") @@ -143,7 +103,7 @@ func TestClusterConfigRunE_UnsupportedProvider(t *testing.T) { kubectl := &KubectlClient{exec: mockKubectl, validators: nil} mgr := NewClusterManager(kubectl, mockExec, zap.NewNop()) - configCmd := findClusterSubcommand(t, NewClusterCmdWithManager(mgr), "config") + configCmd := findClusterSubcommand(t, newTestClusterCommand(mgr), "config") if err := configCmd.Flags().Set("provider", "unknown"); err != nil { t.Fatalf("set provider: %v", err) } @@ -167,6 +127,52 @@ func findClusterSubcommand(t *testing.T, root *cobra.Command, name string) *cobr return nil } +func newTestClusterCommand(mgr *ClusterManager) *cobra.Command { + cmd := &cobra.Command{Use: "cluster"} + + var ingressMode string + var ingressManifest string + var forceIngressInstall bool + var configKubeconfig string + var configContext string + var provider string + var region string + var clusterName string + var resourceGroup string + var project string + var zone string + configCmd := &cobra.Command{ + Use: "config", + RunE: func(cmd *cobra.Command, args []string) error { + if provider != "" { + if err := mgr.ConfigureKubeconfigFromProvider(provider, region, clusterName, resourceGroup, project, zone, configKubeconfig); err != nil { + return err + } + } + if configKubeconfig != "" || configContext != "" || provider != "" { + if err := mgr.ConfigureKubeconfig(configKubeconfig, configContext); err != nil { + return err + } + } + return mgr.ConfigureClusterWithValues(ingressMode, ingressManifest, forceIngressInstall) + }, + } + configCmd.Flags().StringVar(&ingressMode, "ingress", "traefik", "") + configCmd.Flags().StringVar(&ingressManifest, "ingress-manifest", "config/ingress/overlays/prod", "") + configCmd.Flags().BoolVar(&forceIngressInstall, "force-ingress-install", false, "") + configCmd.Flags().StringVar(&configKubeconfig, "kubeconfig", "", "") + configCmd.Flags().StringVar(&configContext, "context", "", "") + configCmd.Flags().StringVar(&provider, "provider", "", "") + configCmd.Flags().StringVar(®ion, "region", "us-west-1", "") + configCmd.Flags().StringVar(&clusterName, "name", "mcp-runtime", "") + configCmd.Flags().StringVar(&resourceGroup, "resource-group", "", "") + configCmd.Flags().StringVar(&project, "project", "", "") + configCmd.Flags().StringVar(&zone, "zone", "", "") + + cmd.AddCommand(configCmd) + return cmd +} + func hasCommand(cmds []ExecSpec, name string, args ...string) bool { for _, cmd := range cmds { if cmd.Name != name { @@ -456,57 +462,6 @@ func TestConfigureEKSKubeconfig(t *testing.T) { }) } -func TestClusterInitCmdRunE(t *testing.T) { - tmpDir := t.TempDir() - kubeconfig := filepath.Join(tmpDir, "config") - if err := os.WriteFile(kubeconfig, []byte("apiVersion: v1\nkind: Config\n"), 0644); err != nil { - t.Fatal(err) - } - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewClusterManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newClusterInitCmd() - _ = cmd.Flags().Set("kubeconfig", kubeconfig) - - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestClusterStatusCmdRunE(t *testing.T) { - mock := &MockExecutor{ - DefaultOutput: []byte("Kubernetes control plane is running"), - } - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewClusterManager(kubectl, mock, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := mgr.newClusterStatusCmd() - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestClusterProvisionCmdRunE(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewClusterManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newClusterProvisionCmd() - _ = cmd.Flags().Set("provider", "kind") - - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - func TestProvisionCluster(t *testing.T) { t.Run("dispatches to kind", func(t *testing.T) { mock := &MockExecutor{} @@ -981,7 +936,7 @@ func TestConfigureClusterConfigCmdFlags(t *testing.T) { kubectl := &KubectlClient{exec: mock, validators: nil} mgr := NewClusterManager(kubectl, mock, zap.NewNop()) - cmd := mgr.newClusterConfigCmd() + cmd := findClusterSubcommand(t, newTestClusterCommand(mgr), "config") // Verify all flags are registered flags := []string{"ingress", "ingress-manifest", "force-ingress-install", "kubeconfig", "context", "provider", "region", "name", "resource-group", "project", "zone"} diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 10dff4b..dcddd1e 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -68,6 +68,10 @@ func newWithSentinel(base error, msg string) error { return errx.FromSentinel(base, lookupSpec, msg, nil) } +func NewWithSentinel(base error, msg string) error { + return newWithSentinel(base, msg) +} + // wrapWithSentinel wraps a cause error using the appropriate errx category helper. // The base error (sentinel) is used to determine the category, and the message provides context. func wrapWithSentinel(base, cause error, msg string) error { @@ -77,6 +81,10 @@ func wrapWithSentinel(base, cause error, msg string) error { return errx.FromSentinel(base, lookupSpec, msg, cause) } +func WrapWithSentinel(base, cause error, msg string) error { + return wrapWithSentinel(base, cause, msg) +} + // wrapWithSentinelAndContext wraps an error with additional structured context. // This is useful for adding debugging information like namespace, resource names, etc. func wrapWithSentinelAndContext(base, cause error, msg string, context map[string]any) error { @@ -87,6 +95,14 @@ func wrapWithSentinelAndContext(base, cause error, msg string, context map[strin return err } +func WrapWithSentinelAndContext(base, cause error, msg string, context map[string]any) error { + return wrapWithSentinelAndContext(base, cause, msg, context) +} + +func NewSetupStepFailedError() error { + return newWithSentinel(ErrSetupStepFailed, "cluster doctor found unmet prerequisites; see docs/cluster-readiness.md") +} + // Sentinel errors for CLI operations. // Errors are defined and registered in one step using newSentinelError to eliminate redundancy. var ( @@ -304,3 +320,7 @@ func logStructuredError(logger *zap.Logger, err error, msg string) { } } } + +func LogStructuredError(logger *zap.Logger, err error, msg string) { + logStructuredError(logger, err, msg) +} diff --git a/internal/cli/pipeline.go b/internal/cli/pipeline.go deleted file mode 100644 index 3b57bdf..0000000 --- a/internal/cli/pipeline.go +++ /dev/null @@ -1,251 +0,0 @@ -package cli - -// This file implements the "pipeline" command for CI/CD integration. -// It handles generating CRDs from metadata and deploying manifests to Kubernetes clusters. - -import ( - "fmt" - "path/filepath" - - "github.com/spf13/cobra" - "go.uber.org/zap" - - "mcp-runtime/pkg/metadata" -) - -// filepathGlob is a test seam for filepath.Glob. -var filepathGlob = filepath.Glob - -// PipelineManager handles pipeline operations with injected dependencies. -type PipelineManager struct { - kubectl *KubectlClient - logger *zap.Logger -} - -// NewPipelineManager creates a PipelineManager with the given dependencies. -func NewPipelineManager(kubectl *KubectlClient, logger *zap.Logger) *PipelineManager { - return &PipelineManager{ - kubectl: kubectl, - logger: logger, - } -} - -// DefaultPipelineManager returns a PipelineManager using default clients. -func DefaultPipelineManager(logger *zap.Logger) *PipelineManager { - return NewPipelineManager(kubectlClient, logger) -} - -// NewPipelineCmd returns the pipeline subcommand for generate/deploy flows. -func NewPipelineCmd(logger *zap.Logger) *cobra.Command { - mgr := DefaultPipelineManager(logger) - return NewPipelineCmdWithManager(mgr) -} - -// NewPipelineCmdWithManager returns the pipeline subcommand using the provided manager. -func NewPipelineCmdWithManager(mgr *PipelineManager) *cobra.Command { - cmd := &cobra.Command{ - Use: "pipeline", - Short: "Pipeline integration commands", - Long: "Commands for CI/CD pipeline integration to generate and deploy CRDs", - } - - cmd.AddCommand(mgr.newPipelineGenerateCmd()) - cmd.AddCommand(mgr.newPipelineDeployCmd()) - - return cmd -} - -func (m *PipelineManager) newPipelineGenerateCmd() *cobra.Command { - var metadataFile string - var metadataDir string - var outputDir string - - cmd := &cobra.Command{ - Use: "generate", - Short: "Generate CRD files from metadata", - Long: `Generate Kubernetes CRD files from metadata/registry files. -This command reads server definitions and creates CRD YAML files that -the operator will use to deploy MCP servers.`, - RunE: func(cmd *cobra.Command, args []string) error { - return m.GenerateCRDsFromMetadata(metadataFile, metadataDir, outputDir) - }, - } - - cmd.Flags().StringVar(&metadataFile, "file", "", "Path to metadata file (YAML)") - cmd.Flags().StringVar(&metadataDir, "dir", ".mcp", "Directory containing metadata files") - cmd.Flags().StringVar(&outputDir, "output", "manifests", "Output directory for CRD files") - - return cmd -} - -func (m *PipelineManager) newPipelineDeployCmd() *cobra.Command { - var manifestsDir string - var namespace string - - cmd := &cobra.Command{ - Use: "deploy", - Short: "Deploy CRD files to cluster", - Long: `Deploy generated CRD files to the Kubernetes cluster. -This applies all CRD manifests to the cluster, which triggers -the operator to create the necessary Kubernetes resources.`, - RunE: func(cmd *cobra.Command, args []string) error { - return m.DeployCRDs(manifestsDir, namespace) - }, - } - - cmd.Flags().StringVar(&manifestsDir, "dir", "manifests", "Directory containing CRD files") - cmd.Flags().StringVar(&namespace, "namespace", "", "Namespace to deploy to (overrides metadata)") - - return cmd -} - -// GenerateCRDsFromMetadata generates CRD files from metadata. -func (m *PipelineManager) GenerateCRDsFromMetadata(metadataFile, metadataDir, outputDir string) error { - var registry *metadata.RegistryFile - var err error - - if metadataFile != "" { - m.logger.Info("Loading metadata from file", zap.String("file", metadataFile)) - registry, err = metadata.LoadFromFile(metadataFile) - } else { - m.logger.Info("Loading metadata from directory", zap.String("dir", metadataDir)) - registry, err = metadata.LoadFromDirectory(metadataDir) - } - - if err != nil { - wrappedErr := wrapWithSentinel(ErrLoadMetadataFailed, err, fmt.Sprintf("failed to load metadata: %v", err)) - Error("Failed to load metadata") - logStructuredError(m.logger, wrappedErr, "Failed to load metadata") - return wrappedErr - } - - if len(registry.Servers) == 0 { - err := ErrNoServersInMetadata - Error("No servers found in metadata") - logStructuredError(m.logger, err, "No servers found in metadata") - return err - } - - // Kubelet pulls use the node DNS / containerd config, not in-cluster CoreDNS. When defaults - // use registry.local, set MCP_REGISTRY_INGRESS_HOST to a node-reachable host:port and configure - // HTTP (insecure) registries in Docker / k3s, or add registry.local in node /etc/hosts. - if metadata.ResolveRegistryHost() == metadata.DefaultRegistryHost { - m.logger.Warn("Using default image host registry.local for generated MCPServer image refs. If cluster pulls fail, set MCP_REGISTRY_INGRESS_HOST to your registry (e.g. ClusterIP:port) and configure containerd/k3s for HTTP, or use public DNS and TLS.") - } - - m.logger.Info("Generating CRD files", zap.Int("count", len(registry.Servers)), zap.String("output", outputDir)) - - if err := metadata.GenerateCRDsFromRegistry(registry, outputDir); err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrGenerateCRDsFailed, - err, - fmt.Sprintf("failed to generate CRDs: %v", err), - map[string]any{"output_dir": outputDir, "server_count": len(registry.Servers), "component": "pipeline"}, - ) - Error("Failed to generate CRDs") - logStructuredError(m.logger, wrappedErr, "Failed to generate CRDs") - return wrappedErr - } - - m.logger.Info("CRD files generated successfully", zap.String("output", outputDir)) - - // List generated files - files, _ := filepath.Glob(filepath.Join(outputDir, "*.yaml")) - for _, file := range files { - Success(fmt.Sprintf("Generated: %s", file)) - } - - return nil -} - -// DeployCRDs deploys CRD files to the cluster. -func (m *PipelineManager) DeployCRDs(manifestsDir, namespace string) error { - if _, kerr := m.kubectl.CombinedOutput([]string{"version", "--request-timeout=5s"}); kerr != nil { - if _, perr := newPlatformClient(); perr == nil { - return newWithSentinel(ErrApplyManifestFailed, "pipeline deploy applies YAML with kubectl and needs a working kubeconfig. mcp-runtime auth is for the platform API only, not for applying manifests. Run deploy from a host with cluster access, or fix KUBECONFIG, then retry.") - } - } - m.logger.Info("Deploying CRD files", zap.String("dir", manifestsDir)) - - // Find all YAML files - files, err := filepathGlob(filepath.Join(manifestsDir, "*.yaml")) - if err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrListManifestFilesFailed, - err, - fmt.Sprintf("failed to list manifest files: %v", err), - map[string]any{"manifest_dir": manifestsDir, "component": "pipeline"}, - ) - Error("Failed to list manifest files") - logStructuredError(m.logger, wrappedErr, "Failed to list manifest files") - return wrappedErr - } - - ymlFiles, err := filepathGlob(filepath.Join(manifestsDir, "*.yml")) - if err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrListManifestFilesFailed, - err, - fmt.Sprintf("failed to list manifest files: %v", err), - map[string]any{"manifest_dir": manifestsDir, "component": "pipeline"}, - ) - Error("Failed to list manifest files") - logStructuredError(m.logger, wrappedErr, "Failed to list manifest files") - return wrappedErr - } - - files = append(files, ymlFiles...) - - if len(files) == 0 { - err := newWithSentinel(ErrNoManifestFilesFound, fmt.Sprintf("no manifest files found in %s", manifestsDir)) - Error("No manifest files found") - logStructuredError(m.logger, err, "No manifest files found") - return err - } - - // Apply each file - for _, file := range files { - m.logger.Info("Applying manifest", zap.String("file", file)) - - absPath, err := resolveRegularFilePath(file) - if err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrApplyManifestFailed, - err, - fmt.Sprintf("failed to resolve %s: %v", file, err), - map[string]any{"file": file, "namespace": namespace, "component": "pipeline"}, - ) - Error("Failed to resolve manifest file") - logStructuredError(m.logger, wrappedErr, "Failed to resolve manifest file") - return wrappedErr - } - - manifestBytes, err := readFileAtPath(absPath) - if err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrApplyManifestFailed, - err, - fmt.Sprintf("failed to read %s: %v", absPath, err), - map[string]any{"file": file, "namespace": namespace, "component": "pipeline"}, - ) - Error("Failed to read manifest file") - logStructuredError(m.logger, wrappedErr, "Failed to read manifest file") - return wrappedErr - } - - if err := applyManifestContentWithNamespace(m.kubectl, string(manifestBytes), namespace); err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrApplyManifestFailed, - err, - fmt.Sprintf("failed to apply %s: %v", file, err), - map[string]any{"file": file, "namespace": namespace, "component": "pipeline"}, - ) - Error("Failed to apply manifest") - logStructuredError(m.logger, wrappedErr, "Failed to apply manifest") - return wrappedErr - } - } - - m.logger.Info("All CRD files deployed successfully") - return nil -} diff --git a/internal/cli/pipeline/pipeline.go b/internal/cli/pipeline/pipeline.go index 6234d76..e9f4584 100644 --- a/internal/cli/pipeline/pipeline.go +++ b/internal/cli/pipeline/pipeline.go @@ -2,18 +2,38 @@ package pipeline import ( + "fmt" + "path/filepath" + "github.com/spf13/cobra" + "go.uber.org/zap" "mcp-runtime/internal/cli" + "mcp-runtime/pkg/metadata" ) +// filepathGlob is a test seam for filepath.Glob. +var filepathGlob = filepath.Glob + +type manager struct { + kubectl *cli.KubectlClient + logger *zap.Logger +} + +func newManager(runtime *cli.Runtime) *manager { + return &manager{ + kubectl: runtime.KubectlClient(), + logger: runtime.Logger(), + } +} + // New returns the pipeline command. func New(runtime *cli.Runtime) *cobra.Command { - return NewWithManager(runtime.PipelineManager()) + return NewWithManager(newManager(runtime)) } // NewWithManager returns the pipeline command using the provided manager. -func NewWithManager(mgr *cli.PipelineManager) *cobra.Command { +func NewWithManager(mgr *manager) *cobra.Command { cmd := &cobra.Command{ Use: "pipeline", Short: "Pipeline integration commands", @@ -55,3 +75,145 @@ the operator to create the necessary Kubernetes resources.`, cmd.AddCommand(generateCmd, deployCmd) return cmd } + +func (m *manager) GenerateCRDsFromMetadata(metadataFile, metadataDir, outputDir string) error { + var registry *metadata.RegistryFile + var err error + + if metadataFile != "" { + m.logger.Info("Loading metadata from file", zap.String("file", metadataFile)) + registry, err = metadata.LoadFromFile(metadataFile) + } else { + m.logger.Info("Loading metadata from directory", zap.String("dir", metadataDir)) + registry, err = metadata.LoadFromDirectory(metadataDir) + } + + if err != nil { + wrappedErr := cli.WrapWithSentinel(cli.ErrLoadMetadataFailed, err, fmt.Sprintf("failed to load metadata: %v", err)) + cli.Error("Failed to load metadata") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to load metadata") + return wrappedErr + } + + if len(registry.Servers) == 0 { + err := cli.ErrNoServersInMetadata + cli.Error("No servers found in metadata") + cli.LogStructuredError(m.logger, err, "No servers found in metadata") + return err + } + + if metadata.ResolveRegistryHost() == metadata.DefaultRegistryHost { + m.logger.Warn("Using default image host registry.local for generated MCPServer image refs. If cluster pulls fail, set MCP_REGISTRY_INGRESS_HOST to your registry (e.g. ClusterIP:port) and configure containerd/k3s for HTTP, or use public DNS and TLS.") + } + + m.logger.Info("Generating CRD files", zap.Int("count", len(registry.Servers)), zap.String("output", outputDir)) + + if err := metadata.GenerateCRDsFromRegistry(registry, outputDir); err != nil { + wrappedErr := cli.WrapWithSentinelAndContext( + cli.ErrGenerateCRDsFailed, + err, + fmt.Sprintf("failed to generate CRDs: %v", err), + map[string]any{"output_dir": outputDir, "server_count": len(registry.Servers), "component": "pipeline"}, + ) + cli.Error("Failed to generate CRDs") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to generate CRDs") + return wrappedErr + } + + m.logger.Info("CRD files generated successfully", zap.String("output", outputDir)) + + files, _ := filepath.Glob(filepath.Join(outputDir, "*.yaml")) + for _, file := range files { + cli.Success(fmt.Sprintf("Generated: %s", file)) + } + + return nil +} + +func (m *manager) DeployCRDs(manifestsDir, namespace string) error { + if _, kerr := m.kubectl.CombinedOutput([]string{"version", "--request-timeout=5s"}); kerr != nil { + if cli.HasPlatformClient() { + return cli.NewWithSentinel(cli.ErrApplyManifestFailed, "pipeline deploy applies YAML with kubectl and needs a working kubeconfig. mcp-runtime auth is for the platform API only, not for applying manifests. Run deploy from a host with cluster access, or fix KUBECONFIG, then retry.") + } + } + m.logger.Info("Deploying CRD files", zap.String("dir", manifestsDir)) + + files, err := filepathGlob(filepath.Join(manifestsDir, "*.yaml")) + if err != nil { + wrappedErr := cli.WrapWithSentinelAndContext( + cli.ErrListManifestFilesFailed, + err, + fmt.Sprintf("failed to list manifest files: %v", err), + map[string]any{"manifest_dir": manifestsDir, "component": "pipeline"}, + ) + cli.Error("Failed to list manifest files") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to list manifest files") + return wrappedErr + } + + ymlFiles, err := filepathGlob(filepath.Join(manifestsDir, "*.yml")) + if err != nil { + wrappedErr := cli.WrapWithSentinelAndContext( + cli.ErrListManifestFilesFailed, + err, + fmt.Sprintf("failed to list manifest files: %v", err), + map[string]any{"manifest_dir": manifestsDir, "component": "pipeline"}, + ) + cli.Error("Failed to list manifest files") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to list manifest files") + return wrappedErr + } + + files = append(files, ymlFiles...) + if len(files) == 0 { + err := cli.NewWithSentinel(cli.ErrNoManifestFilesFound, fmt.Sprintf("no manifest files found in %s", manifestsDir)) + cli.Error("No manifest files found") + cli.LogStructuredError(m.logger, err, "No manifest files found") + return err + } + + for _, file := range files { + m.logger.Info("Applying manifest", zap.String("file", file)) + + absPath, err := cli.ResolveRegularFilePath(file) + if err != nil { + wrappedErr := cli.WrapWithSentinelAndContext( + cli.ErrApplyManifestFailed, + err, + fmt.Sprintf("failed to resolve %s: %v", file, err), + map[string]any{"file": file, "namespace": namespace, "component": "pipeline"}, + ) + cli.Error("Failed to resolve manifest file") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to resolve manifest file") + return wrappedErr + } + + manifestBytes, err := cli.ReadFileAtPath(absPath) + if err != nil { + wrappedErr := cli.WrapWithSentinelAndContext( + cli.ErrApplyManifestFailed, + err, + fmt.Sprintf("failed to read %s: %v", absPath, err), + map[string]any{"file": file, "namespace": namespace, "component": "pipeline"}, + ) + cli.Error("Failed to read manifest file") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to read manifest file") + return wrappedErr + } + + if err := cli.ApplyManifestContentWithNamespace(m.kubectl, string(manifestBytes), namespace); err != nil { + wrappedErr := cli.WrapWithSentinelAndContext( + cli.ErrApplyManifestFailed, + err, + fmt.Sprintf("failed to apply %s: %v", file, err), + map[string]any{"file": file, "namespace": namespace, "component": "pipeline"}, + ) + cli.Error("Failed to apply manifest") + cli.LogStructuredError(m.logger, wrappedErr, "Failed to apply manifest") + return wrappedErr + } + } + + m.logger.Info("All CRD files deployed successfully") + return nil +} diff --git a/internal/cli/pipeline/pipeline_test.go b/internal/cli/pipeline/pipeline_test.go new file mode 100644 index 0000000..baa2b72 --- /dev/null +++ b/internal/cli/pipeline/pipeline_test.go @@ -0,0 +1,168 @@ +package pipeline + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" + + "mcp-runtime/internal/cli" +) + +func TestManagerDeployCRDs(t *testing.T) { + t.Run("returns error when no manifests found", func(t *testing.T) { + mock := &cli.MockExecutor{} + kubectl, err := cli.NewKubectlClient(mock) + if err != nil { + t.Fatalf("failed to create kubectl client: %v", err) + } + mgr := &manager{kubectl: kubectl, logger: zap.NewNop()} + + runErr := mgr.DeployCRDs(t.TempDir(), "test-ns") + if runErr == nil { + t.Fatal("expected error when no manifests found") + } + }) + + t.Run("applies each manifest file", func(t *testing.T) { + var appliedManifests []string + mock := &cli.MockExecutor{ + CommandFunc: func(spec cli.ExecSpec) *cli.MockCommand { + cmd := &cli.MockCommand{Args: spec.Args} + cmd.RunFunc = func() error { + if cmd.StdinR != nil { + data, err := io.ReadAll(cmd.StdinR) + if err != nil { + return err + } + appliedManifests = append(appliedManifests, string(data)) + } + return nil + } + return cmd + }, + } + kubectl, err := cli.NewKubectlClient(mock) + if err != nil { + t.Fatalf("failed to create kubectl client: %v", err) + } + mgr := &manager{kubectl: kubectl, logger: zap.NewNop()} + + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "server1.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "server2.yml"), []byte("apiVersion: v1"), 0o600); err != nil { + t.Fatal(err) + } + + err = mgr.DeployCRDs(tmpDir, "test-ns") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + applyCount := 0 + for _, cmd := range mock.Commands { + if cmd.Name == "kubectl" && contains(cmd.Args, "apply") { + applyCount++ + } + } + if applyCount != 2 { + t.Fatalf("expected 2 kubectl apply calls, got %d", applyCount) + } + if len(appliedManifests) != 2 { + t.Fatalf("expected 2 applied manifests, got %d", len(appliedManifests)) + } + }) +} + +func TestManagerGenerateCRDsFromMetadata(t *testing.T) { + t.Run("returns error for missing metadata", func(t *testing.T) { + mgr := &manager{logger: zap.NewNop()} + if err := mgr.GenerateCRDsFromMetadata("nonexistent.yaml", "", t.TempDir()); err == nil { + t.Fatal("expected error for missing metadata file") + } + }) + + t.Run("generates CRDs from file successfully", func(t *testing.T) { + var buf bytes.Buffer + origWriter := cli.DefaultPrinter.Writer + cli.DefaultPrinter.Writer = &buf + t.Cleanup(func() { cli.DefaultPrinter.Writer = origWriter }) + + mgr := &manager{kubectl: &cli.KubectlClient{}, logger: zap.NewNop()} + tmpDir := t.TempDir() + outputDir := filepath.Join(tmpDir, "output") + metadataFile := filepath.Join(tmpDir, "servers.yaml") + content := `version: "1" +servers: + - name: test-server + image: test-image:latest +` + if err := os.WriteFile(metadataFile, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if err := mgr.GenerateCRDsFromMetadata(metadataFile, "", outputDir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + files, _ := filepath.Glob(filepath.Join(outputDir, "*.yaml")) + if len(files) == 0 { + t.Fatal("expected CRD files to be generated") + } + }) +} + +func TestManagerDeployCRDsErrors(t *testing.T) { + t.Run("apply error", func(t *testing.T) { + mock := &cli.MockExecutor{DefaultRunErr: errors.New("apply failed")} + kubectl, err := cli.NewKubectlClient(mock) + if err != nil { + t.Fatalf("failed to create kubectl client: %v", err) + } + mgr := &manager{kubectl: kubectl, logger: zap.NewNop()} + + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "test.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { + t.Fatal(err) + } + + if err := mgr.DeployCRDs(tmpDir, ""); err == nil { + t.Fatal("expected error when apply fails") + } + }) + + t.Run("glob yaml error", func(t *testing.T) { + originalGlob := filepathGlob + t.Cleanup(func() { filepathGlob = originalGlob }) + filepathGlob = func(pattern string) ([]string, error) { + return nil, errors.New("glob error") + } + + mock := &cli.MockExecutor{} + kubectl, err := cli.NewKubectlClient(mock) + if err != nil { + t.Fatalf("failed to create kubectl client: %v", err) + } + mgr := &manager{kubectl: kubectl, logger: zap.NewNop()} + + if err := mgr.DeployCRDs("/some/dir", ""); err == nil { + t.Fatal("expected error when glob fails") + } + }) +} + +func contains(slice []string, val string) bool { + for _, s := range slice { + if strings.TrimSpace(s) == val { + return true + } + } + return false +} diff --git a/internal/cli/pipeline_test.go b/internal/cli/pipeline_test.go deleted file mode 100644 index d6928ec..0000000 --- a/internal/cli/pipeline_test.go +++ /dev/null @@ -1,506 +0,0 @@ -package cli - -import ( - "bytes" - "errors" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestNewPipelineCmd(t *testing.T) { - logger := zap.NewNop() - cmd := NewPipelineCmd(logger) - - t.Run("command-created", func(t *testing.T) { - if cmd == nil { - t.Fatal("NewPipelineCmd should not return nil") - } - if cmd.Use != "pipeline" { - t.Errorf("expected Use='pipeline', got %q", cmd.Use) - } - }) - - t.Run("has-subcommands", func(t *testing.T) { - subcommands := cmd.Commands() - if len(subcommands) < 2 { - t.Errorf("expected at least 2 subcommands (generate, deploy), got %d", len(subcommands)) - } - - expectedSubs := map[string]bool{"generate": false, "deploy": false} - for _, sub := range subcommands { - if _, ok := expectedSubs[sub.Use]; ok { - expectedSubs[sub.Use] = true - } - } - - for name, found := range expectedSubs { - if !found { - t.Errorf("expected subcommand %q not found", name) - } - } - }) -} - -func TestPipelineManager_DeployCRDs(t *testing.T) { - t.Run("returns error when no manifests found", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - // Use empty temp dir - tmpDir := t.TempDir() - - err := mgr.DeployCRDs(tmpDir, "test-ns") - if err == nil { - t.Fatal("expected error when no manifests found") - } - }) - - t.Run("applies each manifest file", func(t *testing.T) { - var appliedManifests []string - mock := &MockExecutor{ - CommandFunc: func(spec ExecSpec) *MockCommand { - cmd := &MockCommand{Args: spec.Args} - cmd.RunFunc = func() error { - if cmd.StdinR != nil { - data, err := io.ReadAll(cmd.StdinR) - if err != nil { - return err - } - appliedManifests = append(appliedManifests, string(data)) - } - return nil - } - return cmd - }, - } - kubectl, err := NewKubectlClient(mock) - if err != nil { - t.Fatalf("failed to create kubectl client: %v", err) - } - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - // Create temp dir with manifest files - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "server1.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tmpDir, "server2.yml"), []byte("apiVersion: v1"), 0o600); err != nil { - t.Fatal(err) - } - - err = mgr.DeployCRDs(tmpDir, "test-ns") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should have called kubectl apply twice - applyCount := 0 - for _, cmd := range mock.Commands { - if cmd.Name == "kubectl" && contains(cmd.Args, "apply") { - if !contains(cmd.Args, "-") { - t.Errorf("expected kubectl apply to stream manifest over stdin, got %v", cmd.Args) - } - applyCount++ - } - } - if applyCount != 2 { - t.Errorf("expected 2 kubectl apply calls, got %d", applyCount) - } - if len(appliedManifests) != 2 { - t.Fatalf("expected 2 applied manifests, got %d", len(appliedManifests)) - } - applied := strings.Join(appliedManifests, "\n---\n") - if !strings.Contains(applied, "apiVersion: v1") { - t.Fatalf("expected manifest contents on stdin, got %q", applied) - } - }) - - t.Run("includes namespace in kubectl args", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "test.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.DeployCRDs(tmpDir, "my-namespace") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cmd := mock.LastCommand() - if !contains(cmd.Args, "-n") || !contains(cmd.Args, "my-namespace") { - t.Errorf("expected -n my-namespace in args, got %v", cmd.Args) - } - }) -} - -func TestPipelineManager_GenerateCRDsFromMetadata(t *testing.T) { - t.Run("returns error for missing metadata", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - err := mgr.GenerateCRDsFromMetadata("nonexistent.yaml", "", t.TempDir()) - if err == nil { - t.Fatal("expected error for missing metadata file") - } - }) - - t.Run("returns error for empty servers", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - tmpDir := t.TempDir() - metadataFile := filepath.Join(tmpDir, "empty.yaml") - if err := os.WriteFile(metadataFile, []byte("version: \"1\"\nservers: []\n"), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.GenerateCRDsFromMetadata(metadataFile, "", t.TempDir()) - if err == nil { - t.Fatal("expected error for empty servers") - } - }) - - t.Run("generates CRDs from file successfully", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - tmpDir := t.TempDir() - outputDir := filepath.Join(tmpDir, "output") - metadataFile := filepath.Join(tmpDir, "servers.yaml") - content := `version: "1" -servers: - - name: test-server - image: test-image:latest -` - if err := os.WriteFile(metadataFile, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.GenerateCRDsFromMetadata(metadataFile, "", outputDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Check output file was created - files, _ := filepath.Glob(filepath.Join(outputDir, "*.yaml")) - if len(files) == 0 { - t.Error("expected CRD files to be generated") - } - }) - - t.Run("generates CRDs from directory successfully", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - tmpDir := t.TempDir() - metadataDir := filepath.Join(tmpDir, ".mcp") - outputDir := filepath.Join(tmpDir, "output") - if err := os.MkdirAll(metadataDir, 0o755); err != nil { - t.Fatal(err) - } - - metadataFile := filepath.Join(metadataDir, "servers.yaml") - content := `version: "1" -servers: - - name: dir-server - image: dir-image:v1 -` - if err := os.WriteFile(metadataFile, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.GenerateCRDsFromMetadata("", metadataDir, outputDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("returns error for missing metadata directory", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - err := mgr.GenerateCRDsFromMetadata("", "/nonexistent/dir", t.TempDir()) - if err == nil { - t.Fatal("expected error for missing metadata directory") - } - }) -} - -func TestDefaultPipelineManager(t *testing.T) { - logger := zap.NewNop() - mgr := DefaultPipelineManager(logger) - - if mgr == nil { - t.Fatal("DefaultPipelineManager should not return nil") - } - if mgr.logger != logger { - t.Error("expected logger to be set") - } -} - -func TestNewPipelineCmdCreatesCommand(t *testing.T) { - logger := zap.NewNop() - cmd := NewPipelineCmd(logger) - - if cmd == nil { - t.Fatal("NewPipelineCmd should not return nil") - } - if cmd.Use != "pipeline" { - t.Errorf("expected Use='pipeline', got %q", cmd.Use) - } -} - -func TestNewPipelineCmdWithManager(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - cmd := NewPipelineCmdWithManager(mgr) - - t.Run("command_created", func(t *testing.T) { - if cmd == nil { - t.Fatal("NewPipelineCmdWithManager should not return nil") - } - if cmd.Use != "pipeline" { - t.Errorf("expected Use='pipeline', got %q", cmd.Use) - } - }) - - t.Run("has_subcommands", func(t *testing.T) { - subcommands := cmd.Commands() - if len(subcommands) != 2 { - t.Errorf("expected 2 subcommands (generate, deploy), got %d", len(subcommands)) - } - }) -} - -func TestPipelineGenerateCmd(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - cmd := mgr.newPipelineGenerateCmd() - - t.Run("command_created", func(t *testing.T) { - if cmd == nil { - t.Fatal("newPipelineGenerateCmd should not return nil") - } - if cmd.Use != "generate" { - t.Errorf("expected Use='generate', got %q", cmd.Use) - } - }) - - t.Run("has_flags", func(t *testing.T) { - flags := cmd.Flags() - expectedFlags := []string{"file", "dir", "output"} - for _, name := range expectedFlags { - if flags.Lookup(name) == nil { - t.Errorf("expected flag %q not found", name) - } - } - }) - - t.Run("executes_generate", func(t *testing.T) { - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - tmpDir := t.TempDir() - metadataFile := filepath.Join(tmpDir, "test.yaml") - content := `version: "1" -servers: - - name: gen-test - image: test:v1 -` - if err := os.WriteFile(metadataFile, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - outputDir := filepath.Join(tmpDir, "out") - cmd.SetArgs([]string{"--file", metadataFile, "--output", outputDir}) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) -} - -func TestPipelineDeployCmd(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - cmd := mgr.newPipelineDeployCmd() - - t.Run("command_created", func(t *testing.T) { - if cmd == nil { - t.Fatal("newPipelineDeployCmd should not return nil") - } - if cmd.Use != "deploy" { - t.Errorf("expected Use='deploy', got %q", cmd.Use) - } - }) - - t.Run("has_flags", func(t *testing.T) { - flags := cmd.Flags() - expectedFlags := []string{"dir", "namespace"} - for _, name := range expectedFlags { - if flags.Lookup(name) == nil { - t.Errorf("expected flag %q not found", name) - } - } - }) - - t.Run("executes_deploy", func(t *testing.T) { - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "test.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { - t.Fatal(err) - } - - cmd.SetArgs([]string{"--dir", tmpDir}) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) -} - -func TestPipelineManager_DeployCRDs_WithoutNamespace(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "test.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.DeployCRDs(tmpDir, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cmd := mock.LastCommand() - // Should not have -n flag when namespace is empty - for i, arg := range cmd.Args { - if arg == "-n" { - t.Errorf("should not have -n flag when namespace is empty, got args: %v at index %d", cmd.Args, i) - } - } -} - -func TestPipelineManager_DeployCRDs_ApplyError(t *testing.T) { - mock := &MockExecutor{DefaultRunErr: errors.New("apply failed")} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - tmpDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tmpDir, "test.yaml"), []byte("apiVersion: v1"), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.DeployCRDs(tmpDir, "") - if err == nil { - t.Fatal("expected error when apply fails") - } -} - -func TestPipelineManager_GenerateCRDsFromMetadata_CRDGenerationError(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - tmpDir := t.TempDir() - metadataFile := filepath.Join(tmpDir, "servers.yaml") - content := `version: "1" -servers: - - name: test-server - image: test-image:latest -` - if err := os.WriteFile(metadataFile, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - // Create a file at outputDir path to cause mkdir to fail - outputPath := filepath.Join(tmpDir, "output") - if err := os.WriteFile(outputPath, []byte("not a dir"), 0o600); err != nil { - t.Fatal(err) - } - - err := mgr.GenerateCRDsFromMetadata(metadataFile, "", outputPath) - if err == nil { - t.Fatal("expected error when CRD generation fails") - } -} - -func TestPipelineManager_DeployCRDs_GlobYamlError(t *testing.T) { - // Save and restore original filepathGlob - originalGlob := filepathGlob - defer func() { filepathGlob = originalGlob }() - - callCount := 0 - filepathGlob = func(pattern string) ([]string, error) { - callCount++ - if callCount == 1 { - // First call for *.yaml - return error - return nil, errors.New("glob error") - } - return nil, nil - } - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - err := mgr.DeployCRDs("/some/dir", "") - if err == nil { - t.Fatal("expected error when glob fails for yaml") - } -} - -func TestPipelineManager_DeployCRDs_GlobYmlError(t *testing.T) { - // Save and restore original filepathGlob - originalGlob := filepathGlob - defer func() { filepathGlob = originalGlob }() - - callCount := 0 - filepathGlob = func(pattern string) ([]string, error) { - callCount++ - if callCount == 1 { - // First call for *.yaml - return empty - return []string{}, nil - } - if callCount == 2 { - // Second call for *.yml - return error - return nil, errors.New("glob error") - } - return nil, nil - } - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewPipelineManager(kubectl, zap.NewNop()) - - err := mgr.DeployCRDs("/some/dir", "") - if err == nil { - t.Fatal("expected error when glob fails for yml") - } -} diff --git a/internal/cli/platform_client.go b/internal/cli/platform_client.go index 4094796..443e2e4 100644 --- a/internal/cli/platform_client.go +++ b/internal/cli/platform_client.go @@ -56,6 +56,11 @@ func newPlatformClient() (*platformClient, error) { }, nil } +func HasPlatformClient() bool { + _, err := newPlatformClient() + return err == nil +} + func (c *platformClient) do(ctx context.Context, method, relPath, query string, body io.Reader) (*http.Response, error) { u, err := url.Parse(c.baseURL) if err != nil { diff --git a/internal/cli/registry.go b/internal/cli/registry.go index 0051e70..3a18dae 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/spf13/cobra" "go.uber.org/zap" "gopkg.in/yaml.v3" ) @@ -41,28 +40,6 @@ func DefaultRegistryManager(logger *zap.Logger) *RegistryManager { return NewRegistryManager(kubectlClient, execExecutor, logger) } -// NewRegistryCmd builds the registry subcommand for managing registry lifecycle. -func NewRegistryCmd(logger *zap.Logger) *cobra.Command { - mgr := DefaultRegistryManager(logger) - return NewRegistryCmdWithManager(mgr) -} - -// NewRegistryCmdWithManager returns the registry subcommand using the provided manager. -func NewRegistryCmdWithManager(mgr *RegistryManager) *cobra.Command { - cmd := &cobra.Command{ - Use: "registry", - Short: "Manage container registry", - Long: "Commands for managing the container registry", - } - - cmd.AddCommand(mgr.newRegistryStatusCmd()) - cmd.AddCommand(mgr.newRegistryInfoCmd()) - cmd.AddCommand(mgr.newRegistryProvisionCmd()) - cmd.AddCommand(mgr.newRegistryPushCmd()) - - return cmd -} - // RunRegistryProvision contains the registry provision command flow for folder packages. func RunRegistryProvision(mgr *RegistryManager, url, username, password, operatorImage string) error { flagCfg := &ExternalRegistryConfig{ @@ -166,176 +143,6 @@ func RunRegistryPush(mgr *RegistryManager, image, registryURL, name, mode, helpe } } -func (m *RegistryManager) newRegistryStatusCmd() *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "status", - Short: "Check registry status", - Long: "Check the status of the container registry", - RunE: func(cmd *cobra.Command, args []string) error { - return m.CheckRegistryStatus(namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceRegistry, "Registry namespace") - - return cmd -} - -func (m *RegistryManager) newRegistryInfoCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "info", - Short: "Show registry information", - Long: "Show registry URL and connection information", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ShowRegistryInfo() - }, - } - - return cmd -} - -func (m *RegistryManager) newRegistryProvisionCmd() *cobra.Command { - var url string - var username string - var password string - var operatorImage string - - cmd := &cobra.Command{ - Use: "provision", - Short: "Configure an external registry", - Long: "Configure an external registry to be used for operator/runtime images", - RunE: func(cmd *cobra.Command, args []string) error { - flagCfg := &ExternalRegistryConfig{ - URL: url, - Username: username, - Password: password, - } - cfg, err := resolveExternalRegistryConfig(flagCfg) - if err != nil { - return err - } - if cfg == nil || cfg.URL == "" { - err := newWithSentinel(ErrRegistryURLRequired, "registry url is required (flag, env PROVISIONED_REGISTRY_URL, or config file)") - Error("Registry URL required") - logStructuredError(m.logger, err, "Registry URL required") - return err - } - if err := saveExternalRegistryConfig(cfg); err != nil { - wrappedErr := wrapWithSentinel(ErrSaveRegistryConfigFailed, err, fmt.Sprintf("failed to save registry config: %v", err)) - Error("Failed to save registry config") - logStructuredError(m.logger, wrappedErr, "Failed to save registry config") - return wrappedErr - } - if cfg.Username != "" && cfg.Password != "" { - m.logger.Info("Performing docker login to external registry", zap.String("url", cfg.URL)) - if err := m.LoginRegistry(cfg.URL, cfg.Username, cfg.Password); err != nil { - return err - } - } - if operatorImage != "" { - m.logger.Info("Building and pushing operator image to external registry", zap.String("image", operatorImage)) - if err := buildOperatorImage(operatorImage); err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrBuildOperatorImageFailed, - err, - fmt.Sprintf("failed to build operator image: %v", err), - map[string]any{"image": operatorImage, "component": "registry"}, - ) - Error("Failed to build operator image") - logStructuredError(m.logger, wrappedErr, "Failed to build operator image") - return wrappedErr - } - if err := pushOperatorImage(operatorImage); err != nil { - wrappedErr := wrapWithSentinelAndContext( - ErrPushOperatorImageFailed, - err, - fmt.Sprintf("failed to push operator image: %v", err), - map[string]any{"image": operatorImage, "component": "registry"}, - ) - Error("Failed to push operator image") - logStructuredError(m.logger, wrappedErr, "Failed to push operator image") - return wrappedErr - } - } - m.logger.Info("External registry configured", zap.String("url", cfg.URL)) - fmt.Printf("External registry configured: %s\n", cfg.URL) - return nil - }, - } - - cmd.Flags().StringVar(&url, "url", "", "External registry URL (e.g., registry.mcpruntime.com)") - cmd.Flags().StringVar(&username, "username", "", "Registry username (optional)") - cmd.Flags().StringVar(&password, "password", "", "Registry password (optional)") - cmd.Flags().StringVar(&operatorImage, "operator-image", "", "Optional: build and push operator image to this external registry (e.g., /mcp-runtime-operator:latest)") - - return cmd -} - -func (m *RegistryManager) newRegistryPushCmd() *cobra.Command { - var image string - var registryURL string - var name string - var mode string - var helperNamespace string - - cmd := &cobra.Command{ - Use: "push", - Short: "Retag and push an image to the platform or provisioned registry", - RunE: func(cmd *cobra.Command, args []string) error { - if image == "" { - err := newWithSentinel(ErrImageRequired, "image is required (use --image)") - Error("Image required") - logStructuredError(m.logger, err, "Image required") - return err - } - targetRegistry := registryURL - if targetRegistry == "" { - if ext, err := resolveExternalRegistryConfig(nil); err == nil && ext != nil && ext.URL != "" { - targetRegistry = strings.TrimSuffix(ext.URL, "/") - } - } - if targetRegistry == "" { - targetRegistry = getPlatformRegistryURL(m.logger) - } - - repo, tag := splitImage(image) - if name != "" { - repo = name - } else { - repo = dropRegistryPrefix(repo) - } - target := targetRegistry + "/" + repo - if tag != "" { - target = target + ":" + tag - } - - m.logger.Info("Pushing image", zap.String("source", image), zap.String("target", target)) - - switch mode { - case "direct": - return m.PushDirect(image, target) - case "in-cluster": - return m.PushInCluster(image, target, helperNamespace) - default: - err := newWithSentinel(ErrUnknownRegistryMode, fmt.Sprintf("unknown mode %q (use direct|in-cluster)", mode)) - Error("Unknown registry mode") - logStructuredError(m.logger, err, "Unknown registry mode") - return err - } - }, - } - - cmd.Flags().StringVar(&image, "image", "", "Local image to push (required)") - cmd.Flags().StringVar(®istryURL, "registry", "", "Target registry (defaults to provisioned or internal)") - cmd.Flags().StringVar(&name, "name", "", "Override target repo/name (default: source name without registry)") - cmd.Flags().StringVar(&mode, "mode", "in-cluster", "Push mode: in-cluster (default, uses skopeo helper) or direct (docker push)") - cmd.Flags().StringVar(&helperNamespace, "namespace", NamespaceRegistry, "Namespace to run the in-cluster helper pod") - - return cmd -} - type ExternalRegistryConfig struct { URL string `yaml:"url"` Username string `yaml:"username,omitempty"` diff --git a/internal/cli/registry_test.go b/internal/cli/registry_test.go index e464b7f..eff0385 100644 --- a/internal/cli/registry_test.go +++ b/internal/cli/registry_test.go @@ -12,28 +12,6 @@ import ( "go.uber.org/zap" ) -func TestNewRegistryCmd(t *testing.T) { - logger := zap.NewNop() - cmd := NewRegistryCmd(logger) - - t.Run("command-created", func(t *testing.T) { - if cmd == nil { - t.Fatal("NewRegistryCmd should not return nil") - } - if cmd.Use != "registry" { - t.Errorf("expected Use='registry', got %q", cmd.Use) - } - }) - - t.Run("has-subcommands", func(t *testing.T) { - subcommands := cmd.Commands() - expectedSubs := []string{"status", "info", "provision", "push"} - if len(subcommands) < len(expectedSubs) { - t.Errorf("expected at least %d subcommands, got %d", len(expectedSubs), len(subcommands)) - } - }) -} - func TestRegistryManager_CheckRegistryStatus(t *testing.T) { t.Run("returns error when deployment not found", func(t *testing.T) { mock := &MockExecutor{ @@ -439,246 +417,6 @@ func TestEnsureRegistryStorageSize(t *testing.T) { }) } -func TestRegistryStatusCmdRunE(t *testing.T) { - mock := &MockExecutor{ - DefaultOutput: []byte("1/1"), - } - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := mgr.newRegistryStatusCmd() - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestRegistryInfoCmdRunE(t *testing.T) { - mock := &MockExecutor{ - CommandFunc: func(spec ExecSpec) *MockCommand { - cmd := &MockCommand{Args: spec.Args} - if contains(spec.Args, "clusterIP") { - cmd.OutputData = []byte("10.0.0.1") - } else if contains(spec.Args, "ports") { - cmd.OutputData = []byte("5000") - } - return cmd - }, - } - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := mgr.newRegistryInfoCmd() - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestRegistryProvisionCmdRunE(t *testing.T) { - t.Run("requires url", func(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - origConfig := DefaultCLIConfig - t.Cleanup(func() { DefaultCLIConfig = origConfig }) - DefaultCLIConfig = &CLIConfig{} - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newRegistryProvisionCmd() - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected error when url missing") - } - if !strings.Contains(err.Error(), "url is required") { - t.Fatalf("expected url required error, got: %v", err) - } - }) - - t.Run("saves config and logs in with credentials", func(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - origConfig := DefaultCLIConfig - t.Cleanup(func() { DefaultCLIConfig = origConfig }) - DefaultCLIConfig = &CLIConfig{} - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newRegistryProvisionCmd() - _ = cmd.Flags().Set("url", "registry.example.com") - _ = cmd.Flags().Set("username", "user") - _ = cmd.Flags().Set("password", "pass") - - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should have called docker login - if !mock.HasCommand("docker") { - t.Error("expected docker login to be called") - } - }) - - t.Run("handles login error", func(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - origConfig := DefaultCLIConfig - t.Cleanup(func() { DefaultCLIConfig = origConfig }) - DefaultCLIConfig = &CLIConfig{} - - mock := &MockExecutor{DefaultRunErr: errors.New("login failed")} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newRegistryProvisionCmd() - _ = cmd.Flags().Set("url", "registry.example.com") - _ = cmd.Flags().Set("username", "user") - _ = cmd.Flags().Set("password", "pass") - - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected error when login fails") - } - }) -} - -func TestRegistryPushCmdRunE(t *testing.T) { - t.Run("requires image", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newRegistryPushCmd() - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected error when image missing") - } - }) - - t.Run("uses external registry config", func(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - origConfig := DefaultCLIConfig - t.Cleanup(func() { DefaultCLIConfig = origConfig }) - DefaultCLIConfig = &CLIConfig{} - - if err := saveExternalRegistryConfig(&ExternalRegistryConfig{URL: "registry.example.com"}); err != nil { - t.Fatal(err) - } - - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := mgr.newRegistryPushCmd() - _ = cmd.Flags().Set("image", "my-image:latest") - _ = cmd.Flags().Set("mode", "direct") - - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("uses name override", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := mgr.newRegistryPushCmd() - _ = cmd.Flags().Set("image", "my-image:latest") - _ = cmd.Flags().Set("registry", "localhost:5000") - _ = cmd.Flags().Set("name", "custom-name") - _ = cmd.Flags().Set("mode", "direct") - - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Check that custom name was used - found := false - for _, cmd := range mock.Commands { - if cmd.Name == "docker" && contains(cmd.Args, "tag") { - for _, arg := range cmd.Args { - if strings.Contains(arg, "custom-name") { - found = true - break - } - } - } - } - if !found { - t.Error("expected custom name in tag command") - } - }) - - t.Run("rejects unknown mode", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newRegistryPushCmd() - _ = cmd.Flags().Set("image", "my-image:latest") - _ = cmd.Flags().Set("registry", "localhost:5000") - _ = cmd.Flags().Set("mode", "unknown") - - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected error for unknown mode") - } - if !strings.Contains(err.Error(), "unknown mode") { - t.Fatalf("expected unknown mode error, got: %v", err) - } - }) - - t.Run("uses in-cluster mode with namespace error", func(t *testing.T) { - mock := &MockExecutor{ - CommandFunc: func(spec ExecSpec) *MockCommand { - cmd := &MockCommand{Args: spec.Args} - // Fail on namespace check - if spec.Name == "kubectl" && contains(spec.Args, "get") && contains(spec.Args, "namespace") { - cmd.RunErr = errors.New("namespace not found") - } - return cmd - }, - } - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - - cmd := mgr.newRegistryPushCmd() - _ = cmd.Flags().Set("image", "my-image:latest") - _ = cmd.Flags().Set("registry", "localhost:5000") - _ = cmd.Flags().Set("mode", "in-cluster") - - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected error for missing namespace") - } - }) -} - func TestShowRegistryInfo(t *testing.T) { t.Run("displays registry info when available", func(t *testing.T) { mock := &MockExecutor{ @@ -1347,11 +1085,7 @@ func TestRegistryProvisionCmdWithOperatorImage(t *testing.T) { kubectl := &KubectlClient{exec: mock, validators: nil} mgr := NewRegistryManager(kubectl, mock, zap.NewNop()) - cmd := mgr.newRegistryProvisionCmd() - _ = cmd.Flags().Set("url", "registry.example.com") - _ = cmd.Flags().Set("operator-image", "registry.example.com/operator:latest") - - err := cmd.RunE(cmd, nil) + err := RunRegistryProvision(mgr, "registry.example.com", "", "", "registry.example.com/operator:latest") if err == nil { t.Fatal("expected error when build fails") } diff --git a/internal/cli/resource_helpers.go b/internal/cli/resource_helpers.go index 660ee53..2297630 100644 --- a/internal/cli/resource_helpers.go +++ b/internal/cli/resource_helpers.go @@ -27,6 +27,10 @@ func resolveRegularFilePath(file string) (string, error) { return absPath, nil } +func ResolveRegularFilePath(file string) (string, error) { + return resolveRegularFilePath(file) +} + func readFileAtPath(path string) ([]byte, error) { absPath, err := filepath.Abs(path) if err != nil { @@ -57,6 +61,10 @@ func readFileAtPath(path string) ([]byte, error) { return io.ReadAll(file) } +func ReadFileAtPath(path string) ([]byte, error) { + return readFileAtPath(path) +} + func applyManifestFromFile(kubectl *KubectlClient, file string, stdout, stderr io.Writer) error { absPath, err := resolveRegularFilePath(file) if err != nil { diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index ca8df4f..71d22bb 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -23,6 +23,16 @@ func (r *Runtime) KubectlRunner() KubectlRunner { return DefaultKubectlRunner() } +// KubectlClient returns the shared kubectl client. +func (r *Runtime) KubectlClient() *KubectlClient { + return kubectlClient +} + +// Executor returns the shared process executor. +func (r *Runtime) Executor() Executor { + return execExecutor +} + // AccessManager returns the access command manager. func (r *Runtime) AccessManager() *AccessManager { return DefaultAccessManager(r.logger) @@ -33,11 +43,6 @@ func (r *Runtime) ClusterManager() *ClusterManager { return DefaultClusterManager(r.logger) } -// PipelineManager returns the pipeline command manager. -func (r *Runtime) PipelineManager() *PipelineManager { - return DefaultPipelineManager(r.logger) -} - // RegistryManager returns the registry command manager. func (r *Runtime) RegistryManager() *RegistryManager { return DefaultRegistryManager(r.logger) diff --git a/internal/cli/sentinel.go b/internal/cli/sentinel.go index f5256b0..dc25f99 100644 --- a/internal/cli/sentinel.go +++ b/internal/cli/sentinel.go @@ -7,7 +7,6 @@ import ( "strconv" "strings" - "github.com/spf13/cobra" "go.uber.org/zap" ) @@ -112,27 +111,6 @@ func DefaultSentinelManager(logger *zap.Logger) *SentinelManager { return NewSentinelManager(kubectlClient, logger) } -func NewSentinelCmd(logger *zap.Logger) *cobra.Command { - mgr := DefaultSentinelManager(logger) - return NewSentinelCmdWithManager(mgr) -} - -func NewSentinelCmdWithManager(mgr *SentinelManager) *cobra.Command { - cmd := &cobra.Command{ - Use: "sentinel", - Short: "Operate the bundled mcp-sentinel stack", - Long: "Commands for inspecting and operating the bundled mcp-sentinel analytics, gateway, and observability stack.", - } - - cmd.AddCommand(mgr.newSentinelStatusCmd()) - cmd.AddCommand(mgr.newSentinelLogsCmd()) - cmd.AddCommand(mgr.newSentinelEventsCmd()) - cmd.AddCommand(mgr.newSentinelPortForwardCmd()) - cmd.AddCommand(mgr.newSentinelRestartCmd()) - - return cmd -} - func SentinelComponentKeys() []string { keys := make([]string, 0, len(sentinelComponents)) for _, component := range sentinelComponents { @@ -170,95 +148,6 @@ func findSentinelPortTarget(name string) (*sentinelPortTarget, error) { return component.PortTarget, nil } -func (m *SentinelManager) newSentinelStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show mcp-sentinel stack status", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ShowSentinelStatus() - }, - } -} - -func (m *SentinelManager) newSentinelLogsCmd() *cobra.Command { - var follow bool - var previous bool - var tail int - var since string - - cmd := &cobra.Command{ - Use: "logs [component]", - Short: "View logs for a mcp-sentinel component", - Args: cobra.ExactArgs(1), - ValidArgs: SentinelComponentKeys(), - RunE: func(cmd *cobra.Command, args []string) error { - return m.ViewSentinelLogs(args[0], follow, previous, tail, since) - }, - } - - cmd.Flags().BoolVar(&follow, "follow", false, "Follow log output") - cmd.Flags().BoolVar(&previous, "previous", false, "Show logs from the previous container instance") - cmd.Flags().IntVar(&tail, "tail", 200, "Number of recent log lines to show (-1 for all)") - cmd.Flags().StringVar(&since, "since", "", "Only return logs newer than a relative duration like 5m or 1h") - - return cmd -} - -func (m *SentinelManager) newSentinelEventsCmd() *cobra.Command { - return &cobra.Command{ - Use: "events", - Short: "Show recent Kubernetes events for mcp-sentinel", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ShowSentinelEvents() - }, - } -} - -func (m *SentinelManager) newSentinelPortForwardCmd() *cobra.Command { - var localPort int - var address string - - cmd := &cobra.Command{ - Use: "port-forward [target]", - Short: "Port-forward a common mcp-sentinel service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.PortForwardSentinelTarget(args[0], localPort, address) - }, - } - - cmd.Flags().IntVar(&localPort, "port", 0, "Local port to bind (defaults to the target service port)") - cmd.Flags().StringVar(&address, "address", "127.0.0.1", "Addresses to listen on") - - return cmd -} - -func (m *SentinelManager) newSentinelRestartCmd() *cobra.Command { - var restartAll bool - - cmd := &cobra.Command{ - Use: "restart [component]", - Short: "Restart one or all mcp-sentinel workloads", - Args: func(cmd *cobra.Command, args []string) error { - if restartAll && len(args) == 0 { - return nil - } - return cobra.ExactArgs(1)(cmd, args) - }, - RunE: func(cmd *cobra.Command, args []string) error { - component := "" - if len(args) > 0 { - component = args[0] - } - return m.RestartSentinel(component, restartAll) - }, - } - - cmd.Flags().BoolVar(&restartAll, "all", false, "Restart every mcp-sentinel workload") - - return cmd -} - func (m *SentinelManager) ShowSentinelStatus() error { Header("MCP Sentinel Status") DefaultPrinter.Println() diff --git a/internal/cli/sentinel_test.go b/internal/cli/sentinel_test.go index 9fb4ede..d1e35d9 100644 --- a/internal/cli/sentinel_test.go +++ b/internal/cli/sentinel_test.go @@ -1,41 +1,11 @@ package cli import ( - "strings" "testing" "go.uber.org/zap" ) -func TestNewSentinelCmd(t *testing.T) { - cmd := NewSentinelCmd(zap.NewNop()) - if cmd == nil { - t.Fatal("NewSentinelCmd should not return nil") - } - if cmd.Use != "sentinel" { - t.Fatalf("expected Use='sentinel', got %q", cmd.Use) - } - - expected := map[string]bool{ - "status": false, - "logs": false, - "events": false, - "port-forward": false, - "restart": false, - } - for _, sub := range cmd.Commands() { - name := strings.Fields(sub.Use)[0] - if _, ok := expected[name]; ok { - expected[name] = true - } - } - for name, found := range expected { - if !found { - t.Fatalf("expected subcommand %q not found", name) - } - } -} - func TestSentinelManager_ViewSentinelLogs(t *testing.T) { mock := &MockExecutor{} kubectl := &KubectlClient{exec: mock, validators: nil} diff --git a/internal/cli/server.go b/internal/cli/server.go index e46bea6..0513618 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -59,45 +59,6 @@ func validateServerInput(name, namespace string) (string, string, error) { return name, namespace, nil } -// NewServerCmd returns the server subcommand (build/deploy helpers). -func NewServerCmd(logger *zap.Logger) *cobra.Command { - mgr := DefaultServerManager(logger) - return NewServerCmdWithManager(mgr) -} - -// NewServerCmdWithManager returns the server subcommand using the provided manager. -// This is useful for testing with mock dependencies. -func NewServerCmdWithManager(mgr *ServerManager) *cobra.Command { - cmd := &cobra.Command{ - Use: "server", - Short: "Manage MCP servers", - Long: `Commands for managing MCP server deployments. - -With mcp-runtime auth login, list, status, and policy use the platform API when ---use-kube is not set. Create, apply, delete, patch, and logs require kubectl -and a cluster kubeconfig (or --use-kube for those operations). - -For building images from source, use 'server build'. -For pushing images, use 'registry push'.`, - } - - cmd.PersistentFlags().BoolVar(&mgr.useKube, "use-kube", false, "Use kubectl and local kubeconfig instead of the platform API for supported commands") - - cmd.AddCommand(mgr.newServerListCmd()) - cmd.AddCommand(mgr.newServerGetCmd()) - cmd.AddCommand(mgr.newServerCreateCmd()) - cmd.AddCommand(mgr.newServerApplyCmd()) - cmd.AddCommand(mgr.newServerExportCmd()) - cmd.AddCommand(mgr.newServerPatchCmd()) - cmd.AddCommand(mgr.newServerDeleteCmd()) - cmd.AddCommand(mgr.newServerLogsCmd()) - cmd.AddCommand(mgr.newServerStatusCmd()) - cmd.AddCommand(mgr.newServerPolicyCmd()) - cmd.AddCommand(newServerBuildCmd(mgr.logger)) - - return cmd -} - // BindUseKubeFlag wires the shared --use-kube flag onto the command. func (m *ServerManager) BindUseKubeFlag(cmd *cobra.Command) { cmd.PersistentFlags().BoolVar(&m.useKube, "use-kube", false, "Use kubectl and local kubeconfig instead of the platform API for supported commands") @@ -108,222 +69,6 @@ func (m *ServerManager) Logger() *zap.Logger { return m.logger } -func newServerBuildCmd(logger *zap.Logger) *cobra.Command { - cmd := &cobra.Command{ - Use: "build", - Short: "Build MCP server images (push via `registry push`)", - } - - // Only expose image build here; pushing is handled by `registry push`. - cmd.AddCommand(newBuildImageCmd(logger)) - - return cmd -} - -func (m *ServerManager) newServerListCmd() *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "list", - Short: "List MCP servers", - Long: "List all MCP server deployments", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ListServers(namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace to list servers from") - - return cmd -} - -func (m *ServerManager) newServerGetCmd() *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "get [name]", - Short: "Get MCP server details", - Long: "Get detailed information about an MCP server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.GetServer(args[0], namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - - return cmd -} - -func (m *ServerManager) newServerCreateCmd() *cobra.Command { - var namespace string - var image string - var imageTag string - var file string - - cmd := &cobra.Command{ - Use: "create [name]", - Short: "Create an MCP server", - Long: "Create a new MCP server deployment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if file != "" { - return m.CreateServerFromFile(file) - } - return m.CreateServer(args[0], namespace, image, imageTag) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - cmd.Flags().StringVar(&image, "image", "", "Container image") - cmd.Flags().StringVar(&imageTag, "tag", "latest", "Image tag") - cmd.Flags().StringVar(&file, "file", "", "YAML file with server spec") - - return cmd -} - -func (m *ServerManager) newServerApplyCmd() *cobra.Command { - var file string - - cmd := &cobra.Command{ - Use: "apply", - Short: "Apply an MCP server manifest", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ApplyServerFromFile(file) - }, - } - - cmd.Flags().StringVar(&file, "file", "", "YAML file with MCPServer manifest") - _ = cmd.MarkFlagRequired("file") - - return cmd -} - -func (m *ServerManager) newServerExportCmd() *cobra.Command { - var namespace string - var file string - - cmd := &cobra.Command{ - Use: "export [name]", - Short: "Export an MCP server manifest", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.ExportServer(args[0], namespace, file) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - cmd.Flags().StringVar(&file, "file", "", "Write the manifest to a file instead of stdout") - - return cmd -} - -func (m *ServerManager) newServerPatchCmd() *cobra.Command { - var namespace string - var patchType string - var patch string - var patchFile string - - cmd := &cobra.Command{ - Use: "patch [name]", - Short: "Patch an MCP server manifest", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.PatchServer(args[0], namespace, patchType, patch, patchFile) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - cmd.Flags().StringVar(&patchType, "type", "merge", "Patch type (merge|json|strategic)") - cmd.Flags().StringVar(&patch, "patch", "", "Inline JSON/YAML patch document") - cmd.Flags().StringVar(&patchFile, "patch-file", "", "Path to a JSON/YAML patch document") - - return cmd -} - -func (m *ServerManager) newServerDeleteCmd() *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "delete [name]", - Short: "Delete an MCP server", - Long: "Delete an MCP server deployment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.DeleteServer(args[0], namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - - return cmd -} - -func (m *ServerManager) newServerLogsCmd() *cobra.Command { - var namespace string - var follow bool - - cmd := &cobra.Command{ - Use: "logs [name]", - Short: "View server logs", - Long: "View logs from an MCP server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.ViewServerLogs(args[0], namespace, follow) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - cmd.Flags().BoolVar(&follow, "follow", false, "Follow log output") - - return cmd -} - -func (m *ServerManager) newServerStatusCmd() *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "status", - Short: "Show MCP server runtime status (pods, images, pull secrets)", - Long: "List MCPServer resources with their Deployment/pod status, image, and pull secrets.", - RunE: func(cmd *cobra.Command, args []string) error { - return m.ServerStatus(namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace to inspect") - - return cmd -} - -func (m *ServerManager) newServerPolicyCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "policy", - Short: "Inspect rendered gateway policy for an MCP server", - } - - cmd.AddCommand(m.newServerPolicyInspectCmd()) - - return cmd -} - -func (m *ServerManager) newServerPolicyInspectCmd() *cobra.Command { - var namespace string - - cmd := &cobra.Command{ - Use: "inspect [name]", - Short: "Show the rendered gateway policy document for a server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return m.InspectServerPolicy(args[0], namespace) - }, - } - - cmd.Flags().StringVar(&namespace, "namespace", NamespaceMCPServers, "Namespace") - - return cmd -} - // ListServers lists all MCP servers in the given namespace. func (m *ServerManager) ListServers(namespace string) error { namespace, err := validateManifestValue("namespace", namespace) diff --git a/internal/cli/server/build.go b/internal/cli/server/build.go new file mode 100644 index 0000000..6ac152e --- /dev/null +++ b/internal/cli/server/build.go @@ -0,0 +1,36 @@ +package server + +import ( + "github.com/spf13/cobra" + "go.uber.org/zap" + + "mcp-runtime/internal/cli" +) + +func newBuildImageCmd(logger *zap.Logger) *cobra.Command { + var dockerfile string + var metadataFile string + var metadataDir string + var registryURL string + var tag string + var contextDir string + + cmd := &cobra.Command{ + Use: "image ", + Short: "Build Docker image for an MCP server", + Long: "Build a Docker image from Dockerfile and update metadata file.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return cli.BuildImage(logger, args[0], dockerfile, metadataFile, metadataDir, registryURL, tag, contextDir) + }, + } + + cmd.Flags().StringVar(&dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") + cmd.Flags().StringVar(&metadataFile, "metadata-file", "", "Path to metadata file") + cmd.Flags().StringVar(&metadataDir, "metadata-dir", ".mcp", "Directory containing metadata files") + cmd.Flags().StringVar(®istryURL, "registry", "", "Registry URL (defaults to platform registry)") + cmd.Flags().StringVar(&tag, "tag", "", "Image tag (defaults to git SHA or 'latest')") + cmd.Flags().StringVar(&contextDir, "context", ".", "Build context directory") + + return cmd +} diff --git a/internal/cli/server/server.go b/internal/cli/server/server.go index 8d17032..9835dfa 100644 --- a/internal/cli/server/server.go +++ b/internal/cli/server/server.go @@ -171,7 +171,7 @@ For pushing images, use 'registry push'.`, Use: "build", Short: "Build MCP server images (push via `registry push`)", } - buildCmd.AddCommand(cli.NewBuildImageCmd(mgr.Logger())) + buildCmd.AddCommand(newBuildImageCmd(mgr.Logger())) cmd.AddCommand(listCmd, getCmd, createCmd, applyCmd, exportCmd, patchCmd, deleteCmd, logsCmd, statusCmd, policyCmd, buildCmd) return cmd diff --git a/internal/cli/server_test.go b/internal/cli/server_test.go index 2e1e4f2..4f2b983 100644 --- a/internal/cli/server_test.go +++ b/internal/cli/server_test.go @@ -12,24 +12,6 @@ import ( "gopkg.in/yaml.v3" ) -func TestNewServerCmd(t *testing.T) { - logger := zap.NewNop() - cmd := NewServerCmd(logger) - - if cmd == nil { - t.Fatal("NewServerCmd should not return nil") - } - - if cmd.Use != "server" { - t.Errorf("Expected command use 'server', got %q", cmd.Use) - } - - subcommands := cmd.Commands() - if len(subcommands) == 0 { - t.Error("Server command should have subcommands") - } -} - func TestServerManager_ListServers(t *testing.T) { t.Run("calls kubectl with correct args", func(t *testing.T) { mock := &MockExecutor{ @@ -427,113 +409,6 @@ func contains(slice []string, val string) bool { return false } -func TestServerCmdSubcommandRunE(t *testing.T) { - t.Run("list_cmd_executes", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewServerManager(kubectl, zap.NewNop()) - - cmd := mgr.newServerListCmd() - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !mock.HasCommand("kubectl") { - t.Error("expected kubectl to be called") - } - }) - - t.Run("get_cmd_executes", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewServerManager(kubectl, zap.NewNop()) - - cmd := mgr.newServerGetCmd() - err := cmd.RunE(cmd, []string{"my-server"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !mock.HasCommand("kubectl") { - t.Error("expected kubectl to be called") - } - }) - - t.Run("delete_cmd_executes", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewServerManager(kubectl, zap.NewNop()) - - cmd := mgr.newServerDeleteCmd() - err := cmd.RunE(cmd, []string{"my-server"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !mock.HasCommand("kubectl") { - t.Error("expected kubectl to be called") - } - }) - - t.Run("status_cmd_executes", func(t *testing.T) { - mock := &MockExecutor{ - DefaultOutput: []byte("server1|image:tag|1|/path|false\n"), - } - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewServerManager(kubectl, zap.NewNop()) - - var buf bytes.Buffer - setDefaultPrinterWriter(t, &buf) - - cmd := mgr.newServerStatusCmd() - err := cmd.RunE(cmd, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("logs_cmd_executes", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewServerManager(kubectl, zap.NewNop()) - - cmd := mgr.newServerLogsCmd() - err := cmd.RunE(cmd, []string{"my-server"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !contains(mock.LastCommand().Args, "--all-containers=true") { - t.Fatalf("expected logs command to include --all-containers=true, got %v", mock.LastCommand().Args) - } - }) - - t.Run("create_cmd_with_file", func(t *testing.T) { - mock := &MockExecutor{} - kubectl := &KubectlClient{exec: mock, validators: nil} - mgr := NewServerManager(kubectl, zap.NewNop()) - - tmpFile, err := os.CreateTemp("", "mcpserver-*.yaml") - if err != nil { - t.Fatal(err) - } - defer os.Remove(tmpFile.Name()) - if _, err := tmpFile.WriteString("apiVersion: v1\nkind: MCPServer\n"); err != nil { - t.Fatal(err) - } - tmpFile.Close() - - cmd := mgr.newServerCreateCmd() - if err := cmd.Flags().Set("file", tmpFile.Name()); err != nil { - t.Fatal(err) - } - err = cmd.RunE(cmd, []string{"my-server"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !contains(mock.LastCommand().Args, "apply") { - t.Error("expected apply command") - } - }) -} - func TestValidateServerInputErrors(t *testing.T) { t.Run("rejects invalid namespace", func(t *testing.T) { _, _, err := validateServerInput("my-server", "bad\tns") diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 0646a64..9f848f2 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -20,7 +20,6 @@ import ( "strings" "time" - "github.com/spf13/cobra" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -233,118 +232,6 @@ func ValidateTLSSetupCLIFlags( return nil } -// NewSetupCmd constructs the top-level setup command for installing the platform. -func NewSetupCmd(logger *zap.Logger) *cobra.Command { - var registryType string - var registryStorageSize string - var storageMode string - var kubeconfig string - var kubeContext string - var ingressMode string - var ingressManifest string - var forceIngressInstall bool - var tlsEnabled bool - var testMode bool - var strictProd bool - var withoutAnalytics bool - var operatorMetricsAddr string - var operatorProbeAddr string - var operatorLeaderElect bool - var acmeEmail string - var acmeStaging bool - var tlsClusterIssuer string - var skipCertManagerInstall bool - cmd := &cobra.Command{ - Use: "setup", - Short: "Setup the complete MCP platform", - Long: `Setup the complete MCP platform including: -- Kubernetes cluster initialization -- Internal container registry deployment (Docker Registry) -- Operator deployment -- Ingress controller configuration - -The platform deploys an internal Docker registry by default, which teams -will use to push and pull container images.`, - RunE: func(cmd *cobra.Command, args []string) error { - if err := validateStorageMode(storageMode); err != nil { - logStructuredError(logger, err, "Invalid --storage-mode") - return err - } - - // Build operator args from flags - operatorArgs := buildOperatorArgs( - operatorMetricsAddr, - operatorProbeAddr, - operatorLeaderElect, - cmd.Flags().Changed("operator-leader-elect"), - ) - - acmeEmailResolved := strings.TrimSpace(acmeEmail) - if acmeEmailResolved == "" { - acmeEmailResolved = strings.TrimSpace(os.Getenv("MCP_ACME_EMAIL")) - } - acmeStagingResolved := acmeStaging - if v := strings.TrimSpace(os.Getenv("MCP_ACME_STAGING")); v == "1" || strings.EqualFold(v, "true") { - acmeStagingResolved = true - } - tlsCIResolved := strings.TrimSpace(tlsClusterIssuer) - if tlsCIResolved == "" { - tlsCIResolved = strings.TrimSpace(os.Getenv("MCP_TLS_CLUSTER_ISSUER")) - } - if err := validateTLSSetupCLIFlags(tlsEnabled, acmeEmailResolved, tlsCIResolved, acmeStagingResolved, skipCertManagerInstall); err != nil { - return err - } - - plan := BuildSetupPlan(SetupPlanInput{ - Kubeconfig: kubeconfig, - Context: kubeContext, - RegistryType: registryType, - RegistryStorageSize: registryStorageSize, - StorageMode: storageMode, - IngressMode: ingressMode, - IngressManifest: ingressManifest, - IngressManifestChanged: cmd.Flags().Changed("ingress-manifest"), - ForceIngressInstall: forceIngressInstall, - TLSEnabled: tlsEnabled, - TestMode: testMode, - StrictProd: strictProd, - DeployAnalytics: !withoutAnalytics, - OperatorArgs: operatorArgs, - ACMEmail: acmeEmailResolved, - ACMEStaging: acmeStagingResolved, - TLSClusterIssuer: tlsCIResolved, - InstallCertManager: !skipCertManagerInstall, - }) - - return setupPlatform(logger, plan) - }, - } - - cmd.Flags().StringVar(®istryType, "registry-type", "docker", "Registry type (docker; harbor coming soon)") - cmd.Flags().StringVar(®istryStorageSize, "registry-storage", "20Gi", "Registry storage size (default: 20Gi)") - cmd.Flags().StringVar(&storageMode, "storage-mode", "dynamic", "Storage mode for local/dev clusters (dynamic|hostpath). Use hostpath for single-node k3s/minikube/kind without a provisioner.") - cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") - cmd.Flags().StringVar(&kubeContext, "context", "", "Kubernetes context to use") - cmd.Flags().StringVar(&ingressMode, "ingress", "traefik", "Ingress controller to install automatically during setup (traefik|none)") - cmd.Flags().StringVar(&ingressManifest, "ingress-manifest", "config/ingress/overlays/http", "Manifest to apply when installing the ingress controller") - cmd.Flags().BoolVar(&forceIngressInstall, "force-ingress-install", false, "Force ingress install even if an ingress class already exists") - cmd.Flags().BoolVar(&tlsEnabled, "with-tls", false, "Enable TLS overlays (ingress/registry). Use --acme-email for public Let's Encrypt, --tls-cluster-issuer for an org ClusterIssuer, or the bundled mcp-runtime-ca private CA (no ACME) when neither is set") - cmd.Flags().StringVar(&acmeEmail, "acme-email", "", "Contact email for Let's Encrypt (HTTP-01 via cert-manager). Mutually exclusive with --tls-cluster-issuer. Overrides env MCP_ACME_EMAIL") - cmd.Flags().StringVar(&tlsClusterIssuer, "tls-cluster-issuer", "", "Use an existing cert-manager ClusterIssuer (e.g. internal CA; setup does not create it). Mutually exclusive with --acme-email. Overrides env MCP_TLS_CLUSTER_ISSUER") - cmd.Flags().BoolVar(&acmeStaging, "acme-staging", false, "Use Let's Encrypt staging CA (also set MCP_ACME_STAGING=1)") - cmd.Flags().BoolVar(&skipCertManagerInstall, "skip-cert-manager-install", false, "Do not install cert-manager; require CRDs to already exist") - cmd.Flags().BoolVar(&testMode, "test-mode", false, "Test mode for local Kind/dev installs; builds and pushes latest-tag runtime images while relaxing production guardrails") - cmd.Flags().BoolVar(&strictProd, "strict-prod", false, "Require production-style registry and TLS validation for non-test setup") - cmd.Flags().BoolVar(&withoutAnalytics, "without-sentinel", false, "Skip deploying the bundled mcp-sentinel stack") - cmd.Flags().BoolVar(&withoutAnalytics, "without-analytics", false, "Deprecated alias for --without-sentinel") - _ = cmd.Flags().MarkDeprecated("without-analytics", "use --without-sentinel") - _ = cmd.Flags().MarkHidden("without-analytics") - cmd.Flags().StringVar(&operatorMetricsAddr, "operator-metrics-addr", "", "Operator metrics bind address (default: :8080 from manager.yaml)") - cmd.Flags().StringVar(&operatorProbeAddr, "operator-probe-addr", "", "Operator health probe bind address (default: :8081 from manager.yaml)") - cmd.Flags().BoolVar(&operatorLeaderElect, "operator-leader-elect", false, "Override operator leader election when set") - return cmd -} - // buildOperatorArgs constructs operator command-line arguments from flags. // Only includes flags that were explicitly set. func BuildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectChanged bool) []string { @@ -388,14 +275,6 @@ func buildOperatorArgs(metricsAddr, probeAddr string, leaderElect, leaderElectCh return BuildOperatorArgs(metricsAddr, probeAddr, leaderElect, leaderElectChanged) } -func validateStorageMode(mode string) error { - return ValidateStorageMode(mode) -} - -func setupPlatform(logger *zap.Logger, plan SetupPlan) error { - return SetupPlatform(logger, plan) -} - func setupPlatformWithDeps(logger *zap.Logger, plan SetupPlan, deps SetupDeps) error { deps = deps.withDefaults(logger) Section("MCP Runtime Setup") @@ -2050,6 +1929,10 @@ func applyManifestContentWithNamespace(kubectl KubectlRunner, manifest, namespac return applyCmd.Run() } +func ApplyManifestContentWithNamespace(kubectl KubectlRunner, manifest, namespace string) error { + return applyManifestContentWithNamespace(kubectl, manifest, namespace) +} + func renderAnalyticsManifest(content string, images AnalyticsImageSet, imagePullSecretName string) (string, error) { replacements := map[string]string{} if strings.TrimSpace(images.Ingest) != "" { diff --git a/internal/cli/setup_steps_test.go b/internal/cli/setup_steps_test.go index decd04f..177a964 100644 --- a/internal/cli/setup_steps_test.go +++ b/internal/cli/setup_steps_test.go @@ -236,24 +236,6 @@ func TestDeployOperatorStepCmdPassesOperatorArgs(t *testing.T) { } } -func TestNewSetupCmdIncludesFeatureFlags(t *testing.T) { - cmd := NewSetupCmd(zap.NewNop()) - - for _, name := range []string{ - "kubeconfig", - "context", - "test-mode", - "strict-prod", - "operator-metrics-addr", - "operator-probe-addr", - "operator-leader-elect", - } { - if flag := cmd.Flags().Lookup(name); flag == nil { - t.Fatalf("expected %q flag to exist", name) - } - } -} - func TestClusterStepPassesKubeconfigAndContext(t *testing.T) { var gotKubeconfig string var gotContext string diff --git a/internal/cli/status.go b/internal/cli/status.go index 46894c0..2ca98aa 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" - "github.com/spf13/cobra" "go.uber.org/zap" ) @@ -36,20 +35,6 @@ var analyticsStatusWorkloads = []platformWorkload{ {Component: "Promtail", Namespace: defaultAnalyticsNamespace, Kind: "daemonset", Name: "promtail"}, } -// NewStatusCmd returns the status subcommand for platform health checks. -func NewStatusCmd(logger *zap.Logger) *cobra.Command { - cmd := &cobra.Command{ - Use: "status", - Short: "Show platform status", - Long: "Show the overall status of the MCP platform", - RunE: func(cmd *cobra.Command, args []string) error { - return showPlatformStatus(logger) - }, - } - - return cmd -} - func ShowPlatformStatus(logger *zap.Logger) error { Header("MCP Platform Status") DefaultPrinter.Println() @@ -151,10 +136,6 @@ func ShowPlatformStatus(logger *zap.Logger) error { return nil } -func showPlatformStatus(logger *zap.Logger) error { - return ShowPlatformStatus(logger) -} - func checkClusterStatusQuiet() error { output, err := runKubectlCombinedOutput([]string{"cluster-info"}) if err == nil { diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 47a39e7..09b9070 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -438,29 +438,6 @@ func TestServerStatus(t *testing.T) { }) } -func TestNewStatusCmd(t *testing.T) { - logger := zap.NewNop() - cmd := NewStatusCmd(logger) - - t.Run("command_created", func(t *testing.T) { - if cmd == nil { - t.Fatal("NewStatusCmd should not return nil") - } - if cmd.Use != "status" { - t.Errorf("expected Use='status', got %q", cmd.Use) - } - if cmd.Short == "" { - t.Error("expected Short description to be set") - } - }) - - t.Run("has_runE", func(t *testing.T) { - if cmd.RunE == nil { - t.Error("expected RunE to be set") - } - }) -} - func setDefaultPrinterWriter(t *testing.T, w *bytes.Buffer) { t.Helper() orig := DefaultPrinter.Writer @@ -502,8 +479,8 @@ func runShowPlatformStatusWithCalls(t *testing.T, responses map[string]commandRe pterm.EnableStyling() }) - if err := showPlatformStatus(logger); err != nil { - t.Fatalf("showPlatformStatus() unexpected error = %v", err) + if err := ShowPlatformStatus(logger); err != nil { + t.Fatalf("ShowPlatformStatus() unexpected error = %v", err) } return buf.String()