Coverage for harbor_cli/commands/api/project.py: 43%

173 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 List 

4from typing import Literal 

5from typing import Optional 

6from typing import overload 

7 

8import typer 

9from harborapi.models.base import BaseModel 

10from harborapi.models.models import Project 

11from harborapi.models.models import ProjectMetadata 

12from harborapi.models.models import ProjectReq 

13 

14from ...logs import logger 

15from ...models import ProjectExtended 

16from ...output.console import exit_err 

17from ...output.render import render_result 

18from ...state import state 

19from ...utils import parse_commalist 

20from ...utils.args import create_updated_model 

21from ...utils.args import model_params_from_ctx 

22from ...utils.args import parse_key_value_args 

23from ...utils.commands import inject_help 

24from ...utils.commands import inject_resource_options 

25 

26# Create a command group 

27app = typer.Typer( 

28 name="project", 

29 help="Manage projects.", 

30 no_args_is_help=True, 

31) 

32scanner_cmd = typer.Typer( 

33 name="scanner", 

34 help="Manage project scanners.", 

35 no_args_is_help=True, 

36) 

37metadata_cmd = typer.Typer( 

38 name="metadata", 

39 help="Manage project metadata.", 

40 no_args_is_help=True, 

41) 

42metadata_field_cmd = typer.Typer( 

43 name="field", 

44 help="Manage project metadata fields.", 

45 no_args_is_help=True, 

46) 

47metadata_cmd.add_typer(metadata_field_cmd) 

48app.add_typer(scanner_cmd) 

49app.add_typer(metadata_cmd) 

50 

51 

52def get_project(name_or_id: str | int) -> Project: 

53 return state.run(state.client.get_project(name_or_id), "Fetching project...") 

54 

55 

56# HarborAsyncClient.get_project() 

57@app.command("get", no_args_is_help=True) 

58def get_project_info( 

59 ctx: typer.Context, 

60 project_name_or_id: str = typer.Argument( 

61 ..., 

62 help="Project name or ID to fetch info for. Numeric strings are interpreted as IDs.", 

63 ), 

64 is_id: bool = typer.Option( 

65 False, 

66 "--is-id", 

67 help="Whether the project name is an ID.", 

68 ), 

69) -> None: 

70 """Get information about a project.""" 

71 arg = get_project_arg(project_name_or_id, is_id) 

72 project = get_project(arg) 

73 p = ProjectExtended(**project.dict()) 

74 render_result(p, ctx) 

75 

76 

77# HarborAsyncClient.get_project_logs() 

78@app.command("logs") 

79@inject_resource_options() 

80def get_project_logs( 

81 ctx: typer.Context, 

82 query: Optional[str], 

83 sort: Optional[str], 

84 page: int, 

85 page_size: int, 

86 limit: Optional[int], 

87 project_name: str = typer.Argument( 

88 ..., 

89 help="Project name to fetch logs for.", 

90 ), 

91) -> None: 

92 """Fetch recent logs for a project.""" 

93 project_repr = get_project_repr(project_name) 

94 logs = state.run( 

95 state.client.get_project_logs( 

96 project_name, 

97 query=query, 

98 sort=sort, 

99 page=page, 

100 page_size=page_size, 

101 limit=limit, 

102 ), 

103 f"Fetching logs for {project_repr}...", 

104 ) 

105 logger.info(f"Fetched {len(logs)} logs.") 

106 render_result(logs, ctx) 

107 

108 

109# HarborAsyncClient.project_exists() 

110@app.command( 

111 "exists", 

112 help="Check if a project with the given name exists.", 

113 no_args_is_help=True, 

114) 

115def project_exists( 

116 ctx: typer.Context, 

117 project_name: str = typer.Argument(..., help="Project name to check existence of."), 

118) -> None: 

119 """Check if a project exists.""" 

120 project_repr = get_project_repr(project_name) 

121 exists = state.run( 

122 state.client.project_exists(project_name), 

123 f"Checking if {project_repr} exists...", 

124 ) 

125 render_result(exists, ctx) 

