Skip to content

Commit 73accda

Browse files
authored
feat: add undeploy.sh script to Helm bundle deployer (#91)
1 parent b9a78e8 commit 73accda

File tree

3 files changed

+145
-4
lines changed

3 files changed

+145
-4
lines changed

pkg/bundler/deployer/helm/helm.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ var componentReadmeTemplate string
4242
//go:embed templates/deploy.sh.tmpl
4343
var deployScriptTemplate string
4444

45+
//go:embed templates/undeploy.sh.tmpl
46+
var undeployScriptTemplate string
47+
4548
// criteriaAny is the wildcard value for criteria fields.
4649
const criteriaAny = "any"
4750

@@ -151,6 +154,15 @@ func (g *Generator) Generate(ctx context.Context, input *GeneratorInput, outputD
151154
output.Files = append(output.Files, deployPath)
152155
output.TotalSize += deploySize
153156

157+
// Generate undeploy.sh
158+
undeployPath, undeploySize, err := g.generateUndeployScript(ctx, input, components, outputDir)
159+
if err != nil {
160+
return nil, errors.Wrap(errors.ErrCodeInternal,
161+
"failed to generate undeploy.sh", err)
162+
}
163+
output.Files = append(output.Files, undeployPath)
164+
output.TotalSize += undeploySize
165+
154166
// Generate checksums.txt if requested
155167
if input.IncludeChecksums {
156168
if err := checksum.GenerateChecksums(ctx, outputDir, output.Files); err != nil {
@@ -425,6 +437,39 @@ func (g *Generator) generateDeployScript(ctx context.Context, input *GeneratorIn
425437
return deployPath, deploySize, nil
426438
}
427439

440+
// generateUndeployScript creates the undeploy.sh automation script.
441+
func (g *Generator) generateUndeployScript(ctx context.Context, input *GeneratorInput, components []ComponentData, outputDir string) (string, int64, error) {
442+
if err := ctx.Err(); err != nil {
443+
return "", 0, err
444+
}
445+
446+
// Build reversed component list for uninstall order
447+
reversed := make([]ComponentData, len(components))
448+
for i, comp := range components {
449+
reversed[len(components)-1-i] = comp
450+
}
451+
452+
data := struct {
453+
BundlerVersion string
454+
ComponentsReversed []ComponentData
455+
}{
456+
BundlerVersion: input.Version,
457+
ComponentsReversed: reversed,
458+
}
459+
460+
undeployPath, undeploySize, err := g.generateFromTemplate(undeployScriptTemplate, data, outputDir, "undeploy.sh")
461+
if err != nil {
462+
return "", 0, err
463+
}
464+
465+
// Make executable
466+
if err := os.Chmod(undeployPath, 0755); err != nil {
467+
return "", 0, errors.Wrap(errors.ErrCodeInternal, "failed to set undeploy.sh permissions", err)
468+
}
469+
470+
return undeployPath, undeploySize, nil
471+
}
472+
428473
// generateFromTemplate renders a template and writes it to baseDir/filename.
429474
// It uses safeJoin to verify the output path stays within baseDir.
430475
func (g *Generator) generateFromTemplate(tmplContent string, data any, baseDir, filename string) (string, int64, error) {

pkg/bundler/deployer/helm/helm_test.go

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func TestGenerate_Success(t *testing.T) {
5757
}
5858

5959
// Verify root files exist
60-
rootFiles := []string{"README.md", "deploy.sh"}
60+
rootFiles := []string{"README.md", "deploy.sh", "undeploy.sh"}
6161
for _, f := range rootFiles {
6262
path := filepath.Join(outputDir, f)
6363
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
@@ -101,9 +101,9 @@ func TestGenerate_Success(t *testing.T) {
101101
t.Error("Chart.yaml should not exist in per-component bundle")
102102
}
103103

104-
// Verify output has reasonable file count (2 root files + 2 component dirs × 2 files each = 6)
105-
if len(output.Files) < 6 {
106-
t.Errorf("expected at least 6 files, got %d", len(output.Files))
104+
// Verify output has reasonable file count (3 root files + 2 component dirs × 2 files each = 7)
105+
if len(output.Files) < 7 {
106+
t.Errorf("expected at least 7 files, got %d", len(output.Files))
107107
}
108108
}
109109

@@ -187,6 +187,9 @@ func TestGenerate_WithChecksums(t *testing.T) {
187187
if !strings.Contains(content, "deploy.sh") {
188188
t.Error("checksums.txt missing deploy.sh")
189189
}
190+
if !strings.Contains(content, "undeploy.sh") {
191+
t.Error("checksums.txt missing undeploy.sh")
192+
}
190193
if !strings.Contains(content, filepath.Join("cert-manager", "values.yaml")) {
191194
t.Error("checksums.txt missing cert-manager/values.yaml")
192195
}
@@ -382,6 +385,65 @@ func TestGenerate_DeployScriptExecutable(t *testing.T) {
382385
}
383386
}
384387

388+
func TestGenerate_UndeployScriptExecutable(t *testing.T) {
389+
g := NewGenerator()
390+
ctx := context.Background()
391+
outputDir := t.TempDir()
392+
393+
input := &GeneratorInput{
394+
RecipeResult: createTestRecipeResult(),
395+
ComponentValues: map[string]map[string]any{
396+
"cert-manager": {},
397+
"gpu-operator": {},
398+
},
399+
Version: "v1.0.0",
400+
}
401+
402+
_, err := g.Generate(ctx, input, outputDir)
403+
if err != nil {
404+
t.Fatalf("Generate failed: %v", err)
405+
}
406+
407+
undeployPath := filepath.Join(outputDir, "undeploy.sh")
408+
info, statErr := os.Stat(undeployPath)
409+
if os.IsNotExist(statErr) {
410+
t.Fatal("undeploy.sh does not exist")
411+
}
412+
413+
// Check executable permission (0755)
414+
mode := info.Mode()
415+
if mode&0111 == 0 {
416+
t.Errorf("undeploy.sh is not executable, mode: %o", mode)
417+
}
418+
419+
// Verify content
420+
content, err := os.ReadFile(undeployPath)
421+
if err != nil {
422+
t.Fatalf("failed to read undeploy.sh: %v", err)
423+
}
424+
script := string(content)
425+
426+
if !strings.HasPrefix(script, "#!/usr/bin/env bash") {
427+
t.Error("undeploy.sh missing shebang")
428+
}
429+
if !strings.Contains(script, "set -euo pipefail") {
430+
t.Error("undeploy.sh missing strict mode")
431+
}
432+
if !strings.Contains(script, "helm uninstall") {
433+
t.Error("undeploy.sh missing helm uninstall command")
434+
}
435+
436+
// Verify reverse order: gpu-operator should appear before cert-manager
437+
gpuIdx := strings.Index(script, "Uninstalling gpu-operator")
438+
certIdx := strings.Index(script, "Uninstalling cert-manager")
439+
if gpuIdx < 0 || certIdx < 0 {
440+
t.Fatal("undeploy.sh missing component uninstall lines")
441+
}
442+
if gpuIdx > certIdx {
443+
t.Error("undeploy.sh components not in reverse order: gpu-operator should come before cert-manager")
444+
}
445+
}
446+
385447
func TestNormalizeVersion(t *testing.T) {
386448
tests := []struct {
387449
input string
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Cloud Native Stack Undeployment Script
5+
# Generated by Eidos Bundler {{ .BundlerVersion }}
6+
#
7+
# Usage: ./undeploy.sh [--keep-namespaces]
8+
# --keep-namespaces Skip deleting namespaces after uninstalling components
9+
10+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11+
12+
KEEP_NS=false
13+
if [[ "${1:-}" == "--keep-namespaces" ]]; then
14+
KEEP_NS=true
15+
fi
16+
17+
echo "Undeploying Cloud Native Stack components..."
18+
19+
# Uninstall components in reverse order
20+
{{ range .ComponentsReversed -}}
21+
{{ if .HasManifests -}}
22+
echo "Deleting manifests for {{ .Name }}..."
23+
kubectl delete -f "${SCRIPT_DIR}/{{ .Name }}/manifests/" --ignore-not-found
24+
{{ end -}}
25+
{{ if .HasChart -}}
26+
echo "Uninstalling {{ .Name }} ({{ .Namespace }})..."
27+
helm uninstall {{ .Name }} -n {{ .Namespace }} --ignore-not-found || true
28+
if [[ "${KEEP_NS}" == "false" ]] && kubectl get namespace {{ .Namespace }} &>/dev/null; then
29+
echo "Deleting namespace {{ .Namespace }}..."
30+
kubectl delete namespace {{ .Namespace }} --ignore-not-found
31+
fi
32+
{{ end -}}
33+
{{ end }}
34+
echo "Undeployment complete."

0 commit comments

Comments
 (0)