@@ -13,6 +13,7 @@ import (
1313 "html/template"
1414 "io"
1515 "io/fs"
16+ "maps"
1617 "net"
1718 "net/http"
1819 "os"
@@ -322,8 +323,12 @@ func (a *App) Reload(ctx context.Context, force, immediate bool, dryRun types.Dr
322323 return false , nil
323324 }
324325
325- if requestTime .Compare (a .reloadStartTime ) == - 1 {
326- // Current request is older than the last reloaded request, ignore
326+ if a .initialized && requestTime .Compare (a .reloadStartTime ) == - 1 {
327+ // Current request is older than the last reloaded request, ignore.
328+ // Applies to initialized apps only (coalescing file-change reloads):
329+ // while the app is uninitialized, a request queued behind a reload
330+ // that then FAILED would otherwise be reported as success here, and
331+ // the caller would serve the app with no router built (nil panic)
327332 a .Info ().Msg ("Ignoring reload request since it is older than the last reload request" )
328333 return false , nil
329334 }
@@ -582,6 +587,15 @@ func (a *App) loadContainerManager(ctx context.Context, stripAppPath bool) error
582587 return fmt .Errorf ("error reading cargs: %w" , err )
583588 }
584589
590+ devSettingsMap , err := apptype .GetDictAttr (configAttr , "dev_settings" , true )
591+ if err != nil {
592+ return fmt .Errorf ("error reading dev_settings: %w" , err )
593+ }
594+ devSettings , err := parseDevSettings (devSettingsMap )
595+ if err != nil {
596+ return fmt .Errorf ("error parsing dev_settings: %w" , err )
597+ }
598+
585599 // Parse the source file specification
586600 var fileName string
587601 switch src {
@@ -631,14 +645,127 @@ func (a *App) loadContainerManager(ctx context.Context, stripAppPath bool) error
631645 a .containerHandler , err = NewContainerHandler (a .Logger , a ,
632646 fileName , a .serverConfig , portInt , lifetime , scheme , health , buildDir ,
633647 a .sourceFS , a .paramValuesStr , a .AppConfig .Container , stripAppPath , volumes ,
634- a .getSecretsAllowed ("container.in" , "config" ), cargs , a .bindings )
648+ a .getSecretsAllowed ("container.in" , "config" ), cargs , a .bindings , devSettings )
635649 if err != nil {
636650 return fmt .Errorf ("error creating container handler: %w" , err )
637651 }
638652
639653 return nil
640654}
641655
656+ // parseDevSettings converts the dev_settings dict from container.config into a
657+ // DevSettings struct. Returns nil when no dev_settings were specified, which
658+ // keeps the original dev reload behavior (full image rebuild on every change).
659+ func parseDevSettings (m map [string ]any ) (* types.DevSettings , error ) {
660+ if len (m ) == 0 {
661+ return nil , nil
662+ }
663+
664+ // Every key must be consumed by one of the getters below; a leftover key
665+ // (allowed by the plugin's validation but not parsed here) fails the load
666+ // instead of being silently dropped
667+ consumed := map [string ]bool {}
668+ getString := func (key string ) (string , error ) {
669+ consumed [key ] = true
670+ v , ok := m [key ]
671+ if ! ok {
672+ return "" , nil
673+ }
674+ s , ok := v .(string )
675+ if ! ok {
676+ return "" , fmt .Errorf ("dev_settings %s must be a string" , key )
677+ }
678+ return s , nil
679+ }
680+ getStringList := func (key string ) ([]string , error ) {
681+ consumed [key ] = true
682+ v , ok := m [key ]
683+ if ! ok {
684+ return nil , nil
685+ }
686+ switch list := v .(type ) {
687+ case []string :
688+ return list , nil
689+ case []any :
690+ ret := make ([]string , 0 , len (list ))
691+ for _ , entry := range list {
692+ s , ok := entry .(string )
693+ if ! ok {
694+ return nil , fmt .Errorf ("dev_settings %s must be a list of strings" , key )
695+ }
696+ ret = append (ret , s )
697+ }
698+ return ret , nil
699+ default :
700+ return nil , fmt .Errorf ("dev_settings %s must be a list of strings" , key )
701+ }
702+ }
703+
704+ ds := & types.DevSettings {}
705+ var err error
706+ if ds .Target , err = getString ("target" ); err != nil {
707+ return nil , err
708+ }
709+ if ds .Command , err = getString ("command" ); err != nil {
710+ return nil , err
711+ }
712+ if ds .Dir , err = getString ("dir" ); err != nil {
713+ return nil , err
714+ }
715+ if ds .Reload , err = getString ("reload" ); err != nil {
716+ return nil , err
717+ }
718+ if ds .EnvFiles , err = getStringList ("env_files" ); err != nil {
719+ return nil , err
720+ }
721+ if ds .AdditionalMounts , err = getStringList ("additional_mounts" ); err != nil {
722+ return nil , err
723+ }
724+ consumed ["port" ] = true
725+ if v , ok := m ["port" ]; ok {
726+ var port int64
727+ switch p := v .(type ) {
728+ case int :
729+ port = int64 (p )
730+ case int64 :
731+ port = p
732+ default :
733+ return nil , fmt .Errorf ("dev_settings port must be an integer higher than or equal to zero" )
734+ }
735+ if port < 0 {
736+ return nil , fmt .Errorf ("dev_settings port must be an integer higher than or equal to zero" )
737+ }
738+ port32 , err := types .Int64ToInt32 (port )
739+ if err != nil {
740+ return nil , fmt .Errorf ("dev_settings port: %w" , err )
741+ }
742+ ds .Port = port32
743+ }
744+
745+ for _ , key := range slices .Sorted (maps .Keys (m )) {
746+ if ! consumed [key ] {
747+ return nil , fmt .Errorf ("unsupported dev_settings key %q" , key )
748+ }
749+ }
750+
751+ if ds .Reload == "" {
752+ ds .Reload = types .DEV_RELOAD_RESTART
753+ }
754+ switch ds .Reload {
755+ case types .DEV_RELOAD_NONE , types .DEV_RELOAD_RESTART , types .DEV_RELOAD_RECREATE :
756+ default :
757+ return nil , fmt .Errorf ("dev_settings reload must be one of none, restart, recreate; got %q" , ds .Reload )
758+ }
759+ if ds .Dir == "" {
760+ return nil , fmt .Errorf ("dev_settings dir must be set, it is the directory where the app source is mounted in the container" )
761+ }
762+ if ! strings .HasPrefix (ds .Dir , "/" ) {
763+ return nil , fmt .Errorf ("dev_settings dir must be an absolute path inside the container" )
764+ }
765+
766+ return ds , nil
767+ }
768+
642769func (a * App ) executeTemplate (w io.Writer , template , partial string , data any ) error {
643770 var err error
644771 if a .template != nil {
0 commit comments