126 raise SystemExit(0 if exists else 1) 

127 

128 

129class ProjectCreateResult(BaseModel): 

130 location: str 

131 project: ProjectReq 

132 

133 

134# HarborAsyncClient.create_project() 

135@app.command("create", no_args_is_help=True) 

136@inject_help(ProjectReq) 

137@inject_help( 

138 ProjectMetadata 

139) # inject this first so its "public" field takes precedence 

140def create_project( 

141 ctx: typer.Context, 

142 project_name: str = typer.Argument( 

143 ..., 

144 ), 

145 storage_limit: Optional[int] = typer.Option( 

146 None, 

147 "--storage-limit", 

148 ), 

149 registry_id: Optional[int] = typer.Option( 

150 None, 

151 "--registry-id", 

152 ), 

153 # Options from the Metadata model 

154 public: Optional[bool] = typer.Option( 

155 None, 

156 "--public", 

157 is_flag=False, 

158 ), 

159 enable_content_trust: Optional[bool] = typer.Option( 

160 None, 

161 "--content-trust", 

162 is_flag=False, 

163 ), 

164 enable_content_trust_cosign: Optional[bool] = typer.Option( 

165 None, 

166 "--content-trust-cosign", 

167 is_flag=False, 

168 ), 

169 prevent_vul: Optional[bool] = typer.Option( 

170 None, 

171 "--prevent-vul", 

172 is_flag=False, 

173 ), 

174 severity: Optional[str] = typer.Option( 

175 None, 

176 "--severity", 

177 # TODO: add custom help text? The original help text has some really broken English... 

178 ), 

179 auto_scan: Optional[bool] = typer.Option( 

180 None, 

181 "--auto-scan", 

182 is_flag=False, 

183 ), 

184 reuse_sys_cve_allowlist: Optional[bool] = typer.Option( 

185 None, 

186 "--reuse-sys-cve-allowlist", 

187 is_flag=False, 

188 ), 

189 retention_id: Optional[str] = typer.Option( 

190 None, 

191 "--retention-id", 

192 ), 

193 # TODO: add support for adding CVE allowlist when creating a project 

194) -> None: 

195 """Create a new project.""" 

196 project_req = ProjectReq( 

197 project_name=project_name, 

198 storage_limit=storage_limit, 

199 registry_id=registry_id, 

200 metadata=ProjectMetadata( 

201 public=public, 

202 enable_content_trust=enable_content_trust, 

203 enable_content_trust_cosign=enable_content_trust_cosign, 

204 prevent_vul=prevent_vul, 

205 severity=severity, 

206 auto_scan=auto_scan, 

207 reuse_sys_cve_allowlist=reuse_sys_cve_allowlist, 

208 retention_id=retention_id, 

209 ), 

210 ) 

211 location = state.run( 

212 state.client.create_project(project_req), "Creating project..." 

213 ) 

214 project_repr = get_project_repr(project_name) 

215 logger.info(f"Created {project_repr}") 

216 res = ProjectCreateResult(location=location, project=project_req) 

217 render_result(res, ctx) 

218 

219 

220# HarborAsyncClient.get_projects() 

221@app.command("list") 

222@inject_resource_options() 

223def list_projects( 

224 ctx: typer.Context, 

225 query: Optional[str], 

226 sort: Optional[str], 

227 page_size: int, 

228 limit: Optional[int], 

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

230 None, 

231 "--name", 

232 help="Name of a specific project to fetch.", 

233 ), 

234 public: Optional[bool] = typer.Option( 

235 None, 

236 help="Filter projects by whether they are public.", 

237 ), 

238 owner: Optional[str] = typer.Option( 

239 None, 

240 "--owner", 

241 help="Filter projects by the user who owns them.", 

242 ), 

243 with_detail: bool = typer.Option( 

244 True, 

245 help="Fetch detailed information about each project.", 

246 ), 

247) -> None: 

248 """Fetch projects.""" 

