2727import tempfile
2828import urllib .parse
2929from asyncio import Task
30+ from dataclasses import dataclass
3031from enum import Enum
3132from typing import Any
3233from typing import Callable
@@ -1557,6 +1558,17 @@ async def wait_with_timeout(coro: Any, timeout: int | float) -> Any:
15571558###################
15581559
15591560
1561+ @dataclass
1562+ class ExistingContainer :
1563+ name : str
1564+ id : str
1565+ service_name : str
1566+ config_hash : str
1567+ exited : bool
1568+ state : str
1569+ status : str
1570+
1571+
15601572class Podman :
15611573 def __init__ (
15621574 self ,
@@ -1739,6 +1751,36 @@ async def volume_ls(self) -> list[str]:
17391751 volumes = output .splitlines ()
17401752 return volumes
17411753
1754+ async def existing_containers (self , project_name : str ) -> dict [str , ExistingContainer ]:
1755+ output = await self .output (
1756+ [],
1757+ "ps" ,
1758+ [
1759+ "--filter" ,
1760+ f"label=io.podman.compose.project={ project_name } " ,
1761+ "-a" ,
1762+ "--format" ,
1763+ "json" ,
1764+ ],
1765+ )
1766+
1767+ containers = json .loads (output )
1768+ return {
1769+ c .get ("Names" )[0 ]: ExistingContainer (
1770+ name = c .get ("Names" )[0 ],
1771+ id = c .get ("Id" ),
1772+ service_name = (
1773+ c .get ("Labels" , {}).get ("io.podman.compose.service" , "" )
1774+ or c .get ("Labels" , {}).get ("com.docker.compose.service" , "" )
1775+ ),
1776+ config_hash = c .get ("Labels" , {}).get ("io.podman.compose.config-hash" , "" ),
1777+ exited = c .get ("Exited" , False ),
1778+ state = c .get ("State" , "" ),
1779+ status = c .get ("Status" , "" ),
1780+ )
1781+ for c in containers
1782+ }
1783+
17421784
17431785def normalize_service (service : dict [str , Any ], sub_dir : str = "" ) -> dict [str , Any ]:
17441786 if isinstance (service , ResetTag ):
@@ -2091,6 +2133,27 @@ async def run(self, argv: list[str] | None = None) -> None:
20912133 if isinstance (retcode , int ):
20922134 sys .exit (retcode )
20932135
2136+ def config_hash (self , service : dict [str , Any ]) -> str :
2137+ """
2138+ Returns a hash of the service configuration.
2139+ This is used to detect changes in the service configuration.
2140+ """
2141+ if "_config_hash" in service :
2142+ return service ["_config_hash" ]
2143+
2144+ # Use a stable representation of the service configuration
2145+ jsonable_servcie = self .original_service (service )
2146+ config_str = json .dumps (jsonable_servcie , sort_keys = True )
2147+ service ["_config_hash" ] = hashlib .sha256 (config_str .encode ('utf-8' )).hexdigest ()
2148+ return service ["_config_hash" ]
2149+
2150+ def original_service (self , service : dict [str , Any ]) -> dict [str , Any ]:
2151+ """
2152+ Returns the original service configuration without any overrides or resets.
2153+ This is used to compare the original service configuration with the current one.
2154+ """
2155+ return {k : v for k , v in service .items () if isinstance (k , str ) and not k .startswith ("_" )}
2156+
20942157 def resolve_pod_name (self ) -> str | None :
20952158 # Priorities:
20962159 # - Command line --in-pod
@@ -2387,7 +2450,6 @@ def _parse_compose_file(self) -> None:
23872450 # volumes: [...]
23882451 self .vols = compose .get ("volumes" , {})
23892452 podman_compose_labels = [
2390- "io.podman.compose.config-hash=" + self .yaml_hash ,
23912453 "io.podman.compose.project=" + project_name ,
23922454 "io.podman.compose.version=" + __version__ ,
23932455 f"PODMAN_SYSTEMD_UNIT=podman-compose@{ project_name } .service" ,
@@ -2445,6 +2507,7 @@ def _parse_compose_file(self) -> None:
24452507 cnt ["ports" ] = norm_ports (cnt .get ("ports" ))
24462508 labels .extend (podman_compose_labels )
24472509 labels .extend ([
2510+ f"io.podman.compose.config-hash={ self .config_hash (service_desc )} " ,
24482511 f"com.docker.compose.container-number={ num } " ,
24492512 f"io.podman.compose.service={ service_name } " ,
24502513 f"com.docker.compose.service={ service_name } " ,
@@ -3148,46 +3211,58 @@ async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int |
31483211
31493212 # if needed, tear down existing containers
31503213
3151- existing_containers : dict [str , str | None ] = {
3152- c ['Names' ][0 ]: c ['Labels' ].get ('io.podman.compose.config-hash' )
3153- for c in json .loads (
3154- await compose .podman .output (
3155- [],
3156- "ps" ,
3157- [
3158- "--filter" ,
3159- f"label=io.podman.compose.project={ compose .project_name } " ,
3160- "-a" ,
3161- "--format" ,
3162- "json" ,
3163- ],
3164- )
3165- )
3166- }
3214+ assert compose .project_name is not None , "Project name must be set before running up command"
3215+ existing_containers = await compose .podman .existing_containers (compose .project_name )
3216+ recreate_services : set [str ] = set ()
3217+ running_services = {c .service_name for c in existing_containers .values () if not c .exited }
31673218
3168- if len ( existing_containers ) > 0 :
3219+ if existing_containers :
31693220 if args .force_recreate and args .no_recreate :
31703221 log .error (
31713222 "Cannot use --force-recreate and --no-recreate at the same time, "
31723223 "please remove one of them"
31733224 )
31743225 return 1
31753226
3176- if args .force_recreate :
3177- teardown_needed = True
3178- elif args .no_recreate :
3179- teardown_needed = False
3180- else :
3181- # default is to tear down everything if any container is stale
3182- teardown_needed = (
3183- len ([h for h in existing_containers .values () if h != compose .yaml_hash ]) > 0
3184- )
3227+ if not args .no_recreate :
3228+ for c in existing_containers .values ():
3229+ if (
3230+ c .service_name in excluded
3231+ or c .service_name not in compose .services # orphaned container
3232+ ):
3233+ continue
3234+
3235+ service = compose .services [c .service_name ]
3236+ if args .force_recreate or c .config_hash != compose .config_hash (service ):
3237+ recreate_services .add (c .service_name )
3238+
3239+ # Running dependents of service are removed by down command
3240+ # so we need to recreate and start them too
3241+ dependents = {
3242+ dep .name
3243+ for dep in service .get (DependField .DEPENDENTS , [])
3244+ if dep .name in running_services
3245+ }
3246+ if dependents :
3247+ log .debug (
3248+ "Service %s's dependents should be recreated and running again: %s" ,
3249+ c .service_name ,
3250+ dependents ,
3251+ )
3252+ recreate_services .update (dependents )
3253+ excluded = excluded - dependents
3254+
3255+ log .debug ("** excluding update: %s" , excluded )
3256+ log .debug ("Prepare to recreate services: %s" , recreate_services )
3257+
3258+ teardown_needed = bool (recreate_services )
31853259
31863260 if teardown_needed :
31873261 log .info ("tearing down existing containers: ..." )
3188- down_args = argparse .Namespace (** dict (args .__dict__ , volumes = False , rmi = None ))
3262+ down_args = argparse .Namespace (
3263+ ** dict (args .__dict__ , volumes = False , rmi = None , services = recreate_services )
3264+ )
31893265 await compose .commands ["down" ](compose , down_args )
3190- existing_containers = {}
31913266 log .info ("tearing down existing containers: done\n \n " )
31923267
31933268 await create_pods (compose )
@@ -3196,7 +3271,9 @@ async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int |
31963271
31973272 create_error_codes : list [int | None ] = []
31983273 for cnt in compose .containers :
3199- if cnt ["_service" ] in excluded or cnt ["name" ] in existing_containers :
3274+ if cnt ["_service" ] in excluded or (
3275+ cnt ["name" ] in existing_containers and cnt ["_service" ] not in recreate_services
3276+ ):
32003277 log .debug ("** skipping create: %s" , cnt ["name" ])
32013278 continue
32023279 podman_args = await container_to_args (compose , cnt , detached = False , no_deps = args .no_deps )
0 commit comments