From 9036926a6473109231ed0e2b517b586e5fdbed7a Mon Sep 17 00:00:00 2001 From: Melvin Hillsman Date: Sun, 14 Jun 2026 16:26:31 -0500 Subject: [PATCH] feat: add ValidatePlaceholders for catalog entry service files Extracts {{key}} placeholders from all service files via regex and cross-references against declared param keys. Returns errors for unknown placeholders (likely typos) and warnings for declared params not referenced in any service file. Signed-off-by: Melvin Hillsman --- internal/workloads/catalog.go | 34 +++++++++ internal/workloads/catalog_test.go | 114 +++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/internal/workloads/catalog.go b/internal/workloads/catalog.go index 06d0085..961c723 100644 --- a/internal/workloads/catalog.go +++ b/internal/workloads/catalog.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" @@ -145,6 +146,39 @@ func (e *CatalogEntry) Factory() WorkloadFactory { } } +var placeholderRe = regexp.MustCompile(`\{\{([^}]+)\}\}`) + +func ValidatePlaceholders(entry *CatalogEntry) (errs []string, warnings []string) { + declaredKeys := make(map[string]bool, len(entry.Manifest.Params)) + for _, p := range entry.Manifest.Params { + declaredKeys[p.Key] = true + } + + usedKeys := make(map[string]bool) + for name, content := range entry.ServiceFiles { + for _, match := range placeholderRe.FindAllStringSubmatch(content, -1) { + key := match[1] + usedKeys[key] = true + if !declaredKeys[key] { + errs = append( + errs, + fmt.Sprintf("placeholder {{%s}} in %s does not match any declared param", key, name), + ) + } + } + } + + for key := range declaredKeys { + if !usedKeys[key] { + warnings = append(warnings, fmt.Sprintf("declared param %q is not used in any service file", key)) + } + } + + sort.Strings(errs) + sort.Strings(warnings) + return errs, warnings +} + var reservedDiskNames = map[string]bool{ "containerdisk": true, "cloudinitdisk": true, diff --git a/internal/workloads/catalog_test.go b/internal/workloads/catalog_test.go index 9baae93..cf11eba 100644 --- a/internal/workloads/catalog_test.go +++ b/internal/workloads/catalog_test.go @@ -625,4 +625,118 @@ packages: Expect(factory).NotTo(BeNil()) }) }) + + Describe("ValidatePlaceholders", func() { + writeFile := func(dir, name, content string) { + err := os.MkdirAll(dir, 0o750) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + Expect(err).NotTo(HaveOccurred()) + } + + It("should return no errors when all placeholders match declared params", func() { + entryDir := filepath.Join(catalogDir, "good") + writeFile(entryDir, "workload.yaml", `description: test +params: + - key: threads + type: int + default: 4 + - key: mode + type: string + default: cpu +`) + writeFile( + entryDir, + "workload.service", + "[Service]\nExecStart=/bin/test --threads={{threads}} --mode={{mode}}\n", + ) + + entry, err := workloads.LoadCatalogEntry(catalogDir, "good") + Expect(err).NotTo(HaveOccurred()) + + errs, warnings := workloads.ValidatePlaceholders(entry) + Expect(errs).To(BeEmpty()) + Expect(warnings).To(BeEmpty()) + }) + + It("should return an error for a placeholder not matching any declared param", func() { + entryDir := filepath.Join(catalogDir, "typo") + writeFile(entryDir, "workload.yaml", `description: test +params: + - key: threads + type: int + default: 4 +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test --threads={{trhreads}}\n") + + entry, err := workloads.LoadCatalogEntry(catalogDir, "typo") + Expect(err).NotTo(HaveOccurred()) + + errs, warnings := workloads.ValidatePlaceholders(entry) + Expect(errs).To(HaveLen(1)) + Expect(errs[0]).To(ContainSubstring("trhreads")) + Expect(warnings).To(HaveLen(1)) + Expect(warnings[0]).To(ContainSubstring("threads")) + }) + + It("should return a warning for a declared param not used in any service file", func() { + entryDir := filepath.Join(catalogDir, "unused") + writeFile(entryDir, "workload.yaml", `description: test +params: + - key: threads + type: int + default: 4 + - key: mode + type: string + default: cpu +`) + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test --threads={{threads}}\n") + + entry, err := workloads.LoadCatalogEntry(catalogDir, "unused") + Expect(err).NotTo(HaveOccurred()) + + errs, warnings := workloads.ValidatePlaceholders(entry) + Expect(errs).To(BeEmpty()) + Expect(warnings).To(HaveLen(1)) + Expect(warnings[0]).To(ContainSubstring("mode")) + }) + + It("should report multiple errors and warnings together", func() { + entryDir := filepath.Join(catalogDir, "mixed") + writeFile(entryDir, "workload.yaml", `description: test +params: + - key: threads + type: int + default: 4 + - key: unused-param + type: string + default: x +`) + writeFile( + entryDir, + "workload.service", + "[Service]\nExecStart=/bin/test --threads={{threads}} --bad={{typo1}} --also={{typo2}}\n", + ) + + entry, err := workloads.LoadCatalogEntry(catalogDir, "mixed") + Expect(err).NotTo(HaveOccurred()) + + errs, warnings := workloads.ValidatePlaceholders(entry) + Expect(errs).To(HaveLen(2)) + Expect(warnings).To(HaveLen(1)) + Expect(warnings[0]).To(ContainSubstring("unused-param")) + }) + + It("should return nothing when entry has no params and no placeholders", func() { + entryDir := filepath.Join(catalogDir, "noparam") + writeFile(entryDir, "workload.service", "[Service]\nExecStart=/bin/test\n") + + entry, err := workloads.LoadCatalogEntry(catalogDir, "noparam") + Expect(err).NotTo(HaveOccurred()) + + errs, warnings := workloads.ValidatePlaceholders(entry) + Expect(errs).To(BeEmpty()) + Expect(warnings).To(BeEmpty()) + }) + }) })