249 projects = state.run( 

250 state.client.get_projects( 

251 query=query, 

252 sort=sort, 

253 name=name, 

254 public=public, 

255 owner=owner, 

256 with_detail=with_detail, 

257 page_size=page_size, 

258 limit=limit, 

259 ), 

260 "Fetching projects...", 

261 ) 

262 logger.info(f"Fetched {len(projects)} projects.") 

263 render_result(projects, ctx) 

264 

265 

266# HarborAsyncClient.update_project() 

267@app.command("update", no_args_is_help=True) 

268@inject_help(ProjectReq) 

269@inject_help( 

270 ProjectMetadata 

271) # inject this first so its "public" field takes precedence 

272def update_project( 

273 ctx: typer.Context, 

274 project_name_or_id: str = typer.Argument( 

275 ..., 

276 help="Name or ID of the project to delete (interpreted as name by default).", 

277 ), 

278 is_id: bool = typer.Option( 

279 False, 

280 "--is-id", 

281 help="Whether the project name is an ID.", 

282 ), 

283 storage_limit: Optional[int] = typer.Option( 

284 None, 

285 "--storage-limit", 

286 ), 

287 registry_id: Optional[int] = typer.Option( 

288 None, 

289 "--registry-id", 

290 ), 

291 # Options from the Metadata model 

292 public: Optional[bool] = typer.Option( 

293 None, 

294 "--public", 

295 is_flag=False, 

296 ), 

297 enable_content_trust: Optional[bool] = typer.Option( 

298 None, 

299 "--content-trust", 

300 is_flag=False, 

301 ), 

302 enable_content_trust_cosign: Optional[bool] = typer.Option( 

303 None, 

304 "--content-trust-cosign", 

305 is_flag=False, 

306 ), 

307 prevent_vul: Optional[bool] = typer.Option( 

308 None, 

309 "--prevent-vul", 

310 is_flag=False, 

311 ), 

312 severity: Optional[str] = typer.Option( 

313 None, 

314 "--severity", 

315 # TODO: add custom help text? The original help text has some really broken English... 

316 ), 

317 auto_scan: Optional[bool] = typer.Option( 

318 None, 

319 "--auto-scan", 

320 is_flag=False, 

321 ), 

322 reuse_sys_cve_allowlist: Optional[bool] = typer.Option( 

323 None, 

324 "--reuse-sys-cve-allowlist", 

325 is_flag=False, 

326 ), 

327 retention_id: Optional[str] = typer.Option( 

328 None, 

329 "--retention-id", 

330 ), 

331) -> None: 

332 """Update project information.""" 

333 req_params = model_params_from_ctx(ctx, ProjectReq) 

334 metadata_params = model_params_from_ctx(ctx, ProjectMetadata) 

335 if not req_params and not metadata_params: 

336 exit_err("No parameters provided.") 

337 

338 arg = get_project_arg(project_name_or_id, is_id) 

339 project = get_project(arg) 

340 if project.metadata is None: 

341 project.metadata = ProjectMetadata() 

342 

343 # Create updated models from params 

344 req = create_updated_model( 

345 project, 

346 ProjectReq, 

347 ctx, 

348 empty_ok=True, 

349 ) 

350 metadata = create_updated_model( 

351 project.metadata, 

352 ProjectMetadata, 

353 ctx, 

354 empty_ok=True, 

355 ) 

356 req.metadata = metadata 

357 

358 state.run(state.client.update_project(arg, req), f"Updating project...") 

359 logger.info(f"Updated {get_project_repr(arg)}") 

360 

361 

362# HarborAsyncClient.delete_project() 

363@app.command("delete") 

364def delete_project( 

365 ctx: typer.Context, 

366 project_name_or_id: str = typer.Argument( 

367 ..., 

368 help="Name or ID of the project to delete (interpreted as name by default).", 

369 ), 

370 is_id: bool = typer.Option( 

371 False, 

372 "--is-id", 

373 help="Whether the project name is an ID.", 

374 ), 

375) -> None: 

376 """Delete a project.""" 

377 arg = get_project_arg(project_name_or_id, is_id) 

378 project_repr = get_project_repr(arg) 

