Skip to content

Commit 2ae0264

Browse files
chore(helm/v2-aplha): Add --input-dir flag to helm/v2-alpha plugin to allow operating on projects in different directories
Generated-by: Claude
1 parent 8c56039 commit 2ae0264

5 files changed

Lines changed: 347 additions & 18 deletions

File tree

docs/book/src/plugins/available/helm-v2-alpha.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,8 @@ The Makefile targets use sensible defaults extracted from your project configura
690690
| Flag | Description |
691691
|---------------------|-----------------------------------------------------------------------------|
692692
| **--manifests** | Path to YAML file containing Kubernetes manifests (default: `dist/install.yaml`) |
693-
| **--output-dir** string | Output directory for chart (default: `dist`) |
693+
| **--output-dir** | Output directory for chart (default: `dist`) |
694+
| **--input-dir** | Path to directory containing PROJECT file. All operations will use files from this directory |
694695
| **--force** | Regenerates preserved files except `Chart.yaml` (`values.yaml`, `NOTES.txt`, `_helpers.tpl`, `.helmignore`, `test-chart.yml`) |
695696

696697
<aside class="note" role="note">

pkg/cli/cli.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
log "log/slog"
2323
"os"
24+
"path/filepath"
2425
"strings"
2526

2627
"github.com/spf13/afero"
@@ -99,6 +100,12 @@ func New(options ...Option) (*CLI, error) {
99100
return nil, err
100101
}
101102

