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