Coverage for harbor_cli/commands/api/artifact.py: 38%

105 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-09 12:09 +0100

1from __future__ import annotations 

2 

3from typing import Optional 

4 

5import typer 

6from harborapi.exceptions import NotFound 

7from harborapi.ext.api import get_artifact 

8from harborapi.ext.api import get_artifacts 

9from harborapi.models import Label 

10from harborapi.models import Tag 

11 

12from ...harbor.artifact import parse_artifact_name 

13from ...logs import logger 

14from ...output.console import console 

15from ...output.console import exit 

16from ...output.console import exit_err 

17from ...output.render import render_result 

18from ...state import state 

19from ...utils.commands import inject_resource_options 

20from ..help import ARTIFACT_HELP_STRING 

21 

22 

23# Create a command group 

24app = typer.Typer( 

25 name="artifact", 

26 help="Manage artifacts.", 

27 no_args_is_help=True, 

28) 

29 

30# Artifact subcommands 

31tag_cmd = typer.Typer( 

32 name="tag", 

33 help="Artifact tag commands", 

34 no_args_is_help=True, 

35) 

36label_cmd = typer.Typer( 

37 name="label", 

38 help="Artifact label commands", 

39 no_args_is_help=True, 

40) 

41 

42app.add_typer(tag_cmd) 

43app.add_typer(label_cmd) 

44 

45# get_artifacts() 

46@app.command("list") 

47@inject_resource_options() 

48def list_artifacts( 

49 ctx: typer.Context, 

50 query: Optional[str], 

51 project: str = typer.Argument( 

52 ..., 

53 help="Name of project to fetch artifacts from.", 

54 ), 

55 repo: Optional[str] = typer.Argument( 

56 None, 

57 help="Specific repository in project to fetch artifacts from.", 

58 ), 

59 tag: Optional[str] = typer.Option( 

60 None, 

61 "--tag", 

62 help="Limit to artifacts with this tag (e.g. 'latest').", 

63 ), 

64 with_report: bool = typer.Option( 

65 False, 

66 "--with-report", 

67 help="Include vulnerability report in output.", 

68 ), 

69 max_connections: int = typer.Option( 

70 5, 

71 "--max-connections", 

72 help=( 

73 "Maximum number of concurrent connections to use. " 

74 "Setting this too high can lead to severe performance degradation." 

75 ), 

76 ), 

77 # TODO: add ArtifactReport filtering options here 

78) -> None: 

79 """List artifacts in a project or in a specific repository.""" 

80 if "/" in project: 

81 logger.debug("Interpreting argument format as <project>/<repo>.") 

82 project, repo = project.split("/", 1) 

83 

84 # TODO: add pagination to output 

85 

86 if project and repo: 

87 repositories = [repo] 

88 else: 

89 repositories = None # all repos 

90 

91 artifacts = state.run( 

92 get_artifacts( 

93 state.client, 

94 projects=[project], 

95 repositories=repositories, 

96 tag=tag, 

97 query=query, 

98 ), 

99 "Fetching artifacts...", 

100 ) 

101 render_result(artifacts, ctx) 

102 logger.info(f"Fetched {len(artifacts)} artifact(s).") 

103 

104 

105# delete_artifact() 

106@app.command("delete", no_args_is_help=True) 

107def delete_artifact( 

108 ctx: typer.Context, 

109 artifact: str = typer.Argument( 

110 ..., 

111 help=ARTIFACT_HELP_STRING, 

112 ), 

113 force: bool = typer.Option( 

114 False, 

115 "--force", 

116 "-f", 

117 help="Force deletion without confirmation.", 

118 ), 

119) -> None: 

120 """Delete an artifact.""" 

121 an = parse_artifact_name(artifact) 

122 if not force: 

123 if not typer.confirm( 

124 f"Are you sure you want to delete {artifact}?", 

125 abort=True, 

126 ): 

127 exit() 

128 

129 try: 

130 state.run( 

131 state.client.delete_artifact(an.project, an.repository, an.reference), 

132 f"Deleting artifact {artifact}...", 

133 no_handle=NotFound, 

134 ) 

135 except NotFound: 

136 exit_err(f"Artifact {artifact} not found.") 

137 else: 

138 logger.info(f"Artifact {artifact} deleted.") 

139 

140 

141# copy_artifact() 

142@app.command("copy", no_args_is_help=True) 

