Skip to content

Commit 8bde856

Browse files
authored
feat: add operations for helm (#17)
Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>
1 parent 1e36b2b commit 8bde856

6 files changed

Lines changed: 587 additions & 3 deletions

File tree

.github/workflows/test.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,10 @@ jobs:
1616
with:
1717
go-version-file: go.mod
1818

19+
- name: Install Helm
20+
uses: azure/setup-helm@v4
21+
with:
22+
version: 'latest'
23+
1924
- name: Run tests
2025
run: make test

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ This extension enables declarative Kubernetes interactions within mcpchecker tas
1212
| `kubernetes.create` | Create a Kubernetes resource |
1313
| `kubernetes.delete` | Delete a Kubernetes resource |
1414
| `kubernetes.getCurrentContext` | Get the current context from kubeconfig |
15+
| `kubernetes.helmInstall` | Install a Helm chart as a release |
16+
| `kubernetes.helmList` | List Helm releases in a namespace or all namespaces |
17+
| `kubernetes.helmUninstall` | Uninstall a Helm release |
1518
| `kubernetes.listContexts` | List all contexts from kubeconfig |
1619
| `kubernetes.viewConfig` | View kubeconfig as YAML (optionally minified) |
1720
| `kubernetes.wait` | Wait for a condition on a resource (e.g., `Ready`, `Available`) |
@@ -87,6 +90,52 @@ spec:
8790
inline: Create an nginx pod named web-server in the test-namespace namespace
8891
```
8992

93+
### Helm Example
94+
95+
Test Helm operations using declarative setup and cleanup:
96+
97+
```yaml
98+
kind: Task
99+
apiVersion: gevals/v1alpha2
100+
metadata:
101+
name: "list-helm-releases"
102+
difficulty: easy
103+
spec:
104+
requires:
105+
- extension: kubernetes
106+
107+
setup:
108+
# Create namespace
109+
- kubernetes.create:
110+
apiVersion: v1
111+
kind: Namespace
112+
metadata:
113+
name: helm-test
114+
115+
# Install a test release
116+
- kubernetes.helmInstall:
117+
chart: oci://registry-1.docker.io/bitnamicharts/nginx
118+
name: test-nginx
119+
namespace: helm-test
120+
121+
cleanup:
122+
# Uninstall the release
123+
- kubernetes.helmUninstall:
124+
name: test-nginx
125+
namespace: helm-test
126+
127+
# Delete namespace
128+
- kubernetes.delete:
129+
apiVersion: v1
130+
kind: Namespace
131+
metadata:
132+
name: helm-test
133+
ignoreNotFound: true
134+
135+
prompt:
136+
inline: List all Helm releases in the cluster
137+
```
138+
90139
## Operation Reference
91140

92141
### kubernetes.create
@@ -135,6 +184,48 @@ Waits for a condition on a resource. Supports configurable timeout and expected
135184
timeout: 5m # optional, defaults to 60s
136185
```
137186

187+
### kubernetes.helmInstall
188+
189+
Installs a Helm chart as a release. Supports chart repositories and OCI registries.
190+
191+
```yaml
192+
- kubernetes.helmInstall:
193+
chart: oci://registry-1.docker.io/bitnamicharts/nginx
194+
name: my-nginx # optional, generates name if not provided
195+
namespace: default # optional
196+
values: # optional Helm values
197+
replicaCount: 2
198+
service:
199+
type: LoadBalancer
200+
```
201+
202+
### kubernetes.helmList
203+
204+
Lists Helm releases in a namespace or across all namespaces.
205+
206+
```yaml
207+
# List in specific namespace
208+
- kubernetes.helmList:
209+
namespace: default
210+
211+
# List across all namespaces
212+
- kubernetes.helmList:
213+
allNamespaces: true
214+
```
215+
216+
**Outputs:**
217+
- `releases`: Information about found Helm releases (name, namespace, status, chart)
218+
219+
### kubernetes.helmUninstall
220+
221+
Uninstalls a Helm release. Gracefully handles releases that don't exist.
222+
223+
```yaml
224+
- kubernetes.helmUninstall:
225+
name: my-nginx
226+
namespace: default # optional
227+
```
228+
138229
### kubernetes.listContexts
139230

140231
Lists all contexts from the kubeconfig file, including which one is currently active.

pkg/extension/extension.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import (
1717
// Extension wraps the SDK extension with Kubernetes client
1818
type Extension struct {
1919
*sdk.Extension
20-
client ResourceClient
20+
client ResourceClient
21+
kubeconfigPath string
2122

22-
mu sync.Mutex
23+
mu sync.Mutex
2324
generatedNamespaces []string
2425
}
2526

@@ -89,6 +90,7 @@ func (e *Extension) handleInitialize(config map[string]any) error {
8990
authzClient: authzClient,
9091
kubeconfigPath: kubeconfigPath,
9192
}
93+
e.kubeconfigPath = kubeconfigPath
9294
return nil
9395
}
9496

pkg/extension/helm.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os/exec"
8+
"strings"
9+
10+
"github.com/mcpchecker/mcpchecker/pkg/extension/sdk"
11+
)
12+
13+
// handleHelmInstall installs a Helm chart as a release
14+
func (e *Extension) handleHelmInstall(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
15+
args, ok := req.Args.(map[string]any)
16+
if !ok {
17+
return sdk.Failure(fmt.Errorf("args must be an object")), nil
18+
}
19+
20+
chart, ok := args["chart"].(string)
21+
if !ok || chart == "" {
22+
return sdk.Failure(fmt.Errorf("chart parameter is required")), nil
23+
}
24+
25+
// Optional parameters
26+
name, _ := args["name"].(string)
27+
namespace, _ := args["namespace"].(string)
28+
values, _ := args["values"].(map[string]interface{})
29+
30+
cmdArgs := []string{"install"}
31+
32+
if name != "" {
33+
cmdArgs = append(cmdArgs, name)
34+
} else {
35+
cmdArgs = append(cmdArgs, "--generate-name")
36+
}
37+
38+
cmdArgs = append(cmdArgs, chart)
39+
40+
if namespace != "" {
41+
cmdArgs = append(cmdArgs, "--namespace", namespace)
42+
}
43+
44+
if e.kubeconfigPath != "" {
45+
cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath)
46+
}
47+
48+
// Add values as --set flags
49+
for k, v := range values {
50+
cmdArgs = append(cmdArgs, "--set", fmt.Sprintf("%s=%v", k, v))
51+
}
52+
53+
e.LogInfo(ctx, "Installing Helm chart", map[string]any{
54+
"chart": chart,
55+
"name": name,
56+
"namespace": namespace,
57+
})
58+
59+
cmd := exec.CommandContext(ctx, "helm", cmdArgs...)
60+
output, err := cmd.CombinedOutput()
61+
if err != nil {
62+
e.LogError(ctx, "Helm install failed", map[string]any{
63+
"chart": chart,
64+
"error": err.Error(),
65+
})
66+
return sdk.Failure(fmt.Errorf("helm install failed: %s\nOutput: %s", err, string(output))), nil
67+
}
68+
69+
e.LogInfo(ctx, "Helm chart installed successfully", map[string]any{
70+
"chart": chart,
71+
"name": name,
72+
})
73+
74+
return sdk.Success(fmt.Sprintf("Helm chart installed successfully\n%s", string(output))), nil
75+
}
76+
77+
// handleHelmList lists Helm releases
78+
func (e *Extension) handleHelmList(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
79+
args, ok := req.Args.(map[string]any)
80+
if !ok {
81+
return sdk.Failure(fmt.Errorf("args must be an object")), nil
82+
}
83+
84+
cmdArgs := []string{"list", "--output", "json"}
85+
86+
namespace, _ := args["namespace"].(string)
87+
allNamespaces, _ := args["allNamespaces"].(bool)
88+
89+
if allNamespaces {
90+
cmdArgs = append(cmdArgs, "--all-namespaces")
91+
} else if namespace != "" {
92+
cmdArgs = append(cmdArgs, "--namespace", namespace)
93+
}
94+
95+
if e.kubeconfigPath != "" {
96+
cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath)
97+
}
98+
99+
e.LogInfo(ctx, "Listing Helm releases", map[string]any{
100+
"namespace": namespace,
101+
"allNamespaces": allNamespaces,
102+
})
103+
104+
cmd := exec.CommandContext(ctx, "helm", cmdArgs...)
105+
output, err := cmd.CombinedOutput()
106+
if err != nil {
107+
e.LogError(ctx, "Helm list failed", map[string]any{
108+
"error": err.Error(),
109+
})
110+
return sdk.Failure(fmt.Errorf("helm list failed: %s\nOutput: %s", err, string(output))), nil
111+
}
112+
113+
// Parse JSON output
114+
var releases []map[string]interface{}
115+
if len(output) > 0 {
116+
if err := json.Unmarshal(output, &releases); err != nil {
117+
return sdk.Failure(fmt.Errorf("failed to parse helm list output: %s", err)), nil
118+
}
119+
}
120+
121+
if len(releases) == 0 {
122+
return sdk.Success("No Helm releases found"), nil
123+
}
124+
125+
// Format releases as a readable string
126+
var result strings.Builder
127+
result.WriteString(fmt.Sprintf("Found %d Helm release(s):\n", len(releases)))
128+
for _, release := range releases {
129+
name, _ := release["name"].(string)
130+
ns, _ := release["namespace"].(string)
131+
status, _ := release["status"].(string)
132+
chart, _ := release["chart"].(string)
133+
result.WriteString(fmt.Sprintf(" - %s (namespace: %s, status: %s, chart: %s)\n", name, ns, status, chart))
134+
}
135+
136+
return sdk.Success(result.String()), nil
137+
}
138+
139+
// handleHelmUninstall uninstalls a Helm release
140+
func (e *Extension) handleHelmUninstall(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
141+
args, ok := req.Args.(map[string]any)
142+
if !ok {
143+
return sdk.Failure(fmt.Errorf("args must be an object")), nil
144+
}
145+
146+
name, ok := args["name"].(string)
147+
if !ok || name == "" {
148+
return sdk.Failure(fmt.Errorf("name parameter is required")), nil
149+
}
150+
151+
cmdArgs := []string{"uninstall", name}
152+
153+
namespace, _ := args["namespace"].(string)
154+
if namespace != "" {
155+
cmdArgs = append(cmdArgs, "--namespace", namespace)
156+
}
157+
158+
if e.kubeconfigPath != "" {
159+
cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath)
160+
}
161+
162+
e.LogInfo(ctx, "Uninstalling Helm release", map[string]any{
163+
"name": name,
164+
"namespace": namespace,
165+
})
166+
167+
cmd := exec.CommandContext(ctx, "helm", cmdArgs...)
168+
output, err := cmd.CombinedOutput()
169+
if err != nil {
170+
// Check if it's a "not found" error
171+
if strings.Contains(string(output), "not found") {
172+
e.LogInfo(ctx, "Helm release not found (ignored)", map[string]any{
173+
"name": name,
174+
})
175+
return sdk.Success(fmt.Sprintf("Helm release '%s' not found (already uninstalled)", name)), nil
176+
}
177+
e.LogError(ctx, "Helm uninstall failed", map[string]any{
178+
"name": name,
179+
"error": err.Error(),
180+
})
181+
return sdk.Failure(fmt.Errorf("helm uninstall failed: %s\nOutput: %s", err, string(output))), nil
182+
}
183+
184+
e.LogInfo(ctx, "Helm release uninstalled successfully", map[string]any{
185+
"name": name,
186+
})
187+
188+
return sdk.Success(fmt.Sprintf("Helm release '%s' uninstalled successfully\n%s", name, string(output))), nil
189+
}

0 commit comments

Comments
 (0)