@@ -164,7 +164,15 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
164164 for _ , toInstall := range paths {
165165 validateQuadletFile := false
166166 if assetFile == "" {
167- assetFile = "." + filepath .Base (toInstall ) + ".asset"
167+ // Check if this is a .quadlets file - if so, treat as an app
168+ ext := filepath .Ext (toInstall )
169+ if ext == ".quadlets" {
170+ // For .quadlets files, use .app extension to group all quadlets as one application
171+ baseName := strings .TrimSuffix (filepath .Base (toInstall ), filepath .Ext (toInstall ))
172+ assetFile = "." + baseName + ".app"
173+ } else {
174+ assetFile = "." + filepath .Base (toInstall ) + ".asset"
175+ }
168176 validateQuadletFile = true
169177 }
170178 switch {
@@ -209,13 +217,65 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
209217 installReport .QuadletErrors [toInstall ] = err
210218 continue
211219 }
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
220+
221+ // Check if this file has a supported extension or is a .quadlets file
222+ hasValidExt := systemdquadlet .IsExtSupported (toInstall )
223+ isQuadletsFile := filepath .Ext (toInstall ) == ".quadlets"
224+
225+ // Handle files with unsupported extensions that are not .quadlets files
226+ // If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets
227+ // Standalone files with unsupported extensions are not allowed
228+ if ! hasValidExt && ! isQuadletsFile && assetFile == "" {
229+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("%q is not a supported Quadlet file type" , filepath .Ext (toInstall ))
216230 continue
217231 }
218- installReport .InstalledQuadlets [toInstall ] = installedPath
232+
233+ if isQuadletsFile {
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+ defer os .Remove (tmpFile .Name ())
250+ // Write the quadlet content to the temporary file
251+ _ , err = tmpFile .WriteString (quadlet .content )
252+ tmpFile .Close ()
253+ if err != nil {
254+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to write quadlet section %s to temporary file: %w" , quadlet .name , err )
255+ continue
256+ }
257+
258+ // Install the quadlet from the temporary file
259+ destName := quadlet .name + quadlet .extension
260+ installedPath , err := ic .installQuadlet (ctx , tmpFile .Name (), destName , installDir , assetFile , true , options .Replace )
261+ if err != nil {
262+ installReport .QuadletErrors [toInstall ] = fmt .Errorf ("unable to install quadlet section %s: %w" , destName , err )
263+ continue
264+ }
265+
266+ // Record the installation (use a unique key for each section)
267+ sectionKey := fmt .Sprintf ("%s#%s" , toInstall , destName )
268+ installReport .InstalledQuadlets [sectionKey ] = installedPath
269+ }
270+ } else {
271+ // If toInstall is a single file with a supported extension, execute the original logic
272+ installedPath , err := ic .installQuadlet (ctx , toInstall , "" , installDir , assetFile , validateQuadletFile , options .Replace )
273+ if err != nil {
274+ installReport .QuadletErrors [toInstall ] = err
275+ continue
276+ }
277+ installReport .InstalledQuadlets [toInstall ] = installedPath
278+ }
219279 }
220280 }
221281
@@ -308,6 +368,14 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins
308368 if err != nil {
309369 return "" , fmt .Errorf ("error while writing non-quadlet filename: %w" , err )
310370 }
371+ } else if strings .HasSuffix (assetFile , ".app" ) {
372+ // For quadlet files that are part of an application (indicated by .app extension),
373+ // also write the quadlet filename to the .app file for proper application tracking
374+ quadletName := filepath .Base (finalPath )
375+ err := appendStringToFile (filepath .Join (installDir , assetFile ), quadletName )
376+ if err != nil {
377+ return "" , fmt .Errorf ("error while writing quadlet filename to app file: %w" , err )
378+ }
311379 }
312380 return finalPath , nil
313381}
@@ -325,6 +393,125 @@ 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+ currentSection .WriteString (line )
425+ currentSection .WriteString ("\n " )
426+ }
427+ }
428+
429+ // Add the last section
430+ if currentSection .Len () > 0 {
431+ sections = append (sections , currentSection .String ())
432+ }
433+
434+ // Pre-allocate slice with capacity based on number of sections
435+ quadlets := make ([]quadletSection , 0 , len (sections ))
436+
437+ for i , section := range sections {
438+ // Trim whitespace from section
439+ section = strings .TrimSpace (section )
440+ if section == "" {
441+ continue // Skip empty sections
442+ }
443+
444+ // Determine quadlet type from section content
445+ extension , err := detectQuadletType (section )
446+ if err != nil {
447+ return nil , fmt .Errorf ("unable to detect quadlet type in section %d: %w" , i + 1 , err )
448+ }
449+
450+ fileName , err := extractFileNameFromSection (section )
451+ if err != nil {
452+ return nil , fmt .Errorf ("section %d: %w" , i + 1 , err )
453+ }
454+ name := fileName
455+
456+ quadlets = append (quadlets , quadletSection {
457+ content : section ,
458+ extension : extension ,
459+ name : name ,
460+ })
461+ }
462+
463+ if len (quadlets ) == 0 {
464+ return nil , fmt .Errorf ("no valid quadlet sections found in file %s" , filePath )
465+ }
466+
467+ return quadlets , nil
468+ }
469+
470+ // extractFileNameFromSection extracts the FileName from a comment in the quadlet section
471+ // The comment must be in the format: # FileName=my-name
472+ func extractFileNameFromSection (content string ) (string , error ) {
473+ lines := strings .Split (content , "\n " )
474+ for _ , line := range lines {
475+ line = strings .TrimSpace (line )
476+ // Look for comment lines starting with #
477+ if strings .HasPrefix (line , "#" ) {
478+ // Remove the # and trim whitespace
479+ commentContent := strings .TrimSpace (line [1 :])
480+ // Check if it's a FileName directive
481+ if strings .HasPrefix (commentContent , "FileName=" ) {
482+ fileName := strings .TrimSpace (commentContent [9 :]) // Remove "FileName="
483+ if fileName == "" {
484+ return "" , fmt .Errorf ("FileName comment found but no filename specified" )
485+ }
486+ // Validate filename (basic validation - no path separators)
487+ if strings .ContainsAny (fileName , "/\\ " ) {
488+ return "" , fmt .Errorf ("FileName '%s' cannot contain path separators" , 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+ expected := "." + sectionName
507+ if systemdquadlet .IsExtSupported ("a" + expected ) {
508+ return expected , nil
509+ }
510+ }
511+ }
512+ return "" , fmt .Errorf ("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])" )
513+ }
514+
328515// buildAppMap scans the given directory for files that start with '.'
329516// and end with '.app', reads their contents (one filename per line), and
330517// returns a map where each filename maps to the .app file that contains it.
0 commit comments