143def copy_artifact( 

144 ctx: typer.Context, 

145 artifact: str = typer.Argument( 

146 ..., 

147 help=ARTIFACT_HELP_STRING, 

148 ), 

149 project: str = typer.Argument( 

150 ..., 

151 help="Destination project.", 

152 ), 

153 repository: str = typer.Argument( 

154 ..., 

155 help="Destination repository (without project name).", 

156 ), 

157) -> None: 

158 """Copy an artifact.""" 

159 

160 # Warn user if they pass a project name in the repository name 

161 # e.g. project="foo", repository="foo/bar" 

162 # When it should be project="foo", repository="bar" 

163 if project in repository: 

164 logger.warning( 

165 "Project name is part of the repository name, you likely don't want this." 

166 ) 

167 

168 try: 

169 resp = state.run( 

170 state.client.copy_artifact(project, repository, artifact), 

171 f"Copying artifact {artifact} to {project}/{repository}...", 

172 no_handle=NotFound, 

173 ) 

174 except NotFound: 

175 exit_err(f"Artifact {artifact} not found.") 

176 else: 

177 logger.info(f"Artifact {artifact} copied to {resp}.") 

178 

179 

180# HarborAsyncClient.get_artifact() 

181@app.command("get") 

182def get( 

183 ctx: typer.Context, 

184 artifact: str = typer.Argument( 

185 ..., 

186 help=ARTIFACT_HELP_STRING, 

187 ), 

188 with_vulnerabilities: bool = typer.Option( 

189 False, 

190 "--with-vuln", 

191 "-v", 

192 ), 

193 # TODO: --tag 

194) -> None: 

195 """Get information about a specific artifact.""" 

196 

197 an = parse_artifact_name(artifact) 

198 # Just use normal endpoint method for a single artifact 

199 artifact = state.run( 

200 state.client.get_artifact(an.project, an.repository, an.reference), # type: ignore 

201 f"Fetching artifact(s)...", 

202 ) 

203 render_result(artifact, ctx) 

204 

205 

206# HarborAsyncClient.get_artifact_tags() 

207@tag_cmd.command("list", no_args_is_help=True) 

208def list_artifact_tags( 

209 ctx: typer.Context, 

210 artifact: str = typer.Argument( 

211 ..., 

212 help=ARTIFACT_HELP_STRING, 

213 ), 

214) -> None: 

215 """List tags for an artifact.""" 

216 an = parse_artifact_name(artifact) 

217 tags = state.run( 

218 state.client.get_artifact_tags(an.project, an.repository, an.reference), 

219 f"Fetching tags for {an!r}...", 

220 ) 

221 

222 if not tags: 

223 exit_err(f"No tags found for {an!r}") 

224 

225 for tag in tags: 

226 console.print(tag) 

227 

228 

229# create_artifact_tag() 

230@tag_cmd.command("create", no_args_is_help=True) 

231def create_artifact_tag( 

232 ctx: typer.Context, 

233 artifact: str = typer.Argument( 

234 ..., 

235 help=ARTIFACT_HELP_STRING, 

236 ), 

237 tag: str = typer.Argument(..., help="Name of the tag to create."), 

238 # signed 

239 # immutable 

240) -> None: 

241 """Create a tag for an artifact.""" 

242 an = parse_artifact_name(artifact) 

243 # NOTE: We might need to fetch repo and artifact IDs 

244 t = Tag(name=tag) 

245 location = state.run( 

246 state.client.create_artifact_tag(an.project, an.repository, an.reference, t), 

247 f"Creating tag {tag!r} for {artifact}...", 

248 ) 

249 logger.info(f"Created {tag!r} for {artifact}: {location}") 

250 

251 

252# delete_artifact_tag() 

253@tag_cmd.command("delete", no_args_is_help=True) 

254def delete_artifact_tag( 

255 ctx: typer.Context, 

256 artifact: str = typer.Argument( 

257 ..., 

258 help=ARTIFACT_HELP_STRING, 

259 ), 

260 tag: str = typer.Argument(..., help="Name of the tag to delete."), 

261 force: bool = typer.Option( 

262 False, 

263 "--force", 

264 "-f", 

265 help="Force deletion without confirmation.", 

266 ), 

267) -> None: 

268 """Delete a tag for an artifact.""" 

269 an = parse_artifact_name(artifact) 

270 # NOTE: We might need to fetch repo and artifact IDs 

271 if not force: 

272 if not typer.confirm( 

273 f"Are you sure you want to delete the tag {tag!r}?", 

274 abort=True, 

275 ): 