103+
// Handle --input-dir flag before building the command tree.
104+
// This must happen before getInfoFromConfigFile() tries to load the PROJECT file.
105+
if err := handleInputDirBeforeConfigLoad(); err != nil {
106+
return nil, err
107+
}
108+
102109
// Build the cmd tree.
103110
if err := c.buildCmd(); err != nil {
104111
c.cmd.RunE = errCmdFunc(err)
@@ -319,6 +326,99 @@ func patchProjectFileInMemoryIfNeeded(fs afero.Fs, path string) error {
319326
return nil
320327
}
321328

329+
// parseInputDirFromArgs extracts --input-dir from raw CLI args without depending on
330+
// Cobra flag registration. It supports both:
331+
//
332+
// --input-dir=/path/to/project
333+
// --input-dir /path/to/project
334+
func parseInputDirFromArgs(args []string) (string, bool) {
335+
for i := range args {
336+
arg := args[i]
337+
338+
if arg == "--input-dir" {
339+
if i+1 >= len(args) {
340+
// Flag needs an argument but none provided
341+
return "", false
342+
}
343+
return args[i+1], true
344+
}
345+
346+
if after, ok := strings.CutPrefix(arg, "--input-dir="); ok {
347+
return after, true
348+
}
349+
}
350+
351+
return "", false
352+
}
353+
354+
// isEditCommand checks if the command invocation is `kubebuilder edit`
355+
// by scanning os.Args. We simply check if "edit" appears as a non-flag argument.
356+
func isEditCommand(args []string) bool {
357+
for _, arg := range args {
358+
// Skip flags (anything starting with -)
359+
if strings.HasPrefix(arg, "-") {
360+
continue
361+
}
362+
// First non-flag argument is the subcommand
363+
if arg == "edit" {
364+
return true
365+
}
366+
// If we found a different subcommand, return false
367+
return false
368+
}
369+
return false
370+
}
371+
372+
// handleInputDirBeforeConfigLoad checks if --input-dir flag is set for edit command
373+
// and changes directory if needed. This must run BEFORE the framework loads PROJECT file.
374+
func handleInputDirBeforeConfigLoad() error {
375+
// Only process --input-dir for edit command
376+
if !isEditCommand(os.Args[1:]) {
377+
return nil
378+
}
379+
380+
inputDir, found := parseInputDirFromArgs(os.Args[1:])
381+
if !found || inputDir == "" {
382+
return nil
383+
}
384+
385+
// Resolve to absolute path
386+
if !filepath.IsAbs(inputDir) {
387+
absPath, err := filepath.Abs(inputDir)
388+
if err != nil {
389+
return fmt.Errorf("failed to resolve input-dir %q: %w", inputDir, err)
390+
}
391+
inputDir = absPath
392+
}
393+
394+
// Validate directory exists
395+
if info, err := os.Stat(inputDir); err != nil {
396+
if os.IsNotExist(err) {
397+
return fmt.Errorf("input-dir does not exist: %q", inputDir)
398+
}
399+
return fmt.Errorf("failed to access input-dir %q: %w", inputDir, err)
400+
} else if !info.IsDir() {
401+
return fmt.Errorf("input-dir is not a directory: %q", inputDir)
402+
}
403+
404+
// Validate PROJECT file exists in input-dir
405+
projectFilePath := filepath.Join(inputDir, yamlstore.DefaultPath)
406+
if _, err := os.Stat(projectFilePath); err != nil {
407+
if os.IsNotExist(err) {
408+
return fmt.Errorf("input-dir does not contain %q: %q", yamlstore.DefaultPath, inputDir)
409+
}
410+
return fmt.Errorf("failed to access project file %q: %w", projectFilePath, err)
411+
}
412+
413+
// Change working directory
414+
if err := os.Chdir(inputDir); err != nil {
415+
return fmt.Errorf("failed to change to input-dir %q: %w", inputDir, err)
416+
}
417+
418+
log.Info("Operating in input directory", "path", inputDir)
419+
return nil
420+
}
421+
322422
// getInfoFromConfig obtains the project version and plugin keys from the project config.
323423
// It is extracted from getInfoFromConfigFile for testing purposes.
324424
func (c *CLI) getInfoFromConfig(projectConfig config.Config) error {

pkg/cli/cli_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"io"
2222
"os"
23+
"path/filepath"
2324
"strings"
2425

2526
. "github.com/onsi/ginkgo/v2"
@@ -807,4 +808,214 @@ plugins:
807808
})
808809
})
809810
})
811+
812+
Context("parseInputDirFromArgs", func() {
813+
It("should return empty when flag is not present", func() {
814+
args := []string{"edit", "--plugins=helm.kubebuilder.io/v2-alpha"}
815+
dir, found := parseInputDirFromArgs(args)
816+
Expect(found).To(BeFalse())
817+
Expect(dir).To(BeEmpty())
818+
})
819+
820+
It("should parse --input-dir with = syntax", func() {
821+
args := []string{"edit", "--input-dir=/path/to/project", "--plugins=helm.kubebuilder.io/v2-alpha"}
822+
dir, found := parseInputDirFromArgs(args)
823+
Expect(found).To(BeTrue())
824+
Expect(dir).To(Equal("/path/to/project"))
825+
})
826+
827+
It("should parse --input-dir with space syntax", func() {
828+
args := []string{"edit", "--input-dir", "/path/to/project", "--plugins=helm.kubebuilder.io/v2-alpha"}
829+
dir, found := parseInputDirFromArgs(args)
830+
Expect(found).To(BeTrue())
831+
Expect(dir).To(Equal("/path/to/project"))
832+
})
833+
834+
It("should return false when --input-dir has no value", func() {
835+
args := []string{"edit", "--input-dir"}
836+
dir, found := parseInputDirFromArgs(args)
837+
Expect(found).To(BeFalse())
838+
Expect(dir).To(BeEmpty())
839+
})
840+
})
841+
842+
Context("isEditCommand", func() {
843+
It("should return true for edit command", func() {
844+
args := []string{"edit", "--plugins=helm.kubebuilder.io/v2-alpha"}
845+
Expect(isEditCommand(args)).To(BeTrue())
846+
})
847+
848+
It("should return true for edit command with flags before", func() {
849+
args := []string{"--verbose", "edit", "--plugins=helm.kubebuilder.io/v2-alpha"}
850+
Expect(isEditCommand(args)).To(BeTrue())
851+
})
852+
853+
It("should return false for init command", func() {
854+
args := []string{"init", "--plugins=go.kubebuilder.io/v4"}
855+
Expect(isEditCommand(args)).To(BeFalse())
856+
})
857+
858+
It("should return false for create command", func() {
859+
args := []string{"create", "api"}
860+
Expect(isEditCommand(args)).To(BeFalse())
861+
})
862+
})
863+
864+
Context("handleInputDirBeforeConfigLoad integration", func() {
865+
var (
866+
originalArgs []string
867+
originalDir string
868+
tmpDir string
869+
)
870+
871+
BeforeEach(func() {
872+
// Save original os.Args and working directory
873+
originalArgs = os.Args
874+
var err error
875+
originalDir, err = os.Getwd()
876+
Expect(err).NotTo(HaveOccurred())
877+
878+
// Create temp directory for testing
879+
tmpDir, err = os.MkdirTemp("", "input-dir-integration-*")
880+
Expect(err).NotTo(HaveOccurred())
881+
})
882+
883+
AfterEach(func() {
884+
// Restore original state
885+
os.Args = originalArgs
886+
if originalDir != "" {
887+
_ = os.Chdir(originalDir)
888+
}
889+
if tmpDir != "" {
890+
_ = os.RemoveAll(tmpDir)
891+
}
892+
})
893+
894+
It("should do nothing when not an edit command", func() {
895+
os.Args = []string{"kubebuilder", "init", "--plugins=go.kubebuilder.io/v4"}
896+
err := handleInputDirBeforeConfigLoad()
897+
Expect(err).NotTo(HaveOccurred())
898+
899+
// Working directory should not have changed
900+
currentDir, err := os.Getwd()
901+
Expect(err).NotTo(HaveOccurred())
902+
Expect(currentDir).To(Equal(originalDir))
903+
})
904+
905+
It("should do nothing when input-dir is not specified", func() {
906+
os.Args = []string{"kubebuilder", "edit", "--plugins=helm.kubebuilder.io/v2-alpha"}
907+
err := handleInputDirBeforeConfigLoad()
908+
Expect(err).NotTo(HaveOccurred())
909+
910+
// Working directory should not have changed
911+
currentDir, err := os.Getwd()
912+
Expect(err).NotTo(HaveOccurred())
913+
Expect(currentDir).To(Equal(originalDir))
914+
})
915+
916+
It("should return error when input-dir does not exist", func() {
917+
os.Args = []string{"kubebuilder", "edit", "--input-dir=/nonexistent/path"}
918+
err := handleInputDirBeforeConfigLoad()
919+
Expect(err).To(HaveOccurred())
920+
Expect(err.Error()).To(ContainSubstring("does not exist"))
921+
})
922+
923+
It("should return error when input-dir is not a directory", func() {
924+
// Create a file instead of directory
925+
filePath := filepath.Join(tmpDir, "testfile")
926+
err := os.WriteFile(filePath, []byte("test"), 0o644)
927+
Expect(err).NotTo(HaveOccurred())
928+
929+
os.Args = []string{"kubebuilder", "edit", "--input-dir=" + filePath}
930+
err = handleInputDirBeforeConfigLoad()
931+
Expect(err).To(HaveOccurred())
932+
Expect(err.Error()).To(ContainSubstring("not a directory"))
933+
})
934+
935+
It("should return error when input-dir does not contain PROJECT file", func() {
936+
// Create empty directory
937+
emptyDir := filepath.Join(tmpDir, "empty")
938+
err := os.MkdirAll(emptyDir, 0o755)
939+
Expect(err).NotTo(HaveOccurred())
940+
941+
os.Args = []string{"kubebuilder", "edit", "--input-dir=" + emptyDir}
942+
err = handleInputDirBeforeConfigLoad()
943+
Expect(err).To(HaveOccurred())
944+
Expect(err.Error()).To(ContainSubstring("does not contain"))
945+
Expect(err.Error()).To(ContainSubstring("PROJECT"))
946+
})
947+
948+
It("should change directory when valid input-dir is provided", func() {
949+
// Create project directory with PROJECT file
950+
projectDir := filepath.Join(tmpDir, "project")
951+
err := os.MkdirAll(projectDir, 0o755)
952+
Expect(err).NotTo(HaveOccurred())
953+
954+
projectFile := filepath.Join(projectDir, "PROJECT")
955+
projectContent := `domain: example.com
956+
layout:
957+
- go.kubebuilder.io/v4
958+
- helm.kubebuilder.io/v2-alpha
959+
projectName: test-project
960+
repo: example.com/test-project
961+
version: "3"
962+
`
963+
err = os.WriteFile(projectFile, []byte(projectContent), 0o644)
964+
Expect(err).NotTo(HaveOccurred())
965+
966+
os.Args = []string{"kubebuilder", "edit", "--input-dir=" + projectDir}
967+
err = handleInputDirBeforeConfigLoad()
968+
Expect(err).NotTo(HaveOccurred())
969+
970+
// Verify directory changed
971+
currentDir, err := os.Getwd()
972+
Expect(err).NotTo(HaveOccurred())
973+
974+
// Resolve symlinks for comparison
975+
expectedDir, err := filepath.EvalSymlinks(projectDir)
976+
Expect(err).NotTo(HaveOccurred())
977+
actualDir, err := filepath.EvalSymlinks(currentDir)
978+
Expect(err).NotTo(HaveOccurred())
979+
980+
Expect(actualDir).To(Equal(expectedDir))
981+
})
982+
983+
It("should handle relative paths correctly", func() {
984+
// Create project directory with PROJECT file
985+
projectDir := filepath.Join(tmpDir, "project")
986+
err := os.MkdirAll(projectDir, 0o755)
987+
Expect(err).NotTo(HaveOccurred())
988+
989+
projectFile := filepath.Join(projectDir, "PROJECT")
990+
projectContent := `domain: example.com
991+
layout:
992+
- go.kubebuilder.io/v4
993+
projectName: test-project
994+
repo: example.com/test-project
995+
version: "3"
996+
`
997+
err = os.WriteFile(projectFile, []byte(projectContent), 0o644)
998+
Expect(err).NotTo(HaveOccurred())
999+
1000+
// Change to tmpDir and use relative path
1001+
err = os.Chdir(tmpDir)
1002+
Expect(err).NotTo(HaveOccurred())
1003+
1004+
os.Args = []string{"kubebuilder", "edit", "--input-dir=./project"}
1005+
err = handleInputDirBeforeConfigLoad()
1006+
Expect(err).NotTo(HaveOccurred())
1007+
1008+
// Verify directory changed
1009+
currentDir, err := os.Getwd()
1010+
Expect(err).NotTo(HaveOccurred())
1011+
1012+
// Resolve symlinks for comparison
1013+
expectedDir, err := filepath.EvalSymlinks(projectDir)
1014+
Expect(err).NotTo(HaveOccurred())
1015+
actualDir, err := filepath.EvalSymlinks(currentDir)
1016+
Expect(err).NotTo(HaveOccurred())
1017+
1018+
Expect(actualDir).To(Equal(expectedDir))
1019+
})
1020+
})
8101021
})

