Coverage for dlabel/compose.py: 75%

165 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 23:07 +0000

1import fnmatch 

2import io 

3import tarfile 

4from logging import getLogger 

5from pathlib import Path 

6from typing import Any 

7 

8import docker 

9import yaml 

10 

11from .util import download_files 

12 

13_log = getLogger(__name__) 

14 

15 

16def envlist2map(env: list[str], sep: str = "=") -> dict[str, str]: 

17 res = {} 

18 for i in env: 

19 kv = i.split(sep, 1) 

20 if len(kv) == 2: 20 ↛ 18line 20 didn't jump to line 18 because the condition on line 20 was always true

21 res[kv[0]] = kv[1] 

22 return res 

23 

24 

25def portmap2compose(pmap: dict) -> list[str | dict]: 

26 res: list[str | dict] = [] 

27 for k, v in pmap.items(): 

28 ctport = k 

29 if ctport.endswith("/tcp") and len(v) == 1: 

30 ctport = k.split("/")[0] 

31 hostip = v[0].get("HostIp") 

32 hostport = v[0].get("HostPort") 

33 if hostip: 

34 res.append(f"{hostip}:{hostport}:{ctport}") 

35 else: 

36 res.append(f"{hostport}:{ctport}") 

37 else: 

38 target, protocol = k.split("/", 1) 

39 res.append( 

40 { 

41 "target": int(target), 

42 "published": int(v[0].get("HostPort")), 

43 "protocol": protocol, 

44 "mode": "host", 

45 } 

46 ) 

47 return res 

48 

49 

50def convdict(convmap: dict[str, str], fromdict: dict[str, Any], todict: dict[str, Any]): 

51 for k, v in convmap.items(): 

52 if fromdict.get(k): 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 todict[v] = fromdict.get(k) 

54 

55 

56def convdict_differ( 

57 convmap: dict[str, str], 

58 dict_img: dict[str, Any], 

59 dict_ctn: dict[str, Any], 

60 todict: dict[str, Any], 

61): 

62 for k, v in convmap.items(): 

63 if k in dict_ctn and dict_img.get(k) != dict_ctn.get(k): 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 todict[v] = dict_ctn[k] 

65 

66 

67def copy_files( 

68 ctn: docker.models.containers.Container, src: str | Path, dst: str | Path 

69): 

70 def tfilter(member, path): 

71 res = tarfile.data_filter(member, path) 

72 if res and "/" in res.name: 

73 _, res.name = res.name.split("/", 1) 

74 return res 

75 return None 

76 

77 _log.info("copy %s:%s -> %s", ctn.name, src, dst) 

78 odir = Path(dst) 

79 bin, arc = ctn.get_archive(str(src)) 

80 _log.debug("arc=%s", arc) 

81 bio = io.BytesIO() 

82 for x in bin: 

83 bio.write(x) 

84 bio.seek(0) 

85 tf = tarfile.TarFile(fileobj=bio) 

86 members = tf.getmembers() 

87 if len(members) == 1 and members[0].isreg(): 

88 _log.info("single file: %s", members[0]) 

89 tf.extractall(odir.parent, filter="data") 

90 else: 

91 odir.mkdir(exist_ok=True, parents=True) 

92 tf.extractall(odir, filter=tfilter) 

93 tf.close() 

94 bio.close() 

95 

96 

97def compose(client: docker.DockerClient, project, volume): 

98 """generate docker-compose.yml from running containers""" 

99 svcs = {} 

100 vols = {} 

101 nets: dict[str, Any] = {} 

102 for ctn in client.containers.list(): 

103 config = ctn.attrs.get("Config", {}) 

104 hostconfig = ctn.attrs.get("HostConfig", {}) 

105 labels: dict[str, str] = config.get("Labels", {}) 

106 proj = labels.get("com.docker.compose.project") 

107 wdir = Path(labels.get("com.docker.compose.project.working_dir", "/")) 

108 if project and not proj: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true

109 _log.debug("skip: no project: %s", ctn.name) 

110 continue 

111 if project and proj and not fnmatch.fnmatch(proj, project): 

112 _log.debug("skip by project (%s)", proj) 

113 continue 

114 name = labels.get("com.docker.compose.service", ctn.name) 

115 _log.info("processing %s, service=%s", ctn.name, name) 

116 img = ctn.image 

117 imglabel = img.labels 

118 imgconfig = img.attrs.get("Config", {}) 

119 for k, v in imglabel.items(): 

120 if labels.get(k) == v: 

121 labels.pop(k) 

122 labels = { 

123 k: v for k, v in labels.items() if not k.startswith("com.docker.compose.") 

124 } 

125 envs = envlist2map(config.get("Env", [])) 

126 imgenv = envlist2map(imgconfig.get("Env", [])) 

127 for k, v in imgenv.items(): 

128 if envs.get(k) == v: 

