@@ -209,13 +209,66 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
209209 installReport .QuadletErrors [toInstall ] = err
210210 continue
211211 }
212- // If toInstall is a single file, execute the original logic
213- installedPath , err := ic .installQuadlet (ctx , toInstall , "" , installDir , assetFile , validateQuadletFile , options .Replace )
212+
213+ // Check if this is a multi-quadlet file
214+ isMulti , err := isMultiQuadletFile (toInstall )
214215 if err != nil {
215- installReport .QuadletErrors [toInstall ] = err
216+ installReport .QuadletErrors [toInstall ] = fmt . Errorf ( "unable to check if file is multi-quadlet: %w" , err )
216217 continue
217218 }
218- installReport .InstalledQuadlets [toInstall ] = installedPath
219+
220+ if isMulti {
221+ // Parse the multi-quadlet file
222+ quadlets , err := parseMultiQuadletFile (toInstall )
223+ if err != nil {
224+ installReport .QuadletErrors [toInstall ] = err
225+ continue
226+ }
227+
228+ // Install each quadlet section as a separate file
229+ for _ , quadlet := range quadlets {
230+ // Create a temporary file for this quadlet section
231+ tmpFile , err := os .CreateTemp ("" , quadlet .name + "*" + quadlet .extension )
232+ if err != nil {
233+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to create temporary file for quadlet section %s: %w" , quadlet .name , err )
234+ continue
235+ }
236+
237+ // Write the quadlet content to the temporary file
238+ _ , err = tmpFile .WriteString (quadlet .content )
239+ if err != nil {
240+ tmpFile .Close ()
241+ os .Remove (tmpFile .Name ())
242+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to write quadlet section %s to temporary file: %w" , quadlet .name , err )
243+ continue
244+ }
245+ tmpFile .Close ()
246+
247+ // Install the quadlet from the temporary file
248+ destName := quadlet .name + quadlet .extension
249+ installedPath , err := ic .installQuadlet (ctx , tmpFile .Name (), destName , installDir , assetFile , true , options .Replace )
250+ if err != nil {
251+ os .Remove (tmpFile .Name ())
252+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to install quadlet section %s: %w" , quadlet .name , err )
253+ continue
254+ }
255+
256+ // Clean up temporary file
257+ os .Remove (tmpFile .Name ())
258+
259+ // Record the installation (use a unique key for each section)
260+ sectionKey := fmt .Sprintf ("%s#%s" , toInstall , quadlet .name )
261+ installReport .InstalledQuadlets [sectionKey ] = installedPath
262+ }
263+ } else {
264+ // If toInstall is a single file, execute the original logic
265+ installedPath , err := ic .installQuadlet (ctx , toInstall , "" , installDir , assetFile , validateQuadletFile , options .Replace )
266+ if err != nil {
267+ installReport .QuadletErrors [toInstall ] = err
268+ continue
269+ }
270+ installReport .InstalledQuadlets [toInstall ] = installedPath
271+ }
219272 }
220273 }
221274
@@ -325,6 +378,170 @@ func appendStringToFile(filePath, text string) error {
325378 return err
326379}
327380
381+ // quadletSection represents a single quadlet extracted from a multi-quadlet file
382+ type quadletSection struct {
383+ content string
384+ extension string
385+ name string
386+ }
387+
388+ // parseMultiQuadletFile parses a file that may contain multiple quadlets separated by "---"
389+ // Returns a slice of quadletSection structs, each representing a separate quadlet
390+ func parseMultiQuadletFile (filePath string ) ([]quadletSection , error ) {
391+ content , err := os .ReadFile (filePath )
392+ if err != nil {
393+ return nil , fmt .Errorf ("unable to read file %s: %w" , filePath , err )
394+ }
395+
396+ // Split content by lines and reconstruct sections manually to handle "---" properly
397+ lines := strings .Split (string (content ), "\n " )
398+ var sections []string
399+ var currentSection strings.Builder
400+
401+ for _ , line := range lines {
402+ if strings .TrimSpace (line ) == "---" {
403+ // Found separator, save current section and start new one
404+ if currentSection .Len () > 0 {
405+ sections = append (sections , currentSection .String ())
406+ currentSection .Reset ()
407+ }
408+ } else {
409+ // Add line to current section
410+ if currentSection .Len () > 0 {
411+ currentSection .WriteString ("\n " )
412+ }
413+ currentSection .WriteString (line )
414+ }
415+ }
416+
417+ // Add the last section
418+ if currentSection .Len () > 0 {
419+ sections = append (sections , currentSection .String ())
420+ }
421+
422+ var quadlets []quadletSection
423+ baseName := strings .TrimSuffix (filepath .Base (filePath ), filepath .Ext (filePath ))
424+ isMultiSection := len (sections ) > 1
425+
426+ for i , section := range sections {
427+ // Trim whitespace from section
428+ section = strings .TrimSpace (section )
429+ if section == "" {
430+ continue // Skip empty sections
431+ }
432+
433+ // Determine quadlet type from section content
434+ extension , err := detectQuadletType (section )
435+ if err != nil {
436+ return nil , fmt .Errorf ("unable to detect quadlet type in section %d: %w" , i + 1 , err )
437+ }
438+
439+ // Extract name for this quadlet section
440+ var name string
441+ if isMultiSection {
442+ // For multi-section files, extract FileName from comments
443+ fileName , err := extractFileNameFromSection (section )
444+ if err != nil {
445+ return nil , fmt .Errorf ("section %d: %w" , i + 1 , err )
446+ }
447+ name = fileName
448+ } else {
449+ // Single section, use original name
450+ name = baseName
451+ }
452+
453+ quadlets = append (quadlets , quadletSection {
454+ content : section ,
455+ extension : extension ,
456+ name : name ,
457+ })
458+ }
459+
460+ if len (quadlets ) == 0 {
461+ return nil , fmt .Errorf ("no valid quadlet sections found in file %s" , filePath )
462+ }
463+
464+ return quadlets , nil
465+ }
466+
467+ // extractFileNameFromSection extracts the FileName from a comment in the quadlet section
468+ // The comment must be in the format: # FileName=my-name
469+ func extractFileNameFromSection (content string ) (string , error ) {
470+ lines := strings .Split (content , "\n " )
471+ for _ , line := range lines {
472+ line = strings .TrimSpace (line )
473+ // Look for comment lines starting with #
474+ if strings .HasPrefix (line , "#" ) {
475+ // Remove the # and trim whitespace
476+ commentContent := strings .TrimSpace (line [1 :])
477+ // Check if it's a FileName directive
478+ if strings .HasPrefix (commentContent , "FileName=" ) {
479+ fileName := strings .TrimSpace (commentContent [9 :]) // Remove "FileName="
480+ if fileName == "" {
481+ return "" , fmt .Errorf ("FileName comment found but no filename specified" )
482+ }
483+ // Validate filename (basic validation - no path separators, no extensions)
484+ if strings .ContainsAny (fileName , "/\\ " ) {
485+ return "" , fmt .Errorf ("FileName '%s' cannot contain path separators" , fileName )
486+ }
487+ if strings .Contains (fileName , "." ) {
488+ return "" , fmt .Errorf ("FileName '%s' should not include file extension" , fileName )
489+ }
490+ return fileName , nil
491+ }
492+ }
493+ }
494+ return "" , fmt .Errorf ("missing required '# FileName=<name>' comment at the beginning of quadlet section" )
495+ }
496+
497+ // detectQuadletType analyzes the content of a quadlet section to determine its type
498+ // Returns the appropriate file extension (.container, .volume, .network, etc.)
499+ func detectQuadletType (content string ) (string , error ) {
500+ // Look for section headers like [Container], [Volume], [Network], etc.
501+ lines := strings .Split (content , "\n " )
502+ for _ , line := range lines {
503+ line = strings .TrimSpace (line )
504+ if strings .HasPrefix (line , "[" ) && strings .HasSuffix (line , "]" ) {
505+ sectionName := strings .ToLower (strings .Trim (line , "[]" ))
506+ switch sectionName {
507+ case "container" :
508+ return ".container" , nil
509+ case "volume" :
510+ return ".volume" , nil
511+ case "network" :
512+ return ".network" , nil
513+ case "kube" :
514+ return ".kube" , nil
515+ case "image" :
516+ return ".image" , nil
517+ case "build" :
518+ return ".build" , nil
519+ case "pod" :
520+ return ".pod" , nil
521+ }
522+ }
523+ }
524+ return "" , fmt .Errorf ("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])" )
525+ }
526+
527+ // isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter
528+ // The delimiter must be on its own line (possibly with whitespace)
529+ func isMultiQuadletFile (filePath string ) (bool , error ) {
530+ content , err := os .ReadFile (filePath )
531+ if err != nil {
532+ return false , err
533+ }
534+
535+ lines := strings .Split (string (content ), "\n " )
536+ for _ , line := range lines {
537+ trimmed := strings .TrimSpace (line )
538+ if trimmed == "---" {
539+ return true , nil
540+ }
541+ }
542+ return false , nil
543+ }
544+
328545// buildAppMap scans the given directory for files that start with '.'
329546// and end with '.app', reads their contents (one filename per line), and
330547// returns a map where each filename maps to the .app file that contains it.
0 commit comments