pkg/plugins/optional/helm/v2alpha/edit.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type editSubcommand struct {
5252
force bool
5353
manifestsFile string
5454
outputDir string
55+
inputDir string
5556
}
5657

5758
//nolint:lll
@@ -76,6 +77,9 @@ distribution of your project. When enabled, adds Helm helpers targets to Makefil
7677
# Generate from custom manifests to custom output directory
7778
%[1]s edit --plugins=%[2]s --manifests=manifests/install.yaml --output-dir=helm-charts
7879
80+
# Work with a project in a different directory (useful for monorepos)
81+
%[1]s edit --plugins=%[2]s --input-dir=./path/to/project
82+
7983
# Typical workflow:
8084
make build-installer # Generate dist/install.yaml with latest changes
8185
%[1]s edit --plugins=%[2]s # Generate/update Helm chart in dist/chart/
@@ -105,10 +109,16 @@ func (p *editSubcommand) BindFlags(fs *pflag.FlagSet) {
105109
fs.BoolVar(&p.force, "force", false, "if true, regenerates all the files")
106110
fs.StringVar(&p.manifestsFile, "manifests", DefaultManifestsFile,
107111
"path to the YAML file containing Kubernetes manifests from kustomize output")
108-
fs.StringVar(&p.outputDir, "output-dir", DefaultOutputDir, "output directory for the generated Helm chart")
112+
fs.StringVar(&p.outputDir, "output-dir", DefaultOutputDir,
113+
"output directory for the generated Helm chart")
114+
fs.StringVar(&p.inputDir, "input-dir", "",
115+
"path to directory containing PROJECT file; "+
116+
"all operations will use files from this directory (defaults to current working directory)")
109117
}
110118

111119
func (p *editSubcommand) InjectConfig(c config.Config) error {
120+
// The --input-dir flag is handled by handleInputDirForEdit in pkg/cli/cli.go
121+
// before this method is called, so the working directory is already set.
112122
p.config = c
113123
return nil
114124
}

0 commit comments

Comments
 (0)