This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new d28b141 Add documentation for keys and other API endpoints
d28b141 is described below
commit d28b141024561aad32fc068c6a8beefee75754c8
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Jul 28 20:39:49 2025 +0100
Add documentation for keys and other API endpoints
---
atr/blueprints/api/api.py | 32 ++++++++++----
atr/db/interaction.py | 104 +++++++++++++++++++++++-----------------------
atr/models/api.py | 60 +++++++++++++-------------
3 files changed, 108 insertions(+), 88 deletions(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 40d9a53..93a174c 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -305,6 +305,11 @@ async def committees_projects(name: str) -> DictResponse:
async def draft_delete(data: models.api.DraftDeleteArgs) -> DictResponse:
"""
Delete a draft release.
+
+ The draft release is deleted, and all of its associated metadata and files
+ are removed from the database and the filesystem. This cannot be undone.
+
+ Warning: we plan to change how draft deletion works.
"""
asf_uid = _jwt_asf_uid()
@@ -312,7 +317,7 @@ async def draft_delete(data: models.api.DraftDeleteArgs) ->
DictResponse:
release_name = sql.release_name(data.project, data.version)
release = await db_data.release(
name=release_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
_committee=True
- ).demand(exceptions.NotFound())
+ ).demand(exceptions.NotFound(f"Draft release '{release_name}' not
found"))
if release.project.committee is None:
raise exceptions.NotFound("Project has no committee")
_committee_member_or_admin(release.project.committee, asf_uid)
@@ -333,9 +338,9 @@ async def draft_delete(data: models.api.DraftDeleteArgs) ->
DictResponse:
# This is the only POST endpoint that does not require a JWT
[email protected]("/jwt", methods=["POST"])
-@quart_schema.validate_request(models.api.JwtArgs)
-async def jwt(data: models.api.JwtArgs) -> DictResponse:
[email protected]("/jwt/create", methods=["POST"])
+@quart_schema.validate_request(models.api.JwtCreateArgs)
+async def jwt_create(data: models.api.JwtCreateArgs) -> DictResponse:
"""
Create a JWT from a valid PAT.
"""
@@ -349,8 +354,8 @@ async def jwt(data: models.api.JwtArgs) -> DictResponse:
raise exceptions.Unauthorized("Invalid PAT")
jwt_token = jwtoken.issue(data.asfuid)
- return models.api.JwtResults(
- endpoint="/jwt",
+ return models.api.JwtCreateResults(
+ endpoint="/jwt/create",
asfuid=data.asfuid,
jwt=jwt_token,
).model_dump(), 200
@@ -363,6 +368,8 @@ async def jwt(data: models.api.JwtArgs) -> DictResponse:
async def keys_endpoint(query_args: models.api.KeysQuery) -> DictResponse:
"""
All public OpenPGP keys, with pagination support.
+
+ Warning: this endpoint is deprecated.
"""
# TODO: Rather than pagination, let's support keys by committee and by user
# That way, consumers can scroll through committees or users
@@ -394,7 +401,10 @@ async def keys_endpoint(query_args: models.api.KeysQuery)
-> DictResponse:
@quart_schema.validate_response(models.api.KeysAddResults, 200)
async def keys_add(data: models.api.KeysAddArgs) -> DictResponse:
"""
- Add a public OpenPGP key to a list of committees.
+ Add a public OpenPGP key to all specified committees.
+
+ Once associated with the specified committees, the key will appear in the
+ automatically generated KEYS file for each committee.
"""
asf_uid = _jwt_asf_uid()
selected_committee_names = data.committees
@@ -426,6 +436,8 @@ async def keys_add(data: models.api.KeysAddArgs) ->
DictResponse:
async def keys_delete(data: models.api.KeysDeleteArgs) -> DictResponse:
"""
Delete a public OpenPGP key from all committees.
+
+ Warning: we plan to change how key deletion works.
"""
asf_uid = _jwt_asf_uid()
fingerprint = data.fingerprint.lower()
@@ -470,10 +482,14 @@ async def keys_delete(data: models.api.KeysDeleteArgs) ->
DictResponse:
async def keys_get(fingerprint: str) -> DictResponse:
"""
A single public OpenPGP key by fingerprint.
+
+ All public OpenPGP keys stored within the database are accessible.
"""
_simple_check(fingerprint)
async with db.session() as data:
- key = await
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
+ key = await
data.public_signing_key(fingerprint=fingerprint.lower()).demand(
+ exceptions.NotFound(f"Key '{fingerprint}' not found")
+ )
return models.api.KeysGetResults(
endpoint="/keys/get",
key=key,
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 49bb188..337b1dc 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -59,6 +59,16 @@ class PathInfo(schema.Strict):
warnings: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
+async def candidate_drafts(project: sql.Project) -> list[sql.Release]:
+ """Get the candidate drafts for the project."""
+ return await releases_by_phase(project,
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+
+
+async def candidates(project: sql.Project) -> list[sql.Release]:
+ """Get the candidate releases for the project."""
+ return await releases_by_phase(project, sql.ReleasePhase.RELEASE_CANDIDATE)
+
+
@contextlib.asynccontextmanager
async def ephemeral_gpg_home() -> AsyncGenerator[str]:
"""Create a temporary directory for an isolated GPG home, and clean it up
on exit."""
@@ -66,6 +76,11 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
yield str(temp_dir)
+async def full_releases(project: sql.Project) -> list[sql.Release]:
+ """Get the full releases for the project."""
+ return await releases_by_phase(project, sql.ReleasePhase.RELEASE)
+
+
async def has_failing_checks(release: sql.Release, revision_number: str,
caller_data: db.Session | None = None) -> bool:
async with db.ensure_session(caller_data) as data:
query = (
@@ -106,6 +121,11 @@ async def path_info(release: sql.Release, paths:
list[pathlib.Path]) -> PathInfo
return info
+async def previews(project: sql.Project) -> list[sql.Release]:
+ """Get the preview releases for the project."""
+ return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
+
+
async def release_delete(
release_name: str, phase: db.Opt[sql.ReleasePhase] = db.NOT_SET,
include_downloads: bool = True
) -> None:
@@ -144,6 +164,38 @@ async def release_delete(
await _delete_release_data_filesystem(release_dir, release_name)
+async def releases_by_phase(project: sql.Project, phase: sql.ReleasePhase) ->
list[sql.Release]:
+ """Get the releases for the project by phase."""
+
+ query = (
+ sqlmodel.select(sql.Release)
+ .where(
+ sql.Release.project_name == project.name,
+ sql.Release.phase == phase,
+ )
+
.order_by(sql.validate_instrumented_attribute(sql.Release.created).desc())
+ )
+
+ results = []
+ async with db.session() as data:
+ for result in (await data.execute(query)).all():
+ release = result[0]
+ results.append(release)
+
+ for release in results:
+ # Don't need to eager load and lose it when the session closes
+ release.project = project
+ return results
+
+
+async def releases_in_progress(project: sql.Project) -> list[sql.Release]:
+ """Get the releases in progress for the project."""
+ drafts = await candidate_drafts(project)
+ cands = await candidates(project)
+ prevs = await previews(project)
+ return drafts + cands + prevs
+
+
async def tasks_ongoing(project_name: str, version_name: str, revision_number:
str | None = None) -> int:
tasks = sqlmodel.select(sqlalchemy.func.count()).select_from(sql.Task)
async with db.session() as data:
@@ -321,55 +373,3 @@ async def _successes_errors_warnings(
for error in errors:
if primary_rel_path := error.primary_rel_path:
info.errors.setdefault(pathlib.Path(primary_rel_path),
[]).append(error)
-
-
-async def releases_by_phase(project: sql.Project, phase: sql.ReleasePhase) ->
list[sql.Release]:
- """Get the releases for the project by phase."""
-
- query = (
- sqlmodel.select(sql.Release)
- .where(
- sql.Release.project_name == project.name,
- sql.Release.phase == phase,
- )
-
.order_by(sql.validate_instrumented_attribute(sql.Release.created).desc())
- )
-
- results = []
- async with db.session() as data:
- for result in (await data.execute(query)).all():
- release = result[0]
- results.append(release)
-
- for release in results:
- # Don't need to eager load and lose it when the session closes
- release.project = project
- return results
-
-
-async def candidate_drafts(project: sql.Project) -> list[sql.Release]:
- """Get the candidate drafts for the project."""
- return await releases_by_phase(project,
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
-
-
-async def candidates(project: sql.Project) -> list[sql.Release]:
- """Get the candidate releases for the project."""
- return await releases_by_phase(project, sql.ReleasePhase.RELEASE_CANDIDATE)
-
-
-async def previews(project: sql.Project) -> list[sql.Release]:
- """Get the preview releases for the project."""
- return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
-
-
-async def full_releases(project: sql.Project) -> list[sql.Release]:
- """Get the full releases for the project."""
- return await releases_by_phase(project, sql.ReleasePhase.RELEASE)
-
-
-async def releases_in_progress(project: sql.Project) -> list[sql.Release]:
- """Get the releases in progress for the project."""
- drafts = await candidate_drafts(project)
- cands = await candidates(project)
- prevs = await previews(project)
- return drafts + cands + prevs
diff --git a/atr/models/api.py b/atr/models/api.py
index 820d466..2abeb5f 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -66,7 +66,7 @@ class ChecksListResults(schema.Strict):
class ChecksOngoingResults(schema.Strict):
endpoint: Literal["/checks/ongoing"] = schema.Field(alias="endpoint")
- ongoing: int
+ ongoing: int = schema.Field(..., **example(10))
class CommitteesGetResults(schema.Strict):
@@ -90,13 +90,13 @@ class CommitteesProjectsResults(schema.Strict):
class DraftDeleteArgs(schema.Strict):
- project: str
- version: str
+ project: str = schema.Field(..., **example("example"))
+ version: str = schema.Field(..., **example("0.0.1"))
class DraftDeleteResults(schema.Strict):
endpoint: Literal["/draft/delete"] = schema.Field(alias="endpoint")
- success: str
+ success: str = schema.Field(..., **example("Draft 'example-0.0.1'
deleted"))
class ListResults(schema.Strict):
@@ -104,15 +104,15 @@ class ListResults(schema.Strict):
rel_paths: Sequence[str]
-class JwtArgs(schema.Strict):
- asfuid: str
- pat: str
+class JwtCreateArgs(schema.Strict):
+ asfuid: str = schema.Field(..., **example("user"))
+ pat: str = schema.Field(...,
**example("8M5t4GCU63EdOy4NNXgXn7o-bc-muK8TRg5W-DeBaWY"))
-class JwtResults(schema.Strict):
- endpoint: Literal["/jwt"] = schema.Field(alias="endpoint")
- asfuid: str
- jwt: str
+class JwtCreateResults(schema.Strict):
+ endpoint: Literal["/jwt/create"] = schema.Field(alias="endpoint")
+ asfuid: str = schema.Field(..., **example("user"))
+ jwt: str = schema.Field(...,
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
@dataclasses.dataclass
@@ -124,19 +124,21 @@ class KeysQuery:
class KeysResults(schema.Strict):
endpoint: Literal["/keys"] = schema.Field(alias="endpoint")
data: Sequence[sql.PublicSigningKey]
- count: int
+ count: int = schema.Field(..., **example(10))
class KeysAddArgs(schema.Strict):
- asfuid: str
- key: str
- committees: list[str]
+ asfuid: str = schema.Field(..., **example("user"))
+ key: str = schema.Field(
+ ..., **example("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n...\n-----END
PGP PUBLIC KEY BLOCK-----\n")
+ )
+ committees: list[str] = schema.Field(..., **example(["example"]))
class KeysAddResults(schema.Strict):
endpoint: Literal["/keys/add"] = schema.Field(alias="endpoint")
- success: str
- fingerprint: str
+ success: str = schema.Field(..., **example("Key added"))
+ fingerprint: str = schema.Field(...,
**example("0123456789abcdef0123456789abcdef01234567"))
# class KeysCommitteeResults(schema.Strict):
@@ -145,12 +147,12 @@ class KeysAddResults(schema.Strict):
class KeysDeleteArgs(schema.Strict):
- fingerprint: str
+ fingerprint: str = schema.Field(...,
**example("0123456789abcdef0123456789abcdef01234567"))
class KeysDeleteResults(schema.Strict):
endpoint: Literal["/keys/delete"] = schema.Field(alias="endpoint")
- success: str
+ success: str = schema.Field(..., **example("Key deleted"))
class KeysGetResults(schema.Strict):
@@ -159,15 +161,17 @@ class KeysGetResults(schema.Strict):
class KeysUploadArgs(schema.Strict):
- filetext: str
- committee: str
+ filetext: str = schema.Field(
+ ..., **example("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n...\n-----END
PGP PUBLIC KEY BLOCK-----\n")
+ )
+ committee: str = schema.Field(..., **example("example"))
class KeysUploadException(schema.Strict):
status: Literal["error"] = schema.Field(alias="status")
key: sql.PublicSigningKey | None
- error: str
- error_type: str
+ error: str = schema.Field(..., **example("Error message"))
+ error_type: str = schema.Field(..., **example("KeysUploadError"))
class KeysUploadResult(schema.Strict):
@@ -193,9 +197,9 @@ KeysUploadOutcomeAdapter =
pydantic.TypeAdapter(KeysUploadOutcome)
class KeysUploadResults(schema.Strict):
endpoint: Literal["/keys/upload"] = schema.Field(alias="endpoint")
results: Sequence[KeysUploadResult | KeysUploadException]
- success_count: int
- error_count: int
- submitted_committee: str
+ success_count: int = schema.Field(..., **example(1))
+ error_count: int = schema.Field(..., **example(0))
+ submitted_committee: str = schema.Field(..., **example("example"))
class KeysUserResults(schema.Strict):
@@ -421,7 +425,7 @@ Results = Annotated[
| CommitteesListResults
| CommitteesProjectsResults
| DraftDeleteResults
- | JwtResults
+ | JwtCreateResults
| KeysResults
| KeysAddResults
| KeysDeleteResults
@@ -474,7 +478,7 @@ validate_committees_keys = validator(CommitteesKeysResults)
validate_committees_list = validator(CommitteesListResults)
validate_committees_projects = validator(CommitteesProjectsResults)
validate_draft_delete = validator(DraftDeleteResults)
-validate_jwt = validator(JwtResults)
+validate_jwt_create = validator(JwtCreateResults)
validate_keys = validator(KeysResults)
validate_keys_add = validator(KeysAddResults)
# validate_keys_committee = validator(KeysCommitteeResults)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]