276 exit() 

277 

278 state.run( 

279 state.client.delete_artifact_tag(an.project, an.repository, an.reference, tag), 

280 f"Deleting tag {tag!r} for {artifact}...", 

281 ) 

282 

283 

284# add_artifact_label() 

285@label_cmd.command("add", no_args_is_help=True) 

286def add_artifact_label( 

287 ctx: typer.Context, 

288 artifact: str = typer.Argument( 

289 ..., 

290 help=ARTIFACT_HELP_STRING, 

291 ), 

292 name: Optional[str] = typer.Option( 

293 None, 

294 "--name", 

295 help="Name of the label.", 

296 ), 

297 description: Optional[str] = typer.Option( 

298 None, 

299 "--description", 

300 help="Description of the label.", 

301 ), 

302 color: Optional[str] = typer.Option( 

303 None, 

304 "--color", 

305 help="Label color.", 

306 ), 

307 scope: Optional[str] = typer.Option( 

308 None, 

309 "--scope", 

310 help="Scope of the label.", 

311 ), 

312) -> None: 

313 """Add a label to an artifact.""" 

314 # TODO: add parameter validation. Name is probably required? 

315 # Otherwise, we can just leave validation to the API, and 

316 # print a default error message. 

317 an = parse_artifact_name(artifact) 

318 label = Label( 

319 name=name, 

320 description=description, 

321 color=color, 

322 scope=scope, 

323 ) 

324 state.run( 

325 state.client.add_artifact_label(an.project, an.repository, an.reference, label), 

326 f"Adding label {label.name!r} to {artifact}...", 

327 ) 

328 logger.info(f"Added label {label.name!r} to {artifact}.") 

329 

330 

331# HarborAsyncClient.delete_artifact_label() 

332@label_cmd.command("delete", no_args_is_help=True) 

333def delete_artifact_label( 

334 ctx: typer.Context, 

335 artifact: str = typer.Argument( 

336 ..., 

337 help=ARTIFACT_HELP_STRING, 

338 ), 

339 label_id: int = typer.Argument( 

340 ..., 

341 help="ID of the label to delete.", 

342 ), 

343) -> None: 

344 """Add a label to an artifact.""" 

345 an = parse_artifact_name(artifact) 

346 state.run( 

347 state.client.delete_artifact_label( 

348 an.project, an.repository, an.reference, label_id 

349 ), 

350 f"Deleting label {label_id} from {artifact}...", 

351 ) 

352 

353 

354# HarborAsyncClient.get_artifact_vulnerabilities() 

355# HarborAsyncClient.get_artifact_accessories() 

356@app.command("accessories") 

357def get_accessories( 

358 ctx: typer.Context, 

359 artifact: str = typer.Argument( 

360 ..., 

361 help=ARTIFACT_HELP_STRING, 

362 ), 

363) -> None: 

364 """Get accessories for an artifact.""" 

365 an = parse_artifact_name(artifact) 

366 accessories = state.run( 

367 state.client.get_artifact_accessories(an.project, an.repository, an.reference), 

368 f"Getting accessories for {artifact}...", 

369 ) 

370 render_result(accessories, ctx) 

371 

372 

373# HarborAsyncClient.get_artifact_build_history() 

374@app.command("buildhistory", no_args_is_help=True) 

375def get_buildhistory( 

376 ctx: typer.Context, 

377 artifact: str = typer.Argument( 

378 ..., 

379 help=ARTIFACT_HELP_STRING, 

380 ), 

381) -> None: 

382 """Get build history for an artifact.""" 

383 an = parse_artifact_name(artifact) 

384 history = state.run( 

385 state.client.get_artifact_build_history( 

386 an.project, an.repository, an.reference 

387 ), 

388 f"Getting build history for {artifact}...", 

389 ) 

390 render_result(history, ctx) 

391 

392 

393# harborapi.ext.api.get_artifact 

394@app.command("vulnerabilities") 

395def get_vulnerabilities( 

396 ctx: typer.Context, 

397 artifact: str = typer.Argument( 

398 ..., 

399 help=ARTIFACT_HELP_STRING, 

400 ), 

401) -> None: 

402 """Get vulnerabilities for an artifact.""" 

403 an = parse_artifact_name(artifact) 

404 a = state.run( 

405 get_artifact(state.client, an.project, an.repository, an.reference), 

406 f"Getting vulnerabilities for {artifact}...", 

407 ) 

408 render_result(a, ctx)