379 state.run(state.client.delete_project(arg), f"Deleting {project_repr}...") 

380 logger.info(f"Deleted {project_repr}.") 

381 

382 

383# HarborAsyncClient.get_project_summary() 

384@app.command("summary") 

385def get_project_summary( 

386 ctx: typer.Context, 

387 project_name_or_id: str = typer.Argument( 

388 ..., 

389 help="Name or ID of the project to delete (interpreted as name by default).", 

390 ), 

391 is_id: bool = typer.Option( 

392 False, 

393 "--is-id", 

394 help="Whether the project name is an ID.", 

395 ), 

396) -> None: 

397 """Fetch project summary.""" 

398 arg = get_project_arg(project_name_or_id, is_id) 

399 project_repr = get_project_repr(arg) 

400 summary = state.run( 

401 state.client.get_project_summary(arg), f"Fetching summary for {project_repr}..." 

402 ) 

403 render_result(summary, ctx) 

404 

405 

406# HarborAsyncClient.get_project_scanner() 

407@scanner_cmd.command("get") 

408def get_project_scanner( 

409 ctx: typer.Context, 

410 project_name_or_id: str = typer.Argument( 

411 ..., 

412 help="Name or ID of the project to delete (interpreted as name by default).", 

413 ), 

414 is_id: bool = typer.Option( 

415 False, 

416 "--is-id", 

417 help="Whether the project name is an ID.", 

418 ), 

419) -> None: 

420 arg = get_project_arg(project_name_or_id, is_id) 

421 scanner = state.run(state.client.get_project_scanner(arg)) 

422 render_result(scanner, ctx) 

423 

424 

425# HarborAsyncClient.set_project_scanner() 

426@scanner_cmd.command("set") 

427def set_project_scanner( 

428 ctx: typer.Context, 

429 project_name_or_id: str = typer.Argument( 

430 ..., 

431 help="Name or ID of the project to delete (interpreted as name by default).", 

432 ), 

433 is_id: bool = typer.Option( 

434 False, 

435 "--is-id", 

436 help="Whether the project name is an ID or not.", 

437 ), 

438 scanner_id: str = typer.Argument( 

439 ..., 

440 help="ID of the scanner to set.", 

441 ), 

442) -> None: 

443 arg = get_project_arg(project_name_or_id, is_id) 

444 project_repr = get_project_repr(arg) 

445 scanner_repr = f"scanner with ID {scanner_id!r}" 

446 state.run( 

447 state.client.set_project_scanner(arg, scanner_id), f"Setting project scanner..." 

448 ) 

449 logger.info(f"Set scanner for {project_repr} to {scanner_repr}") 

450 

451 

452# HarborAsyncClient.get_project_scanner_candidates() 

453@scanner_cmd.command("candidates") 

454@inject_resource_options 

455def get_project_scanner_candidates( 

456 ctx: typer.Context, 

457 query: Optional[str], 

458 sort: Optional[str], 

459 page: int, 

460 page_size: int, 

461 project_name_or_id: str = typer.Argument( 

462 ..., 

463 help="Name or ID of the project to delete (interpreted as name by default).", 

464 ), 

465 is_id: bool = typer.Option( 

466 False, 

467 "--is-id", 

468 help="Whether the project name is an ID or not.", 

469 ), 

470) -> None: 

471 arg = get_project_arg(project_name_or_id, is_id) 

472 project_repr = get_project_repr(arg) 

473 candidates = state.run( 

474 state.client.get_project_scanner_candidates( 

475 arg, 

476 query=query, 

477 sort=sort, 

478 page=page, 

479 page_size=page_size, 

480 ), 

481 f"Fetching scanner candidates for {project_repr}...", 

482 ) 

483 render_result(candidates, ctx) 

484 

485 

486def get_project_repr(arg: str | int) -> str: 

487 """Get a human-readable representation of a project argument.""" 

488 if isinstance(arg, str): 

489 return f"project {arg!r}" 

490 else: 

491 return f"project (id={arg})" 

492 

493 

494@overload 

495def get_project_arg(arg: str, is_id: Literal[False]) -> str: 

