Skip to content

Commit acc1aad

Browse files
Preflight docs and template subcommands (#1847)
* Added docs and template subcommands with test files * uses helm templating preflight yaml files * merge doc requirements for multiple inputs * Helm aware rendering and markdown output * v1beta3 yaml structure better mirrors beta2 * Update sample-preflight-templated.yaml * Added docs and template subcommands with test files * uses helm templating preflight yaml files * merge doc requirements for multiple inputs * Helm aware rendering and markdown output * v1beta3 yaml structure better mirrors beta2 * Update sample-preflight-templated.yaml * Added/updated documentation on subcommands * Update docs.go * commit to trigger actions
1 parent 8027e27 commit acc1aad

12 files changed

+1132
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ sbom/
4848
# Ignore generated support bundles
4949
*.tar.gz
5050
!testdata/supportbundle/*.tar.gz
51+
preflight

cmd/preflight/cli/docs.go

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"text/template"
9+
10+
"github.com/Masterminds/sprig/v3"
11+
"github.com/pkg/errors"
12+
"github.com/replicatedhq/troubleshoot/pkg/preflight"
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
15+
"gopkg.in/yaml.v2"
16+
"helm.sh/helm/v3/pkg/strvals"
17+
)
18+
19+
func DocsCmd() *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "docs [preflight-file...]",
22+
Short: "Extract and display documentation from a preflight spec",
23+
Long: `Extract all docString fields from enabled requirements in one or more preflight YAML files.
24+
This command processes templated preflight specs, evaluates conditionals, and outputs
25+
only the documentation for requirements that would be included based on the provided values.
26+
27+
Examples:
28+
# Extract docs with default values
29+
preflight docs ml-platform-preflight.yaml
30+
31+
# Extract docs from multiple specs with values from files
32+
preflight docs spec1.yaml spec2.yaml --values base-values.yaml --values prod-values.yaml
33+
34+
# Extract docs with inline values
35+
preflight docs ml-platform-preflight.yaml --set jupyter.enabled=true --set monitoring.enabled=false
36+
37+
# Extract docs and save to file
38+
preflight docs ml-platform-preflight.yaml --output requirements.md`,
39+
Args: cobra.MinimumNArgs(1),
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
v := viper.GetViper()
42+
43+
templateFiles := args
44+
valuesFiles := v.GetStringSlice("values")
45+
outputFile := v.GetString("output")
46+
setValues := v.GetStringSlice("set")
47+
48+
return extractDocs(templateFiles, valuesFiles, setValues, outputFile)
49+
},
50+
}
51+
52+
cmd.Flags().StringSlice("values", []string{}, "Path to YAML files containing template values (can be used multiple times)")
53+
cmd.Flags().StringSlice("set", []string{}, "Set template values on the command line (can be used multiple times)")
54+
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
55+
56+
// Bind flags to viper
57+
viper.BindPFlag("values", cmd.Flags().Lookup("values"))
58+
viper.BindPFlag("set", cmd.Flags().Lookup("set"))
59+
viper.BindPFlag("output", cmd.Flags().Lookup("output"))
60+
61+
return cmd
62+
}
63+
64+
// PreflightDoc supports both legacy (requirements) and beta3 (spec.analyzers)
65+
type PreflightDoc struct {
66+
APIVersion string `yaml:"apiVersion"`
67+
Kind string `yaml:"kind"`
68+
Metadata map[string]interface{} `yaml:"metadata"`
69+
Spec struct {
70+
Analyzers []map[string]interface{} `yaml:"analyzers"`
71+
} `yaml:"spec"`
72+
// Legacy (pre-beta3 drafts)
73+
Requirements []Requirement `yaml:"requirements"`
74+
}
75+
76+
type Requirement struct {
77+
Name string `yaml:"name"`
78+
DocString string `yaml:"docString"`
79+
Checks []map[string]interface{} `yaml:"checks,omitempty"`
80+
}
81+
82+
func extractDocs(templateFiles []string, valuesFiles []string, setValues []string, outputFile string) error {
83+
// Prepare the values map (merge all files, then apply sets)
84+
values := make(map[string]interface{})
85+
86+
for _, valuesFile := range valuesFiles {
87+
fileValues, err := loadValuesFile(valuesFile)
88+
if err != nil {
89+
return errors.Wrapf(err, "failed to load values file %s", valuesFile)
90+
}
91+
values = mergeMaps(values, fileValues)
92+
}
93+
94+
// Normalize maps for Helm set merging
95+
values = normalizeStringMaps(values)
96+
97+
for _, setValue := range setValues {
98+
if err := applySetValue(values, setValue); err != nil {
99+
return errors.Wrapf(err, "failed to apply set value: %s", setValue)
100+
}
101+
}
102+
103+
var combinedDocs strings.Builder
104+
105+
for _, templateFile := range templateFiles {
106+
templateContent, err := os.ReadFile(templateFile)
107+
if err != nil {
108+
return errors.Wrapf(err, "failed to read template file %s", templateFile)
109+
}
110+
111+
useHelm := shouldUseHelmEngine(string(templateContent))
112+
var rendered string
113+
if useHelm {
114+
rendered, err = preflight.RenderWithHelmTemplate(string(templateContent), values)
115+
if err != nil {
116+
execValues := legacyContext(values)
117+
rendered, err = renderTemplate(string(templateContent), execValues)
118+
if err != nil {
119+
return errors.Wrap(err, "failed to render template (helm fallback also failed)")
120+
}
121+
}
122+
} else {
123+
execValues := legacyContext(values)
124+
rendered, err = renderTemplate(string(templateContent), execValues)
125+
if err != nil {
126+
return errors.Wrap(err, "failed to render template")
127+
}
128+
}
129+
130+
docs, err := extractDocStrings(rendered)
131+
if err != nil {
132+
return errors.Wrap(err, "failed to extract documentation")
133+
}
134+
135+
if strings.TrimSpace(docs) != "" {
136+
if combinedDocs.Len() > 0 {
137+
combinedDocs.WriteString("\n\n")
138+
}
139+
combinedDocs.WriteString(docs)
140+
}
141+
}
142+
143+
if outputFile != "" {
144+
if err := os.WriteFile(outputFile, []byte(combinedDocs.String()), 0644); err != nil {
145+
return errors.Wrapf(err, "failed to write output file %s", outputFile)
146+
}
147+
fmt.Printf("Documentation extracted successfully to %s\n", outputFile)
148+
} else {
149+
fmt.Print(combinedDocs.String())
150+
}
151+
152+
return nil
153+
}
154+
155+
func shouldUseHelmEngine(content string) bool {
156+
return strings.Contains(content, ".Values")
157+
}
158+
159+
func legacyContext(values map[string]interface{}) map[string]interface{} {
160+
ctx := make(map[string]interface{}, len(values)+1)
161+
for k, v := range values {
162+
ctx[k] = v
163+
}
164+
ctx["Values"] = values
165+
return ctx
166+
}
167+
168+
func normalizeStringMaps(v interface{}) map[string]interface{} {
169+
// Avoid unsafe type assertion; normalizeMap may return non-map types.
170+
if v == nil {
171+
return map[string]interface{}{}
172+
}
173+
normalized := normalizeMap(v)
174+
if m, ok := normalized.(map[string]interface{}); ok {
175+
return m
176+
}
177+
return map[string]interface{}{}
178+
}
179+
180+
func normalizeMap(v interface{}) interface{} {
181+
switch t := v.(type) {
182+
case map[string]interface{}:
183+
m := make(map[string]interface{}, len(t))
184+
for k, val := range t {
185+
m[k] = normalizeMap(val)
186+
}
187+
return m
188+
case map[interface{}]interface{}:
189+
m := make(map[string]interface{}, len(t))
190+
for k, val := range t {
191+
key := fmt.Sprintf("%v", k)
192+
m[key] = normalizeMap(val)
193+
}
194+
return m
195+
case []interface{}:
196+
a := make([]interface{}, len(t))
197+
for i, val := range t {
198+
a[i] = normalizeMap(val)
199+
}
200+
return a
201+
default:
202+
return v
203+
}
204+
}
205+
206+
func extractDocStrings(yamlContent string) (string, error) {
207+
var preflightDoc PreflightDoc
208+
if err := yaml.Unmarshal([]byte(yamlContent), &preflightDoc); err != nil {
209+
return "", errors.Wrap(err, "failed to parse YAML")
210+
}
211+
212+
var docs strings.Builder
213+
first := true
214+
215+
// Prefer beta3 analyzers docStrings
216+
if len(preflightDoc.Spec.Analyzers) > 0 {
217+
for _, analyzer := range preflightDoc.Spec.Analyzers {
218+
if raw, ok := analyzer["docString"]; ok {
219+
text, _ := raw.(string)
220+
text = strings.TrimSpace(text)
221+
if text == "" {
222+
continue
223+
}
224+
if !first {
225+
docs.WriteString("\n\n")
226+
}
227+
first = false
228+
writeMarkdownSection(&docs, text, "")
229+
}
230+
}
231+
return docs.String(), nil
232+
}
233+
234+
// Fallback: legacy requirements with docString
235+
for _, req := range preflightDoc.Requirements {
236+
if strings.TrimSpace(req.DocString) == "" {
237+
continue
238+
}
239+
if !first {
240+
docs.WriteString("\n\n")
241+
}
242+
first = false
243+
writeMarkdownSection(&docs, req.DocString, req.Name)
244+
}
245+
246+
return docs.String(), nil
247+
}
248+
249+
// writeMarkdownSection prints a heading from Title: or name, then the rest
250+
func writeMarkdownSection(b *strings.Builder, docString string, fallbackName string) {
251+
lines := strings.Split(docString, "\n")
252+
title := strings.TrimSpace(fallbackName)
253+
contentStart := 0
254+
for i, line := range lines {
255+
trim := strings.TrimSpace(line)
256+
if strings.HasPrefix(trim, "Title:") {
257+
parts := strings.SplitN(trim, ":", 2)
258+
if len(parts) == 2 {
259+
t := strings.TrimSpace(parts[1])
260+
if t != "" {
261+
title = t
262+
}
263+
}
264+
contentStart = i + 1
265+
break
266+
}
267+
}
268+
if title != "" {
269+
b.WriteString("### ")
270+
b.WriteString(title)
271+
b.WriteString("\n\n")
272+
}
273+
remaining := strings.Join(lines[contentStart:], "\n")
274+
remaining = strings.TrimSpace(remaining)
275+
if remaining != "" {
276+
b.WriteString(remaining)
277+
b.WriteString("\n")
278+
}
279+
}
280+
281+
// loadValuesFile loads values from a YAML file
282+
func loadValuesFile(filename string) (map[string]interface{}, error) {
283+
data, err := os.ReadFile(filename)
284+
if err != nil {
285+
return nil, err
286+
}
287+
288+
var values map[string]interface{}
289+
if err := yaml.Unmarshal(data, &values); err != nil {
290+
return nil, errors.Wrap(err, "failed to parse values file as YAML")
291+
}
292+
293+
return values, nil
294+
}
295+
296+
// applySetValue applies a single --set value to the values map (Helm semantics)
297+
func applySetValue(values map[string]interface{}, setValue string) error {
298+
if idx := strings.Index(setValue, "="); idx > 0 {
299+
key := setValue[:idx]
300+
val := setValue[idx+1:]
301+
if strings.HasPrefix(key, "Values.") {
302+
key = strings.TrimPrefix(key, "Values.")
303+
setValue = key + "=" + val
304+
}
305+
}
306+
if err := strvals.ParseInto(setValue, values); err != nil {
307+
return fmt.Errorf("parsing --set: %w", err)
308+
}
309+
return nil
310+
}
311+
312+
// setNestedValue sets a value in a nested map structure
313+
func setNestedValue(m map[string]interface{}, keys []string, value interface{}) {
314+
if len(keys) == 0 {
315+
return
316+
}
317+
if len(keys) == 1 {
318+
m[keys[0]] = value
319+
return
320+
}
321+
if _, ok := m[keys[0]]; !ok {
322+
m[keys[0]] = make(map[string]interface{})
323+
}
324+
if nextMap, ok := m[keys[0]].(map[string]interface{}); ok {
325+
setNestedValue(nextMap, keys[1:], value)
326+
} else {
327+
m[keys[0]] = make(map[string]interface{})
328+
setNestedValue(m[keys[0]].(map[string]interface{}), keys[1:], value)
329+
}
330+
}
331+
332+
func mergeMaps(base, overlay map[string]interface{}) map[string]interface{} {
333+
result := make(map[string]interface{})
334+
for k, v := range base {
335+
result[k] = v
336+
}
337+
for k, v := range overlay {
338+
if baseVal, exists := result[k]; exists {
339+
if baseMap, ok := baseVal.(map[string]interface{}); ok {
340+
if overlayMap, ok := v.(map[string]interface{}); ok {
341+
result[k] = mergeMaps(baseMap, overlayMap)
342+
continue
343+
}
344+
}
345+
}
346+
result[k] = v
347+
}
348+
return result
349+
}
350+
351+
func renderTemplate(templateContent string, values map[string]interface{}) (string, error) {
352+
tmpl := template.New("preflight").Funcs(sprig.FuncMap())
353+
tmpl, err := tmpl.Parse(templateContent)
354+
if err != nil {
355+
return "", errors.Wrap(err, "failed to parse template")
356+
}
357+
var buf bytes.Buffer
358+
if err := tmpl.Execute(&buf, values); err != nil {
359+
return "", errors.Wrap(err, "failed to execute template")
360+
}
361+
result := cleanRenderedYAML(buf.String())
362+
return result, nil
363+
}
364+
365+
func cleanRenderedYAML(content string) string {
366+
lines := strings.Split(content, "\n")
367+
var cleaned []string
368+
var lastWasEmpty bool
369+
for _, line := range lines {
370+
trimmed := strings.TrimRight(line, " \t")
371+
if trimmed == "" {
372+
if !lastWasEmpty {
373+
cleaned = append(cleaned, "")
374+
lastWasEmpty = true
375+
}
376+
} else {
377+
cleaned = append(cleaned, trimmed)
378+
lastWasEmpty = false
379+
}
380+
}
381+
for len(cleaned) > 0 && cleaned[len(cleaned)-1] == "" {
382+
cleaned = cleaned[:len(cleaned)-1]
383+
}
384+
return strings.Join(cleaned, "\n") + "\n"
385+
}

cmd/preflight/cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ that a cluster meets the requirements to run an application.`,
8686

8787
cmd.AddCommand(util.VersionCmd())
8888
cmd.AddCommand(OciFetchCmd())
89+
cmd.AddCommand(TemplateCmd())
90+
cmd.AddCommand(DocsCmd())
8991
preflight.AddFlags(cmd.PersistentFlags())
9092

9193
// Dry run flag should be in cmd.PersistentFlags() flags made available to all subcommands

0 commit comments

Comments
 (0)