129 envs.pop(k) 

130 imgvol = imgconfig.get("Volumes", {}) 

131 cvols = [] 

132 for i in hostconfig.get("Binds") or []: 

133 v = i.split(":", 2) 

134 if imgvol and v[1] in imgvol: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 continue 

136 src = Path(v[0]) 

137 dest = v[1] 

138 if src.is_relative_to(wdir): 

139 srcstr = "./" + str(src.relative_to(wdir)) 

140 else: 

141 srcstr = str(src) 

142 if len(v) == 2 or v[2] == "rw": 

143 cvols.append(f"{srcstr}:{dest}") 

144 elif len(v) == 3: 144 ↛ 146line 144 didn't jump to line 146 because the condition on line 144 was always true

145 cvols.append(f"{srcstr}:{dest}:{v[2]}") 

146 if volume and srcstr.startswith("./"): 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 for is_dir, tinfo, bin in download_files(ctn, dest): 

148 _log.debug( 

149 "read from volume: src=%s, is_dir=%s, name=%s, %s bytes", 

150 srcstr, 

151 is_dir, 

152 tinfo.name, 

153 len(bin), 

154 ) 

155 yield Path(srcstr) / ".." / tinfo.name, bin 

156 elif volume: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 _log.info("skip copy: %s:%s -> %s", name, dest, srcstr) 

158 for m in hostconfig.get("Mounts", []): 

159 if imgvol and m.get("Target") in imgvol: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 continue 

161 volname = m.get("Source") 

162 if proj and volname.startswith(proj + "_"): 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was always true

163 volname = volname[len(proj) + 1 :] 

164 if m.get("Type") == "volume": 164 ↛ 166line 164 didn't jump to line 166 because the condition on line 164 was always true

165 vols[volname] = m.get("VolumeOptions", {}) 

166 if m.get("Target"): 166 ↛ 158line 166 didn't jump to line 158 because the condition on line 166 was always true

167 cvols.append(f"{volname}:{m['Target']}") 

168 nwmode: str | None = None 

169 cnws = [] 

170 if not proj or hostconfig.get("NetworkMode") != f"{proj}_default": 170 ↛ 172line 170 didn't jump to line 172 because the condition on line 170 was always true

171 nwmode = hostconfig.get("NetworkMode") 

172 if isinstance(nwmode, str) and nwmode not in ("host", "none"): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true

173 nets[nwmode] = {} 

174 cnws.append(nwmode) 

175 nwmode = None 

176 svc = { 

177 "image": config.get("Image"), 

178 } 

179 if proj and not ctn.name.startswith(proj + "_"): 

180 svc["container_name"] = ctn.name 

181 if nwmode: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 svc["network_mode"] = nwmode 

183 if cvols: 

184 svc["volumes"] = cvols 

185 if cnws: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 svc["networks"] = cnws 

187 if hostconfig.get("PortBindings"): 

188 svc["ports"] = portmap2compose(hostconfig.get("PortBindings", {})) 

189 if hostconfig.get("RestartPolicy", {}).get("Name") not in ("no", None): 

190 svc["restart"] = hostconfig.get("RestartPolicy", {}).get("Name") 

191 if labels: 

192 svc["labels"] = labels 

193 if envs: 

194 svc["environment"] = envs 

195 convmap_hostconfig = { 

196 "ExtraHosts": "extra_hosts", 

197 "CpuShares": "cpu_shares", 

198 "CpuPeriod": "cpu_period", 

199 "CpuPercent": "cpu_percent", 

200 "CpuCount": "cpu_count", 

201 "CpuQuota": "cpu_quota", 

202 "CpuRealtimeRuntime": "cpu_rt_runtime", 

203 "CpuRealtimePeriod": "cpu_rt_period", 

204 "CpusetCpus": "cpuset", 

205 "CapAdd": "cap_add", 

206 "CapDrop": "cap_drop", 

207 "CgroupParent": "cgroup_parent", 

208 "GroupAdd": "group_add", 

209 "Privileged": "privileged", 

210 } 

211 convmap_label = { 

212 "com.docker.compose.depends_on": "depends_on", 

213 } 

214 convdict(convmap_hostconfig, hostconfig, svc) 

215 convdict(convmap_label, config.get("Labels", {}), svc) 

216 diffcopy_config = { 

217 "Cmd": "command", 

218 "Entrypoint": "entrypoint", 

219 } 

220 convdict_differ(diffcopy_config, imgconfig, config, svc) 

221 svcs[name] = svc 

222 res = {} 

223 if svcs: 

224 res["services"] = svcs 

225 if vols: 

226 res["volumes"] = vols 

227 if nets: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true

228 res["networks"] = nets 

229 yield ( 

230 Path("compose.yml"), 

231 yaml.dump(res, allow_unicode=True, encoding="utf-8", sort_keys=False), 

232 ) 

233 return res