From 7b7909a71fb951dde31c3ca64f0a2f2d0723593b Mon Sep 17 00:00:00 2001 From: MengT Date: Tue, 30 Dec 2025 22:38:42 +0800 Subject: [PATCH 01/10] feat(api): add apiflask doc for job endpoints and schema --- server/src/testflinger/api/schemas.py | 12 ++ server/src/testflinger/api/v1.py | 169 +++++++++++++++++++++----- 2 files changed, 152 insertions(+), 29 deletions(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index dd62cf2ea..5aaa660ad 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -372,6 +372,18 @@ class JobId(Schema): job_id = fields.String(required=True) +class JobGetQuery(Schema): + """SJob GET query schema""" + + queue = fields.List( + fields.String(), + required=True, + metadata={ + "description": "List of queue name(s) that the agent can process" + }, + ) + + class JobSearchRequest(Schema): """Job search request schema.""" diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index 858e30415..e099bf3aa 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -76,10 +76,36 @@ def get_version(): @v1.post("/job") @authenticate -@v1.input(schemas.Job, location="json") -@v1.output(schemas.JobId) +@v1.input( + schemas.Job, + location="json", + example={ + "job_queue": "myqueue", + "name": "Example Test Job", + "tags": ["test", "sample"], + "provision_data": {"url": ""}, + "test_data": {"test_cmds": "lsb_release -a"}, + }, +) +@v1.output( + schemas.JobId, + status_code=200, + description="(OK) Returns the job_id (UUID) of the newly created job", + example={"job_id": "550e8400-1234-1234-1234-446655440000"}, +) +@v1.doc( + responses={ + 422: { + "description": "(Unprocessable Content) The submitted job contains references to secrets that are inaccessible" + } + } +) def job_post(json_data: dict): - """Add a job to the queue.""" + """ + Create a test job request and place it on the specified queue. + + Most parameters passed in the data section of this API will be specific to the type of agent receiving them. The `job_queue` parameter is used to designate the queue used, but all others will be passed along to the agent. + """ try: job_queue = json_data.get("job_queue") except (AttributeError, BadRequest): @@ -191,10 +217,35 @@ def job_builder(data: dict): @v1.get("/job") -@v1.output(schemas.Job) -@v1.doc(responses=schemas.job_empty) +@v1.input( + schema=schemas.JobGetQuery, + location="query", + arg_name="queue", + example=["foo", "bar"], +) +@v1.output( + schemas.Job, + status_code=200, + description="(OK) JSON job data that was submitted by the requester", +) +@v1.doc( + responses={ + 204: { + "description": "(No Content) No jobs available in the specified queues" + }, + 400: { + "description": "(Bad request) No queue is specified in the request" + }, + } +) def job_get(): - """Request a job to run from supported queues.""" + """Get a test job from the specified queue(s). + + When an agent wants to request a job for processing, it can make this request along with a list of one or more queues that it is configured to process. The server will only return one job. + + Note: + Any secrets that are referenced in the job are "resolved" when the job is retrieved by an agent through this endpoint. Any secrets that are inaccessible at the time of retrieval will be resolved to the empty string. + """ queue_list = request.args.getlist("queue") if not queue_list: return "No queue(s) specified in request", HTTPStatus.BAD_REQUEST @@ -242,16 +293,18 @@ def retrieve_secrets(data: dict) -> dict | None: @v1.get("/job/") -@v1.output(schemas.Job) +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.output( + schemas.Job, status_code=200, description="(OK) JSON data for the job" +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 204: {"description": "(No Content) Job not found"}, + } +) def job_get_id(job_id): - """Request the json job definition for a specified job, even if it has - already run. - - :param job_id: - UUID as a string for the job - :return: - JSON data for the job or error string and http error - """ + """Request the json job definition for a specified job, even if it has already run.""" if not check_valid_uuid(job_id): abort(400, message="Invalid job_id specified") response = database.mongo.db.jobs.find_one( @@ -265,14 +318,21 @@ def job_get_id(job_id): @v1.get("/job//attachments") +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.output( + {}, + status_code=200, + content_type="application/gzip", + description="(OK) `send_file` stream of the attachment tarball to download for the specified job", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 204: {"description": "(No Content) No attachments found for this job"}, + } +) def attachment_get(job_id): - """Return the attachments bundle for a specified job_id. - - :param job_id: - UUID as a string for the job - :return: - send_file stream of attachment tarball to download - """ + """Return the attachments bundle for a specified job_id.""" if not check_valid_uuid(job_id): return "Invalid job id\n", 400 try: @@ -283,6 +343,25 @@ def attachment_get(job_id): @v1.post("/job//attachments") +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.input( + {}, + location="files", + content_type="multipart/form-data", +) +@v1.output( + {}, + status_code=200, + description="(OK) Attachments successfully uploaded", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 422: { + "description": "(Unprocessable Entity) Job not awaiting attachments or the job_id is not valid" + }, + } +) def attachments_post(job_id): """Post attachment bundle for a specified job_id. @@ -313,10 +392,33 @@ def attachments_post(job_id): @v1.get("/job/search") -@v1.input(schemas.JobSearchRequest, location="query") -@v1.output(schemas.JobSearchResponse) +@v1.input( + schemas.JobSearchRequest, + location="query", + example={"tags": ["foo", "bar"], "match": "all"}, +) +@v1.output( + schemas.JobSearchResponse, + status_code=200, + example={ + "jobs": [ + { + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "job_queue": "myqueue", + } + ] + }, +) def search_jobs(query_data): - """Search for jobs by tags.""" + """Search for jobs by tag(s) and state(s). + + Parameters: + + - `tags` (array): List of string tags to search for + - `match` (string): Match mode for + - `tags` (string, "all" or "any", default: "any") + - `state` (array): List of job states to include (or "active" to search all states other than cancelled and completed) + """ tags = query_data.get("tags") match = request.args.get("match", "any") states = request.args.getlist("state") @@ -516,12 +618,21 @@ def result_get(job_id: str): @v1.post("/job//action") -@v1.input(schemas.ActionIn, location="json") +@v1.input(schemas.ActionIn, location="json", example={"action": "cancel"}) +@v1.doc( + responses={ + 400: {"description": "The job is already completed or cancelled"}, + 404: {"description": "The job isn't found"}, + 422: { + "description": "The action or the argument to it could not be processed" + }, + } +) def action_post(job_id, json_data): - """Take action on the job status for a specified job ID. + """Execute action for the specified job_id. - :param job_id: - UUID as a string for the job + Supported actions: + - cancel: Cancel a job that hasn't been completed yet """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 From 935306eb123bc2cac249d8cb9fbb2be8f72f131d Mon Sep 17 00:00:00 2001 From: MengT Date: Tue, 30 Dec 2025 23:53:29 +0800 Subject: [PATCH 02/10] feat(api): add apiflask doc for Result endpoints --- server/src/testflinger/api/schemas.py | 71 ++++++++-- server/src/testflinger/api/v1.py | 196 ++++++++++++++++++++++---- 2 files changed, 225 insertions(+), 42 deletions(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index 5aaa660ad..c9a9ba0c4 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -444,9 +444,18 @@ class ResultPost(Schema): keys=fields.String(validate=OneOf(TestPhases)), values=fields.Integer(), required=False, + metadata={ + "description": "Dictionary mapping phase names to exit codes" + }, + ) + device_info = fields.Dict( + required=False, + metadata={"description": "Device information"}, + ) + job_state = fields.String( + required=False, + metadata={"description": "Current job state"}, ) - device_info = fields.Dict(required=False) - job_state = fields.String(required=False) class JobEvent(Schema): @@ -482,17 +491,44 @@ class RestrictedQueueOut(Schema): class LogPost(Schema): """Schema for POST of log fragments.""" - fragment_number = fields.Integer(required=True) - timestamp = fields.DateTime(required=True) - phase = fields.String(required=True, validate=OneOf(TestPhases)) - log_data = fields.String(required=True) + fragment_number = fields.Integer( + required=True, + metadata={ + "description": "Sequential fragment number of the log fragment being posted, starting from 0" + }, + ) + timestamp = fields.DateTime( + required=True, + metadata={ + "description": "Timestamp in ISO 8601 format of when the log fragment was created" + }, + ) + phase = fields.String( + required=True, + validate=OneOf(TestPhases), + metadata={ + "description": "Test phase name Test phase name (setup, provision, firmware_update, test, allocate, reserve, cleanup)" + }, + ) + log_data = fields.String( + required=True, + metadata={"description": "The log content for this fragment"}, + ) class LogGetItem(Schema): """Schema for GET of logs for a single phase.""" - last_fragment_number = fields.Integer(required=True) - log_data = fields.String(required=True) + last_fragment_number = fields.Integer( + required=True, + metadata={"description": "The highest fragment number for this phase"}, + ) + log_data = fields.String( + required=True, + metadata={ + "description": "Combined log text from all matching fragments for this phase" + }, + ) class LogGet(Schema): @@ -512,10 +548,23 @@ class LogQueryParams(Schema): """Schema for Log GET Query parameters.""" start_fragment = fields.Integer( - required=False, validate=validators.Range(min=0) + required=False, + validate=validators.Range(min=0), + metadata={ + "description": "Starting fragment number to query from, defaults to 0" + }, + ) + start_timestamp = fields.DateTime( + required=False, + metadata={ + "description": "Starting timestamp to query from in ISO 8601 format" + }, + ) + phase = fields.String( + required=False, + validate=OneOf(TestPhases), + metadata={"description": "Test phase name to filter logs"}, ) - start_timestamp = fields.DateTime(required=False) - phase = fields.String(required=False, validate=OneOf(TestPhases)) job_empty = { diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index e099bf3aa..8c92723f4 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -454,11 +454,25 @@ def search_jobs(query_data): @v1.post("/result//artifact") +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.input( + {}, + location="files", + content_type="multipart/form-data", +) +@v1.output( + {}, + status_code=200, + description="(OK) Artifact bundle successfully uploaded", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + } +) def artifacts_post(job_id): - """Post artifact bundle for a specified job_id. - - :param job_id: - UUID as a string for the job + """ + Upload a file artifact for the specified job_id """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 @@ -470,14 +484,21 @@ def artifacts_post(job_id): @v1.get("/result//artifact") +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.output( + {}, + status_code=200, + content_type="application/gzip", + description="(OK) `send_file` stream of the artifact JSON data previously submitted to the specified job_id", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 204: {"description": "(No Content) No artifact found for this job"}, + } +) def artifacts_get(job_id): - """Return artifact bundle for a specified job_id. - - :param job_id: - UUID as a string for the job - :return: - send_file stream of artifact tarball to download - """ + """Download previously submitted artifact for the specified job_id""" if not check_valid_uuid(job_id): return "Invalid job id\n", 400 try: @@ -503,14 +524,64 @@ def to_url(self, obj): @v1.get("/result//log/") -@v1.output(schemas.LogGet) +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.input( + schema=LogType, + location="path", + arg_name="log_type", + examples={ + "Get all output logs for a job": {"log_type": "output"}, + }, +) +@v1.input( + schemas.LogQueryParams, + location="query", + examples={ + "Get only setup phase output logs": {"phase": "setup"}, + "Get logs from fragment 5 onwards": {"start_fragment": 5}, + "Get logs after a specific timestamp": { + "start_timestamp": "2025-10-15T10:30:00Z" + }, + }, +) +@v1.output( + schemas.LogGet, + status_code=200, + description="(OK) JSON object with logs organized by phase", + example={ + "output": { + "setup": { + "last_fragment_number": 5, + "log_data": "Starting setup...\nSetup complete\n", + }, + "provision": { + "last_fragment_number": 12, + "log_data": "Provisioning device...\nDevice ready\n", + }, + } + }, +) +@v1.doc( + responses={ + 204: { + "description": "(No Content) No logs found for this job_id and log_type" + }, + 400: { + "description": "(Bad Request) Invalid job_id, log_type or query parameter specified" + }, + } +) def log_get(job_id: str, log_type: LogType): - """Get logs for a specified job_id. + """ + Retrieve logs for the specified job_id and log type - :param job_id: UUID as a string for the job - :param log_type: LogType enum value for the type of log requested - :raises HTTPError: If the job_id is not a valid UUID or if invalid query - :return: Dictionary with log data + This endpoint supports querying logs with optional filtering by phase, fragment number, or timestamp. Logs are persistent and can be retrieved multiple times. + + Query Parameters (all optional): + + - `phase` (string): Filter logs to a specific test phase + - `start_fragment` (integer): Return only fragments from this number onwards + - `start_timestamp` (string): Return only logs created after this ISO 8601 timestamp """ args = request.args if not check_valid_uuid(job_id): @@ -546,14 +617,37 @@ def log_get(job_id: str, log_type: LogType): @v1.post("/result//log/") -@v1.input(schemas.LogPost, location="json") +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.input( + schema=LogType, + location="path", + arg_name="log_type", +) +@v1.input( + schemas.LogPost, + location="json", + example={ + "fragment_number": 0, + "timestamp": "2025-10-15T10:00:00+00:00", + "phase": "setup", + "log_data": "Starting setup phase...", + }, +) +@v1.output( + None, status_code=200, description="(OK) Log fragment posted successfully" +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Invalid job_id or log_type specified" + } + } +) def log_post(job_id: str, log_type: LogType, json_data: dict) -> str: - """Post logs for a specified job ID. + """ + Post a log fragment for the specified job_id and log type - :param job_id: UUID as a string for the job - :param log_type: LogType enum value for the type of log being posted - :raises HTTPError: If the job_id is not a valid UUID - :param json_data: Dictionary with log data + This is the new logging endpoint that agents use to stream log data in fragments. Each fragment includes metadata for tracking and querying. """ if not check_valid_uuid(job_id): abort(HTTPStatus.BAD_REQUEST, message="Invalid job_id specified") @@ -571,12 +665,33 @@ def log_post(job_id: str, log_type: LogType, json_data: dict) -> str: @v1.post("/result/") -@v1.input(schemas.ResultSchema, location="json") +@v1.input(schemas.JobId, location="path", arg_name="job_id") +@v1.input( + schemas.ResultSchema, + location="json", + example={ + "status": {"setup": 0, "provision": 0, "test": 0}, + "device_info": {}, + }, +) +@v1.output( + None, + status_code=200, + description="(OK) Job outcome data posted successfully", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 413: { + "description": "(Request Entity Too Large) Payload exceeds 16MB BSON size limit" + }, + } +) def result_post(job_id: str, json_data: dict) -> str: - """Post a result for a specified job_id. + """Post job outcome data for the specified job_id. - :param job_id: UUID as a string for the job - :raises HTTPError: If the job_id is not a valid UUID + Submit test results including exit codes for each phase, device information, + and job state. The payload must not exceed the 16MB BSON size limit. """ if not check_valid_uuid(job_id): abort(HTTPStatus.BAD_REQUEST, message="Invalid job_id specified") @@ -592,12 +707,31 @@ def result_post(job_id: str, json_data: dict) -> str: @v1.get("/result/") -@v1.output(schemas.ResultGet) +@v1.input(schemas.JobId, location="path", arg_name="job_id") +@v1.output( + schemas.ResultGet, + status_code=200, + description="(OK) JSON data with flattened structure", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 204: {"description": "(No Content) No results found for this job_id"}, + } +) def result_get(job_id: str): - """Return results for a specified job_id. + """Return previously submitted job outcome data for the specified job_id. + + This endpoint reconstructs results from the new logging system to maintain backward compatibility. It combines phase status information with logs to provide a complete view of job results. + + Returns: + + JSON data with flattened structure including: + - `{phase}_status`: Exit code for each phase + - `{phase}_output`: Standard output logs for each phase (if available) + - `{phase}_serial`: Serial console logs for each phase (if available) + - Additional metadata fields (device_info, job_state, etc.) - :param job_id: UUID as a string for the job - :raises HTTPError: If the job_id is not a valid UUID """ if not check_valid_uuid(job_id): abort(HTTPStatus.BAD_REQUEST, message="Invalid job_id specified") From 45e2079276b8bba3d0539f3c759ffc2a315731db Mon Sep 17 00:00:00 2001 From: MengT Date: Wed, 31 Dec 2025 00:37:41 +0800 Subject: [PATCH 03/10] feat(api): add doc for agent endpoints and schemas fix: schemas for file uploads and queue inputs --- server/src/testflinger/api/schemas.py | 79 +++++++++- server/src/testflinger/api/v1.py | 209 ++++++++++++++++---------- 2 files changed, 200 insertions(+), 88 deletions(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index c9a9ba0c4..4cdccc3a8 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -49,16 +49,37 @@ class ProvisionLogsIn(Schema): detail = fields.String(required=False) +class AgentName(Schema): + """Agent name schema.""" + + agent_name = fields.String(required=True) + + class AgentIn(Schema): """Agent data input schema.""" identifier = fields.String(required=False) - job_id = fields.String(required=False) - location = fields.String(required=False) - log = fields.List(fields.String(), required=False) + job_id = fields.String( + required=False, + metadata={"description": "Job ID the device is running, if any"}, + ) + location = fields.String( + required=False, metadata={"description": "Location of the device"} + ) + log = fields.List( + fields.String(), + required=False, + metadata={"description": "Push and keep only the last 100 lines"}, + ) provision_type = fields.String(required=False) - queues = fields.List(fields.String(), required=False) - state = fields.String(required=False) + queues = fields.List( + fields.String(), + required=False, + metadata={"description": "Queues the device is listening on"}, + ) + state = fields.String( + required=False, metadata={"description": "State the device is in"} + ) comment = fields.String(required=False) @@ -92,6 +113,12 @@ class Attachment(Schema): device = fields.String(required=False) +class FileUpload(Schema): + """Schema for file upload requests.""" + + file = fields.File(required=True) + + class CM3ProvisionData(Schema): """Schema for the `provision_data` section of a CM3 job.""" @@ -475,6 +502,22 @@ class StatusUpdate(Schema): events = fields.List(fields.Nested(JobEvent), required=False) +class QueueName(Schema): + """Queue name schema.""" + + queue = fields.String(required=True) + + +class QueueIn(Schema): + """Queue input schema.""" + + data = fields.Dict( + keys=fields.Nested(QueueName), + values=fields.String(metadata={"description": "Queue description"}), + required=True, + ) + + class RestrictedQueueIn(Schema): """Restricted queue input schema.""" @@ -488,6 +531,16 @@ class RestrictedQueueOut(Schema): owners = fields.List(fields.String(), required=True) +class LogTypeParam(Schema): + """Schema for Log type parameter.""" + + log_type = fields.String( + required=True, + validate=OneOf(["output", "serial"]), + metadata={"description": "Type of log to retrieve (output or serial)"}, + ) + + class LogPost(Schema): """Schema for POST of log fragments.""" @@ -619,6 +672,22 @@ class LogQueryParams(Schema): } +class ImagePostIn(Schema): + """Agent image input schema.""" + + data = fields.Dict( + keys=fields.String(metadata={"description": "Queue name"}), + values=fields.Dict( + keys=fields.String(metadata={"description": "Image name"}), + values=fields.String( + metadata={"description": "Image provision data"} + ), + metadata={"description": "Image data for the queue"}, + ), + required=True, + ) + + class ClientPermissionsIn(Schema): """Client Permissions output schema.""" diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index 8c92723f4..8cfe8fc52 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -319,12 +319,6 @@ def job_get_id(job_id): @v1.get("/job//attachments") @v1.input(schema=schemas.JobId, location="path", arg_name="job_id") -@v1.output( - {}, - status_code=200, - content_type="application/gzip", - description="(OK) `send_file` stream of the attachment tarball to download for the specified job", -) @v1.doc( responses={ 400: {"description": "(Bad Request) Invalid job_id specified"}, @@ -332,7 +326,10 @@ def job_get_id(job_id): } ) def attachment_get(job_id): - """Return the attachments bundle for a specified job_id.""" + """Download the attachments bundle for a specified job_id. + + Returns a gzip-compressed tarball containing all files that were uploaded as attachments. + """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 try: @@ -345,14 +342,8 @@ def attachment_get(job_id): @v1.post("/job//attachments") @v1.input(schema=schemas.JobId, location="path", arg_name="job_id") @v1.input( - {}, + schema=schemas.FileUpload, location="files", - content_type="multipart/form-data", -) -@v1.output( - {}, - status_code=200, - description="(OK) Attachments successfully uploaded", ) @v1.doc( responses={ @@ -365,8 +356,8 @@ def attachment_get(job_id): def attachments_post(job_id): """Post attachment bundle for a specified job_id. - :param job_id: - UUID as a string for the job + Upload a gzip-compressed tarball containing files to be used as attachments for the job. + The job must be in a state where it's awaiting attachments. """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 @@ -456,14 +447,8 @@ def search_jobs(query_data): @v1.post("/result//artifact") @v1.input(schema=schemas.JobId, location="path", arg_name="job_id") @v1.input( - {}, + schema=schemas.FileUpload, location="files", - content_type="multipart/form-data", -) -@v1.output( - {}, - status_code=200, - description="(OK) Artifact bundle successfully uploaded", ) @v1.doc( responses={ @@ -471,8 +456,9 @@ def search_jobs(query_data): } ) def artifacts_post(job_id): - """ - Upload a file artifact for the specified job_id + """Upload a file artifact for the specified job_id. + + Upload a gzip-compressed tarball containing test artifacts or results files. """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 @@ -485,12 +471,6 @@ def artifacts_post(job_id): @v1.get("/result//artifact") @v1.input(schema=schemas.JobId, location="path", arg_name="job_id") -@v1.output( - {}, - status_code=200, - content_type="application/gzip", - description="(OK) `send_file` stream of the artifact JSON data previously submitted to the specified job_id", -) @v1.doc( responses={ 400: {"description": "(Bad Request) Invalid job_id specified"}, @@ -498,7 +478,10 @@ def artifacts_post(job_id): } ) def artifacts_get(job_id): - """Download previously submitted artifact for the specified job_id""" + """Download previously submitted artifact for the specified job_id. + + Returns a gzip-compressed tarball containing test artifacts or results files. + """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 try: @@ -526,7 +509,7 @@ def to_url(self, obj): @v1.get("/result//log/") @v1.input(schema=schemas.JobId, location="path", arg_name="job_id") @v1.input( - schema=LogType, + schema=schemas.LogTypeParam, location="path", arg_name="log_type", examples={ @@ -572,16 +555,10 @@ def to_url(self, obj): } ) def log_get(job_id: str, log_type: LogType): - """ - Retrieve logs for the specified job_id and log type - - This endpoint supports querying logs with optional filtering by phase, fragment number, or timestamp. Logs are persistent and can be retrieved multiple times. - - Query Parameters (all optional): + """Retrieve logs for the specified job_id and log type. - - `phase` (string): Filter logs to a specific test phase - - `start_fragment` (integer): Return only fragments from this number onwards - - `start_timestamp` (string): Return only logs created after this ISO 8601 timestamp + This endpoint supports querying logs with optional filtering by phase, fragment number, + or timestamp. Logs are persistent and can be retrieved multiple times. """ args = request.args if not check_valid_uuid(job_id): @@ -619,7 +596,7 @@ def log_get(job_id: str, log_type: LogType): @v1.post("/result//log/") @v1.input(schema=schemas.JobId, location="path", arg_name="job_id") @v1.input( - schema=LogType, + schema=schemas.LogTypeParam, location="path", arg_name="log_type", ) @@ -752,13 +729,19 @@ def result_get(job_id: str): @v1.post("/job//action") +@v1.input(schemas.JobId, location="path", arg_name="job_id") @v1.input(schemas.ActionIn, location="json", example={"action": "cancel"}) +@v1.output( + None, status_code=200, description="(OK) Action executed successfully" +) @v1.doc( responses={ - 400: {"description": "The job is already completed or cancelled"}, - 404: {"description": "The job isn't found"}, + 400: { + "description": " (Bad Request) The job is already completed or cancelled" + }, + 404: {"description": "(Not Found) The job isn't found"}, 422: { - "description": "The action or the argument to it could not be processed" + "description": "(Unprocessable Entity) The action or the argument to it could not be processed" }, } ) @@ -800,6 +783,11 @@ def queues_get(): @v1.post("/agents/queues") +@v1.input( + schema=schemas.QueueIn, + location="json", + example={"myqueue": "queue 1", "myqueue2": "queue 2"}, +) def queues_post(): """Tell testflinger the queue names that are being serviced. @@ -818,9 +806,14 @@ def queues_post(): @v1.get("/agents/images/") +@v1.input(schema=schemas.QueueName, location="path", arg_name="queue") @v1.doc(responses=schemas.images_out) def images_get(queue): - """Get a dict of known images for a given queue.""" + """Get a dict of all known image names and the associated provisioning data for a given queue. + + Returns a dictionary mapping image names to their provisioning URLs or data. + Returns an empty dict if the queue doesn't exist or has no images. + """ queue_data = database.mongo.db.queues.find_one( {"name": queue}, {"_id": False, "images": True} ) @@ -831,19 +824,21 @@ def images_get(queue): @v1.post("/agents/images") -def images_post(): - """Tell testflinger about known images for a specified queue - images will be stored in a dict of key/value pairs as part of the queues - collection. That dict will contain image_name:provision_data mappings, ex: - { - "some_queue": { +@v1.input( + schema=schemas.ImagePostIn, + location="json", + example={ + "myqueue": { "core22": "http://cdimage.ubuntu.com/.../core-22.tar.gz", - "jammy": "http://cdimage.ubuntu.com/.../ubuntu-22.04.tar.gz" + "jammy": "http://cdimage.ubuntu.com/.../ubuntu-22.04.tar.gz", }, - "other_queue": { - ... - } - }. + "other_queue": {"image1": "data1", "image2": "data2"}, + }, +) +def images_post(): + """Tell testflinger about known images for a specified queue + + Images will be stored in a dict of key/value pairs as part of the queues collection. """ image_dict = request.get_json() # We need to delete and recreate the images in case some were removed @@ -857,9 +852,17 @@ def images_post(): @v1.get("/agents/data") -@v1.output(schemas.AgentOut(many=True)) +@v1.output( + schemas.AgentOut(many=True), + status_code=200, + description="JSON data for all known agents, useful for external systems that need to gather this information", +) def agents_get_all(): - """Get all agent data.""" + """Get all agent data. + + Returns JSON data for all known agents, including their state, queues, location, and + information about restricted queue ownership. Useful for external systems monitoring agents. + """ agents = database.get_agents() restricted_queues = database.get_restricted_queues() restricted_queues_owners = database.get_restricted_queues_owners() @@ -876,14 +879,22 @@ def agents_get_all(): @v1.get("/agents/data/") -@v1.output(schemas.AgentOut) +@v1.input(schemas.AgentName, location="path", arg_name="agent_name") +@v1.output( + schemas.AgentOut, + status_code=200, + description="JSON data for the specified agent, useful for getting information from a single agent. ", +) +@v1.doc( + responses={ + 404: {"description": "(Not Found) The specified agent was not found"} + } +) def agents_get_one(agent_name): """Get the information from a specified agent. - :param agent_name: - String with the name of the agent to retrieve information from. - :return: - JSON data with the specified agent information. + Returns JSON data for the specified agent, including state, queues, location, and + restricted queue ownership information. """ agent_data = database.get_agent_info(agent_name) @@ -930,9 +941,23 @@ def agents_post(agent_name, json_data): @v1.post("/agents/provision_logs/") -@v1.input(schemas.ProvisionLogsIn, location="json") +@v1.input(schemas.AgentName, location="path", arg_name="agent_name") +@v1.input( + schemas.ProvisionLogsIn, + location="json", + example={ + "job_id": "00000000-0000-0000-0000-000000000000", + "exit_code": 1, + "detail": "foo", + }, +) def agents_provision_logs_post(agent_name, json_data): - """Post provision logs for the agent to the server.""" + """Post provision logs for the agent to the server. + + Submit provision log entries including job_id, exit_code, and detail information. + The server maintains the last 100 provision log entries per agent and tracks provision + success/failure streaks. + """ agent_record = {} # timestamp this agent record and provision log entry @@ -971,25 +996,43 @@ def agents_provision_logs_post(agent_name, json_data): @v1.post("/job//events") -@v1.input(schemas.StatusUpdate, location="json") -def agents_status_post(job_id, json_data): - """Post status updates from the agent to the server to be forwarded - to TestObserver. - - The json sent to this endpoint may contain data such as the following: - { - "agent_id": "", - "job_queue": "", - "job_status_webhook": "", +@v1.input(schemas.JobId, location="path", arg_name="job_id") +@v1.input( + schemas.StatusUpdate, + location="json", + example={ + "agent_id": "agent-00", + "job_queue": "myqueue", + "job_status_webhook": "http://mywebhook", "events": [ - { - "event_name": "", - "timestamp": "", - "detail": "" + { + "event_name": "started_provisioning", + "timestamp": "2024-05-03T19:11:33.541130+00:00", + "detail": "my_detailed_message", + } + ], + }, +) +@v1.output( + None, + status_code=200, + description="(OK) Text response from the webhook if the server was successfully able to post.", +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Invalid job_id or JSON data specified" + }, + 504: { + "description": "(Gateway Timeout) The webhook did not respond in time" }, - ... - ] } +) +def agents_status_post(job_id, json_data): + """Post job status updates from the agent to the server and posts them to the specified webhook URL. + + The `job_status_webhook` parameter is required for this endpoint. Other + parameters included here will be forwarded to the webhook. """ _ = job_id From 6b3cef2fde7a21e9ff13183a749f0d83f4771d96 Mon Sep 17 00:00:00 2001 From: MengT Date: Wed, 31 Dec 2025 01:49:36 +0800 Subject: [PATCH 04/10] feat(api): add api doc for Queues endpoints Update queue endpoints --- server/src/testflinger/api/schemas.py | 68 ++++++++++++++++++++------- server/src/testflinger/api/v1.py | 68 +++++++++++++++++++++++---- 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index 4cdccc3a8..06dde89f9 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -502,22 +502,6 @@ class StatusUpdate(Schema): events = fields.List(fields.Nested(JobEvent), required=False) -class QueueName(Schema): - """Queue name schema.""" - - queue = fields.String(required=True) - - -class QueueIn(Schema): - """Queue input schema.""" - - data = fields.Dict( - keys=fields.Nested(QueueName), - values=fields.String(metadata={"description": "Queue description"}), - required=True, - ) - - class RestrictedQueueIn(Schema): """Restricted queue input schema.""" @@ -651,6 +635,58 @@ class LogQueryParams(Schema): }, } + +class QueueName(Schema): + """Queue name schema.""" + + queue = fields.String(required=True) + + +class QueueDict(Schema): + """Queue input schema.""" + + data = fields.Dict( + keys=fields.Nested(QueueName), + values=fields.String(metadata={"description": "Queue description"}), + required=True, + ) + + +class QueueList(Schema): + """Queue list schema.""" + + queue = fields.List( + fields.Nested(QueueName), + required=False, + metadata={"description": "List of queue names"}, + ) + + +class QueueWaitTimePercentilesOut(Schema): + """Queue wait time percentiles output schema.""" + + data = fields.Dict( + keys=fields.Nested(QueueName), + values=fields.Dict( + keys=fields.String(validate=OneOf(["5", "10", "50", "90", "95"])), + values=fields.Float(), + metadata={ + "description": "Percentile statistics for job wait times in seconds" + }, + ), + required=True, + ) + + +class JobInQueueOut(Schema): + """Job in queue output schema.""" + + job_id = fields.String(required=True) + created_at = fields.String(required=True) + job_state = fields.String(required=True) + job_queue = fields.String(required=True) + + images_out = { 200: { "description": "Mapping of image names and provision data", diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index 8cfe8fc52..2d9e15e08 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -784,7 +784,7 @@ def queues_get(): @v1.post("/agents/queues") @v1.input( - schema=schemas.QueueIn, + schema=schemas.QueueDict, location="json", example={"myqueue": "queue 1", "myqueue2": "queue 2"}, ) @@ -1072,8 +1072,20 @@ def check_valid_uuid(job_id): @v1.get("/job//position") +@v1.input(schema=schemas.JobId, location="path", arg_name="job_id") +@v1.output( + str, + status_code=200, + description="(OK) Zero-based position indicating how many jobs are ahead of this job in the queue.", +) +@v1.doc( + responses={ + 400: {"description": "(Bad Request) Invalid job_id specified"}, + 410: {"description": "(Gone) Job not found or already started"}, + } +) def job_position_get(job_id): - """Return the position of the specified jobid in the queue.""" + """Return the position of the specified job_id in the queue.""" job_data, status = job_get_id(job_id) if status == 204: return "Job not found or already started\n", 410 @@ -1118,8 +1130,22 @@ def cancel_job(job_id): @v1.get("/queues/wait_times") +@v1.input(schema=schemas.QueueList, location="query", arg_name="queue") +@v1.output( + schemas.QueueWaitTimePercentilesOut, + status_code=200, + description="(OK) JSON mapping of queue names to wait time metrics percentiles", + example={ + "myqueue": {5: 10.5, 10: 15.2, 50: 30.0, 90: 60.8, 95: 75.3}, + "otherqueue": {5: 8.0, 10: 12.5, 50: 25.0, 90: 50.0, 95: 65.0}, + }, +) def queue_wait_time_percentiles_get(): - """Get wait time metrics - optionally take a list of queues.""" + """Get wait time metrics for queues, optionally take a list of queues. + + Returns percentile statistics (p5, p10, p50, p90, p95) for job wait times in the specified queues. + If no queues are specified, returns metrics for all queues. + """ queues = request.args.getlist("queue") wait_times = database.get_queue_wait_times(queues) queue_percentile_data = {} @@ -1131,7 +1157,20 @@ def queue_wait_time_percentiles_get(): @v1.get("/queues//agents") -@v1.output(schemas.AgentOut(many=True)) +@v1.input(schemas.QueueName, location="path", arg_name="queue_name") +@v1.output( + schemas.AgentOut(many=True), + status_code=200, + description="JSON data with an array of agent objects listening to the specified queue, including the agent state, location, and current job information.", +) +@v1.doc( + responses={ + 204: { + "description": "(No Content) No agents found listening to the specified queue" + }, + 404: {"description": "(Not Found) The specified queue does not exist"}, + } +) def get_agents_on_queue(queue_name): """Get the list of all data for agents listening to a specified queue.""" if not database.queue_exists(queue_name): @@ -1147,13 +1186,24 @@ def get_agents_on_queue(queue_name): @v1.get("/queues//jobs") +@v1.input(schemas.QueueName, location="path", arg_name="queue_name") +@v1.output( + schemas.JobInQueueOut(many=True), + status_code=200, + description="JSON data with an array of job objects including job_id, created_at timestamp, job_state, and job_queue for all jobs in the specified queue.", +) +@v1.doc( + responses={ + 204: { + "description": "(No Content) No jobs found in the specified queue" + }, + } +) def get_jobs_by_queue(queue_name): - """Get the jobs in a specified queue along with its state. + """Get the jobs in a specified queue along with their state. - :param queue_name - String with the queue name where to perform the query. - :return: - JSON data with the jobs allocated to the specified queue. + Returns JSON data with an array of job objects including job_id, created_at timestamp, + job_state, and job_queue for all jobs in the specified queue. """ jobs = database.get_jobs_on_queue(queue_name) From 5867a4daeefb762dc07709a5ec0cb2f22d22a7cd Mon Sep 17 00:00:00 2001 From: MengT Date: Wed, 31 Dec 2025 16:01:26 +0800 Subject: [PATCH 05/10] feat(api): add docstring to OAuth2 token endpoints --- server/src/testflinger/api/schemas.py | 26 ++++++++++ server/src/testflinger/api/v1.py | 70 ++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index 06dde89f9..0737602cb 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -832,3 +832,29 @@ def _dump(self, obj, **kwargs): # So we need to remove it result.pop(self.type_field) return result + + +class Oauth2Token(Schema): + """Token output schema.""" + + access_token = fields.String(required=True) + token_type = fields.String(required=True) + expires_in = fields.Integer(required=True) + refresh_token = fields.String(required=True) + + +class Oauth2RefreshTokenIn(Schema): + """Refresh token input schema.""" + + refresh_token = fields.String( + required=True, + metadata={"description": "Opaque refresh token"}, + ) + + +class Oauth2RefreshTokenOut(Schema): + """Refresh token output schema.""" + + access_token = fields.String(required=True) + token_type = fields.String(required=True) + expires_in = fields.Integer(required=True) diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index 2d9e15e08..37f85942f 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -22,7 +22,7 @@ from http import HTTPStatus import requests -from apiflask import APIBlueprint, abort +from apiflask import APIBlueprint, abort, security from flask import current_app, g, jsonify, request, send_file from marshmallow import ValidationError from prometheus_client import Counter @@ -1230,6 +1230,27 @@ def get_jobs_by_queue(queue_name): @v1.post("/oauth2/token") +@v1.auth_required( + auth=security.HTTPBasicAuth( + description="Base64 encoded pair of client_id:client_key" + ) +) +@v1.output( + schemas.Oauth2Token, + status_code=200, + description="(OK) JSON object containing access token, token type, expiration time, and refresh token", + example={ + "access_token": "", + "token_type": "Bearer", + "expires_in": 30, + "refresh_token": "", + }, +) +@v1.doc( + responses={ + 401: {"description": "(Unauthorized) Invalid client id or client key"}, + } +) def retrieve_token(): """ Issue both access token and refresh token for a client. @@ -1247,6 +1268,10 @@ def retrieve_token(): max_reservation_time: , } } + Notes: + - `expires_in` is the lifetime (in seconds) of the access token. + - Refresh tokens default to 30 days; admin may issue non-expiring tokens for trusted integrations. + """ auth_header = request.authorization if auth_header is None: @@ -1286,6 +1311,28 @@ def retrieve_token(): @v1.post("/oauth2/refresh") +@v1.input( + schema=schemas.Oauth2RefreshTokenIn, + location="json", + example={"refresh_token": ""}, +) +@v1.output( + schemas.Oauth2RefreshTokenOut, + status_code=200, + description="(OK) JSON object containing new access token, token type, and expiration time", + example={ + "access_token": "", + "token_type": "Bearer", + "expires_in": 30, + }, +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Missing, invalid, revoked, or expired refresh token" + }, + } +) def refresh_access_token(): """Refresh access token using a valid refresh token.""" data = request.get_json() @@ -1308,6 +1355,27 @@ def refresh_access_token(): @v1.post("/oauth2/revoke") +@v1.input( + schema=schemas.Oauth2RefreshTokenIn, + location="json", + example={"refresh_token": ""}, +) +@v1.output( + str, + status_code=200, + description="(OK) Text response indicating successful revocation of the refresh token", + example={"message": "Refresh token revoked successfully"}, +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Missing, invalid, or already revoked refresh token" + }, + 401: { + "description": "(Unauthorized) Admin privileges required to revoke refresh tokens" + }, + } +) @authenticate @require_role(ServerRoles.ADMIN) def revoke_refresh_token(): From 2f2b2e939035fa7014c442728298b041b8958111 Mon Sep 17 00:00:00 2001 From: MengT Date: Wed, 31 Dec 2025 17:15:49 +0800 Subject: [PATCH 06/10] feat(api): add api doc to restricted queues endpoints --- server/src/testflinger/api/v1.py | 89 ++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index 37f85942f..a92cd4a9e 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -1401,6 +1401,20 @@ def revoke_refresh_token(): @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER, ServerRoles.CONTRIBUTOR) @v1.output(schemas.RestrictedQueueOut(many=True)) +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with permissions to access restricted queues", + ) +) +@v1.doc( + responses={ + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user to access restricted queues" + }, + } +) def get_all_restricted_queues() -> list[dict]: """List all agent's restricted queues and its owners.""" restricted_queues = database.get_restricted_queues() @@ -1423,6 +1437,24 @@ def get_all_restricted_queues() -> list[dict]: @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER, ServerRoles.CONTRIBUTOR) @v1.output(schemas.RestrictedQueueOut) +@v1.input(schemas.QueueName, location="path", arg_name="queue_name") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with permissions to access restricted queues", + ) +) +@v1.doc( + responses={ + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user to access restricted queues" + }, + 404: { + "description": "(Not Found) The specified restricted queue was not found" + }, + } +) def get_restricted_queue(queue_name: str) -> dict: """Get restricted queues for a specific agent.""" if not database.check_queue_restricted(queue_name): @@ -1440,11 +1472,37 @@ def get_restricted_queue(queue_name: str) -> dict: @v1.post("/restricted-queues/") +@v1.input( + schemas.RestrictedQueueIn, + location="json", + example={"client_id": "myclient"}, +) +@v1.input(schemas.QueueName, location="path", arg_name="queue_name") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with admin or manager permissions", + ) +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Missing client_id to set as owner of restricted queue" + }, + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user to associate restricted queues" + }, + 404: { + "description": "(Not Found) The specified restricted queue does not exist or is not associated to an agent" + }, + } +) @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) -@v1.input(schemas.RestrictedQueueIn, location="json") def add_restricted_queue(queue_name: str, json_data: dict) -> dict: - """Add an owner to the specific restricted queue.""" + """Add an owner to the specific restricted queue. + If the queue does not exist yet, it will be created automatically.""" client_id = json_data.get("client_id", "") # Validate client ID is available in request data @@ -1470,9 +1528,34 @@ def add_restricted_queue(queue_name: str, json_data: dict) -> dict: @v1.delete("/restricted-queues/") +@v1.input( + schemas.RestrictedQueueIn, + location="json", + example={"client_id": "myclient"}, +) +@v1.input(schemas.QueueName, location="path", arg_name="queue_name") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with admin or manager permissions", + ) +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Missing client_id to remove as owner of restricted queue" + }, + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user to remove restricted queues" + }, + 404: { + "description": "(Not Found) The specified queue was not found or it is not in the restricted queue list" + }, + } +) @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) -@v1.input(schemas.RestrictedQueueIn, location="json") def delete_restricted_queue(queue_name: str, json_data: dict) -> dict: """Delete an owner from the specific restricted queue.""" if not database.check_queue_restricted(queue_name): From c969bfbefc4eb64532304bd01b84a447bef98ffc Mon Sep 17 00:00:00 2001 From: MengT Date: Wed, 31 Dec 2025 17:48:20 +0800 Subject: [PATCH 07/10] feat(api): add api doc for client perm and secrect endpoints --- server/src/testflinger/api/schemas.py | 12 +++ server/src/testflinger/api/v1.py | 143 +++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index 0737602cb..a7abc8e78 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -724,6 +724,12 @@ class ImagePostIn(Schema): ) +class ClientId(Schema): + """Client ID input schema.""" + + client_id = fields.String(required=True) + + class ClientPermissionsIn(Schema): """Client Permissions output schema.""" @@ -766,6 +772,12 @@ class SecretIn(Schema): value = fields.String(required=True) +class SecretPath(Schema): + """Secret path schema.""" + + path = fields.String(required=True) + + class ResultLegacy(Schema): """Legacy Result Post schema for backwards compatibility.""" diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index a92cd4a9e..5431e6a7d 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -1573,16 +1573,54 @@ def delete_restricted_queue(queue_name: str, json_data: dict) -> dict: @v1.get("/client-permissions") @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) -@v1.output(schemas.ClientPermissionsOut(many=True)) +@v1.output( + schemas.ClientPermissionsOut(many=True), + description="JSON data with a list all client IDs and its permission excluding the hashed secret stored in database", +) +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with admin or manager permissions", + ) +) +@v1.doc( + responses={ + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user" + }, + } +) def get_all_client_permissions() -> list[dict]: - """Retrieve all client permissions from database.""" + """Retrieve all all client_id and their permissions from database.""" return database.get_client_permissions() @v1.get("/client-permissions/") @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) -@v1.output(schemas.ClientPermissionsOut) +@v1.output( + schemas.ClientPermissionsOut, + description="JSON data with the permissions of a specified client excluding the hashed secret stored in database", +) +@v1.input(schemas.ClientId, location="path", arg_name="client_id") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with admin or manager permissions", + ) +) +@v1.doc( + responses={ + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user" + }, + 404: { + "description": "(Not Found) The specified client_id was not found" + }, + } +) def get_client_permissions(client_id) -> list[dict]: """Retrieve single client-permissions from database.""" if not database.check_client_exists(client_id): @@ -1597,7 +1635,41 @@ def get_client_permissions(client_id) -> list[dict]: @v1.put("/client-permissions/") @authenticate @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) -@v1.input(schemas.ClientPermissionsIn) +@v1.input( + schemas.ClientPermissionsIn, + location="json", + example={ + "client_id": "myclient", + "client_secret": "my-secret-password", + "max_priority": {}, + "max_reservation_time": {"*": 40000}, + "role": "contributor", + }, +) +@v1.input(schemas.ClientId, location="path", arg_name="client_id") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with admin or manager permissions", + ) +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) Missing client_secret when creating new client permissions" + }, + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user to modify client permissions" + }, + 404: { + "description": "(Not Found) The specified client_id was not found" + }, + 422: { + "description": "(Unprocessable Entity) System admin client cannot be modified using the API" + }, + } +) def set_client_permissions(client_id: str, json_data: dict) -> str: """Add or create client permissions for a specified user.""" # Testflinger Admin credential can't be modified from API!' @@ -1656,6 +1728,27 @@ def set_client_permissions(client_id: str, json_data: dict) -> str: @v1.delete("/client-permissions/") @authenticate @require_role(ServerRoles.ADMIN) +@v1.input(schemas.ClientId, location="path", arg_name="client_id") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with admin permissions", + ) +) +@v1.doc( + responses={ + 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 403: { + "description": "(Forbidden) Insufficient permissions for the authenticated user to delete client permissions" + }, + 404: { + "description": "(Not Found) The specified client_id was not found" + }, + 422: { + "description": "(Unprocessable Entity) System admin can't be removed using the API" + }, + } +) def delete_client_permissions(client_id: str) -> str: """Delete client id along with its permissions.""" # Testflinger Admin credential can't be removed from API!' @@ -1679,6 +1772,27 @@ def delete_client_permissions(client_id: str) -> str: @v1.put("/secrets//") @authenticate @v1.input(schemas.SecretIn, location="json") +@v1.input(schemas.ClientId, location="path", arg_name="client_id") +@v1.input(schemas.SecretPath, location="path", arg_name="path") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with permissions to store secrets", + ) +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) No secrets store configured or access error" + }, + 403: { + "description": "(Forbidden) client_id does not match authenticated client id" + }, + 500: { + "description": "(Internal Server Error) Error storing the secret value" + }, + } +) def secrets_put(client_id, path, json_data): """Store a secret value for the specified client_id and path.""" if current_app.secrets_store is None: @@ -1700,6 +1814,27 @@ def secrets_put(client_id, path, json_data): @v1.delete("/secrets//") @authenticate +@v1.input(schemas.ClientId, location="path", arg_name="client_id") +@v1.input(schemas.SecretPath, location="path", arg_name="path") +@v1.auth_required( + auth=security.HTTPTokenAuth( + scheme="Bearer", + description="Based64-encoded JWT access token with permissions to delete secrets", + ) +) +@v1.doc( + responses={ + 400: { + "description": "(Bad Request) No secrets store configured or access error" + }, + 403: { + "description": "(Forbidden) client_id does not match authenticated client id" + }, + 500: { + "description": "(Internal Server Error) Error deleting the secret value" + }, + } +) def secrets_delete(client_id, path): """Remove a secret value for the specified client_id and path.""" if current_app.secrets_store is None: From fd5399f36fe909c644a37921b467d253a9f36a6a Mon Sep 17 00:00:00 2001 From: MengT Date: Wed, 31 Dec 2025 01:55:05 +0800 Subject: [PATCH 08/10] feat(api): mark legacy output endpoints as deprecated --- server/src/testflinger/api/v1.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index 5431e6a7d..cbb925181 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -1855,6 +1855,7 @@ def secrets_delete(client_id, path): @v1.get("/result//output") +@v1.doc(deprecated=True) def legacy_output_get(job_id: str) -> str: """Legacy endpoint to get job output for a specified job_id. @@ -1876,6 +1877,7 @@ def legacy_output_get(job_id: str) -> str: @v1.post("/result//output") +@v1.doc(deprecated=True) def legacy_output_post(job_id: str) -> str: """Legacy endpoint to post output for a specified job_id. @@ -1898,6 +1900,7 @@ def legacy_output_post(job_id: str) -> str: @v1.get("/result//serial_output") +@v1.doc(deprecated=True) def legacy_serial_output_get(job_id: str) -> str: """Legacy endpoint to get latest serial output for a specified job ID. @@ -1919,6 +1922,7 @@ def legacy_serial_output_get(job_id: str) -> str: @v1.post("/result//serial_output") +@v1.doc(deprecated=True) def legacy_serial_output_post(job_id: str) -> str: """Legacy endpoint to post serial output for a specified job ID. From f35a21e88be3e39da62d6bfbbf71573c53d9b34c Mon Sep 17 00:00:00 2001 From: MengT Date: Thu, 8 Jan 2026 16:47:54 +0800 Subject: [PATCH 09/10] chore: fix format and linting fix sorting with numeric keys in examples --- server/src/testflinger/api/schemas.py | 33 +- server/src/testflinger/api/v1.py | 456 +++++++++++++++++++------- 2 files changed, 361 insertions(+), 128 deletions(-) diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py index a7abc8e78..a8ca6fa70 100644 --- a/server/src/testflinger/api/schemas.py +++ b/server/src/testflinger/api/schemas.py @@ -400,7 +400,7 @@ class JobId(Schema): class JobGetQuery(Schema): - """SJob GET query schema""" + """Job GET query schema.""" queue = fields.List( fields.String(), @@ -531,20 +531,29 @@ class LogPost(Schema): fragment_number = fields.Integer( required=True, metadata={ - "description": "Sequential fragment number of the log fragment being posted, starting from 0" + "description": ( + "Sequential fragment number of the log fragment " + "being posted, starting from 0" + ) }, ) timestamp = fields.DateTime( required=True, metadata={ - "description": "Timestamp in ISO 8601 format of when the log fragment was created" + "description": ( + "Timestamp in ISO 8601 format of when the log " + "fragment was created" + ) }, ) phase = fields.String( required=True, validate=OneOf(TestPhases), metadata={ - "description": "Test phase name Test phase name (setup, provision, firmware_update, test, allocate, reserve, cleanup)" + "description": ( + "Test phase name (setup, provision, firmware_update, " + "test, allocate, reserve, cleanup)" + ) }, ) log_data = fields.String( @@ -563,7 +572,9 @@ class LogGetItem(Schema): log_data = fields.String( required=True, metadata={ - "description": "Combined log text from all matching fragments for this phase" + "description": ( + "Combined log text from all matching fragments for this phase" + ) }, ) @@ -588,13 +599,17 @@ class LogQueryParams(Schema): required=False, validate=validators.Range(min=0), metadata={ - "description": "Starting fragment number to query from, defaults to 0" + "description": ( + "Starting fragment number to query from, defaults to 0" + ) }, ) start_timestamp = fields.DateTime( required=False, metadata={ - "description": "Starting timestamp to query from in ISO 8601 format" + "description": ( + "Starting timestamp to query from, in ISO 8601 format" + ) }, ) phase = fields.String( @@ -671,7 +686,9 @@ class QueueWaitTimePercentilesOut(Schema): keys=fields.String(validate=OneOf(["5", "10", "50", "90", "95"])), values=fields.Float(), metadata={ - "description": "Percentile statistics for job wait times in seconds" + "description": ( + "Percentile statistics for job wait times in seconds" + ) }, ), required=True, diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index cbb925181..f03ef2b3b 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -96,15 +96,20 @@ def get_version(): @v1.doc( responses={ 422: { - "description": "(Unprocessable Content) The submitted job contains references to secrets that are inaccessible" + "description": ( + "(Unprocessable Content) The submitted job contains " + "references to secrets that are inaccessible" + ) } } ) def job_post(json_data: dict): - """ - Create a test job request and place it on the specified queue. + """Create a test job request and place it on the specified queue. - Most parameters passed in the data section of this API will be specific to the type of agent receiving them. The `job_queue` parameter is used to designate the queue used, but all others will be passed along to the agent. + Most parameters passed in the data section of this API will be specific + to the type of agent receiving them. The `job_queue` parameter is used + to designate the queue used, but all others will be passed along to + the agent. """ try: job_queue = json_data.get("job_queue") @@ -231,20 +236,29 @@ def job_builder(data: dict): @v1.doc( responses={ 204: { - "description": "(No Content) No jobs available in the specified queues" + "description": ( + "(No Content) No jobs available in the specified queues" + ) }, 400: { - "description": "(Bad request) No queue is specified in the request" + "description": ( + "(Bad request) No queue is specified in the request" + ) }, } ) def job_get(): """Get a test job from the specified queue(s). - When an agent wants to request a job for processing, it can make this request along with a list of one or more queues that it is configured to process. The server will only return one job. + When an agent wants to request a job for processing, it can make this + request along with a list of one or more queues that it is configured + to process. The server will only return one job. Note: - Any secrets that are referenced in the job are "resolved" when the job is retrieved by an agent through this endpoint. Any secrets that are inaccessible at the time of retrieval will be resolved to the empty string. + Any secrets that are referenced in the job are "resolved" when the + job is retrieved by an agent through this endpoint. Any secrets that + are inaccessible at the time of retrieval will be resolved to the + empty string. """ queue_list = request.args.getlist("queue") if not queue_list: @@ -304,7 +318,10 @@ def retrieve_secrets(data: dict) -> dict | None: } ) def job_get_id(job_id): - """Request the json job definition for a specified job, even if it has already run.""" + """Request the json job definition for a specified job. + + Returns the job definition even if the job has already run. + """ if not check_valid_uuid(job_id): abort(400, message="Invalid job_id specified") response = database.mongo.db.jobs.find_one( @@ -328,7 +345,8 @@ def job_get_id(job_id): def attachment_get(job_id): """Download the attachments bundle for a specified job_id. - Returns a gzip-compressed tarball containing all files that were uploaded as attachments. + Returns a gzip-compressed tarball containing all files that were + uploaded as attachments. """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 @@ -349,14 +367,18 @@ def attachment_get(job_id): responses={ 400: {"description": "(Bad Request) Invalid job_id specified"}, 422: { - "description": "(Unprocessable Entity) Job not awaiting attachments or the job_id is not valid" + "description": ( + "(Unprocessable Entity) Job not awaiting " + "attachments or the job_id is not valid" + ) }, } ) def attachments_post(job_id): """Post attachment bundle for a specified job_id. - Upload a gzip-compressed tarball containing files to be used as attachments for the job. + Upload a gzip-compressed tarball containing files to be used as + attachments for the job. The job must be in a state where it's awaiting attachments. """ if not check_valid_uuid(job_id): @@ -404,11 +426,11 @@ def search_jobs(query_data): """Search for jobs by tag(s) and state(s). Parameters: - - `tags` (array): List of string tags to search for - `match` (string): Match mode for - `tags` (string, "all" or "any", default: "any") - - `state` (array): List of job states to include (or "active" to search all states other than cancelled and completed) + - `state` (array): List of job states to include (or "active" to + search all states other than cancelled and completed) """ tags = query_data.get("tags") match = request.args.get("match", "any") @@ -458,7 +480,8 @@ def search_jobs(query_data): def artifacts_post(job_id): """Upload a file artifact for the specified job_id. - Upload a gzip-compressed tarball containing test artifacts or results files. + Upload a gzip-compressed tarball containing test artifacts or results + files. """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 @@ -480,7 +503,8 @@ def artifacts_post(job_id): def artifacts_get(job_id): """Download previously submitted artifact for the specified job_id. - Returns a gzip-compressed tarball containing test artifacts or results files. + Returns a gzip-compressed tarball containing test artifacts or results + files. """ if not check_valid_uuid(job_id): return "Invalid job id\n", 400 @@ -547,18 +571,24 @@ def to_url(self, obj): @v1.doc( responses={ 204: { - "description": "(No Content) No logs found for this job_id and log_type" + "description": ( + "(No Content) No logs found for this job_id and log_type" + ) }, 400: { - "description": "(Bad Request) Invalid job_id, log_type or query parameter specified" + "description": ( + "(Bad Request) Invalid job_id, log_type or query " + "parameter specified" + ) }, } ) def log_get(job_id: str, log_type: LogType): """Retrieve logs for the specified job_id and log type. - This endpoint supports querying logs with optional filtering by phase, fragment number, - or timestamp. Logs are persistent and can be retrieved multiple times. + This endpoint supports querying logs with optional filtering by phase, + fragment number, or timestamp. Logs are persistent and can be + retrieved multiple times. """ args = request.args if not check_valid_uuid(job_id): @@ -621,10 +651,11 @@ def log_get(job_id: str, log_type: LogType): } ) def log_post(job_id: str, log_type: LogType, json_data: dict) -> str: - """ - Post a log fragment for the specified job_id and log type + """Post a log fragment for the specified job_id and log type. - This is the new logging endpoint that agents use to stream log data in fragments. Each fragment includes metadata for tracking and querying. + This is the new logging endpoint that agents use to stream log data + in fragments. Each fragment includes metadata for tracking and + querying. """ if not check_valid_uuid(job_id): abort(HTTPStatus.BAD_REQUEST, message="Invalid job_id specified") @@ -660,15 +691,19 @@ def log_post(job_id: str, log_type: LogType, json_data: dict) -> str: responses={ 400: {"description": "(Bad Request) Invalid job_id specified"}, 413: { - "description": "(Request Entity Too Large) Payload exceeds 16MB BSON size limit" + "description": ( + "(Request Entity Too Large) Payload exceeds " + "16MB BSON size limit" + ) }, } ) def result_post(job_id: str, json_data: dict) -> str: """Post job outcome data for the specified job_id. - Submit test results including exit codes for each phase, device information, - and job state. The payload must not exceed the 16MB BSON size limit. + Submit test results including exit codes for each phase, device + information, and job state. The payload must not exceed the + 16MB BSON size limit. """ if not check_valid_uuid(job_id): abort(HTTPStatus.BAD_REQUEST, message="Invalid job_id specified") @@ -697,16 +732,19 @@ def result_post(job_id: str, json_data: dict) -> str: } ) def result_get(job_id: str): - """Return previously submitted job outcome data for the specified job_id. + """Return job outcome data for the specified job_id. - This endpoint reconstructs results from the new logging system to maintain backward compatibility. It combines phase status information with logs to provide a complete view of job results. + This endpoint reconstructs results from the new logging system to + maintain backward compatibility. It combines phase status information + with logs to provide a complete view of job results. Returns: - JSON data with flattened structure including: - `{phase}_status`: Exit code for each phase - - `{phase}_output`: Standard output logs for each phase (if available) - - `{phase}_serial`: Serial console logs for each phase (if available) + - `{phase}_output`: Standard output logs for each phase + (if available) + - `{phase}_serial`: Serial console logs for each phase + (if available) - Additional metadata fields (device_info, job_state, etc.) """ @@ -737,11 +775,16 @@ def result_get(job_id: str): @v1.doc( responses={ 400: { - "description": " (Bad Request) The job is already completed or cancelled" + "description": ( + " (Bad Request) The job is already completed or cancelled" + ) }, 404: {"description": "(Not Found) The job isn't found"}, 422: { - "description": "(Unprocessable Entity) The action or the argument to it could not be processed" + "description": ( + "(Unprocessable Entity) The action or the argument " + "to it could not be processed" + ) }, } ) @@ -809,10 +852,11 @@ def queues_post(): @v1.input(schema=schemas.QueueName, location="path", arg_name="queue") @v1.doc(responses=schemas.images_out) def images_get(queue): - """Get a dict of all known image names and the associated provisioning data for a given queue. + """Get image names and provisioning data for a queue. - Returns a dictionary mapping image names to their provisioning URLs or data. - Returns an empty dict if the queue doesn't exist or has no images. + Returns a dictionary mapping image names to their provisioning URLs + or data. Returns an empty dict if the queue doesn't exist or has no + images. """ queue_data = database.mongo.db.queues.find_one( {"name": queue}, {"_id": False, "images": True} @@ -836,9 +880,10 @@ def images_get(queue): }, ) def images_post(): - """Tell testflinger about known images for a specified queue + """Tell testflinger about known images for a specified queue. - Images will be stored in a dict of key/value pairs as part of the queues collection. + Images will be stored in a dict of key/value pairs as part of the + queues collection. """ image_dict = request.get_json() # We need to delete and recreate the images in case some were removed @@ -855,13 +900,17 @@ def images_post(): @v1.output( schemas.AgentOut(many=True), status_code=200, - description="JSON data for all known agents, useful for external systems that need to gather this information", + description=( + "JSON data for all known agents, useful for external systems " + "that need to gather this information" + ), ) def agents_get_all(): """Get all agent data. - Returns JSON data for all known agents, including their state, queues, location, and - information about restricted queue ownership. Useful for external systems monitoring agents. + Returns JSON data for all known agents, including their state, + queues, location, and information about restricted queue ownership. + Useful for external systems monitoring agents. """ agents = database.get_agents() restricted_queues = database.get_restricted_queues() @@ -883,7 +932,10 @@ def agents_get_all(): @v1.output( schemas.AgentOut, status_code=200, - description="JSON data for the specified agent, useful for getting information from a single agent. ", + description=( + "JSON data for the specified agent, useful for getting " + "information from a single agent. " + ), ) @v1.doc( responses={ @@ -893,8 +945,8 @@ def agents_get_all(): def agents_get_one(agent_name): """Get the information from a specified agent. - Returns JSON data for the specified agent, including state, queues, location, and - restricted queue ownership information. + Returns JSON data for the specified agent, including state, queues, + location, and restricted queue ownership information. """ agent_data = database.get_agent_info(agent_name) @@ -954,9 +1006,9 @@ def agents_post(agent_name, json_data): def agents_provision_logs_post(agent_name, json_data): """Post provision logs for the agent to the server. - Submit provision log entries including job_id, exit_code, and detail information. - The server maintains the last 100 provision log entries per agent and tracks provision - success/failure streaks. + Submit provision log entries including job_id, exit_code, and detail + information. The server maintains the last 100 provision log entries + per agent and tracks provision success/failure streaks. """ agent_record = {} @@ -1016,23 +1068,30 @@ def agents_provision_logs_post(agent_name, json_data): @v1.output( None, status_code=200, - description="(OK) Text response from the webhook if the server was successfully able to post.", + description=( + "(OK) Text response from the webhook if the server was " + "successfully able to post." + ), ) @v1.doc( responses={ 400: { - "description": "(Bad Request) Invalid job_id or JSON data specified" + "description": ( + "(Bad Request) Invalid job_id or JSON data specified" + ) }, 504: { - "description": "(Gateway Timeout) The webhook did not respond in time" + "description": ( + "(Gateway Timeout) The webhook did not respond in time" + ) }, } ) def agents_status_post(job_id, json_data): - """Post job status updates from the agent to the server and posts them to the specified webhook URL. + """Post job status updates to the specified webhook URL. - The `job_status_webhook` parameter is required for this endpoint. Other - parameters included here will be forwarded to the webhook. + The `job_status_webhook` parameter is required for this endpoint. + Other parameters included here will be forwarded to the webhook. """ _ = job_id @@ -1076,7 +1135,10 @@ def check_valid_uuid(job_id): @v1.output( str, status_code=200, - description="(OK) Zero-based position indicating how many jobs are ahead of this job in the queue.", + description=( + "(OK) Zero-based position indicating how many jobs are ahead " + "of this job in the queue." + ), ) @v1.doc( responses={ @@ -1134,17 +1196,20 @@ def cancel_job(job_id): @v1.output( schemas.QueueWaitTimePercentilesOut, status_code=200, - description="(OK) JSON mapping of queue names to wait time metrics percentiles", + description=( + "(OK) JSON mapping of queue names to wait time metrics percentiles" + ), example={ - "myqueue": {5: 10.5, 10: 15.2, 50: 30.0, 90: 60.8, 95: 75.3}, - "otherqueue": {5: 8.0, 10: 12.5, 50: 25.0, 90: 50.0, 95: 65.0}, + "myqueue": {"5": 2.0, "10": 5.0, "50": 15.0, "90": 45.0, "95": 60.0}, + "otherqueue": {"5": 10.0, "10": 20.0, "50": 60.0, "90": 100.0, "95": 180.0}, }, ) def queue_wait_time_percentiles_get(): - """Get wait time metrics for queues, optionally take a list of queues. + """Get wait time metrics for queues. - Returns percentile statistics (p5, p10, p50, p90, p95) for job wait times in the specified queues. - If no queues are specified, returns metrics for all queues. + Returns percentile statistics (p5, p10, p50, p90, p95) for job wait + times in the specified queues. If no queues are specified, returns + metrics for all queues. """ queues = request.args.getlist("queue") wait_times = database.get_queue_wait_times(queues) @@ -1161,14 +1226,22 @@ def queue_wait_time_percentiles_get(): @v1.output( schemas.AgentOut(many=True), status_code=200, - description="JSON data with an array of agent objects listening to the specified queue, including the agent state, location, and current job information.", + description=( + "JSON data with an array of agent objects listening to the " + "specified queue, including the agent state, location, and " + "current job information." + ), ) @v1.doc( responses={ 204: { - "description": "(No Content) No agents found listening to the specified queue" + "description": ( + "(No Content) No agents found listening to the specified queue" + ) + }, + 404: { + "description": ("(Not Found) The specified queue does not exist") }, - 404: {"description": "(Not Found) The specified queue does not exist"}, } ) def get_agents_on_queue(queue_name): @@ -1190,20 +1263,27 @@ def get_agents_on_queue(queue_name): @v1.output( schemas.JobInQueueOut(many=True), status_code=200, - description="JSON data with an array of job objects including job_id, created_at timestamp, job_state, and job_queue for all jobs in the specified queue.", + description=( + "JSON data with an array of job objects including job_id, " + "created_at timestamp, job_state, and job_queue for all jobs " + "in the specified queue." + ), ) @v1.doc( responses={ 204: { - "description": "(No Content) No jobs found in the specified queue" + "description": ( + "(No Content) No jobs found in the specified queue" + ) }, } ) def get_jobs_by_queue(queue_name): """Get the jobs in a specified queue along with their state. - Returns JSON data with an array of job objects including job_id, created_at timestamp, - job_state, and job_queue for all jobs in the specified queue. + Returns JSON data with an array of job objects including job_id, + created_at timestamp, job_state, and job_queue for all jobs in the + specified queue. """ jobs = database.get_jobs_on_queue(queue_name) @@ -1238,7 +1318,10 @@ def get_jobs_by_queue(queue_name): @v1.output( schemas.Oauth2Token, status_code=200, - description="(OK) JSON object containing access token, token type, expiration time, and refresh token", + description=( + "(OK) JSON object containing access token, token type, " + "expiration time, and refresh token" + ), example={ "access_token": "", "token_type": "Bearer", @@ -1270,7 +1353,8 @@ def retrieve_token(): } Notes: - `expires_in` is the lifetime (in seconds) of the access token. - - Refresh tokens default to 30 days; admin may issue non-expiring tokens for trusted integrations. + - Refresh tokens default to 30 days; admin may issue non-expiring + tokens for trusted integrations. """ auth_header = request.authorization @@ -1319,7 +1403,10 @@ def retrieve_token(): @v1.output( schemas.Oauth2RefreshTokenOut, status_code=200, - description="(OK) JSON object containing new access token, token type, and expiration time", + description=( + "(OK) JSON object containing new access token, token type, " + "and expiration time" + ), example={ "access_token": "", "token_type": "Bearer", @@ -1329,7 +1416,10 @@ def retrieve_token(): @v1.doc( responses={ 400: { - "description": "(Bad Request) Missing, invalid, revoked, or expired refresh token" + "description": ( + "(Bad Request) Missing, invalid, revoked, or expired " + "refresh token" + ) }, } ) @@ -1363,16 +1453,25 @@ def refresh_access_token(): @v1.output( str, status_code=200, - description="(OK) Text response indicating successful revocation of the refresh token", + description=( + "(OK) Text response indicating successful revocation of the " + "refresh token" + ), example={"message": "Refresh token revoked successfully"}, ) @v1.doc( responses={ 400: { - "description": "(Bad Request) Missing, invalid, or already revoked refresh token" + "description": ( + "(Bad Request) Missing, invalid, or already revoked " + "refresh token" + ) }, 401: { - "description": "(Unauthorized) Admin privileges required to revoke refresh tokens" + "description": ( + "(Unauthorized) Admin privileges required to revoke " + "refresh tokens" + ) }, } ) @@ -1404,14 +1503,22 @@ def revoke_refresh_token(): @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with permissions to access restricted queues", + description=( + "Based64-encoded JWT access token with permissions to " + "access restricted queues" + ), ) ) @v1.doc( responses={ - 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") + }, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user to access restricted queues" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user to access restricted queues" + ) }, } ) @@ -1441,17 +1548,27 @@ def get_all_restricted_queues() -> list[dict]: @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with permissions to access restricted queues", + description=( + "Based64-encoded JWT access token with permissions to " + "access restricted queues" + ), ) ) @v1.doc( responses={ - 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") + }, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user to access restricted queues" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user to access restricted queues" + ) }, 404: { - "description": "(Not Found) The specified restricted queue was not found" + "description": ( + "(Not Found) The specified restricted queue was not found" + ) }, } ) @@ -1481,20 +1598,34 @@ def get_restricted_queue(queue_name: str) -> dict: @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with admin or manager permissions", + description=( + "Based64-encoded JWT access token with admin or manager " + "permissions" + ), ) ) @v1.doc( responses={ 400: { - "description": "(Bad Request) Missing client_id to set as owner of restricted queue" + "description": ( + "(Bad Request) Missing client_id to set as owner of " + "restricted queue" + ) + }, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") }, - 401: {"description": "(Unauthorized) Missing or invalid access token"}, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user to associate restricted queues" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user to associate restricted queues" + ) }, 404: { - "description": "(Not Found) The specified restricted queue does not exist or is not associated to an agent" + "description": ( + "(Not Found) The specified restricted queue does not " + "exist or is not associated to an agent" + ) }, } ) @@ -1502,7 +1633,8 @@ def get_restricted_queue(queue_name: str) -> dict: @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) def add_restricted_queue(queue_name: str, json_data: dict) -> dict: """Add an owner to the specific restricted queue. - If the queue does not exist yet, it will be created automatically.""" + If the queue does not exist yet, it will be created automatically. + """ client_id = json_data.get("client_id", "") # Validate client ID is available in request data @@ -1537,20 +1669,34 @@ def add_restricted_queue(queue_name: str, json_data: dict) -> dict: @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with admin or manager permissions", + description=( + "Based64-encoded JWT access token with admin or manager " + "permissions" + ), ) ) @v1.doc( responses={ 400: { - "description": "(Bad Request) Missing client_id to remove as owner of restricted queue" + "description": ( + "(Bad Request) Missing client_id to remove as owner " + "of restricted queue" + ) + }, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") }, - 401: {"description": "(Unauthorized) Missing or invalid access token"}, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user to remove restricted queues" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user to remove restricted queues" + ) }, 404: { - "description": "(Not Found) The specified queue was not found or it is not in the restricted queue list" + "description": ( + "(Not Found) The specified queue was not found or it " + "is not in the restricted queue list" + ) }, } ) @@ -1575,19 +1721,30 @@ def delete_restricted_queue(queue_name: str, json_data: dict) -> dict: @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) @v1.output( schemas.ClientPermissionsOut(many=True), - description="JSON data with a list all client IDs and its permission excluding the hashed secret stored in database", + description=( + "JSON data with a list all client IDs and its permission " + "excluding the hashed secret stored in database" + ), ) @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with admin or manager permissions", + description=( + "Based64-encoded JWT access token with admin or manager " + "permissions" + ), ) ) @v1.doc( responses={ - 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") + }, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user" + ) }, } ) @@ -1601,23 +1758,36 @@ def get_all_client_permissions() -> list[dict]: @require_role(ServerRoles.ADMIN, ServerRoles.MANAGER) @v1.output( schemas.ClientPermissionsOut, - description="JSON data with the permissions of a specified client excluding the hashed secret stored in database", + description=( + "JSON data with the permissions of a specified client " + "excluding the hashed secret stored in database" + ), ) @v1.input(schemas.ClientId, location="path", arg_name="client_id") @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with admin or manager permissions", + description=( + "Based64-encoded JWT access token with admin or manager " + "permissions" + ), ) ) @v1.doc( responses={ - 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") + }, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user" + ) }, 404: { - "description": "(Not Found) The specified client_id was not found" + "description": ( + "(Not Found) The specified client_id was not found" + ) }, } ) @@ -1650,23 +1820,39 @@ def get_client_permissions(client_id) -> list[dict]: @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with admin or manager permissions", + description=( + "Based64-encoded JWT access token with admin or manager " + "permissions" + ), ) ) @v1.doc( responses={ 400: { - "description": "(Bad Request) Missing client_secret when creating new client permissions" + "description": ( + "(Bad Request) Missing client_secret when creating " + "new client permissions" + ) + }, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") }, - 401: {"description": "(Unauthorized) Missing or invalid access token"}, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user to modify client permissions" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user to modify client permissions" + ) }, 404: { - "description": "(Not Found) The specified client_id was not found" + "description": ( + "(Not Found) The specified client_id was not found" + ) }, 422: { - "description": "(Unprocessable Entity) System admin client cannot be modified using the API" + "description": ( + "(Unprocessable Entity) System admin client cannot be " + "modified using the API" + ) }, } ) @@ -1732,20 +1918,32 @@ def set_client_permissions(client_id: str, json_data: dict) -> str: @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with admin permissions", + description=( + "Based64-encoded JWT access token with admin permissions" + ), ) ) @v1.doc( responses={ - 401: {"description": "(Unauthorized) Missing or invalid access token"}, + 401: { + "description": ("(Unauthorized) Missing or invalid access token") + }, 403: { - "description": "(Forbidden) Insufficient permissions for the authenticated user to delete client permissions" + "description": ( + "(Forbidden) Insufficient permissions for the " + "authenticated user to delete client permissions" + ) }, 404: { - "description": "(Not Found) The specified client_id was not found" + "description": ( + "(Not Found) The specified client_id was not found" + ) }, 422: { - "description": "(Unprocessable Entity) System admin can't be removed using the API" + "description": ( + "(Unprocessable Entity) System admin can't be " + "removed using the API" + ) }, } ) @@ -1777,19 +1975,28 @@ def delete_client_permissions(client_id: str) -> str: @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with permissions to store secrets", + description=( + "Based64-encoded JWT access token with permissions to " + "store secrets" + ), ) ) @v1.doc( responses={ 400: { - "description": "(Bad Request) No secrets store configured or access error" + "description": ( + "(Bad Request) No secrets store configured or access error" + ) }, 403: { - "description": "(Forbidden) client_id does not match authenticated client id" + "description": ( + "(Forbidden) client_id does not match authenticated client id" + ) }, 500: { - "description": "(Internal Server Error) Error storing the secret value" + "description": ( + "(Internal Server Error) Error storing the secret value" + ) }, } ) @@ -1819,19 +2026,28 @@ def secrets_put(client_id, path, json_data): @v1.auth_required( auth=security.HTTPTokenAuth( scheme="Bearer", - description="Based64-encoded JWT access token with permissions to delete secrets", + description=( + "Based64-encoded JWT access token with permissions to " + "delete secrets" + ), ) ) @v1.doc( responses={ 400: { - "description": "(Bad Request) No secrets store configured or access error" + "description": ( + "(Bad Request) No secrets store configured or access error" + ) }, 403: { - "description": "(Forbidden) client_id does not match authenticated client id" + "description": ( + "(Forbidden) client_id does not match authenticated client id" + ) }, 500: { - "description": "(Internal Server Error) Error deleting the secret value" + "description": ( + "(Internal Server Error) Error deleting the secret value" + ) }, } ) From a9fd21d55dce376a956abc26c121a2934046f6c7 Mon Sep 17 00:00:00 2001 From: MengT Date: Thu, 8 Jan 2026 16:48:25 +0800 Subject: [PATCH 10/10] chore: update api spec json --- server/schemas/openapi.json | 1274 +++++++++++++++++++++++++++++++---- 1 file changed, 1138 insertions(+), 136 deletions(-) diff --git a/server/schemas/openapi.json b/server/schemas/openapi.json index 5f6d700dd..70bef2b69 100644 --- a/server/schemas/openapi.json +++ b/server/schemas/openapi.json @@ -26,12 +26,15 @@ "type": "string" }, "job_id": { + "description": "Job ID the device is running, if any", "type": "string" }, "location": { + "description": "Location of the device", "type": "string" }, "log": { + "description": "Push and keep only the last 100 lines", "items": { "type": "string" }, @@ -41,12 +44,14 @@ "type": "string" }, "queues": { + "description": "Queues the device is listening on", "items": { "type": "string" }, "type": "array" }, "state": { + "description": "State the device is in", "type": "string" } }, @@ -172,6 +177,19 @@ ], "type": "object" }, + "FileUpload": { + "additionalProperties": false, + "properties": { + "file": { + "format": "binary", + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + }, "HTTPError": { "properties": { "detail": { @@ -183,6 +201,26 @@ }, "type": "object" }, + "ImagePostIn": { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": { + "additionalProperties": { + "description": "Image provision data", + "type": "string" + }, + "description": "Image data for the queue", + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "Job": { "additionalProperties": false, "properties": { @@ -284,6 +322,30 @@ ], "type": "object" }, + "JobInQueueOut": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "job_id": { + "type": "string" + }, + "job_queue": { + "type": "string" + }, + "job_state": { + "type": "string" + } + }, + "required": [ + "created_at", + "job_id", + "job_queue", + "job_state" + ], + "type": "object" + }, "JobSearchResponse": { "additionalProperties": false, "properties": { @@ -321,9 +383,11 @@ "additionalProperties": false, "properties": { "last_fragment_number": { + "description": "The highest fragment number for this phase", "type": "integer" }, "log_data": { + "description": "Combined log text from all matching fragments for this phase", "type": "string" } }, @@ -337,12 +401,15 @@ "additionalProperties": false, "properties": { "fragment_number": { + "description": "Sequential fragment number of the log fragment being posted, starting from 0", "type": "integer" }, "log_data": { + "description": "The log content for this fragment", "type": "string" }, "phase": { + "description": "Test phase name (setup, provision, firmware_update, test, allocate, reserve, cleanup)", "enum": [ "setup", "provision", @@ -355,6 +422,7 @@ "type": "string" }, "timestamp": { + "description": "Timestamp in ISO 8601 format of when the log fragment was created", "format": "date-time", "type": "string" } @@ -367,6 +435,63 @@ ], "type": "object" }, + "Oauth2RefreshTokenIn": { + "additionalProperties": false, + "properties": { + "refresh_token": { + "description": "Opaque refresh token", + "type": "string" + } + }, + "required": [ + "refresh_token" + ], + "type": "object" + }, + "Oauth2RefreshTokenOut": { + "additionalProperties": false, + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "token_type": { + "type": "string" + } + }, + "required": [ + "access_token", + "expires_in", + "token_type" + ], + "type": "object" + }, + "Oauth2Token": { + "additionalProperties": false, + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + }, + "token_type": { + "type": "string" + } + }, + "required": [ + "access_token", + "expires_in", + "refresh_token", + "token_type" + ], + "type": "object" + }, "ProvisionData": { "additionalProperties": false, "properties": {}, @@ -391,6 +516,53 @@ ], "type": "object" }, + "QueueDict": { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": { + "description": "Queue description", + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "QueueName": { + "additionalProperties": false, + "properties": { + "queue": { + "type": "string" + } + }, + "required": [ + "queue" + ], + "type": "object" + }, + "QueueWaitTimePercentilesOut": { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": { + "additionalProperties": { + "type": "number" + }, + "description": "Percentile statistics for job wait times in seconds", + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ReserveData": { "additionalProperties": false, "properties": { @@ -608,6 +780,63 @@ }, "type": "object" } + }, + "securitySchemes": { + "BasicAuth": { + "description": "Base64 encoded pair of client_id:client_key", + "scheme": "basic", + "type": "http" + }, + "BearerAuth": { + "description": "Based64-encoded JWT access token with permissions to access restricted queues", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_10": { + "description": "Based64-encoded JWT access token with permissions to delete secrets", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_2": { + "description": "Based64-encoded JWT access token with permissions to access restricted queues", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_3": { + "description": "Based64-encoded JWT access token with admin or manager permissions", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_4": { + "description": "Based64-encoded JWT access token with admin or manager permissions", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_5": { + "description": "Based64-encoded JWT access token with admin or manager permissions", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_6": { + "description": "Based64-encoded JWT access token with admin or manager permissions", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_7": { + "description": "Based64-encoded JWT access token with admin or manager permissions", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_8": { + "description": "Based64-encoded JWT access token with admin permissions", + "scheme": "bearer", + "type": "http" + }, + "BearerAuth_9": { + "description": "Based64-encoded JWT access token with permissions to store secrets", + "scheme": "bearer", + "type": "http" + } } }, "info": { @@ -653,6 +882,7 @@ }, "/v1/agents/data": { "get": { + "description": "Returns JSON data for all known agents, including their state,\nqueues, location, and information about restricted queue ownership.\nUseful for external systems monitoring agents.", "parameters": [], "responses": { "200": { @@ -666,7 +896,7 @@ } } }, - "description": "Successful response" + "description": "JSON data for all known agents, useful for external systems that need to gather this information" } }, "summary": "Get all agent data.", @@ -677,7 +907,7 @@ }, "/v1/agents/data/{agent_name}": { "get": { - "description": ":param agent_name:\nString with the name of the agent to retrieve information from.\n:return:\nJSON data with the specified agent information.", + "description": "Returns JSON data for the specified agent, including state, queues,\nlocation, and restricted queue ownership information.", "parameters": [ { "in": "path", @@ -697,7 +927,7 @@ } } }, - "description": "Successful response" + "description": "JSON data for the specified agent, useful for getting information from a single agent. " }, "404": { "content": { @@ -707,7 +937,17 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified agent was not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, "summary": "Get the information from a specified agent.", @@ -774,8 +1014,27 @@ }, "/v1/agents/images": { "post": { - "description": "images will be stored in a dict of key/value pairs as part of the queues\ncollection. That dict will contain image_name:provision_data mappings, ex:\n{\n\"some_queue\": {\n\"core22\": \"http://cdimage.ubuntu.com/.../core-22.tar.gz\",\n\"jammy\": \"http://cdimage.ubuntu.com/.../ubuntu-22.04.tar.gz\"\n},\n\"other_queue\": {\n...\n}\n}.", + "description": "Images will be stored in a dict of key/value pairs as part of the\nqueues collection.", "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "myqueue": { + "core22": "http://cdimage.ubuntu.com/.../core-22.tar.gz", + "jammy": "http://cdimage.ubuntu.com/.../ubuntu-22.04.tar.gz" + }, + "other_queue": { + "image1": "data1", + "image2": "data2" + } + }, + "schema": { + "$ref": "#/components/schemas/ImagePostIn" + } + } + } + }, "responses": { "200": { "content": { @@ -784,9 +1043,19 @@ } }, "description": "Successful response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Tell testflinger about known images for a specified queue", + "summary": "Tell testflinger about known images for a specified queue.", "tags": [ "V1" ] @@ -794,6 +1063,7 @@ }, "/v1/agents/images/{queue}": { "get": { + "description": "Returns a dictionary mapping image names to their provisioning URLs\nor data. Returns an empty dict if the queue doesn't exist or has no\nimages.", "parameters": [ { "in": "path", @@ -831,9 +1101,19 @@ } }, "description": "Not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Get a dict of known images for a given queue.", + "summary": "Get image names and provisioning data for a queue.", "tags": [ "V1" ] @@ -841,6 +1121,7 @@ }, "/v1/agents/provision_logs/{agent_name}": { "post": { + "description": "Submit provision log entries including job_id, exit_code, and detail\ninformation. The server maintains the last 100 provision log entries\nper agent and tracks provision success/failure streaks.", "parameters": [ { "in": "path", @@ -854,6 +1135,11 @@ "requestBody": { "content": { "application/json": { + "example": { + "detail": "foo", + "exit_code": 1, + "job_id": "00000000-0000-0000-0000-000000000000" + }, "schema": { "$ref": "#/components/schemas/ProvisionLogsIn" } @@ -927,6 +1213,19 @@ "post": { "description": "Some agents may want to advertise some of the queues they listen on so that\nthe user can check which queues are valid to use.", "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "myqueue": "queue 1", + "myqueue2": "queue 2" + }, + "schema": { + "$ref": "#/components/schemas/QueueDict" + } + } + } + }, "responses": { "200": { "content": { @@ -935,6 +1234,16 @@ } }, "description": "Successful response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, "summary": "Tell testflinger the queue names that are being serviced.", @@ -958,10 +1267,29 @@ } } }, - "description": "Successful response" + "description": "JSON data with a list all client IDs and its permission excluding the hashed secret stored in database" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user" } }, - "summary": "Retrieve all client permissions from database.", + "security": [ + { + "BearerAuth_5": [] + } + ], + "summary": "Retrieve all all client_id and their permissions from database.", "tags": [ "V1" ] @@ -980,13 +1308,19 @@ } ], "responses": { - "200": { + "401": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user to delete client permissions" }, "404": { "content": { @@ -996,9 +1330,24 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified client_id was not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "(Unprocessable Entity) System admin can't be removed using the API" } }, + "security": [ + { + "BearerAuth_8": [] + } + ], "summary": "Delete client id along with its permissions.", "tags": [ "V1" @@ -1024,7 +1373,21 @@ } } }, - "description": "Successful response" + "description": "JSON data with the permissions of a specified client excluding the hashed secret stored in database" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user" }, "404": { "content": { @@ -1034,9 +1397,24 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified client_id was not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, + "security": [ + { + "BearerAuth_6": [] + } + ], "summary": "Retrieve single client-permissions from database.", "tags": [ "V1" @@ -1056,6 +1434,15 @@ "requestBody": { "content": { "application/json": { + "example": { + "client_id": "myclient", + "client_secret": "my-secret-password", + "max_priority": {}, + "max_reservation_time": { + "*": 40000 + }, + "role": "contributor" + }, "schema": { "$ref": "#/components/schemas/ClientPermissionsIn" } @@ -1063,13 +1450,23 @@ } }, "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) Missing client_secret when creating new client permissions" + }, + "401": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user to modify client permissions" }, "404": { "content": { @@ -1079,7 +1476,7 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified client_id was not found" }, "422": { "content": { @@ -1089,9 +1486,14 @@ } } }, - "description": "Validation error" + "description": "(Unprocessable Entity) System admin client cannot be modified using the API" } }, + "security": [ + { + "BearerAuth_7": [] + } + ], "summary": "Add or create client permissions for a specified user.", "tags": [ "V1" @@ -1100,7 +1502,23 @@ }, "/v1/job": { "get": { - "parameters": [], + "description": "When an agent wants to request a job for processing, it can make this\nrequest along with a list of one or more queues that it is configured\nto process. The server will only return one job.\n\nNote:\nAny secrets that are referenced in the job are \"resolved\" when the\njob is retrieved by an agent through this endpoint. Any secrets that\nare inaccessible at the time of retrieval will be resolved to the\nempty string.", + "parameters": [ + { + "description": "List of queue name(s) that the agent can process", + "explode": true, + "in": "query", + "name": "queue", + "required": true, + "schema": { + "items": { + "type": "string" + }, + "type": "array" + }, + "style": "form" + } + ], "responses": { "200": { "content": { @@ -1110,30 +1528,52 @@ } } }, - "description": "Successful response" + "description": "(OK) JSON job data that was submitted by the requester" }, "204": { + "content": {}, + "description": "(No Content) No jobs available in the specified queues" + }, + "400": { + "content": {}, + "description": "(Bad request) No queue is specified in the request" + }, + "422": { "content": { "application/json": { "schema": { - "properties": {}, - "type": "object" + "$ref": "#/components/schemas/ValidationError" } } }, - "description": "No job found" + "description": "Validation error" } }, - "summary": "Request a job to run from supported queues.", + "summary": "Get a test job from the specified queue(s).", "tags": [ "V1" ] }, "post": { + "description": "Most parameters passed in the data section of this API will be specific\nto the type of agent receiving them. The `job_queue` parameter is used\nto designate the queue used, but all others will be passed along to\nthe agent.", "parameters": [], "requestBody": { "content": { "application/json": { + "example": { + "job_queue": "myqueue", + "name": "Example Test Job", + "provision_data": { + "url": "" + }, + "tags": [ + "test", + "sample" + ], + "test_data": { + "test_cmds": "lsb_release -a" + } + }, "schema": { "$ref": "#/components/schemas/Job" } @@ -1144,12 +1584,15 @@ "200": { "content": { "application/json": { + "example": { + "job_id": "550e8400-1234-1234-1234-446655440000" + }, "schema": { "$ref": "#/components/schemas/JobId" } } }, - "description": "Successful response" + "description": "(OK) Returns the job_id (UUID) of the newly created job" }, "422": { "content": { @@ -1159,10 +1602,10 @@ } } }, - "description": "Validation error" + "description": "(Unprocessable Content) The submitted job contains references to secrets that are inaccessible" } }, - "summary": "Add a job to the queue.", + "summary": "Create a test job request and place it on the specified queue.", "tags": [ "V1" ] @@ -1170,6 +1613,7 @@ }, "/v1/job/search": { "get": { + "description": "Parameters:\n- `tags` (array): List of string tags to search for\n- `match` (string): Match mode for\n- `tags` (string, \"all\" or \"any\", default: \"any\")\n- `state` (array): List of job states to include (or \"active\" to\nsearch all states other than cancelled and completed)", "parameters": [ { "description": "List of tags to search for", @@ -1230,6 +1674,14 @@ "200": { "content": { "application/json": { + "example": { + "jobs": [ + { + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "job_queue": "myqueue" + } + ] + }, "schema": { "$ref": "#/components/schemas/JobSearchResponse" } @@ -1248,7 +1700,7 @@ "description": "Validation error" } }, - "summary": "Search for jobs by tags.", + "summary": "Search for jobs by tag(s) and state(s).", "tags": [ "V1" ] @@ -1256,7 +1708,7 @@ }, "/v1/job/{job_id}": { "get": { - "description": "already run.\n\n:param job_id:\nUUID as a string for the job\n:return:\nJSON data for the job or error string and http error", + "description": "Returns the job definition even if the job has already run.", "parameters": [ { "in": "path", @@ -1276,7 +1728,15 @@ } } }, - "description": "Successful response" + "description": "(OK) JSON data for the job" + }, + "204": { + "content": {}, + "description": "(No Content) Job not found" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" }, "404": { "content": { @@ -1287,9 +1747,19 @@ } }, "description": "Not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Request the json job definition for a specified job, even if it has", + "summary": "Request the json job definition for a specified job.", "tags": [ "V1" ] @@ -1297,7 +1767,7 @@ }, "/v1/job/{job_id}/action": { "post": { - "description": ":param job_id:\nUUID as a string for the job", + "description": "Supported actions:\n- cancel: Cancel a job that hasn't been completed yet", "parameters": [ { "in": "path", @@ -1311,6 +1781,9 @@ "requestBody": { "content": { "application/json": { + "example": { + "action": "cancel" + }, "schema": { "$ref": "#/components/schemas/ActionIn" } @@ -1321,10 +1794,16 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/None" + } } }, - "description": "Successful response" + "description": "(OK) Action executed successfully" + }, + "400": { + "content": {}, + "description": " (Bad Request) The job is already completed or cancelled" }, "404": { "content": { @@ -1334,7 +1813,7 @@ } } }, - "description": "Not found" + "description": "(Not Found) The job isn't found" }, "422": { "content": { @@ -1344,10 +1823,10 @@ } } }, - "description": "Validation error" + "description": "(Unprocessable Entity) The action or the argument to it could not be processed" } }, - "summary": "Take action on the job status for a specified job ID.", + "summary": "Execute action for the specified job_id.", "tags": [ "V1" ] @@ -1355,7 +1834,7 @@ }, "/v1/job/{job_id}/attachments": { "get": { - "description": ":param job_id:\nUUID as a string for the job\n:return:\nsend_file stream of attachment tarball to download", + "description": "Returns a gzip-compressed tarball containing all files that were\nuploaded as attachments.", "parameters": [ { "in": "path", @@ -1367,32 +1846,42 @@ } ], "responses": { - "200": { + "204": { + "content": {}, + "description": "(No Content) No attachments found for this job" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" + }, + "404": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "Not found" }, - "404": { + "422": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPError" + "$ref": "#/components/schemas/ValidationError" } } }, - "description": "Not found" + "description": "Validation error" } }, - "summary": "Return the attachments bundle for a specified job_id.", + "summary": "Download the attachments bundle for a specified job_id.", "tags": [ "V1" ] }, "post": { - "description": ":param job_id:\nUUID as a string for the job", + "description": "Upload a gzip-compressed tarball containing files to be used as\nattachments for the job.\nThe job must be in a state where it's awaiting attachments.", "parameters": [ { "in": "path", @@ -1403,24 +1892,39 @@ } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FileUpload" + } + } + } + }, "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" + }, + "404": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "Not found" }, - "404": { + "422": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPError" + "$ref": "#/components/schemas/ValidationError" } } }, - "description": "Not found" + "description": "(Unprocessable Entity) Job not awaiting attachments or the job_id is not valid" } }, "summary": "Post attachment bundle for a specified job_id.", @@ -1431,7 +1935,7 @@ }, "/v1/job/{job_id}/events": { "post": { - "description": "to TestObserver.\n\nThe json sent to this endpoint may contain data such as the following:\n{\n\"agent_id\": \"\",\n\"job_queue\": \"\",\n\"job_status_webhook\": \"\",\n\"events\": [\n{\n\"event_name\": \"\",\n\"timestamp\": \"\",\n\"detail\": \"\"\n},\n...\n]\n}", + "description": "The `job_status_webhook` parameter is required for this endpoint.\nOther parameters included here will be forwarded to the webhook.", "parameters": [ { "in": "path", @@ -1445,6 +1949,18 @@ "requestBody": { "content": { "application/json": { + "example": { + "agent_id": "agent-00", + "events": [ + { + "detail": "my_detailed_message", + "event_name": "started_provisioning", + "timestamp": "2024-05-03T19:11:33.541130+00:00" + } + ], + "job_queue": "myqueue", + "job_status_webhook": "http://mywebhook" + }, "schema": { "$ref": "#/components/schemas/StatusUpdate" } @@ -1455,10 +1971,16 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/None" + } } }, - "description": "Successful response" + "description": "(OK) Text response from the webhook if the server was successfully able to post." + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id or JSON data specified" }, "404": { "content": { @@ -1479,9 +2001,13 @@ } }, "description": "Validation error" + }, + "504": { + "content": {}, + "description": "(Gateway Timeout) The webhook did not respond in time" } }, - "summary": "Post status updates from the agent to the server to be forwarded", + "summary": "Post job status updates to the specified webhook URL.", "tags": [ "V1" ] @@ -1503,10 +2029,16 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/" + } } }, - "description": "Successful response" + "description": "(OK) Zero-based position indicating how many jobs are ahead of this job in the queue." + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" }, "404": { "content": { @@ -1517,9 +2049,23 @@ } }, "description": "Not found" + }, + "410": { + "content": {}, + "description": "(Gone) Job not found or already started" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Return the position of the specified jobid in the queue.", + "summary": "Return the position of the specified job_id in the queue.", "tags": [ "V1" ] @@ -1528,14 +2074,47 @@ "/v1/oauth2/refresh": { "post": { "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "refresh_token": "" + }, + "schema": { + "$ref": "#/components/schemas/Oauth2RefreshTokenIn" + } + } + } + }, "responses": { "200": { "content": { "application/json": { - "schema": {} + "example": { + "access_token": "", + "expires_in": 30, + "token_type": "Bearer" + }, + "schema": { + "$ref": "#/components/schemas/Oauth2RefreshTokenOut" + } } }, - "description": "Successful response" + "description": "(OK) JSON object containing new access token, token type, and expiration time" + }, + "400": { + "content": {}, + "description": "(Bad Request) Missing, invalid, revoked, or expired refresh token" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, "summary": "Refresh access token using a valid refresh token.", @@ -1547,14 +2126,49 @@ "/v1/oauth2/revoke": { "post": { "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "refresh_token": "" + }, + "schema": { + "$ref": "#/components/schemas/Oauth2RefreshTokenIn" + } + } + } + }, "responses": { "200": { "content": { "application/json": { - "schema": {} + "example": { + "message": "Refresh token revoked successfully" + }, + "schema": { + "$ref": "#/components/schemas/" + } } }, - "description": "Successful response" + "description": "(OK) Text response indicating successful revocation of the refresh token" + }, + "400": { + "content": {}, + "description": "(Bad Request) Missing, invalid, or already revoked refresh token" + }, + "401": { + "content": {}, + "description": "(Unauthorized) Admin privileges required to revoke refresh tokens" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, "summary": "Revoke a refresh token. Only admins can perform this action.", @@ -1565,18 +2179,41 @@ }, "/v1/oauth2/token": { "post": { - "description": "Get JWT with priority and queue permissions.\n\nBefore being encrypted, the JWT can contain fields like:\n{\nexp: ,\niat: ,\nsub: ,\npermissions: {\nmax_priority: ,\nallowed_queues: ,\nmax_reservation_time: ,\n}\n}", + "description": "Get JWT with priority and queue permissions.\n\nBefore being encrypted, the JWT can contain fields like:\n{\nexp: ,\niat: ,\nsub: ,\npermissions: {\nmax_priority: ,\nallowed_queues: ,\nmax_reservation_time: ,\n}\n}\nNotes:\n- `expires_in` is the lifetime (in seconds) of the access token.\n- Refresh tokens default to 30 days; admin may issue non-expiring\ntokens for trusted integrations.", "parameters": [], "responses": { "200": { "content": { "application/json": { - "schema": {} + "example": { + "access_token": "", + "expires_in": 30, + "refresh_token": "", + "token_type": "Bearer" + }, + "schema": { + "$ref": "#/components/schemas/Oauth2Token" + } } }, - "description": "Successful response" + "description": "(OK) JSON object containing access token, token type, expiration time, and refresh token" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "(Unauthorized) Invalid client id or client key" } }, + "security": [ + { + "BasicAuth": [] + } + ], "summary": "Issue both access token and refresh token for a client.", "tags": [ "V1" @@ -1585,18 +2222,62 @@ }, "/v1/queues/wait_times": { "get": { - "parameters": [], + "description": "Returns percentile statistics (p5, p10, p50, p90, p95) for job wait\ntimes in the specified queues. If no queues are specified, returns\nmetrics for all queues.", + "parameters": [ + { + "description": "List of queue names", + "explode": true, + "in": "query", + "name": "queue", + "required": false, + "schema": { + "items": { + "$ref": "#/components/schemas/QueueName" + }, + "type": "array" + }, + "style": "form" + } + ], "responses": { "200": { "content": { "application/json": { - "schema": {} + "example": { + "myqueue": { + "10": 5.0, + "5": 2.0, + "50": 15.0, + "90": 45.0, + "95": 60.0 + }, + "otherqueue": { + "10": 20.0, + "5": 10.0, + "50": 60.0, + "90": 100.0, + "95": 180.0 + } + }, + "schema": { + "$ref": "#/components/schemas/QueueWaitTimePercentilesOut" + } } }, - "description": "Successful response" + "description": "(OK) JSON mapping of queue names to wait time metrics percentiles" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Get wait time metrics - optionally take a list of queues.", + "summary": "Get wait time metrics for queues.", "tags": [ "V1" ] @@ -1607,7 +2288,7 @@ "parameters": [ { "in": "path", - "name": "queue_name", + "name": "queue", "required": true, "schema": { "type": "string" @@ -1626,7 +2307,11 @@ } } }, - "description": "Successful response" + "description": "JSON data with an array of agent objects listening to the specified queue, including the agent state, location, and current job information." + }, + "204": { + "content": {}, + "description": "(No Content) No agents found listening to the specified queue" }, "404": { "content": { @@ -1636,7 +2321,17 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified queue does not exist" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, "summary": "Get the list of all data for agents listening to a specified queue.", @@ -1647,11 +2342,11 @@ }, "/v1/queues/{queue_name}/jobs": { "get": { - "description": ":param queue_name\nString with the queue name where to perform the query.\n:return:\nJSON data with the jobs allocated to the specified queue.", + "description": "Returns JSON data with an array of job objects including job_id,\ncreated_at timestamp, job_state, and job_queue for all jobs in the\nspecified queue.", "parameters": [ { "in": "path", - "name": "queue_name", + "name": "queue", "required": true, "schema": { "type": "string" @@ -1662,10 +2357,19 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "$ref": "#/components/schemas/JobInQueueOut" + }, + "type": "array" + } } }, - "description": "Successful response" + "description": "JSON data with an array of job objects including job_id, created_at timestamp, job_state, and job_queue for all jobs in the specified queue." + }, + "204": { + "content": {}, + "description": "(No Content) No jobs found in the specified queue" }, "404": { "content": { @@ -1676,9 +2380,19 @@ } }, "description": "Not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Get the jobs in a specified queue along with its state.", + "summary": "Get the jobs in a specified queue along with their state.", "tags": [ "V1" ] @@ -1700,8 +2414,27 @@ } }, "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user to access restricted queues" } }, + "security": [ + { + "BearerAuth": [] + } + ], "summary": "List all agent's restricted queues and its owners.", "tags": [ "V1" @@ -1713,7 +2446,7 @@ "parameters": [ { "in": "path", - "name": "queue_name", + "name": "queue", "required": true, "schema": { "type": "string" @@ -1723,6 +2456,9 @@ "requestBody": { "content": { "application/json": { + "example": { + "client_id": "myclient" + }, "schema": { "$ref": "#/components/schemas/RestrictedQueueIn" } @@ -1730,13 +2466,23 @@ } }, "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) Missing client_id to remove as owner of restricted queue" + }, + "401": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user to remove restricted queues" }, "404": { "content": { @@ -1746,7 +2492,7 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified queue was not found or it is not in the restricted queue list" }, "422": { "content": { @@ -1759,6 +2505,11 @@ "description": "Validation error" } }, + "security": [ + { + "BearerAuth_4": [] + } + ], "summary": "Delete an owner from the specific restricted queue.", "tags": [ "V1" @@ -1768,7 +2519,7 @@ "parameters": [ { "in": "path", - "name": "queue_name", + "name": "queue", "required": true, "schema": { "type": "string" @@ -1786,6 +2537,20 @@ }, "description": "Successful response" }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + }, + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user to access restricted queues" + }, "404": { "content": { "application/json": { @@ -1794,19 +2559,35 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified restricted queue was not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, + "security": [ + { + "BearerAuth_2": [] + } + ], "summary": "Get restricted queues for a specific agent.", "tags": [ "V1" ] }, "post": { + "description": "If the queue does not exist yet, it will be created automatically.", "parameters": [ { "in": "path", - "name": "queue_name", + "name": "queue", "required": true, "schema": { "type": "string" @@ -1816,6 +2597,9 @@ "requestBody": { "content": { "application/json": { + "example": { + "client_id": "myclient" + }, "schema": { "$ref": "#/components/schemas/RestrictedQueueIn" } @@ -1823,13 +2607,23 @@ } }, "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) Missing client_id to set as owner of restricted queue" + }, + "401": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "(Unauthorized) Missing or invalid access token" + }, + "403": { + "content": {}, + "description": "(Forbidden) Insufficient permissions for the authenticated user to associate restricted queues" }, "404": { "content": { @@ -1839,7 +2633,7 @@ } } }, - "description": "Not found" + "description": "(Not Found) The specified restricted queue does not exist or is not associated to an agent" }, "422": { "content": { @@ -1852,6 +2646,11 @@ "description": "Validation error" } }, + "security": [ + { + "BearerAuth_3": [] + } + ], "summary": "Add an owner to the specific restricted queue.", "tags": [ "V1" @@ -1860,7 +2659,7 @@ }, "/v1/result/{job_id}": { "get": { - "description": ":param job_id: UUID as a string for the job\n:raises HTTPError: If the job_id is not a valid UUID", + "description": "This endpoint reconstructs results from the new logging system to\nmaintain backward compatibility. It combines phase status information\nwith logs to provide a complete view of job results.\n\nReturns:\nJSON data with flattened structure including:\n- `{phase}_status`: Exit code for each phase\n- `{phase}_output`: Standard output logs for each phase\n(if available)\n- `{phase}_serial`: Serial console logs for each phase\n(if available)\n- Additional metadata fields (device_info, job_state, etc.)", "parameters": [ { "in": "path", @@ -1880,7 +2679,15 @@ } } }, - "description": "Successful response" + "description": "(OK) JSON data with flattened structure" + }, + "204": { + "content": {}, + "description": "(No Content) No results found for this job_id" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" }, "404": { "content": { @@ -1891,15 +2698,25 @@ } }, "description": "Not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Return results for a specified job_id.", + "summary": "Return job outcome data for the specified job_id.", "tags": [ "V1" ] }, "post": { - "description": ":param job_id: UUID as a string for the job\n:raises HTTPError: If the job_id is not a valid UUID", + "description": "Submit test results including exit codes for each phase, device\ninformation, and job state. The payload must not exceed the\n16MB BSON size limit.", "parameters": [ { "in": "path", @@ -1913,6 +2730,14 @@ "requestBody": { "content": { "application/json": { + "example": { + "device_info": {}, + "status": { + "provision": 0, + "setup": 0, + "test": 0 + } + }, "schema": { "$ref": "#/components/schemas/Result" } @@ -1923,10 +2748,16 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/None" + } } }, - "description": "Successful response" + "description": "(OK) Job outcome data posted successfully" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" }, "404": { "content": { @@ -1938,6 +2769,10 @@ }, "description": "Not found" }, + "413": { + "content": {}, + "description": "(Request Entity Too Large) Payload exceeds 16MB BSON size limit" + }, "422": { "content": { "application/json": { @@ -1949,7 +2784,7 @@ "description": "Validation error" } }, - "summary": "Post a result for a specified job_id.", + "summary": "Post job outcome data for the specified job_id.", "tags": [ "V1" ] @@ -1957,7 +2792,7 @@ }, "/v1/result/{job_id}/artifact": { "get": { - "description": ":param job_id:\nUUID as a string for the job\n:return:\nsend_file stream of artifact tarball to download", + "description": "Returns a gzip-compressed tarball containing test artifacts or results\nfiles.", "parameters": [ { "in": "path", @@ -1969,32 +2804,42 @@ } ], "responses": { - "200": { + "204": { + "content": {}, + "description": "(No Content) No artifact found for this job" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" + }, + "404": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "Not found" }, - "404": { + "422": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPError" + "$ref": "#/components/schemas/ValidationError" } } }, - "description": "Not found" + "description": "Validation error" } }, - "summary": "Return artifact bundle for a specified job_id.", + "summary": "Download previously submitted artifact for the specified job_id.", "tags": [ "V1" ] }, "post": { - "description": ":param job_id:\nUUID as a string for the job", + "description": "Upload a gzip-compressed tarball containing test artifacts or results\nfiles.", "parameters": [ { "in": "path", @@ -2005,27 +2850,42 @@ } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FileUpload" + } + } + } + }, "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id specified" + }, + "404": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "Not found" }, - "404": { + "422": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPError" + "$ref": "#/components/schemas/ValidationError" } } }, - "description": "Not found" + "description": "Validation error" } }, - "summary": "Post artifact bundle for a specified job_id.", + "summary": "Upload a file artifact for the specified job_id.", "tags": [ "V1" ] @@ -2033,19 +2893,62 @@ }, "/v1/result/{job_id}/log/{log_type}": { "get": { - "description": ":param job_id: UUID as a string for the job\n:param log_type: LogType enum value for the type of log requested\n:raises HTTPError: If the job_id is not a valid UUID or if invalid query\n:return: Dictionary with log data", + "description": "This endpoint supports querying logs with optional filtering by phase,\nfragment number, or timestamp. Logs are persistent and can be\nretrieved multiple times.", "parameters": [ { + "description": "Starting fragment number to query from, defaults to 0", + "in": "query", + "name": "start_fragment", + "required": false, + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Starting timestamp to query from, in ISO 8601 format", + "in": "query", + "name": "start_timestamp", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Test phase name to filter logs", + "in": "query", + "name": "phase", + "required": false, + "schema": { + "enum": [ + "setup", + "provision", + "firmware_update", + "test", + "allocate", + "reserve", + "cleanup" + ], + "type": "string" + } + }, + { + "description": "Type of log to retrieve (output or serial)", "in": "path", - "name": "job_id", + "name": "log_type", "required": true, "schema": { + "enum": [ + "output", + "serial" + ], "type": "string" } }, { "in": "path", - "name": "log_type", + "name": "job_id", "required": true, "schema": { "type": "string" @@ -2056,12 +2959,32 @@ "200": { "content": { "application/json": { + "example": { + "output": { + "provision": { + "last_fragment_number": 12, + "log_data": "Provisioning device...\nDevice ready\n" + }, + "setup": { + "last_fragment_number": 5, + "log_data": "Starting setup...\nSetup complete\n" + } + } + }, "schema": { "$ref": "#/components/schemas/LogGet" } } }, - "description": "Successful response" + "description": "(OK) JSON object with logs organized by phase" + }, + "204": { + "content": {}, + "description": "(No Content) No logs found for this job_id and log_type" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id, log_type or query parameter specified" }, "404": { "content": { @@ -2072,27 +2995,42 @@ } }, "description": "Not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" } }, - "summary": "Get logs for a specified job_id.", + "summary": "Retrieve logs for the specified job_id and log type.", "tags": [ "V1" ] }, "post": { - "description": ":param job_id: UUID as a string for the job\n:param log_type: LogType enum value for the type of log being posted\n:raises HTTPError: If the job_id is not a valid UUID\n:param json_data: Dictionary with log data", + "description": "This is the new logging endpoint that agents use to stream log data\nin fragments. Each fragment includes metadata for tracking and\nquerying.", "parameters": [ { + "description": "Type of log to retrieve (output or serial)", "in": "path", - "name": "job_id", + "name": "log_type", "required": true, "schema": { + "enum": [ + "output", + "serial" + ], "type": "string" } }, { "in": "path", - "name": "log_type", + "name": "job_id", "required": true, "schema": { "type": "string" @@ -2102,6 +3040,12 @@ "requestBody": { "content": { "application/json": { + "example": { + "fragment_number": 0, + "log_data": "Starting setup phase...", + "phase": "setup", + "timestamp": "2025-10-15T10:00:00+00:00" + }, "schema": { "$ref": "#/components/schemas/LogPost" } @@ -2112,10 +3056,16 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/None" + } } }, - "description": "Successful response" + "description": "(OK) Log fragment posted successfully" + }, + "400": { + "content": {}, + "description": "(Bad Request) Invalid job_id or log_type specified" }, "404": { "content": { @@ -2138,7 +3088,7 @@ "description": "Validation error" } }, - "summary": "Post logs for a specified job ID.", + "summary": "Post a log fragment for the specified job_id and log type.", "tags": [ "V1" ] @@ -2146,6 +3096,7 @@ }, "/v1/result/{job_id}/output": { "get": { + "deprecated": true, "description": "TODO: Remove after CLI/agent migration completes.\n\n:param job_id: UUID as a string for the job\n:raises HTTPError: BAD_REQUEST when job_id is invalid\n:return: Plain text output", "parameters": [ { @@ -2183,6 +3134,7 @@ ] }, "post": { + "deprecated": true, "description": "TODO: Remove after CLI/agent migration completes.\n\n:param job_id: UUID as a string for the job\n:raises HTTPError: BAD_REQUEST when job_id is invalid\n:return: \"OK\" on success", "parameters": [ { @@ -2222,6 +3174,7 @@ }, "/v1/result/{job_id}/serial_output": { "get": { + "deprecated": true, "description": "TODO: Remove after CLI/agent migration completes.\n\n:param job_id: UUID as a string for the job\n:raises HTTPError: BAD_REQUEST when job_id is invalid\n:return: Plain text serial output", "parameters": [ { @@ -2259,6 +3212,7 @@ ] }, "post": { + "deprecated": true, "description": "TODO: Remove after CLI/agent migration completes.\n\n:param job_id: UUID as a string for the job\n:raises HTTPError: BAD_REQUEST when job_id is invalid\n:return: \"OK\" on success", "parameters": [ { @@ -2301,7 +3255,7 @@ "parameters": [ { "in": "path", - "name": "client_id", + "name": "path", "required": true, "schema": { "type": "string" @@ -2309,7 +3263,7 @@ }, { "in": "path", - "name": "path", + "name": "client_id", "required": true, "schema": { "type": "string" @@ -2317,13 +3271,23 @@ } ], "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) No secrets store configured or access error" + }, + "401": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "Authentication error" + }, + "403": { + "content": {}, + "description": "(Forbidden) client_id does not match authenticated client id" }, "404": { "content": { @@ -2334,8 +3298,27 @@ } }, "description": "Not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Validation error" + }, + "500": { + "content": {}, + "description": "(Internal Server Error) Error deleting the secret value" } }, + "security": [ + { + "BearerAuth_10": [] + } + ], "summary": "Remove a secret value for the specified client_id and path.", "tags": [ "V1" @@ -2345,7 +3328,7 @@ "parameters": [ { "in": "path", - "name": "client_id", + "name": "path", "required": true, "schema": { "type": "string" @@ -2353,7 +3336,7 @@ }, { "in": "path", - "name": "path", + "name": "client_id", "required": true, "schema": { "type": "string" @@ -2370,13 +3353,23 @@ } }, "responses": { - "200": { + "400": { + "content": {}, + "description": "(Bad Request) No secrets store configured or access error" + }, + "401": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HTTPError" + } } }, - "description": "Successful response" + "description": "Authentication error" + }, + "403": { + "content": {}, + "description": "(Forbidden) client_id does not match authenticated client id" }, "404": { "content": { @@ -2397,8 +3390,17 @@ } }, "description": "Validation error" + }, + "500": { + "content": {}, + "description": "(Internal Server Error) Error storing the secret value" } }, + "security": [ + { + "BearerAuth_9": [] + } + ], "summary": "Store a secret value for the specified client_id and path.", "tags": [ "V1"