Coverage for dlabel/api.py: 65%

118 statements  

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

1import io 

2import json 

3import tarfile 

4import tempfile 

5import time 

6from abc import ABCMeta, abstractmethod 

7from logging import getLogger 

8from typing import Any 

9 

10import crossplane 

11import docker 

12import jsonpointer 

13from fastapi import APIRouter, HTTPException 

14from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse 

15 

16from .compose import compose 

17from .dockerfile import get_dockerfile 

18from .traefik import traefik2nginx, traefik_dump 

19from .traefik_conf import TraefikConfig 

20 

21_log = getLogger(__name__) 

22 

23 

24class CommonRoute(metaclass=ABCMeta): 

25 def __init__(self, client: docker.DockerClient): 

26 self.client = client 

27 self.router = APIRouter() 

28 kwargs = { 

29 "response_model_exclude_none": True, 

30 "response_model_exclude_unset": True, 

31 } 

32 self.router.add_api_route("/", self.getroot, methods=["GET"], **kwargs) 

33 self.router.add_api_route( 

34 "/{path:path}", self.getsub, methods=["GET"], **kwargs 

35 ) 

36 

37 @abstractmethod 

38 def getroot(self, **kwargs) -> dict: 

39 raise NotImplementedError("GET /: not implemented") 

40 

41 def subpath(self, obj: dict, path: str) -> Any: 

42 try: 

43 res = jsonpointer.resolve_pointer(obj, "/" + path) 

44 if isinstance(res, (int, str)): 

45 return PlainTextResponse(content=str(res)) 

46 return JSONResponse(content=res) 

47 except jsonpointer.JsonPointerException as e: 

48 raise HTTPException( 

49 status_code=404, detail={"path": path, "message": e.args[0]} 

50 ) 

51 

52 @abstractmethod 

53 def getsub(self, path: str, **kwargs) -> Any: 

54 return self.subpath(self.getroot(**kwargs), path=path) 

55 

56 

57class ComposeRoute(CommonRoute): 

58 def getroot(self, project: str | None = None) -> dict: 

59 try: 

60 g = compose(self.client, project=project, volume=False) 

61 while True: 

62 _ = next(g) 

63 except StopIteration as e: 

64 return e.value 

65 

66 def getsub(self, path: str, project: str | None = None) -> Any: 

67 if path == "_tar": 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true

68 return self.getarchive(project) 

69 return super().getsub(path, project=project) 

70 

71 def getarchive(self, project: str | None = None): 

72 def arc(): 

73 yield "hello" 

74 

75 return StreamingResponse(arc, media_type="application/x-tar") 

76 

77 

78class DockerfileRoute: 

79 def __init__(self, client: docker.DockerClient): 

80 self.client = client 

81 self.router = APIRouter() 

82 kwargs = { 

83 "response_model_exclude_none": True, 

84 "response_model_exclude_unset": True, 

85 } 

86 self.router.add_api_route("/", self.getroot, methods=["GET"], **kwargs) 

87 self.router.add_api_route( 

88 "/{container:path}/Dockerfile", 

89 self.get_dockerfile, 

90 methods=["GET"], 

91 response_class=PlainTextResponse, 

92 **kwargs, 

93 ) 

94 self.router.add_api_route( 

95 "/{container:path}/archive.tar", 

96 self.get_archive, 

97 methods=["GET"], 

98 response_class=StreamingResponse, 

99 responses={ 

100 200: { 

101 "content": {"application/x-tar": {}}, 

102 } 

103 }, 

104 **kwargs, 

105 ) 

106 

107 def getroot(self) -> list[str]: 

108 # list containers 

109 return [x.name for x in self.client.containers.list()] 

110 

111 def get_dockerfile( 

112 self, container: str, ignore: list[str] | None = None, labels: bool = False 

113 ) -> Any: 

114 # get dockerfile 

115 ctn = self.client.containers.get(container) 

116 for _, bin in get_dockerfile(ctn, ignore or [], labels, do_output=False): 

117 return PlainTextResponse(bin.decode()) 

118 

119 def get_archive( 

120 self, container: str, ignore: list[str] | None = None, labels: bool = False 

121 ): 

122 ctn = self.client.containers.get(container) 

123 

124 def arc(): 

125 ofp = io.BytesIO() 

126 osk = ofp.tell() 

127 with tarfile.open(mode="w", fileobj=ofp, format=tarfile.GNU_FORMAT) as tf: 

128 for name, bin in get_dockerfile( 

129 ctn, ignore or [], labels, do_output=True 

130 ): 

131 ti = tarfile.TarInfo(name) 

132 ti.mode = 0o644 

133 ti.mtime = time.time() 

134 ti.size = len(bin) 

135 tf.addfile(ti, io.BytesIO(bin)) 

136 _log.info("addfile %s, size=%s", name, len(bin)) 

137 if osk != ofp.tell(): 

138 ofp.seek(osk) 

139 yield ofp.read() 

140 osk = ofp.tell() 

141 if osk != ofp.tell(): 

142 ofp.seek(osk) 

143 yield ofp.read() 

144 _log.info("finished: %s", container) 

145 

146 return StreamingResponse(arc(), media_type="application/x-tar") 

147 

148 

149class TraefikRoute(CommonRoute): 

150 def getroot(self) -> TraefikConfig: 

151 return traefik_dump(self.client) 

152 

153 def getsub(self, path: str) -> Any: 

154 return self.subpath(self.getroot().to_dict(), path=path) 

155 

156 

157class NginxRoute(CommonRoute): 

158 base_url = "http://localhost/" 

159 

160 def __init__(self, client: docker.DockerClient): 

161 self.client = client 

162 self.router = APIRouter() 

163 self.router.add_api_route( 

164 "/", self.getroot, methods=["GET"], response_class=PlainTextResponse 

165 ) 

166 self.router.add_api_route("/json", self.getplane, methods=["GET"]) 

167 self.router.add_api_route( 

168 "/json/{path:path}", self.getplanesub, methods=["GET"] 

169 ) 

170 

171 def getroot(self, ipaddr: bool = True) -> PlainTextResponse: 

172 tmp = io.StringIO() 

173 traefik2nginx( 

174 traefik_dump(self.client), 

175 tmp, 

176 baseconf=None, 

177 server_url=self.base_url, 

178 ipaddr=ipaddr, 

179 ) 

180 return PlainTextResponse(tmp.getvalue()) 

181 

182 def getplane(self, ipaddr: bool = True) -> dict: 

183 with tempfile.NamedTemporaryFile("r+") as tf: 

184 traefik2nginx( 

185 traefik_dump(self.client), 

186 tf, 

187 baseconf=None, 

188 server_url=self.base_url, 

189 ipaddr=ipaddr, 

190 ) 

191 tf.flush() 

192 res = crossplane.parse(tf.name, combine=True) 

193 return json.loads( 

194 json.dumps(res).replace('"' + tf.name + '"', '"nginx.conf"') 

195 ) 

196 

197 def getplanesub(self, path: str, ipaddr: bool = True) -> dict: 

198 return self.subpath(self.getplane(ipaddr), path) 

199 

200 def getsub(self, path): 

201 pass