11package containers
22
33import (
4+ "bytes"
5+ "encoding/xml"
46 "fmt"
57 "io"
68 "net/http"
@@ -553,15 +555,39 @@ func injectDocBase(xmlContent string, docBase string) string {
553555 return xmlContent [:idx ] + newContextTag + xmlContent [endIdx :]
554556}
555557
558+ // contextXMLFilename converts a context path to a Tomcat context XML filename.
559+ // Tomcat convention: /foo/bar → foo#bar.xml, / or empty → ROOT.xml
560+ func contextXMLFilename (contextPath string ) string {
561+ name := strings .Trim (contextPath , "/" )
562+ if name == "" {
563+ return "ROOT.xml"
564+ }
565+ name = strings .ReplaceAll (name , "/" , "#" )
566+ return name + ".xml"
567+ }
568+
556569// Finalize performs final Tomcat configuration
557570func (t * TomcatContainer ) Finalize () error {
558571 t .context .Log .BeginStep ("Finalizing Tomcat" )
559572
573+ if t .config == nil {
574+ var err error
575+ t .config , err = t .loadConfig ()
576+ if err != nil {
577+ return fmt .Errorf ("failed to load tomcat config: %w" , err )
578+ }
579+ }
580+
560581 buildDir := t .context .Stager .BuildDir ()
561- contextXMLPath := filepath .Join (t .tomcatDir (), "conf" , "Catalina" , "localhost" , "ROOT.xml" )
582+ contextXMLName := contextXMLFilename (t .config .Tomcat .ContextPath )
583+ contextXMLPath := filepath .Join (t .tomcatDir (), "conf" , "Catalina" , "localhost" , contextXMLName )
562584
563585 webInf := filepath .Join (buildDir , "WEB-INF" )
564- if _ , err := os .Stat (webInf ); err == nil {
586+ _ , webInfErr := os .Stat (webInf )
587+ if webInfErr != nil && ! os .IsNotExist (webInfErr ) {
588+ return fmt .Errorf ("failed to check WEB-INF directory: %w" , webInfErr )
589+ }
590+ if webInfErr == nil {
565591 // the script name is prefixed with 'zzz' as it is important to be the last script sourced from profile.d
566592 // so that the previous scripts assembling the CLASSPATH variable(left from frameworks) are sourced previous to it.
567593 if err := t .context .Stager .WriteProfileD ("zzz_classpath_symlinks.sh" , fmt .Sprintf (symlinkScript , filepath .Join ("WEB-INF" , "lib" ))); err != nil {
@@ -573,27 +599,89 @@ func (t *TomcatContainer) Finalize() error {
573599 return fmt .Errorf ("failed to create context directory: %w" , err )
574600 }
575601
576- appContextXML := filepath .Join (buildDir , "META-INF" , "context.xml" )
577- var contextContent string
578-
579- if _ , err := os .Stat (appContextXML ); err == nil {
580- xmlBytes , err := os .ReadFile (appContextXML )
581- if err != nil {
582- return fmt .Errorf ("failed to read META-INF/context.xml: %w" , err )
602+ _ , statErr := os .Stat (contextXMLPath )
603+ if statErr != nil && ! os .IsNotExist (statErr ) {
604+ return fmt .Errorf ("failed to check context XML %s: %w" , contextXMLName , statErr )
605+ }
606+ if os .IsNotExist (statErr ) {
607+ appContextXML := filepath .Join (buildDir , "META-INF" , "context.xml" )
608+ var contextContent string
609+
610+ _ , appStatErr := os .Stat (appContextXML )
611+ if appStatErr != nil && ! os .IsNotExist (appStatErr ) {
612+ // A non-IsNotExist error (permissions/IO) must not be silently
613+ // treated as "no context.xml" — that would drop user-provided
614+ // Realm/Resource config. Surface it.
615+ return fmt .Errorf ("failed to check META-INF/context.xml: %w" , appStatErr )
616+ }
617+ if appStatErr == nil {
618+ xmlBytes , err := os .ReadFile (appContextXML )
619+ if err != nil {
620+ return fmt .Errorf ("failed to read META-INF/context.xml: %w" , err )
621+ }
622+
623+ xmlStr := string (xmlBytes )
624+ xmlStr = strings .TrimSpace (xmlStr )
625+
626+ contextContent = injectDocBase (xmlStr , "${user.home}/app" )
627+ t .context .Log .Info ("Merged META-INF/context.xml with %s - realm and resource configurations preserved" , contextXMLName )
628+ } else {
629+ contextContent = fmt .Sprintf ("<Context docBase=\" ${user.home}/app\" reloadable=\" false\" >\n </Context>\n " )
630+ t .context .Log .Info ("Created %s with docBase pointing to application directory" , contextXMLName )
583631 }
584632
585- xmlStr := string (xmlBytes )
586- xmlStr = strings .TrimSpace (xmlStr )
587-
588- contextContent = injectDocBase (xmlStr , "${user.home}/app" )
589- t .context .Log .Info ("Merged META-INF/context.xml with ROOT.xml - realm and resource configurations preserved" )
633+ if err := os .WriteFile (contextXMLPath , []byte (contextContent ), 0644 ); err != nil {
634+ return fmt .Errorf ("failed to write %s: %w" , contextXMLName , err )
635+ }
590636 } else {
591- contextContent = fmt .Sprintf ("<Context docBase=\" ${user.home}/app\" reloadable=\" false\" >\n </Context>\n " )
592- t .context .Log .Info ("Created ROOT.xml with docBase pointing to application directory" )
637+ t .context .Log .Info ("Context XML %s already exists (e.g. from external config), skipping generation" , contextXMLName )
638+ }
639+ if contextXMLName != "ROOT.xml" {
640+ rootXMLPath := filepath .Join (t .tomcatDir (), "conf" , "Catalina" , "localhost" , "ROOT.xml" )
641+ if err := os .Remove (rootXMLPath ); err != nil && ! os .IsNotExist (err ) {
642+ return fmt .Errorf ("failed to remove ROOT.xml: %w" , err )
643+ }
644+ }
645+ } else {
646+ warMatches , err := filepath .Glob (filepath .Join (buildDir , "*.war" ))
647+ if err != nil {
648+ return fmt .Errorf ("failed to find WAR files in build directory: %w" , err )
593649 }
650+ if len (warMatches ) == 1 {
651+ warFilename := filepath .Base (warMatches [0 ])
652+ // Escape XML-sensitive characters (&, <, >, ", ') so a WAR filename
653+ // containing them cannot produce an invalid Tomcat context descriptor.
654+ var escapedWarFilename bytes.Buffer
655+ if err := xml .EscapeText (& escapedWarFilename , []byte (warFilename )); err != nil {
656+ return fmt .Errorf ("failed to XML-escape WAR filename %q: %w" , warFilename , err )
657+ }
658+ contextContent := fmt .Sprintf ("<Context docBase=\" ${user.home}/app/%s\" reloadable=\" false\" >\n </Context>\n " , escapedWarFilename .String ())
659+
660+ contextXMLDir := filepath .Dir (contextXMLPath )
661+ if err := os .MkdirAll (contextXMLDir , 0755 ); err != nil {
662+ return fmt .Errorf ("failed to create context directory: %w" , err )
663+ }
594664
595- if err := os .WriteFile (contextXMLPath , []byte (contextContent ), 0644 ); err != nil {
596- return fmt .Errorf ("failed to write ROOT.xml: %w" , err )
665+ _ , statErr := os .Stat (contextXMLPath )
666+ if statErr != nil && ! os .IsNotExist (statErr ) {
667+ return fmt .Errorf ("failed to check context XML %s: %w" , contextXMLName , statErr )
668+ }
669+ if os .IsNotExist (statErr ) {
670+ if err := os .WriteFile (contextXMLPath , []byte (contextContent ), 0644 ); err != nil {
671+ return fmt .Errorf ("failed to write %s: %w" , contextXMLName , err )
672+ }
673+ t .context .Log .Info ("Created %s with docBase pointing to %s" , contextXMLName , warFilename )
674+ } else {
675+ t .context .Log .Info ("Context XML %s already exists (e.g. from external config), skipping generation" , contextXMLName )
676+ }
677+ if contextXMLName != "ROOT.xml" {
678+ rootXMLPath := filepath .Join (t .tomcatDir (), "conf" , "Catalina" , "localhost" , "ROOT.xml" )
679+ if err := os .Remove (rootXMLPath ); err != nil && ! os .IsNotExist (err ) {
680+ return fmt .Errorf ("failed to remove ROOT.xml: %w" , err )
681+ }
682+ }
683+ } else if len (warMatches ) > 1 {
684+ t .context .Log .Warning ("Multiple WAR files found in build directory; cannot determine which to deploy, skipping context descriptor generation" )
597685 }
598686 }
599687
@@ -647,6 +735,7 @@ type tomcatConfig struct {
647735type Tomcat struct {
648736 Version string `yaml:"version"`
649737 ExternalConfigurationEnabled bool `yaml:"external_configuration_enabled"`
738+ ContextPath string `yaml:"context_path"`
650739}
651740
652741type ExternalConfiguration struct {
0 commit comments