From d5313154560fe1a09666965da148e412d6aaeae6 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Wed, 27 Aug 2025 14:20:43 -0500 Subject: [PATCH] Create new endpoint for multi agent submission of child jobs --- .../devices/multi/multi.py | 3 +- .../devices/multi/tests/test_multi.py | 4 + .../devices/multi/tfclient.py | 13 ++ server/src/testflinger/api/v1.py | 70 +++++++++- server/src/testflinger/database.py | 20 ++- server/tests/test_v1_authorization.py | 124 ++++++++++++++++++ 6 files changed, 228 insertions(+), 6 deletions(-) diff --git a/device-connectors/src/testflinger_device_connectors/devices/multi/multi.py b/device-connectors/src/testflinger_device_connectors/devices/multi/multi.py index 7e805d2a9..29e4ad7d3 100644 --- a/device-connectors/src/testflinger_device_connectors/devices/multi/multi.py +++ b/device-connectors/src/testflinger_device_connectors/devices/multi/multi.py @@ -142,7 +142,8 @@ def create_jobs(self): updated_job = self.inject_parent_jobid(updated_job) try: - job_id = self.client.submit_job(updated_job) + # Use agent job submission for credential inheritance + job_id = self.client.submit_agent_job(updated_job) except requests.exceptions.HTTPError as exc: logger.error("Unable to create job: %s", exc.response.text) self.cancel_jobs(self.jobs) diff --git a/device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py b/device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py index f5a516f73..745bffaa9 100644 --- a/device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py +++ b/device-connectors/src/testflinger_device_connectors/devices/multi/tests/test_multi.py @@ -29,6 +29,10 @@ def submit_job(self, job_data): """Return a fake job id.""" return str(uuid4()) + def submit_agent_job(self, job_data): + """Return a fake agent job id.""" + return self.submit_job(job_data) + def test_bad_tfclient_url(): """Test that Multi raises an exception when TFClient URL is bad.""" diff --git a/device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py b/device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py index 8843b52fe..0f33a158d 100644 --- a/device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py +++ b/device-connectors/src/testflinger_device_connectors/devices/multi/tfclient.py @@ -145,6 +145,19 @@ def submit_job(self, job_data): response = self.post(endpoint, job_data) return json.loads(response).get("job_id") + def submit_agent_job(self, job_data): + """Submit a child job to the testflinger server with + credential inheritance. + + :param job_data: + dict of data for the job to submit, must include parent_job_id + :return: + ID for the test job + """ + endpoint = "/v1/agent/jobs" + response = self.post(endpoint, job_data) + return json.loads(response).get("job_id") + def cancel_job(self, job_id): """Tell the server to cancel a specified job_id.""" try: diff --git a/server/src/testflinger/api/v1.py b/server/src/testflinger/api/v1.py index f9411c2b4..41ad68175 100644 --- a/server/src/testflinger/api/v1.py +++ b/server/src/testflinger/api/v1.py @@ -140,6 +140,47 @@ def validate_secrets(data: dict): data["client_id"] = client_id +@v1.post("/agent/jobs") +@authenticate +@v1.input(schemas.Job, location="json") +@v1.output(schemas.JobId) +def agent_job_post(json_data: dict): + """ + Add a child job to the queue with inherited credentials + from parent job. + """ + try: + job_queue = json_data.get("job_queue") + parent_job_id = json_data.get("parent_job_id") + except (AttributeError, BadRequest): + job_queue = "" + parent_job_id = None + + if not job_queue: + abort(422, message="Invalid data or no job_queue specified") + + if not parent_job_id: + abort(422, message="parent_job_id required for agent job submission") + + if not check_valid_uuid(parent_job_id): + abort(400, message="Invalid parent_job_id specified") + + # Retrieve parent job permissions for credential inheritance + inherited_permissions = database.retrieve_parent_permissions(parent_job_id) + + try: + job = job_builder(json_data, inherited_permissions) + except ValueError: + abort(400, message="Invalid job_id specified") + + jobs_metric.labels(queue=job_queue).inc() + if "reserve_data" in json_data: + reservations_metric.labels(queue=job_queue).inc() + + database.add_job(job) + return jsonify(job_id=job.get("job_id")) + + def has_attachments(data: dict) -> bool: """Predicate if the job described by `data` involves attachments.""" return any( @@ -150,8 +191,13 @@ def has_attachments(data: dict) -> bool: ) -def job_builder(data: dict): - """Build a job from a dictionary of data.""" +def job_builder(data: dict, inherited_permissions: dict = None): + """Build a job from a dictionary of data. + + :param data: Job data dictionary + :param inherited_permissions: Optional permissions inherited from + parent job + """ job = { "created_at": datetime.now(timezone.utc), "result_data": { @@ -174,12 +220,30 @@ def job_builder(data: dict): data["attachments_status"] = "waiting" priority_level = data.get("job_priority", 0) + + # Use inherited permissions if provided, otherwise use current + # user's permissions + if inherited_permissions: + permissions_to_check = inherited_permissions + else: + permissions_to_check = g.permissions auth.check_permissions( - g.permissions, + permissions_to_check, data, ) job["job_priority"] = priority_level + # Store authentication permissions for credential inheritance + if inherited_permissions: + job["auth_permissions"] = inherited_permissions + elif g.is_authenticated: + job["auth_permissions"] = g.permissions + + # Store parent job relationship if this is a child job + parent_job_id = data.get("parent_job_id") + if parent_job_id: + job["parent_job_id"] = parent_job_id + job["job_id"] = job_id job["job_data"] = data return job diff --git a/server/src/testflinger/database.py b/server/src/testflinger/database.py index f14819e62..345babfc5 100644 --- a/server/src/testflinger/database.py +++ b/server/src/testflinger/database.py @@ -441,8 +441,7 @@ def check_client_exists(client_id: str) -> bool: def add_client_permissions(data: dict) -> None: """Create a new client_id along with its permissions. - - :param data: client_id along with its permissions + :param data: client_id along with its permissions. """ mongo.db.client_permissions.insert_one(data) @@ -540,3 +539,20 @@ def delete_refresh_token(token: str) -> None: :param token: The refresh token to delete. """ mongo.db.refresh_tokens.delete_one({"refresh_token": token}) + + +def retrieve_parent_permissions(parent_job_id: str) -> dict: + """Retrieve auth permissions from parent job for credential inheritance. + + :param parent_job_id: UUID of the parent job to inherit permissions from. + :return: Dictionary with parent job's auth permissions, + or empty dict if none. + """ + parent_job = mongo.db.jobs.find_one( + {"job_id": parent_job_id}, {"auth_permissions": True, "_id": False} + ) + + if not parent_job: + return {} + + return parent_job.get("auth_permissions", {}) diff --git a/server/tests/test_v1_authorization.py b/server/tests/test_v1_authorization.py index 6e67fa9c9..2e7e6b6ea 100644 --- a/server/tests/test_v1_authorization.py +++ b/server/tests/test_v1_authorization.py @@ -1232,3 +1232,127 @@ def test_refresh_token_last_accessed_update(mongo_app_with_permissions): last_accessed_after = token_entry_after["last_accessed"] assert last_accessed_after > last_accessed_before + + +def test_retrieve_parent_permissions_valid(mongo_app): + """Test retrieving auth permissions from valid parent job.""" + from testflinger import database + + app, mongo = mongo_app + parent_permissions = { + "client_id": "test_client", + "max_priority": {"queue1": 100}, + "allowed_queues": ["restricted_queue"], + "max_reservation_time": {"queue1": 7200}, + } + + # Insert parent job with permissions + parent_job = { + "job_id": "parent-123", + "auth_permissions": parent_permissions, + "job_data": {"job_queue": "queue1"}, + } + mongo.jobs.insert_one(parent_job) + + # Test retrieval + retrieved_permissions = database.retrieve_parent_permissions("parent-123") + assert retrieved_permissions == parent_permissions + + +def test_retrieve_parent_permissions_no_permissions(mongo_app): + """Test retrieving permissions from parent job with no auth_permissions.""" + from testflinger import database + + app, mongo = mongo_app + + # Insert parent job without permissions + parent_job = {"job_id": "parent-456", "job_data": {"job_queue": "queue1"}} + mongo.jobs.insert_one(parent_job) + + # Should return empty dict, not error + retrieved_permissions = database.retrieve_parent_permissions("parent-456") + assert retrieved_permissions == {} + + +def test_retrieve_parent_permissions_nonexistent(mongo_app): + """Test retrieving permissions from non-existent parent job.""" + from testflinger import database + + app, mongo = mongo_app + + # Should return empty dict, not error + retrieved_permissions = database.retrieve_parent_permissions( + "nonexistent-job" + ) + assert retrieved_permissions == {} + + +def test_agent_jobs_endpoint_with_credentials(mongo_app_with_permissions): + """Test agent endpoint submits child job with inherited credentials.""" + app, mongo, client_id, client_key, _ = mongo_app_with_permissions + + # Create parent job with permissions + token = get_access_token(app, client_id, client_key) + parent_job = {"job_queue": "myqueue2", "job_priority": 200} + parent_response = app.post( + "/v1/job", json=parent_job, headers={"Authorization": token} + ) + parent_job_id = parent_response.json.get("job_id") + + # Submit child job via agent endpoint + child_job = { + "job_queue": "myqueue2", + "job_priority": 200, + "parent_job_id": parent_job_id, + } + child_response = app.post("/v1/agent/jobs", json=child_job) + assert child_response.status_code == 200 + + child_job_id = child_response.json.get("job_id") + assert child_job_id is not None + + # Verify child job inherited parent permissions + child_job_data = mongo.jobs.find_one({"job_id": child_job_id}) + assert child_job_data["parent_job_id"] == parent_job_id + assert "auth_permissions" in child_job_data + + +def test_agent_jobs_endpoint_missing_parent_job_id(mongo_app): + """Test agent endpoint rejects jobs without parent_job_id.""" + app, _ = mongo_app + + child_job = {"job_queue": "myqueue"} + response = app.post("/v1/agent/jobs", json=child_job) + assert response.status_code == 422 + assert "parent_job_id required" in response.get_json()["message"] + + +def test_agent_jobs_endpoint_invalid_parent_job_id(mongo_app): + """Test agent endpoint rejects jobs with invalid parent_job_id.""" + app, _ = mongo_app + + child_job = {"job_queue": "myqueue", "parent_job_id": "invalid-uuid"} + response = app.post("/v1/agent/jobs", json=child_job) + assert response.status_code == 400 + assert "Invalid parent_job_id" in response.get_json()["message"] + + +def test_agent_jobs_endpoint_no_parent_permissions(mongo_app): + """Test agent endpoint works when parent has no permissions.""" + app, mongo = mongo_app + + # Create parent job without authentication (no permissions) + parent_job = {"job_queue": "myqueue"} + parent_response = app.post("/v1/job", json=parent_job) + parent_job_id = parent_response.json.get("job_id") + + # Submit child job via agent endpoint + child_job = {"job_queue": "myqueue", "parent_job_id": parent_job_id} + child_response = app.post("/v1/agent/jobs", json=child_job) + assert child_response.status_code == 200 + + # Verify child job was created successfully + child_job_id = child_response.json.get("job_id") + child_job_data = mongo.jobs.find_one({"job_id": child_job_id}) + assert child_job_data["parent_job_id"] == parent_job_id + assert "auth_permissions" not in child_job_data