496 ... 

497 

498 

499@overload 

500def get_project_arg(arg: str, is_id: Literal[True]) -> int: 

501 ... 

502 

503 

504# this one looks unnecessary, but it isn't... 

505@overload 

506def get_project_arg(arg: str, is_id: bool) -> str | int: 

507 ... 

508 

509 

510def get_project_arg(arg: str, is_id: bool) -> str | int: 

511 """Converts a project argument to the correct type based on the is_id flag. 

512 

513 We need to pass an int argument to harborapi if the project is specified by ID, 

514 and likewise a string if it's specified by name. This function converts the 

515 argument to the correct type based on the is_id flag. 

516 """ 

517 if is_id: 

518 try: 

519 return int(arg) 

520 except ValueError: 

521 raise typer.BadParameter(f"Project ID {arg!r} is not an integer.") 

522 else: 

523 return arg 

524 

525 

526# Metadata commands (which are in their own category in Harbor API) 

527 

528# HarborAsyncClient.get_project_metadata() 

529@metadata_cmd.command("get") 

530def get_project_metadata( 

531 ctx: typer.Context, 

532 project_name_or_id: str = typer.Argument( 

533 ..., 

534 help="Name or ID of the project to delete (interpreted as name by default).", 

535 ), 

536 is_id: bool = typer.Option( 

537 False, 

538 "--is-id", 

539 help="Whether the project name is an ID or not.", 

540 ), 

541) -> None: 

542 """Get metadata for a project.""" 

543 arg = get_project_arg(project_name_or_id, is_id) 

544 metadata = state.run(state.client.get_project_metadata(arg), "Fetching metadata...") 

545 render_result(metadata, ctx) 

546 

547 

548# HarborAsyncClient.set_project_metadata() 

549@metadata_cmd.command("set", short_help="Set metadata for a project.") 

550@inject_help(ProjectMetadata) 

551def set_project_metadata( 

552 ctx: typer.Context, 

553 project_name_or_id: str = typer.Argument( 

554 ..., 

555 help="Name or ID of the project (interpreted as name by default).", 

556 ), 

557 is_id: bool = typer.Option( 

558 False, 

559 "--is-id", 

560 help="Whether the argument is a project ID or name (by default name)", 

561 ), 

562 public: Optional[bool] = typer.Option( 

563 None, 

564 "--public", 

565 is_flag=False, 

566 ), 

567 enable_content_trust: Optional[bool] = typer.Option( 

568 None, 

569 "--content-trust", 

570 is_flag=False, 

571 ), 

572 content_trust_cosign: Optional[bool] = typer.Option( 

573 None, 

574 "--content-trust-cosign", 

575 is_flag=False, 

576 ), 

577 prevent_vul: Optional[bool] = typer.Option( 

578 None, 

579 "--prevent-vul", 

580 is_flag=False, 

581 ), 

582 severity: Optional[str] = typer.Option( 

583 None, 

584 "--severity", 

585 ), 

586 auto_scan: Optional[bool] = typer.Option( 

587 None, 

588 "--auto-scan", 

589 is_flag=False, 

590 ), 

591 reuse_sys_cve_allowlist: Optional[bool] = typer.Option( 

592 None, 

593 "--reuse-sys-cve-allowlist", 

594 is_flag=False, 

595 ), 

596 retention_id: Optional[int] = typer.Option( 

597 None, 

598 "--retention-id", 

599 ), 

600 extra: List[str] = typer.Option( 

601 [], 

602 "--extra", 

603 help=( 

604 "Extra metadata to set beyond the fields in the spec." 

605 "Format: [green]key[/green][magenta]=[/magenta][cyan]value[/cyan]. " 

606 "May be specified multiple times, or as a comma-separated list." 

607 ), 

608 metavar="KEY=VALUE", 

609 callback=parse_commalist, 

610 ), 

611) -> None: 

612 """Set metadata for a project. Until Harbor API spec""" 

613 # Model field args 

614 params = model_params_from_ctx(ctx, ProjectMetadata) 

615 

