@@ -209,13 +209,81 @@ 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 file has a supported extension or could be a multi-quadlet file
214+ hasValidExt := systemdquadlet .IsExtSupported (toInstall )
215+ isMulti , err := isMultiQuadletFile (toInstall )
214216 if err != nil {
215- installReport .QuadletErrors [toInstall ] = err
217+ installReport .QuadletErrors [toInstall ] = fmt . Errorf ( "unable to check if file is multi-quadlet: %w" , err )
216218 continue
217219 }
218- installReport .InstalledQuadlets [toInstall ] = installedPath
220+
221+ // If the file doesn't have a valid extension, it must be a multi-quadlet file or contain quadlets
222+ if ! hasValidExt && ! isMulti {
223+ // Try to parse it as a potential single quadlet in an unsupported extension file
224+ quadlets , parseErr := parseMultiQuadletFile (toInstall )
225+ if parseErr != nil || len (quadlets ) == 0 {
226+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("%q is not a supported Quadlet file type" , filepath .Ext (toInstall ))
227+ continue
228+ }
229+ // If we successfully parsed quadlets, treat it as a multi-quadlet file
230+ isMulti = true
231+ }
232+
233+ if isMulti {
234+ // Parse the multi-quadlet file
235+ quadlets , err := parseMultiQuadletFile (toInstall )
236+ if err != nil {
237+ installReport .QuadletErrors [toInstall ] = err
238+ continue
239+ }
240+
241+ // Install each quadlet section as a separate file
242+ for _ , quadlet := range quadlets {
243+ // Create a temporary file for this quadlet section
244+ tmpFile , err := os .CreateTemp ("" , quadlet .name + "*" + quadlet .extension )
245+ if err != nil {
246+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to create temporary file for quadlet section %s: %w" , quadlet .name , err )
247+ continue
248+ }
249+
250+ // Write the quadlet content to the temporary file
251+ _ , err = tmpFile .WriteString (quadlet .content )
252+ if err != nil {
253+ tmpFile .Close ()
254+ os .Remove (tmpFile .Name ())
255+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to write quadlet section %s to temporary file: %w" , quadlet .name , err )
256+ continue
257+ }
258+ tmpFile .Close ()
259+
260+ // Install the quadlet from the temporary file
261+ destName := quadlet .name + quadlet .extension
262+ installedPath , err := ic .installQuadlet (ctx , tmpFile .Name (), destName , installDir , assetFile , true , options .Replace )
263+ if err != nil {
264+ os .Remove (tmpFile .Name ())
265+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to install quadlet section %s: %w" , quadlet .name , err )
266+ continue
267+ }
268+
269+ // Clean up temporary file
270+ os .Remove (tmpFile .Name ())
271+
272+ // Record the installation (use a unique key for each section)
273+ sectionKey := fmt .Sprintf ("%s#%s" , toInstall , quadlet .name )
274+ installReport .InstalledQuadlets [sectionKey ] = installedPath
275+ }
276+ } else {
277+ // If toInstall is a single file, execute the original logic
278+ // Don't validate extension for multi-quadlet files that were detected but had only one section
279+ shouldValidate := validateQuadletFile
280+ installedPath , err := ic .installQuadlet (ctx , toInstall , "" , installDir , assetFile , shouldValidate , options .Replace )
281+ if err != nil {
282+ installReport .QuadletErrors [toInstall ] = err
283+ continue
284+ }
285+ installReport .InstalledQuadlets [toInstall ] = installedPath
286+ }
219287 }
220288 }
221289
@@ -325,6 +393,172 @@ func appendStringToFile(filePath, text string) error {
325393 return err
326394}
327395
396+ // quadletSection represents a single quadlet extracted from a multi-quadlet file
397+ type quadletSection struct {
398+ content string
399+ extension string
400+ name string
401+ }
402+
403+ // parseMultiQuadletFile parses a file that may contain multiple quadlets separated by "---"
404+ // Returns a slice of quadletSection structs, each representing a separate quadlet
405+ func parseMultiQuadletFile (filePath string ) ([]quadletSection , error ) {
406+ content , err := os .ReadFile (filePath )
407+ if err != nil {
408+ return nil , fmt .Errorf ("unable to read file %s: %w" , filePath , err )
409+ }
410+
411+ // Split content by lines and reconstruct sections manually to handle "---" properly
412+ lines := strings .Split (string (content ), "\n " )
413+ var sections []string
414+ var currentSection strings.Builder
415+
416+ for _ , line := range lines {
417+ if strings .TrimSpace (line ) == "---" {
418+ // Found separator, save current section and start new one
419+ if currentSection .Len () > 0 {
420+ sections = append (sections , currentSection .String ())
421+ currentSection .Reset ()
422+ }
423+ } else {
424+ // Add line to current section
425+ if currentSection .Len () > 0 {
426+ currentSection .WriteString ("\n " )
427+ }
428+ currentSection .WriteString (line )
429+ }
430+ }
431+
432+ // Add the last section
433+ if currentSection .Len () > 0 {
434+ sections = append (sections , currentSection .String ())
435+ }
436+
437+ baseName := strings .TrimSuffix (filepath .Base (filePath ), filepath .Ext (filePath ))
438+ isMultiSection := len (sections ) > 1
439+
440+ // Pre-allocate slice with capacity based on number of sections
441+ quadlets := make ([]quadletSection , 0 , len (sections ))
442+
443+ for i , section := range sections {
444+ // Trim whitespace from section
445+ section = strings .TrimSpace (section )
446+ if section == "" {
447+ continue // Skip empty sections
448+ }
449+
450+ // Determine quadlet type from section content
451+ extension , err := detectQuadletType (section )
452+ if err != nil {
453+ return nil , fmt .Errorf ("unable to detect quadlet type in section %d: %w" , i + 1 , err )
454+ }
455+
456+ // Extract name for this quadlet section
457+ var name string
458+ if isMultiSection {
459+ // For multi-section files, extract FileName from comments
460+ fileName , err := extractFileNameFromSection (section )
461+ if err != nil {
462+ return nil , fmt .Errorf ("section %d: %w" , i + 1 , err )
463+ }
464+ name = fileName
465+ } else {
466+ // Single section, use original name
467+ name = baseName
468+ }
469+
470+ quadlets = append (quadlets , quadletSection {
471+ content : section ,
472+ extension : extension ,
473+ name : name ,
474+ })
475+ }
476+
477+ if len (quadlets ) == 0 {
478+ return nil , fmt .Errorf ("no valid quadlet sections found in file %s" , filePath )
479+ }
480+
481+ return quadlets , nil
482+ }
483+
484+ // extractFileNameFromSection extracts the FileName from a comment in the quadlet section
485+ // The comment must be in the format: # FileName=my-name
486+ func extractFileNameFromSection (content string ) (string , error ) {
487+ lines := strings .Split (content , "\n " )
488+ for _ , line := range lines {
489+ line = strings .TrimSpace (line )
490+ // Look for comment lines starting with #
491+ if strings .HasPrefix (line , "#" ) {
492+ // Remove the # and trim whitespace
493+ commentContent := strings .TrimSpace (line [1 :])
494+ // Check if it's a FileName directive
495+ if strings .HasPrefix (commentContent , "FileName=" ) {
496+ fileName := strings .TrimSpace (commentContent [9 :]) // Remove "FileName="
497+ if fileName == "" {
498+ return "" , fmt .Errorf ("FileName comment found but no filename specified" )
499+ }
500+ // Validate filename (basic validation - no path separators, no extensions)
501+ if strings .ContainsAny (fileName , "/\\ " ) {
502+ return "" , fmt .Errorf ("FileName '%s' cannot contain path separators" , fileName )
503+ }
504+ if strings .Contains (fileName , "." ) {
505+ return "" , fmt .Errorf ("FileName '%s' should not include file extension" , fileName )
506+ }
507+ return fileName , nil
508+ }
509+ }
510+ }
511+ return "" , fmt .Errorf ("missing required '# FileName=<name>' comment at the beginning of quadlet section" )
512+ }
513+
514+ // detectQuadletType analyzes the content of a quadlet section to determine its type
515+ // Returns the appropriate file extension (.container, .volume, .network, etc.)
516+ func detectQuadletType (content string ) (string , error ) {
517+ // Look for section headers like [Container], [Volume], [Network], etc.
518+ lines := strings .Split (content , "\n " )
519+ for _ , line := range lines {
520+ line = strings .TrimSpace (line )
521+ if strings .HasPrefix (line , "[" ) && strings .HasSuffix (line , "]" ) {
522+ sectionName := strings .ToLower (strings .Trim (line , "[]" ))
523+ switch sectionName {
524+ case "container" :
525+ return ".container" , nil
526+ case "volume" :
527+ return ".volume" , nil
528+ case "network" :
529+ return ".network" , nil
530+ case "kube" :
531+ return ".kube" , nil
532+ case "image" :
533+ return ".image" , nil
534+ case "build" :
535+ return ".build" , nil
536+ case "pod" :
537+ return ".pod" , nil
538+ }
539+ }
540+ }
541+ return "" , fmt .Errorf ("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])" )
542+ }
543+
544+ // isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter
545+ // The delimiter must be on its own line (possibly with whitespace)
546+ func isMultiQuadletFile (filePath string ) (bool , error ) {
547+ content , err := os .ReadFile (filePath )
548+ if err != nil {
549+ return false , err
550+ }
551+
552+ lines := strings .Split (string (content ), "\n " )
553+ for _ , line := range lines {
554+ trimmed := strings .TrimSpace (line )
555+ if trimmed == "---" {
556+ return true , nil
557+ }
558+ }
559+ return false , nil
560+ }
561+
328562// buildAppMap scans the given directory for files that start with '.'
329563// and end with '.app', reads their contents (one filename per line), and
330564// returns a map where each filename maps to the .app file that contains it.
0 commit comments