@@ -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,172 @@ 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+ baseName := strings .TrimSuffix (filepath .Base (filePath ), filepath .Ext (filePath ))
423+ isMultiSection := len (sections ) > 1
424+
425+ // Pre-allocate slice with capacity based on number of sections
426+ quadlets := make ([]quadletSection , 0 , len (sections ))
427+
428+ for i , section := range sections {
429+ // Trim whitespace from section
430+ section = strings .TrimSpace (section )
431+ if section == "" {
432+ continue // Skip empty sections
433+ }
434+
435+ // Determine quadlet type from section content
436+ extension , err := detectQuadletType (section )
437+ if err != nil {
438+ return nil , fmt .Errorf ("unable to detect quadlet type in section %d: %w" , i + 1 , err )
439+ }
440+
441+ // Extract name for this quadlet section
442+ var name string
443+ if isMultiSection {
444+ // For multi-section files, extract FileName from comments
445+ fileName , err := extractFileNameFromSection (section )
446+ if err != nil {
447+ return nil , fmt .Errorf ("section %d: %w" , i + 1 , err )
448+ }
449+ name = fileName
450+ } else {
451+ // Single section, use original name
452+ name = baseName
453+ }
454+
455+ quadlets = append (quadlets , quadletSection {
456+ content : section ,
457+ extension : extension ,
458+ name : name ,
459+ })
460+ }
461+
462+ if len (quadlets ) == 0 {
463+ return nil , fmt .Errorf ("no valid quadlet sections found in file %s" , filePath )
464+ }
465+
466+ return quadlets , nil
467+ }
468+
469+ // extractFileNameFromSection extracts the FileName from a comment in the quadlet section
470+ // The comment must be in the format: # FileName=my-name
471+ func extractFileNameFromSection (content string ) (string , error ) {
472+ lines := strings .Split (content , "\n " )
473+ for _ , line := range lines {
474+ line = strings .TrimSpace (line )
475+ // Look for comment lines starting with #
476+ if strings .HasPrefix (line , "#" ) {
477+ // Remove the # and trim whitespace
478+ commentContent := strings .TrimSpace (line [1 :])
479+ // Check if it's a FileName directive
480+ if strings .HasPrefix (commentContent , "FileName=" ) {
481+ fileName := strings .TrimSpace (commentContent [9 :]) // Remove "FileName="
482+ if fileName == "" {
483+ return "" , fmt .Errorf ("FileName comment found but no filename specified" )
484+ }
485+ // Validate filename (basic validation - no path separators, no extensions)
486+ if strings .ContainsAny (fileName , "/\\ " ) {
487+ return "" , fmt .Errorf ("FileName '%s' cannot contain path separators" , fileName )
488+ }
489+ if strings .Contains (fileName , "." ) {
490+ return "" , fmt .Errorf ("FileName '%s' should not include file extension" , fileName )
491+ }
492+ return fileName , nil
493+ }
494+ }
495+ }
496+ return "" , fmt .Errorf ("missing required '# FileName=<name>' comment at the beginning of quadlet section" )
497+ }
498+
499+ // detectQuadletType analyzes the content of a quadlet section to determine its type
500+ // Returns the appropriate file extension (.container, .volume, .network, etc.)
501+ func detectQuadletType (content string ) (string , error ) {
502+ // Look for section headers like [Container], [Volume], [Network], etc.
503+ lines := strings .Split (content , "\n " )
504+ for _ , line := range lines {
505+ line = strings .TrimSpace (line )
506+ if strings .HasPrefix (line , "[" ) && strings .HasSuffix (line , "]" ) {
507+ sectionName := strings .ToLower (strings .Trim (line , "[]" ))
508+ switch sectionName {
509+ case "container" :
510+ return ".container" , nil
511+ case "volume" :
512+ return ".volume" , nil
513+ case "network" :
514+ return ".network" , nil
515+ case "kube" :
516+ return ".kube" , nil
517+ case "image" :
518+ return ".image" , nil
519+ case "build" :
520+ return ".build" , nil
521+ case "pod" :
522+ return ".pod" , nil
523+ }
524+ }
525+ }
526+ return "" , fmt .Errorf ("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])" )
527+ }
528+
529+ // isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter
530+ // The delimiter must be on its own line (possibly with whitespace)
531+ func isMultiQuadletFile (filePath string ) (bool , error ) {
532+ content , err := os .ReadFile (filePath )
533+ if err != nil {
534+ return false , err
535+ }
536+
537+ lines := strings .Split (string (content ), "\n " )
538+ for _ , line := range lines {
539+ trimmed := strings .TrimSpace (line )
540+ if trimmed == "---" {
541+ return true , nil
542+ }
543+ }
544+ return false , nil
545+ }
546+
328547// buildAppMap scans the given directory for files that start with '.'
329548// and end with '.app', reads their contents (one filename per line), and
330549// returns a map where each filename maps to the .app file that contains it.
0 commit comments