616 # Extra metadata args 

617 extra_metadata = parse_key_value_args(extra) 

618 arg = get_project_arg(project_name_or_id, is_id) 

619 

620 metadata = ProjectMetadata( 

621 **params, 

622 **extra_metadata, 

623 ) 

624 

625 project_repr = get_project_repr(arg) 

626 state.run( 

627 state.client.set_project_metadata(arg, metadata), 

628 f"Setting metadata for {project_repr}...", 

629 ) 

630 logger.info(f"Set metadata for {project_repr}.") 

631 

632 

633# HarborAsyncClient.get_project_metadata_entry() 

634@metadata_field_cmd.command("get") 

635def get_project_metadata_field( 

636 ctx: typer.Context, 

637 project_name_or_id: str = typer.Argument( 

638 ..., 

639 help="Name or ID of the project to update (interpreted as name by default).", 

640 ), 

641 is_id: bool = typer.Option( 

642 False, 

643 "--is-id", 

644 help="Whether the argument is a project ID or name (by default name)", 

645 ), 

646 field: str = typer.Argument( 

647 ..., 

648 help="The name of the field to get.", 

649 ), 

650) -> None: 

651 """Get a single field from the metadata for a project. NOTE: does not support table output currently.""" 

652 arg = get_project_arg(project_name_or_id, is_id) 

653 project_repr = get_project_repr(arg) 

654 metadata = state.run( 

655 state.client.get_project_metadata_entry(arg, field), 

656 f"Fetching metadata field {field!r} for {project_repr}...", 

657 ) 

658 render_result(metadata, ctx) 

659 

660 

661# HarborAsyncClient.update_project_metadata_entry() 

662@metadata_field_cmd.command("set") 

663def set_project_metadata_field( 

664 ctx: typer.Context, 

665 project_name_or_id: str = typer.Argument( 

666 ..., 

667 help="Name or ID of the project to update (interpreted as name by default).", 

668 ), 

669 is_id: bool = typer.Option( 

670 False, 

671 "--is-id", 

672 help="Whether the argument is a project ID or name (by default name)", 

673 ), 

674 field: str = typer.Argument( 

675 ..., 

676 help="The name of the field to set.", 

677 ), 

678 value: str = typer.Argument( 

679 ..., 

680 help="The value to set.", 

681 ), 

682) -> None: 

683 """Set a single field in the metadata for a project.""" 

684 if field not in ProjectMetadata.__fields__: 

685 logger.warning(f"Field {field!r} is not a known project metadata field.") 

686 

687 arg = get_project_arg(project_name_or_id, is_id) 

688 project_repr = get_project_repr(arg) 

689 

690 metadata = ProjectMetadata.parse_obj({field: value}) 

691 

692 state.run( 

693 state.client.update_project_metadata_entry(arg, field, metadata), 

694 f"Setting metadata for {project_repr}...", 

695 ) 

696 logger.info(f"Set metadata for {project_repr}.") 

697 

698 

699# HarborAsyncClient.delete_project_metadata_entry() 

700@metadata_field_cmd.command("delete") 

701def delete_project_metadata_field( 

702 ctx: typer.Context, 

703 project_name_or_id: str = typer.Argument( 

704 ..., 

705 help="Name or ID of the project (interpreted as name by default).", 

706 ), 

707 field: str = typer.Argument( 

708 ..., 

709 help="The metadata field to delete.", 

710 ), 

711 is_id: bool = typer.Option( 

712 False, 

713 "--is-id", 

714 help="Whether the argument is a project ID or name (by default name)", 

715 ), 

716) -> None: 

717 """Delete a single field in the metadata for a project.""" 

718 if field not in ProjectMetadata.__fields__: 

719 logger.warning(f"Field {field!r} is not a known project metadata field.") 

720 

721 arg = get_project_arg(project_name_or_id, is_id) 

722 

723 state.run( 

724 state.client.delete_project_metadata_entry(arg, field), 

725 f"Deleting metadata field {field!r}...", 

726 ) 

727 logger.info(f"Deleted metadata field {field!r}.")