diff --git a/pkg/certmanager/resources.go b/pkg/certmanager/resources.go new file mode 100644 index 000000000..e6d7a94f3 --- /dev/null +++ b/pkg/certmanager/resources.go @@ -0,0 +1,577 @@ +package certmanager + +import ( + "context" + "fmt" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +// GVRs for cert-manager resources (used internally for dynamic client) +var ( + certificateGVR = schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificates", + } + certificateRequestGVR = schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificaterequests", + } + issuerGVR = schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "issuers", + } + clusterIssuerGVR = schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "clusterissuers", + } + orderGVR = schema.GroupVersionResource{ + Group: "acme.cert-manager.io", + Version: "v1", + Resource: "orders", + } + challengeGVR = schema.GroupVersionResource{ + Group: "acme.cert-manager.io", + Version: "v1", + Resource: "challenges", + } + deploymentGVR = schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + } + certManagerOperatorGVR = schema.GroupVersionResource{ + Group: "operator.openshift.io", + Version: "v1alpha1", + Resource: "certmanagers", + } +) + +// GetCertificateDetails fetches a Certificate and all related resources +func GetCertificateDetails(ctx context.Context, client dynamic.Interface, namespace, name string) (*CertificateDetails, error) { + details := &CertificateDetails{} + + // 1. Get the Certificate + cert, err := client.Resource(certificateGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get certificate: %w", err) + } + details.Certificate = cert + + // 2. Get CertificateRequests for this Certificate + crList, err := client.Resource(certificateRequestGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", LabelCertificateName, name), + }) + if err == nil && crList != nil { + for i := range crList.Items { + details.CertificateRequests = append(details.CertificateRequests, &crList.Items[i]) + } + } + + // 3. Get the Issuer + issuerRef := GetIssuerRef(cert) + if issuerRef.Kind == "ClusterIssuer" { + issuer, err := client.Resource(clusterIssuerGVR).Get(ctx, issuerRef.Name, metav1.GetOptions{}) + if err == nil { + details.Issuer = issuer + } + } else { + issuer, err := client.Resource(issuerGVR).Namespace(namespace).Get(ctx, issuerRef.Name, metav1.GetOptions{}) + if err == nil { + details.Issuer = issuer + } + } + + // 4. Get Orders and Challenges (for ACME issuers) + if details.Issuer != nil && IsACMEIssuer(details.Issuer) { + // Get Orders related to CertificateRequests + for _, cr := range details.CertificateRequests { + orderName := getAnnotation(cr, "cert-manager.io/order-name") + if orderName != "" { + order, err := client.Resource(orderGVR).Namespace(namespace).Get(ctx, orderName, metav1.GetOptions{}) + if err == nil { + details.Orders = append(details.Orders, order) + } + } + } + + // Get Challenges + challenges, err := client.Resource(challengeGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err == nil && challenges != nil { + for i := range challenges.Items { + details.Challenges = append(details.Challenges, &challenges.Items[i]) + } + } + } + + // 5. Get Events for the Certificate + details.Events = GetEventsForResource(ctx, client, namespace, "Certificate", name) + + return details, nil +} + +// GetIssuerDetails fetches an Issuer and related information +func GetIssuerDetails(ctx context.Context, client dynamic.Interface, namespace, name string, isClusterIssuer bool) (*IssuerDetails, error) { + details := &IssuerDetails{} + + var issuer *unstructured.Unstructured + var err error + + if isClusterIssuer { + issuer, err = client.Resource(clusterIssuerGVR).Get(ctx, name, metav1.GetOptions{}) + } else { + issuer, err = client.Resource(issuerGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + } + + if err != nil { + return nil, fmt.Errorf("failed to get issuer: %w", err) + } + details.Issuer = issuer + + // Get Events + kind := "Issuer" + if isClusterIssuer { + kind = "ClusterIssuer" + namespace = "" // ClusterIssuers have cluster-scoped events + } + details.Events = GetEventsForResource(ctx, client, namespace, kind, name) + + return details, nil +} + +// GetIssuerRef extracts the issuer reference from a Certificate +func GetIssuerRef(cert *unstructured.Unstructured) IssuerRef { + issuerRef := IssuerRef{ + Kind: "Issuer", // default + Group: "cert-manager.io", + } + + if ref, found, _ := unstructured.NestedMap(cert.Object, "spec", "issuerRef"); found { + if name, ok := ref["name"].(string); ok { + issuerRef.Name = name + } + if kind, ok := ref["kind"].(string); ok { + issuerRef.Kind = kind + } + if group, ok := ref["group"].(string); ok { + issuerRef.Group = group + } + } + + return issuerRef +} + +// IsACMEIssuer checks if an issuer is an ACME issuer +func IsACMEIssuer(issuer *unstructured.Unstructured) bool { + if issuer == nil { + return false + } + _, found, _ := unstructured.NestedMap(issuer.Object, "spec", "acme") + return found +} + +// GetIssuerType returns the type of issuer (selfSigned, ca, acme, vault, venafi) +func GetIssuerType(issuer *unstructured.Unstructured) string { + if issuer == nil { + return "unknown" + } + + spec, found, _ := unstructured.NestedMap(issuer.Object, "spec") + if !found { + return "unknown" + } + + // Check for each issuer type + issuerTypes := []string{"selfSigned", "ca", "acme", "vault", "venafi"} + for _, t := range issuerTypes { + if _, found := spec[t]; found { + return t + } + } + + return "unknown" +} + +// ExtractConditions extracts conditions from a resource's status +func ExtractConditions(obj *unstructured.Unstructured) []Condition { + if obj == nil { + return nil + } + + conditions, found, _ := unstructured.NestedSlice(obj.Object, "status", "conditions") + if !found { + return nil + } + + result := make([]Condition, 0, len(conditions)) + for _, c := range conditions { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + result = append(result, Condition{ + Type: getString(cond, "type"), + Status: getString(cond, "status"), + Reason: getString(cond, "reason"), + Message: getString(cond, "message"), + LastTransitionTime: getString(cond, "lastTransitionTime"), + }) + } + return result +} + +// GetEventsForResource fetches events for a specific resource +func GetEventsForResource(ctx context.Context, client dynamic.Interface, namespace, kind, name string) []Event { + eventGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "events"} + + var events *unstructured.UnstructuredList + var err error + + fieldSelector := fmt.Sprintf("involvedObject.kind=%s,involvedObject.name=%s", kind, name) + + if namespace != "" { + events, err = client.Resource(eventGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + FieldSelector: fieldSelector, + }) + } else { + events, err = client.Resource(eventGVR).List(ctx, metav1.ListOptions{ + FieldSelector: fieldSelector, + }) + } + + if err != nil || events == nil { + return nil + } + + result := make([]Event, 0, len(events.Items)) + for _, e := range events.Items { + result = append(result, Event{ + Type: getString(e.Object, "type"), + Reason: getString(e.Object, "reason"), + Message: getString(e.Object, "message"), + Timestamp: getString(e.Object, "lastTimestamp"), + Count: getInt32(e.Object, "count"), + }) + } + + // Sort by timestamp descending (most recent first) + sort.Slice(result, func(i, j int) bool { + return result[i].Timestamp > result[j].Timestamp + }) + + return result +} + +// GetOperatorStatus fetches the status of cert-manager operator components +func GetOperatorStatus(ctx context.Context, client dynamic.Interface) (*OperatorStatus, error) { + status := &OperatorStatus{} + + // Get Controller deployment + controller, err := client.Resource(deploymentGVR).Namespace(CertManagerNamespace).Get(ctx, ControllerDeploymentName, metav1.GetOptions{}) + if err == nil { + status.Controller = extractDeploymentStatus(controller, "Controller") + } else { + status.Controller = ComponentStatus{Name: "Controller", Ready: false, Message: fmt.Sprintf("Not found: %v", err)} + } + + // Get Webhook deployment + webhook, err := client.Resource(deploymentGVR).Namespace(CertManagerNamespace).Get(ctx, WebhookDeploymentName, metav1.GetOptions{}) + if err == nil { + status.Webhook = extractDeploymentStatus(webhook, "Webhook") + } else { + status.Webhook = ComponentStatus{Name: "Webhook", Ready: false, Message: fmt.Sprintf("Not found: %v", err)} + } + + // Get CAInjector deployment + cainjector, err := client.Resource(deploymentGVR).Namespace(CertManagerNamespace).Get(ctx, CAInjectorDeploymentName, metav1.GetOptions{}) + if err == nil { + status.CAInjector = extractDeploymentStatus(cainjector, "CAInjector") + } else { + status.CAInjector = ComponentStatus{Name: "CAInjector", Ready: false, Message: fmt.Sprintf("Not found: %v", err)} + } + + // Try to get the CertManager operator CR + certManagerCR, err := client.Resource(certManagerOperatorGVR).Get(ctx, "cluster", metav1.GetOptions{}) + if err == nil { + status.Conditions = ExtractConditions(certManagerCR) + } + + return status, nil +} + +// extractDeploymentStatus extracts status from a deployment +func extractDeploymentStatus(deployment *unstructured.Unstructured, name string) ComponentStatus { + status := ComponentStatus{Name: name} + + spec, _, _ := unstructured.NestedMap(deployment.Object, "spec") + statusMap, _, _ := unstructured.NestedMap(deployment.Object, "status") + + if replicas, ok := spec["replicas"].(int64); ok { + status.DesiredReplicas = int32(replicas) + } + if available, ok := statusMap["availableReplicas"].(int64); ok { + status.AvailableReplicas = int32(available) + } + + status.Ready = status.AvailableReplicas >= status.DesiredReplicas && status.DesiredReplicas > 0 + + if status.Ready { + status.Message = fmt.Sprintf("%d/%d replicas available", status.AvailableReplicas, status.DesiredReplicas) + } else { + status.Message = fmt.Sprintf("%d/%d replicas available (not ready)", status.AvailableReplicas, status.DesiredReplicas) + } + + return status +} + +// BuildDiagnosticReport creates a human-readable diagnostic report from certificate details +func BuildDiagnosticReport(details *CertificateDetails) string { + var report strings.Builder + + certName := details.Certificate.GetName() + certNamespace := details.Certificate.GetNamespace() + + report.WriteString(fmt.Sprintf("# Certificate Troubleshooting Report: %s/%s\n\n", certNamespace, certName)) + + // Certificate Status + report.WriteString("## Certificate Status\n\n") + conditions := ExtractConditions(details.Certificate) + if len(conditions) == 0 { + report.WriteString("āš ļø No status conditions found - certificate may not have been reconciled yet\n\n") + } else { + for _, c := range conditions { + icon := getStatusIcon(c.Status) + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, c.Type, c.Status)) + if c.Reason != "" { + report.WriteString(fmt.Sprintf(" - Reason: %s\n", c.Reason)) + } + if c.Message != "" { + report.WriteString(fmt.Sprintf(" - Message: %s\n", c.Message)) + } + } + } + + // Expiry information + notAfter, found, _ := unstructured.NestedString(details.Certificate.Object, "status", "notAfter") + if found { + report.WriteString(fmt.Sprintf("\nšŸ“… **Expires**: %s\n", notAfter)) + } + renewalTime, found, _ := unstructured.NestedString(details.Certificate.Object, "status", "renewalTime") + if found { + report.WriteString(fmt.Sprintf("šŸ”„ **Renewal scheduled**: %s\n", renewalTime)) + } + + // CertificateRequest Status + report.WriteString("\n## CertificateRequest Status\n\n") + if len(details.CertificateRequests) == 0 { + report.WriteString("āš ļø No CertificateRequests found for this certificate\n") + } else { + for _, cr := range details.CertificateRequests { + crName := cr.GetName() + crConditions := ExtractConditions(cr) + report.WriteString(fmt.Sprintf("### %s\n", crName)) + for _, c := range crConditions { + icon := getStatusIcon(c.Status) + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, c.Type, c.Status)) + if c.Message != "" { + report.WriteString(fmt.Sprintf(" - %s\n", c.Message)) + } + } + } + } + + // Issuer Status + report.WriteString("\n## Issuer Status\n\n") + if details.Issuer == nil { + issuerRef := GetIssuerRef(details.Certificate) + report.WriteString(fmt.Sprintf("āŒ **Issuer not found**: %s/%s\n", issuerRef.Kind, issuerRef.Name)) + report.WriteString(" - Verify the issuer exists and is spelled correctly\n") + } else { + issuerName := details.Issuer.GetName() + issuerKind := details.Issuer.GetKind() + issuerType := GetIssuerType(details.Issuer) + issuerConditions := ExtractConditions(details.Issuer) + + report.WriteString(fmt.Sprintf("**%s**: %s (type: %s)\n", issuerKind, issuerName, issuerType)) + for _, c := range issuerConditions { + icon := getStatusIcon(c.Status) + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, c.Type, c.Status)) + if c.Message != "" { + report.WriteString(fmt.Sprintf(" - %s\n", c.Message)) + } + } + } + + // ACME Orders (if applicable) + if len(details.Orders) > 0 { + report.WriteString("\n## ACME Orders\n\n") + for _, order := range details.Orders { + orderName := order.GetName() + state, _, _ := unstructured.NestedString(order.Object, "status", "state") + report.WriteString(fmt.Sprintf("- **%s**: %s\n", orderName, state)) + } + } + + // ACME Challenges (if applicable) + if len(details.Challenges) > 0 { + report.WriteString("\n## ACME Challenges\n\n") + for _, challenge := range details.Challenges { + challengeName := challenge.GetName() + challengeType, _, _ := unstructured.NestedString(challenge.Object, "spec", "type") + state, _, _ := unstructured.NestedString(challenge.Object, "status", "state") + reason, _, _ := unstructured.NestedString(challenge.Object, "status", "reason") + + icon := "ā³" + switch state { + case "valid": + icon = "āœ…" + case "invalid": + icon = "āŒ" + } + + report.WriteString(fmt.Sprintf("%s **%s** (%s): %s\n", icon, challengeName, challengeType, state)) + if reason != "" { + report.WriteString(fmt.Sprintf(" - %s\n", reason)) + } + } + } + + // Recent Events + report.WriteString("\n## Recent Events\n\n") + if len(details.Events) == 0 { + report.WriteString("No events found\n") + } else { + // Limit to last 10 events + maxEvents := 10 + if len(details.Events) < maxEvents { + maxEvents = len(details.Events) + } + for _, e := range details.Events[:maxEvents] { + icon := "ā„¹ļø" + if e.Type == string(corev1.EventTypeWarning) { + icon = "āš ļø" + } + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, e.Reason, e.Message)) + } + } + + // Suggested next steps + report.WriteString("\n## Suggested Next Steps\n\n") + report.WriteString(generateSuggestions(details)) + + return report.String() +} + +// generateSuggestions creates context-aware suggestions based on the certificate state +func generateSuggestions(details *CertificateDetails) string { + var suggestions strings.Builder + + conditions := ExtractConditions(details.Certificate) + isReady := false + isIssuing := false + + for _, c := range conditions { + if c.Type == "Ready" && c.Status == "True" { + isReady = true + } + if c.Type == "Issuing" && c.Status == "True" { + isIssuing = true + } + } + + if isReady { + suggestions.WriteString("āœ… Certificate is healthy. No action required.\n") + return suggestions.String() + } + + if isIssuing { + suggestions.WriteString("1. Certificate is currently being issued - this is normal\n") + suggestions.WriteString("2. Wait a few minutes and check status again\n") + suggestions.WriteString("3. If stuck, check CertificateRequest status above\n") + } + + if details.Issuer == nil { + suggestions.WriteString("1. Create the missing Issuer/ClusterIssuer\n") + suggestions.WriteString("2. Verify the issuerRef in the Certificate spec is correct\n") + } else { + issuerConditions := ExtractConditions(details.Issuer) + issuerReady := false + for _, c := range issuerConditions { + if c.Type == "Ready" && c.Status == "True" { + issuerReady = true + } + } + if !issuerReady { + suggestions.WriteString("1. **Fix the Issuer first** - it's not ready\n") + suggestions.WriteString("2. Check Issuer configuration and referenced Secrets\n") + } + } + + if len(details.CertificateRequests) == 0 && !isIssuing { + suggestions.WriteString("1. Check if cert-manager controller is running\n") + suggestions.WriteString("2. Use `certmanager_operator_status` to verify components\n") + suggestions.WriteString("3. Check cert-manager controller logs with `certmanager_controller_logs`\n") + } + + // ACME-specific suggestions + for _, challenge := range details.Challenges { + state, _, _ := unstructured.NestedString(challenge.Object, "status", "state") + if state == "pending" || state == "invalid" { + challengeType, _, _ := unstructured.NestedString(challenge.Object, "spec", "type") + switch challengeType { + case "HTTP-01": + suggestions.WriteString("\n**HTTP-01 Challenge Debugging:**\n") + suggestions.WriteString("- Verify Ingress/Route is configured and accessible\n") + suggestions.WriteString("- Check that port 80 is reachable from the internet\n") + suggestions.WriteString("- Test: `curl http:///.well-known/acme-challenge/test`\n") + case "DNS-01": + suggestions.WriteString("\n**DNS-01 Challenge Debugging:**\n") + suggestions.WriteString("- Verify DNS provider credentials are correct\n") + suggestions.WriteString("- Check that the zone ID/domain is correct\n") + suggestions.WriteString("- Test: `dig +short TXT _acme-challenge.`\n") + } + } + } + + return suggestions.String() +} + +// Helper functions +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getInt32(m map[string]interface{}, key string) int32 { + if v, ok := m[key].(int64); ok { + return int32(v) + } + return 0 +} + +func getAnnotation(obj *unstructured.Unstructured, key string) string { + annotations := obj.GetAnnotations() + if annotations == nil { + return "" + } + return annotations[key] +} + +func getStatusIcon(status string) string { + if status == "True" { + return "āœ…" + } + return "āŒ" +} diff --git a/pkg/certmanager/types.go b/pkg/certmanager/types.go new file mode 100644 index 000000000..a9bf33886 --- /dev/null +++ b/pkg/certmanager/types.go @@ -0,0 +1,141 @@ +// Package certmanager provides domain logic for interacting with cert-manager resources. +package certmanager + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GVKs for cert-manager resources +var ( + CertificateGVK = schema.GroupVersionKind{ + Group: "cert-manager.io", + Version: "v1", + Kind: "Certificate", + } + CertificateRequestGVK = schema.GroupVersionKind{ + Group: "cert-manager.io", + Version: "v1", + Kind: "CertificateRequest", + } + IssuerGVK = schema.GroupVersionKind{ + Group: "cert-manager.io", + Version: "v1", + Kind: "Issuer", + } + ClusterIssuerGVK = schema.GroupVersionKind{ + Group: "cert-manager.io", + Version: "v1", + Kind: "ClusterIssuer", + } + OrderGVK = schema.GroupVersionKind{ + Group: "acme.cert-manager.io", + Version: "v1", + Kind: "Order", + } + ChallengeGVK = schema.GroupVersionKind{ + Group: "acme.cert-manager.io", + Version: "v1", + Kind: "Challenge", + } + SecretGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + } + PodGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + } +) + +// GVKs for cert-manager-operator resources +var ( + CertManagerOperatorGVK = schema.GroupVersionKind{ + Group: "operator.openshift.io", + Version: "v1alpha1", + Kind: "CertManager", + } +) + +// Standard labels used by cert-manager for resource relationships +const ( + // LabelCertificateName is the label cert-manager adds to CertificateRequests + LabelCertificateName = "cert-manager.io/certificate-name" + // LabelCertificateRevision is the label for the certificate revision + LabelCertificateRevision = "cert-manager.io/certificate-revision" + // LabelIssuerName is the label for the issuer name + LabelIssuerName = "cert-manager.io/issuer-name" + // LabelIssuerKind is the label for the issuer kind + LabelIssuerKind = "cert-manager.io/issuer-kind" + // LabelIssuerGroup is the label for the issuer group + LabelIssuerGroup = "cert-manager.io/issuer-group" +) + +// Cert-manager component names and namespace +const ( + CertManagerNamespace = "cert-manager" + CertManagerOperatorNamespace = "cert-manager-operator" + ControllerDeploymentName = "cert-manager" + WebhookDeploymentName = "cert-manager-webhook" + CAInjectorDeploymentName = "cert-manager-cainjector" +) + +// Condition represents a Kubernetes-style condition +type Condition struct { + Type string + Status string + Reason string + Message string + LastTransitionTime string +} + +// IssuerRef represents a reference to an Issuer or ClusterIssuer +type IssuerRef struct { + Name string + Kind string + Group string +} + +// CertificateDetails contains a Certificate and all related resources +type CertificateDetails struct { + Certificate *unstructured.Unstructured + CertificateRequests []*unstructured.Unstructured + Issuer *unstructured.Unstructured + Orders []*unstructured.Unstructured + Challenges []*unstructured.Unstructured + Events []Event +} + +// IssuerDetails contains an Issuer and related information +type IssuerDetails struct { + Issuer *unstructured.Unstructured + Events []Event +} + +// Event represents a Kubernetes Event +type Event struct { + Type string + Reason string + Message string + Timestamp string + Count int32 +} + +// ComponentStatus represents the status of a cert-manager component +type ComponentStatus struct { + Name string + Ready bool + AvailableReplicas int32 + DesiredReplicas int32 + Message string +} + +// OperatorStatus represents the overall status of cert-manager operator +type OperatorStatus struct { + Controller ComponentStatus + Webhook ComponentStatus + CAInjector ComponentStatus + Conditions []Condition +} diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 07c07dce8..694e56f39 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -249,7 +249,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali, kubevirt).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: certmanager, config, core, helm, kiali, kubevirt).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 255f42177..b4d18ca72 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -1,6 +1,7 @@ package mcp import ( + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/certmanager" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" diff --git a/pkg/toolsets/certmanager/certificates.go b/pkg/toolsets/certmanager/certificates.go new file mode 100644 index 000000000..0471ea315 --- /dev/null +++ b/pkg/toolsets/certmanager/certificates.go @@ -0,0 +1,289 @@ +package certmanager + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/certmanager" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/output" +) + +func initCertificates() []api.ServerTool { + return []api.ServerTool{ + // certificates_list + { + Tool: api.Tool{ + Name: "certmanager_certificates_list", + Description: "List all cert-manager Certificates in the cluster. Returns certificates with their status including ready state, expiry time, and issuer reference.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional namespace to list certificates from. If not provided, lists from all namespaces", + }, + "labelSelector": { + Type: "string", + Description: "Optional label selector to filter certificates (e.g., 'app=myapp')", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: List Certificates", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: certificatesList, + }, + // certificates_get + { + Tool: api.Tool{ + Name: "certmanager_certificate_get", + Description: "Get a specific cert-manager Certificate with its full status, conditions, expiry time, and renewal information.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace of the Certificate", + }, + "name": { + Type: "string", + Description: "Name of the Certificate", + }, + }, + Required: []string{"name", "namespace"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Get Certificate", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: certificateGet, + }, + // certificaterequests_list + { + Tool: api.Tool{ + Name: "certmanager_certificaterequests_list", + Description: "List CertificateRequests in the cluster. CertificateRequests represent a request for a certificate from an issuer. Useful for debugging certificate issuance.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional namespace to list CertificateRequests from. If not provided, lists from all namespaces", + }, + "certificateName": { + Type: "string", + Description: "Optional: filter CertificateRequests for a specific Certificate name", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: List CertificateRequests", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: certificateRequestsList, + }, + // orders_list + { + Tool: api.Tool{ + Name: "certmanager_orders_list", + Description: "List ACME Orders in the cluster. Orders represent an ACME certificate order and are created when using ACME issuers (like Let's Encrypt).", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional namespace to list Orders from. If not provided, lists from all namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: List ACME Orders", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: ordersList, + }, + // challenges_list + { + Tool: api.Tool{ + Name: "certmanager_challenges_list", + Description: "List ACME Challenges in the cluster. Challenges represent domain validation challenges (HTTP-01 or DNS-01) and are created during ACME certificate issuance.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional namespace to list Challenges from. If not provided, lists from all namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: List ACME Challenges", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: challengesList, + }, + // certificate_renew + { + Tool: api.Tool{ + Name: "certmanager_certificate_renew", + Description: "Trigger renewal of a cert-manager Certificate by deleting its Secret. Cert-manager will automatically detect the missing Secret and issue a new certificate.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace of the Certificate", + }, + "name": { + Type: "string", + Description: "Name of the Certificate to renew", + }, + }, + Required: []string{"name", "namespace"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Renew Certificate", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: certificateRenew, + }, + } +} + +func certificatesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := "" + if ns := params.GetArguments()["namespace"]; ns != nil { + namespace = ns.(string) + } + + listOptions := kubernetes.ResourceListOptions{ + AsTable: params.ListOutput.AsTable(), + } + + if labelSelector := params.GetArguments()["labelSelector"]; labelSelector != nil { + listOptions.LabelSelector = labelSelector.(string) + } + + ret, err := params.ResourcesList(params.Context, &certmanager.CertificateGVK, namespace, listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list certificates: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func certificateGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"].(string) + name := params.GetArguments()["name"].(string) + + ret, err := params.ResourcesGet(params.Context, &certmanager.CertificateGVK, namespace, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get certificate %s/%s: %v", namespace, name, err)), nil + } + return api.NewToolCallResult(output.MarshalYaml(ret)), nil +} + +func certificateRequestsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := "" + if ns := params.GetArguments()["namespace"]; ns != nil { + namespace = ns.(string) + } + + listOptions := kubernetes.ResourceListOptions{ + AsTable: params.ListOutput.AsTable(), + } + + // Filter by certificate name if provided + if certName := params.GetArguments()["certificateName"]; certName != nil { + listOptions.LabelSelector = fmt.Sprintf("%s=%s", certmanager.LabelCertificateName, certName.(string)) + } + + ret, err := params.ResourcesList(params.Context, &certmanager.CertificateRequestGVK, namespace, listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list certificate requests: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func ordersList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := "" + if ns := params.GetArguments()["namespace"]; ns != nil { + namespace = ns.(string) + } + + listOptions := kubernetes.ResourceListOptions{ + AsTable: params.ListOutput.AsTable(), + } + + ret, err := params.ResourcesList(params.Context, &certmanager.OrderGVK, namespace, listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list ACME orders: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func challengesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := "" + if ns := params.GetArguments()["namespace"]; ns != nil { + namespace = ns.(string) + } + + listOptions := kubernetes.ResourceListOptions{ + AsTable: params.ListOutput.AsTable(), + } + + ret, err := params.ResourcesList(params.Context, &certmanager.ChallengeGVK, namespace, listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list ACME challenges: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func certificateRenew(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"].(string) + name := params.GetArguments()["name"].(string) + + // Get the certificate to find the secret name + cert, err := params.ResourcesGet(params.Context, &certmanager.CertificateGVK, namespace, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get certificate %s/%s: %v", namespace, name, err)), nil + } + + // Get the secret name from the certificate spec + secretName, found, _ := unstructured.NestedString(cert.Object, "spec", "secretName") + if !found || secretName == "" { + return api.NewToolCallResult("", fmt.Errorf("certificate %s/%s has no secretName specified", namespace, name)), nil + } + + // Delete the secret to trigger renewal + err = params.ResourcesDelete(params.Context, &certmanager.SecretGVK, namespace, secretName) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to delete secret %s/%s to trigger renewal: %v", namespace, secretName, err)), nil + } + + return api.NewToolCallResult(fmt.Sprintf("āœ… Deleted Secret '%s/%s' to trigger certificate renewal.\n\nCert-manager will now detect the missing Secret and issue a new certificate.\nUse `certmanager_certificate_get` to monitor the renewal progress.", namespace, secretName), nil), nil +} diff --git a/pkg/toolsets/certmanager/issuers.go b/pkg/toolsets/certmanager/issuers.go new file mode 100644 index 000000000..6c1e0f6a8 --- /dev/null +++ b/pkg/toolsets/certmanager/issuers.go @@ -0,0 +1,160 @@ +package certmanager + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/certmanager" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/output" +) + +func initIssuers() []api.ServerTool { + return []api.ServerTool{ + // issuers_list + { + Tool: api.Tool{ + Name: "certmanager_issuers_list", + Description: "List all cert-manager Issuers in the cluster. Issuers are namespaced resources that represent a certificate authority.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional namespace to list Issuers from. If not provided, lists from all namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: List Issuers", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: issuersList, + }, + // issuer_get + { + Tool: api.Tool{ + Name: "certmanager_issuer_get", + Description: "Get a specific cert-manager Issuer with its status and configuration.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace of the Issuer", + }, + "name": { + Type: "string", + Description: "Name of the Issuer", + }, + }, + Required: []string{"name", "namespace"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Get Issuer", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: issuerGet, + }, + // clusterissuers_list + { + Tool: api.Tool{ + Name: "certmanager_clusterissuers_list", + Description: "List all cert-manager ClusterIssuers in the cluster. ClusterIssuers are cluster-scoped resources that can issue certificates in any namespace.", + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: List ClusterIssuers", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: clusterIssuersList, + }, + // clusterissuer_get + { + Tool: api.Tool{ + Name: "certmanager_clusterissuer_get", + Description: "Get a specific cert-manager ClusterIssuer with its status and configuration.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the ClusterIssuer", + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Get ClusterIssuer", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: clusterIssuerGet, + }, + } +} + +func issuersList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := "" + if ns := params.GetArguments()["namespace"]; ns != nil { + namespace = ns.(string) + } + + listOptions := kubernetes.ResourceListOptions{ + AsTable: params.ListOutput.AsTable(), + } + + ret, err := params.ResourcesList(params.Context, &certmanager.IssuerGVK, namespace, listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list issuers: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func issuerGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"].(string) + name := params.GetArguments()["name"].(string) + + ret, err := params.ResourcesGet(params.Context, &certmanager.IssuerGVK, namespace, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get issuer %s/%s: %v", namespace, name, err)), nil + } + return api.NewToolCallResult(output.MarshalYaml(ret)), nil +} + +func clusterIssuersList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + listOptions := kubernetes.ResourceListOptions{ + AsTable: params.ListOutput.AsTable(), + } + + ret, err := params.ResourcesList(params.Context, &certmanager.ClusterIssuerGVK, "", listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list cluster issuers: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func clusterIssuerGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + name := params.GetArguments()["name"].(string) + + ret, err := params.ResourcesGet(params.Context, &certmanager.ClusterIssuerGVK, "", name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get cluster issuer %s: %v", name, err)), nil + } + return api.NewToolCallResult(output.MarshalYaml(ret)), nil +} diff --git a/pkg/toolsets/certmanager/logs.go b/pkg/toolsets/certmanager/logs.go new file mode 100644 index 000000000..6af6b8ebb --- /dev/null +++ b/pkg/toolsets/certmanager/logs.go @@ -0,0 +1,165 @@ +package certmanager + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/certmanager" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +func initLogs() []api.ServerTool { + return []api.ServerTool{ + // controller_logs + { + Tool: api.Tool{ + Name: "certmanager_controller_logs", + Description: `Get logs from the cert-manager controller. + +The controller is responsible for: +- Watching Certificate resources +- Creating CertificateRequests +- Coordinating with Issuers +- Managing certificate lifecycle + +Use this tool when certificates are not being created or updated.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "tail": { + Type: "integer", + Description: "Number of log lines to retrieve (default: 100)", + Default: api.ToRawMessage(100), + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Controller Logs", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: controllerLogs, + }, + // webhook_logs + { + Tool: api.Tool{ + Name: "certmanager_webhook_logs", + Description: `Get logs from the cert-manager webhook. + +The webhook is responsible for: +- Validating cert-manager resources +- Converting between API versions +- Mutating resources with defaults + +Use this tool when you see admission webhook errors or validation failures.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "tail": { + Type: "integer", + Description: "Number of log lines to retrieve (default: 100)", + Default: api.ToRawMessage(100), + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Webhook Logs", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: webhookLogs, + }, + // cainjector_logs + { + Tool: api.Tool{ + Name: "certmanager_cainjector_logs", + Description: `Get logs from the cert-manager CA injector. + +The CA injector is responsible for: +- Injecting CA certificates into webhooks +- Injecting CA certificates into API services +- Managing caBundle annotations + +Use this tool when webhook certificates are not being injected properly.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "tail": { + Type: "integer", + Description: "Number of log lines to retrieve (default: 100)", + Default: api.ToRawMessage(100), + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: CAInjector Logs", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: cainjectorLogs, + }, + } +} + +func controllerLogs(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + return getComponentLogs(params, certmanager.ControllerDeploymentName, "app=cert-manager") +} + +func webhookLogs(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + return getComponentLogs(params, certmanager.WebhookDeploymentName, "app=cert-manager-webhook") +} + +func cainjectorLogs(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + return getComponentLogs(params, certmanager.CAInjectorDeploymentName, "app=cert-manager-cainjector") +} + +func getComponentLogs(params api.ToolHandlerParams, deploymentName, labelSelector string) (*api.ToolCallResult, error) { + // List pods with the app label + listOptions := kubernetes.ResourceListOptions{ + AsTable: false, + } + listOptions.LabelSelector = labelSelector + + pods, err := params.ResourcesList(params.Context, &certmanager.PodGVK, certmanager.CertManagerNamespace, listOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list pods for %s: %v", deploymentName, err)), nil + } + + items, found, _ := unstructured.NestedSlice(pods.UnstructuredContent(), "items") + if !found || len(items) == 0 { + return api.NewToolCallResult(fmt.Sprintf("No pods found for deployment %s in namespace %s", deploymentName, certmanager.CertManagerNamespace), nil), nil + } + + // Get the first pod name + firstPod, ok := items[0].(map[string]interface{}) + if !ok { + return api.NewToolCallResult("", fmt.Errorf("invalid pod structure")), nil + } + podName, _, _ := unstructured.NestedString(firstPod, "metadata", "name") + + // Build log options + tail := int64(100) + if t := params.GetArguments()["tail"]; t != nil { + tail = int64(t.(float64)) + } + + logs, err := params.PodsLog(params.Context, certmanager.CertManagerNamespace, podName, "", false, tail) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get logs for pod %s: %v", podName, err)), nil + } + + header := fmt.Sprintf("# Logs from %s (pod: %s)\n\n```\n", deploymentName, podName) + footer := "\n```" + + return api.NewToolCallResult(header+logs+footer, nil), nil +} diff --git a/pkg/toolsets/certmanager/operator.go b/pkg/toolsets/certmanager/operator.go new file mode 100644 index 000000000..775b85f3e --- /dev/null +++ b/pkg/toolsets/certmanager/operator.go @@ -0,0 +1,280 @@ +package certmanager + +import ( + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/certmanager" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +func initOperator() []api.ServerTool { + return []api.ServerTool{ + // operator_status + { + Tool: api.Tool{ + Name: "certmanager_operator_status", + Description: `Check the health status of cert-manager operator components. + +Returns the status of: +- cert-manager controller deployment +- cert-manager webhook deployment +- cert-manager cainjector deployment +- CertManager operator CR conditions (if using cert-manager-operator) + +Use this tool to verify cert-manager is properly installed and running.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Operator Status", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: operatorStatus, + }, + // operator_health_check + { + Tool: api.Tool{ + Name: "certmanager_health_check", + Description: `Perform a comprehensive health check of the cert-manager installation. + +Checks: +- All cert-manager deployments are running +- Webhook is responding +- At least one Issuer or ClusterIssuer exists +- Recent error events + +Use this tool for a quick overview of cert-manager health.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Health Check", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: healthCheck, + }, + } +} + +func operatorStatus(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + client := params.AccessControlClientset().DynamicClient() + + status, err := certmanager.GetOperatorStatus(params.Context, client) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get operator status: %v", err)), nil + } + + report := buildOperatorStatusReport(status) + + return api.NewToolCallResult(report, nil), nil +} + +func buildOperatorStatusReport(status *certmanager.OperatorStatus) string { + var report strings.Builder + + report.WriteString("# Cert-Manager Operator Status\n\n") + + report.WriteString("## Components\n\n") + + // Controller + icon := "āœ…" + if !status.Controller.Ready { + icon = "āŒ" + } + report.WriteString(fmt.Sprintf("%s **Controller**: %s\n", icon, status.Controller.Message)) + + // Webhook + icon = "āœ…" + if !status.Webhook.Ready { + icon = "āŒ" + } + report.WriteString(fmt.Sprintf("%s **Webhook**: %s\n", icon, status.Webhook.Message)) + + // CAInjector + icon = "āœ…" + if !status.CAInjector.Ready { + icon = "āŒ" + } + report.WriteString(fmt.Sprintf("%s **CAInjector**: %s\n", icon, status.CAInjector.Message)) + + // Overall status + report.WriteString("\n## Overall Status\n\n") + allReady := status.Controller.Ready && status.Webhook.Ready && status.CAInjector.Ready + if allReady { + report.WriteString("āœ… All cert-manager components are healthy\n") + } else { + report.WriteString("āŒ Some cert-manager components are not ready\n") + report.WriteString("\n### Troubleshooting Steps:\n") + if !status.Controller.Ready { + report.WriteString("- Check controller pod logs: `kubectl logs -n cert-manager deploy/cert-manager`\n") + } + if !status.Webhook.Ready { + report.WriteString("- Check webhook pod logs: `kubectl logs -n cert-manager deploy/cert-manager-webhook`\n") + report.WriteString("- Verify webhook service: `kubectl get svc -n cert-manager cert-manager-webhook`\n") + } + if !status.CAInjector.Ready { + report.WriteString("- Check cainjector pod logs: `kubectl logs -n cert-manager deploy/cert-manager-cainjector`\n") + } + } + + // Operator CR conditions (if available) + if len(status.Conditions) > 0 { + report.WriteString("\n## Operator Conditions\n\n") + for _, c := range status.Conditions { + icon := "āœ…" + if c.Status != "True" { + icon = "āŒ" + } + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, c.Type, c.Status)) + if c.Message != "" { + report.WriteString(fmt.Sprintf(" - %s\n", c.Message)) + } + } + } + + return report.String() +} + +func healthCheck(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + client := params.AccessControlClientset().DynamicClient() + ctx := params.Context + + var report strings.Builder + report.WriteString("# Cert-Manager Health Check\n\n") + + issues := 0 + + // 1. Check operator status + status, err := certmanager.GetOperatorStatus(ctx, client) + if err != nil { + report.WriteString("āŒ **Failed to check operator status**: " + err.Error() + "\n") + issues++ + } else { + if status.Controller.Ready && status.Webhook.Ready && status.CAInjector.Ready { + report.WriteString("āœ… **Components**: All cert-manager deployments are running\n") + } else { + report.WriteString("āŒ **Components**: Some deployments are not ready\n") + issues++ + } + } + + // 2. Check for Issuers + listOptions := kubernetes.ResourceListOptions{} + issuers, err := params.ResourcesList(params.Context, &certmanager.IssuerGVK, "", listOptions) + clusterIssuers, err2 := params.ResourcesList(params.Context, &certmanager.ClusterIssuerGVK, "", listOptions) + + totalIssuers := 0 + if err == nil && issuers != nil { + items, _, _ := unstructured.NestedSlice(issuers.UnstructuredContent(), "items") + totalIssuers += len(items) + } + if err2 == nil && clusterIssuers != nil { + items, _, _ := unstructured.NestedSlice(clusterIssuers.UnstructuredContent(), "items") + totalIssuers += len(items) + } + + if totalIssuers > 0 { + report.WriteString(fmt.Sprintf("āœ… **Issuers**: Found %d Issuer(s)/ClusterIssuer(s)\n", totalIssuers)) + } else { + report.WriteString("āš ļø **Issuers**: No Issuers or ClusterIssuers found\n") + } + + // 3. Check for pending/failed Certificates + certs, err := params.ResourcesList(params.Context, &certmanager.CertificateGVK, "", listOptions) + if err == nil && certs != nil { + items, _, _ := unstructured.NestedSlice(certs.UnstructuredContent(), "items") + notReady := 0 + for _, item := range items { + if certMap, ok := item.(map[string]interface{}); ok { + conditions, found, _ := unstructured.NestedSlice(certMap, "status", "conditions") + if found { + isReady := false + for _, c := range conditions { + if cond, ok := c.(map[string]interface{}); ok { + if cond["type"] == "Ready" && cond["status"] == "True" { + isReady = true + break + } + } + } + if !isReady { + notReady++ + } + } + } + } + if notReady > 0 { + report.WriteString(fmt.Sprintf("āš ļø **Certificates**: %d certificate(s) not ready\n", notReady)) + } else if len(items) > 0 { + report.WriteString(fmt.Sprintf("āœ… **Certificates**: All %d certificate(s) are ready\n", len(items))) + } else { + report.WriteString("ā„¹ļø **Certificates**: No certificates found\n") + } + } + + // 4. Check for failed CertificateRequests + crs, err := params.ResourcesList(params.Context, &certmanager.CertificateRequestGVK, "", listOptions) + if err == nil && crs != nil { + items, _, _ := unstructured.NestedSlice(crs.UnstructuredContent(), "items") + failed := 0 + for _, item := range items { + if crMap, ok := item.(map[string]interface{}); ok { + conditions, found, _ := unstructured.NestedSlice(crMap, "status", "conditions") + if found { + for _, c := range conditions { + if cond, ok := c.(map[string]interface{}); ok { + if cond["type"] == "Ready" && cond["status"] == "False" && cond["reason"] == "Failed" { + failed++ + break + } + } + } + } + } + } + if failed > 0 { + report.WriteString(fmt.Sprintf("āŒ **CertificateRequests**: %d failed request(s)\n", failed)) + issues++ + } + } + + // 5. Check for pending Challenges + challenges, err := params.ResourcesList(params.Context, &certmanager.ChallengeGVK, "", listOptions) + if err == nil && challenges != nil { + items, _, _ := unstructured.NestedSlice(challenges.UnstructuredContent(), "items") + pending := 0 + for _, item := range items { + if chMap, ok := item.(map[string]interface{}); ok { + state, _, _ := unstructured.NestedString(chMap, "status", "state") + if state == "pending" || state == "invalid" { + pending++ + } + } + } + if pending > 0 { + report.WriteString(fmt.Sprintf("āš ļø **ACME Challenges**: %d pending/invalid challenge(s)\n", pending)) + } + } + + // Summary + report.WriteString("\n## Summary\n\n") + if issues == 0 { + report.WriteString("āœ… **Cert-manager is healthy!**\n") + } else { + report.WriteString(fmt.Sprintf("āš ļø **Found %d issue(s)** - use troubleshooting tools for details\n", issues)) + } + + return api.NewToolCallResult(report.String(), nil), nil +} diff --git a/pkg/toolsets/certmanager/toolset.go b/pkg/toolsets/certmanager/toolset.go new file mode 100644 index 000000000..f98f3a2f0 --- /dev/null +++ b/pkg/toolsets/certmanager/toolset.go @@ -0,0 +1,41 @@ +// Package certmanager provides MCP tools for managing cert-manager resources. +package certmanager + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +// Toolset implements the cert-manager MCP toolset +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +// GetName returns the name of the toolset +func (t *Toolset) GetName() string { + return "certmanager" +} + +// GetDescription returns a description of the toolset +func (t *Toolset) GetDescription() string { + return "Tools for managing cert-manager certificates, issuers, and troubleshooting TLS certificate issues" +} + +// GetTools returns all tools in this toolset +func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { + return slices.Concat( + initCertificates(), + initIssuers(), + initTroubleshoot(), + initOperator(), + initLogs(), + ) +} + +func init() { + toolsets.Register(&Toolset{}) +} + diff --git a/pkg/toolsets/certmanager/troubleshoot.go b/pkg/toolsets/certmanager/troubleshoot.go new file mode 100644 index 000000000..1f717ec0c --- /dev/null +++ b/pkg/toolsets/certmanager/troubleshoot.go @@ -0,0 +1,340 @@ +package certmanager + +import ( + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/certmanager" +) + +func initTroubleshoot() []api.ServerTool { + return []api.ServerTool{ + // certificate_troubleshoot + { + Tool: api.Tool{ + Name: "certmanager_certificate_troubleshoot", + Description: `Comprehensive troubleshooting for a cert-manager Certificate. + +Analyzes the Certificate and all related resources including: +- Certificate status and conditions +- CertificateRequest status +- Issuer/ClusterIssuer status +- ACME Order and Challenge status (for ACME issuers) +- Recent events +- Suggested next steps + +Use this tool when a Certificate is not Ready or you need to diagnose issuance problems.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace of the Certificate", + }, + "name": { + Type: "string", + Description: "Name of the Certificate to troubleshoot", + }, + }, + Required: []string{"name", "namespace"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Troubleshoot Certificate", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: certificateTroubleshoot, + }, + // issuer_troubleshoot + { + Tool: api.Tool{ + Name: "certmanager_issuer_troubleshoot", + Description: `Troubleshoot a cert-manager Issuer or ClusterIssuer. + +Analyzes the Issuer configuration and status including: +- Issuer type (SelfSigned, CA, ACME, Vault, etc.) +- Ready status and conditions +- ACME account registration status (for ACME issuers) +- Referenced Secret status (for CA/Vault issuers) +- Recent events + +Use this tool when an Issuer is not Ready or Certificates are failing to issue.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace of the Issuer (leave empty for ClusterIssuer)", + }, + "name": { + Type: "string", + Description: "Name of the Issuer or ClusterIssuer", + }, + "isClusterIssuer": { + Type: "boolean", + Description: "Set to true if this is a ClusterIssuer (default: false)", + Default: api.ToRawMessage(false), + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Troubleshoot Issuer", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: issuerTroubleshoot, + }, + // challenge_troubleshoot + { + Tool: api.Tool{ + Name: "certmanager_challenge_troubleshoot", + Description: `Troubleshoot an ACME Challenge. + +Analyzes the Challenge status and provides debugging information for: +- HTTP-01 challenges: Ingress configuration, port 80 accessibility +- DNS-01 challenges: DNS provider configuration, TXT record propagation + +Use this tool when ACME certificate issuance is stuck on challenge validation.`, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace of the Challenge", + }, + "name": { + Type: "string", + Description: "Name of the Challenge to troubleshoot", + }, + }, + Required: []string{"name", "namespace"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Cert-Manager: Troubleshoot ACME Challenge", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: challengeTroubleshoot, + }, + } +} + +func certificateTroubleshoot(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"].(string) + name := params.GetArguments()["name"].(string) + + client := params.AccessControlClientset().DynamicClient() + + details, err := certmanager.GetCertificateDetails(params.Context, client, namespace, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get certificate details: %v", err)), nil + } + + report := certmanager.BuildDiagnosticReport(details) + + return api.NewToolCallResult(report, nil), nil +} + +func issuerTroubleshoot(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + name := params.GetArguments()["name"].(string) + + namespace := "" + if ns := params.GetArguments()["namespace"]; ns != nil { + namespace = ns.(string) + } + + isClusterIssuer := false + if ici := params.GetArguments()["isClusterIssuer"]; ici != nil { + isClusterIssuer = ici.(bool) + } + + // If namespace is empty and not explicitly a ClusterIssuer, assume ClusterIssuer + if namespace == "" { + isClusterIssuer = true + } + + client := params.AccessControlClientset().DynamicClient() + + details, err := certmanager.GetIssuerDetails(params.Context, client, namespace, name, isClusterIssuer) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get issuer details: %v", err)), nil + } + + report := buildIssuerReport(details, isClusterIssuer) + + return api.NewToolCallResult(report, nil), nil +} + +func buildIssuerReport(details *certmanager.IssuerDetails, isClusterIssuer bool) string { + var report strings.Builder + + issuerKind := "Issuer" + if isClusterIssuer { + issuerKind = "ClusterIssuer" + } + + issuerName := details.Issuer.GetName() + issuerNamespace := details.Issuer.GetNamespace() + + if isClusterIssuer { + report.WriteString(fmt.Sprintf("# %s Troubleshooting Report: %s\n\n", issuerKind, issuerName)) + } else { + report.WriteString(fmt.Sprintf("# %s Troubleshooting Report: %s/%s\n\n", issuerKind, issuerNamespace, issuerName)) + } + + // Issuer Type + issuerType := certmanager.GetIssuerType(details.Issuer) + report.WriteString(fmt.Sprintf("**Type**: %s\n\n", issuerType)) + + // Status + report.WriteString("## Status\n\n") + conditions := certmanager.ExtractConditions(details.Issuer) + if len(conditions) == 0 { + report.WriteString("āš ļø No status conditions found\n") + } else { + for _, c := range conditions { + icon := "āœ…" + if c.Status != "True" { + icon = "āŒ" + } + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, c.Type, c.Status)) + if c.Reason != "" { + report.WriteString(fmt.Sprintf(" - Reason: %s\n", c.Reason)) + } + if c.Message != "" { + report.WriteString(fmt.Sprintf(" - Message: %s\n", c.Message)) + } + } + } + + // Type-specific information + report.WriteString("\n## Configuration\n\n") + switch issuerType { + case "selfSigned": + report.WriteString("Self-signed issuer - no external dependencies.\n") + case "ca": + report.WriteString("CA issuer - issues certificates signed by a CA stored in a Secret.\n") + report.WriteString("Check that the referenced Secret exists and contains valid ca.crt and tls.key.\n") + case "acme": + report.WriteString("ACME issuer - issues certificates from an ACME server (e.g., Let's Encrypt).\n") + report.WriteString("Check ACME account registration status in conditions above.\n") + case "vault": + report.WriteString("Vault issuer - issues certificates from HashiCorp Vault PKI.\n") + report.WriteString("Check Vault connectivity and authentication configuration.\n") + } + + // Events + report.WriteString("\n## Recent Events\n\n") + if len(details.Events) == 0 { + report.WriteString("No events found\n") + } else { + maxEvents := 10 + if len(details.Events) < maxEvents { + maxEvents = len(details.Events) + } + for _, e := range details.Events[:maxEvents] { + icon := "ā„¹ļø" + if e.Type == "Warning" { + icon = "āš ļø" + } + report.WriteString(fmt.Sprintf("%s **%s**: %s\n", icon, e.Reason, e.Message)) + } + } + + return report.String() +} + +func challengeTroubleshoot(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"].(string) + name := params.GetArguments()["name"].(string) + + challenge, err := params.ResourcesGet(params.Context, &certmanager.ChallengeGVK, namespace, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get challenge %s/%s: %v", namespace, name, err)), nil + } + + report := buildChallengeReport(challenge) + + return api.NewToolCallResult(report, nil), nil +} + +func buildChallengeReport(challenge *unstructured.Unstructured) string { + var report strings.Builder + + name := challenge.GetName() + namespace := challenge.GetNamespace() + + report.WriteString(fmt.Sprintf("# ACME Challenge Troubleshooting Report: %s/%s\n\n", namespace, name)) + + // Challenge type and domain + challengeType, _, _ := unstructured.NestedString(challenge.Object, "spec", "type") + domain, _, _ := unstructured.NestedString(challenge.Object, "spec", "dnsName") + token, _, _ := unstructured.NestedString(challenge.Object, "spec", "token") + + report.WriteString(fmt.Sprintf("**Challenge Type**: %s\n", challengeType)) + report.WriteString(fmt.Sprintf("**Domain**: %s\n", domain)) + report.WriteString(fmt.Sprintf("**Token**: %s\n\n", token)) + + // Status + report.WriteString("## Status\n\n") + state, _, _ := unstructured.NestedString(challenge.Object, "status", "state") + reason, _, _ := unstructured.NestedString(challenge.Object, "status", "reason") + presented, _, _ := unstructured.NestedBool(challenge.Object, "status", "presented") + + stateIcon := "ā³" + switch state { + case "valid": + stateIcon = "āœ…" + case "invalid": + stateIcon = "āŒ" + } + + report.WriteString(fmt.Sprintf("%s **State**: %s\n", stateIcon, state)) + report.WriteString(fmt.Sprintf("**Presented**: %v\n", presented)) + if reason != "" { + report.WriteString(fmt.Sprintf("**Reason**: %s\n", reason)) + } + + // Type-specific debugging + report.WriteString("\n## Debugging Steps\n\n") + switch challengeType { + case "HTTP-01": + report.WriteString("### HTTP-01 Challenge Debugging\n\n") + report.WriteString("1. **Verify the challenge URL is accessible**:\n") + report.WriteString(fmt.Sprintf(" ```\n curl -v http://%s/.well-known/acme-challenge/%s\n ```\n\n", domain, token)) + report.WriteString("2. **Check Ingress/Route configuration**:\n") + report.WriteString(" - Ensure an Ingress or Route exists for this domain\n") + report.WriteString(" - Verify it routes to the cert-manager solver service\n\n") + report.WriteString("3. **Common issues**:\n") + report.WriteString(" - Port 80 blocked by firewall\n") + report.WriteString(" - Ingress controller not handling the solver Ingress\n") + report.WriteString(" - DNS not pointing to the cluster\n") + report.WriteString(" - NetworkPolicy blocking traffic\n") + case "DNS-01": + report.WriteString("### DNS-01 Challenge Debugging\n\n") + report.WriteString("1. **Check if TXT record exists**:\n") + report.WriteString(fmt.Sprintf(" ```\n dig +short TXT _acme-challenge.%s\n ```\n\n", domain)) + report.WriteString("2. **Verify DNS provider credentials**:\n") + report.WriteString(" - Check the Secret referenced in the Issuer\n") + report.WriteString(" - Verify API keys/tokens are valid\n\n") + report.WriteString("3. **Common issues**:\n") + report.WriteString(" - Invalid DNS provider credentials\n") + report.WriteString(" - Wrong zone ID or domain\n") + report.WriteString(" - DNS propagation delay (wait 5-60 minutes)\n") + report.WriteString(" - Insufficient permissions to create TXT records\n") + } + + return report.String() +}