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
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-09 12:09 +0100
1from __future__ import annotations
3from typing import List
4from typing import Literal
5from typing import Optional
6from typing import overload
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
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
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)
52def get_project(name_or_id: str | int) -> Project:
53 return state.run(state.client.get_project(name_or_id), "Fetching project...")
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)
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)
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)
129class ProjectCreateResult(BaseModel):
130 location: str
131 project: ProjectReq
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)
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)
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.")
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()
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
358 state.run(state.client.update_project(arg, req), f"Updating project...")
359 logger.info(f"Updated {get_project_repr(arg)}")
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}.")
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)
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)
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}")
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)
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})"
494@overload
495def get_project_arg(arg: str, is_id: Literal[False]) -> str:
496 ...
499@overload
500def get_project_arg(arg: str, is_id: Literal[True]) -> int:
501 ...
504# this one looks unnecessary, but it isn't...
505@overload
506def get_project_arg(arg: str, is_id: bool) -> str | int:
507 ...
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.
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
526# Metadata commands (which are in their own category in Harbor API)
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)
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)
616 # Extra metadata args
617 extra_metadata = parse_key_value_args(extra)
618 arg = get_project_arg(project_name_or_id, is_id)
620 metadata = ProjectMetadata(
621 **params,
622 **extra_metadata,
623 )
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}.")
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)
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.")
687 arg = get_project_arg(project_name_or_id, is_id)
688 project_repr = get_project_repr(arg)
690 metadata = ProjectMetadata.parse_obj({field: value})
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}.")
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.")
721 arg = get_project_arg(project_name_or_id, is_id)
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}.")