Skip to content

Commit 95949f1

Browse files
authored
Replace conflicting repository-service-tuf dep (#16098)
* Replace conflicting repository-service-tuf dep Previously, `repository-service-tuf` (i.e. the RSTUF cli) was used to bootstrap an RSTUF repo for development. This PR re-implements the relevant parts of the cli locally in Warehouse and removes the `repository-service-tuf` dependency, which conflicts with other dependencies. Change details - Add lightweight RSTUF API client library (can be re-used for #15815) - Add local `warehouse tuf bootstrap` cli subcommand, to wraps lib calls - Invoke local cli via `make inittuf` - Remove dependency supersedes #15958 (cc @facutuesca @woodruffw) Signed-off-by: Lukas Puehringer <[email protected]> * Make payload arg in tuf cli "lazy" Other than the regular click File, the LazyFile also has the "name" attribute, when passing stdin via "-". We print the name on success. Signed-off-by: Lukas Puehringer <[email protected]> * Add minimal unittest for TUF bootstrap cli Signed-off-by: Lukas Puehringer <[email protected]> * Add unit tests for RSTUF API client lib Signed-off-by: Lukas Puehringer <[email protected]> --------- Signed-off-by: Lukas Puehringer <[email protected]>
1 parent 7eb6030 commit 95949f1

File tree

6 files changed

+236
-2
lines changed

6 files changed

+236
-2
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ initdb: .state/docker-build-base .state/db-populated
128128
inittuf: .state/db-migrated
129129
docker compose up -d rstuf-api
130130
docker compose up -d rstuf-worker
131-
docker compose run --rm web rstuf admin ceremony -b -u -f dev/rstuf/bootstrap.json --api-server http://rstuf-api
131+
docker compose run --rm web python -m warehouse tuf bootstrap dev/rstuf/bootstrap.json --api-server http://rstuf-api
132132

133133
runmigrations: .state/docker-build-base
134134
docker compose run --rm web python -m warehouse db upgrade head

requirements/dev.txt

-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@ hupper>=1.9
33
pip-tools>=1.0
44
pyramid_debugtoolbar>=2.5
55
pip-api
6-
repository-service-tuf

tests/unit/cli/test_tuf.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import json
14+
15+
from pretend import call, call_recorder
16+
17+
from warehouse.cli import tuf
18+
19+
20+
class TestTUF:
21+
def test_bootstrap(self, cli, monkeypatch):
22+
task_id = "123456"
23+
server = "rstuf.api"
24+
payload = ["foo"]
25+
26+
post = call_recorder(lambda *a: task_id)
27+
wait = call_recorder(lambda *a: None)
28+
monkeypatch.setattr(tuf, "post_bootstrap", post)
29+
monkeypatch.setattr(tuf, "wait_for_success", wait)
30+
31+
result = cli.invoke(
32+
tuf.bootstrap, args=["--api-server", server, "-"], input=json.dumps(payload)
33+
)
34+
35+
assert result.exit_code == 0
36+
37+
assert post.calls == [call(server, payload)]
38+
assert wait.calls == [call(server, task_id)]

tests/unit/tuf/test_tuf.py

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import pytest
14+
15+
from pretend import call, call_recorder, stub
16+
17+
from warehouse import tuf
18+
19+
20+
class TestTUF:
21+
server = "rstuf.api"
22+
task_id = "123456"
23+
24+
def test_get_task_state(self, monkeypatch):
25+
state = "SUCCESS"
26+
27+
resp_json = {"data": {"state": state}}
28+
resp = stub(
29+
raise_for_status=(lambda *a: None), json=(lambda *a, **kw: resp_json)
30+
)
31+
get = call_recorder(lambda *a: resp)
32+
monkeypatch.setattr(tuf.requests, "get", get)
33+
34+
result = tuf.get_task_state(self.server, self.task_id)
35+
36+
assert result == state
37+
assert get.calls == [call(f"{self.server}/api/v1/task?task_id={self.task_id}")]
38+
39+
def test_post_bootstrap(self, monkeypatch):
40+
payload = ["foo"]
41+
42+
resp_json = {"data": {"task_id": self.task_id}}
43+
resp = stub(
44+
raise_for_status=(lambda *a: None), json=(lambda *a, **kw: resp_json)
45+
)
46+
post = call_recorder(lambda *a, **kw: resp)
47+
monkeypatch.setattr(tuf.requests, "post", post)
48+
49+
# Test success
50+
result = tuf.post_bootstrap(self.server, payload)
51+
52+
assert result == self.task_id
53+
assert post.calls == [call(f"{self.server}/api/v1/bootstrap", json=payload)]
54+
55+
# Test fail with incomplete response json
56+
del resp_json["data"]
57+
with pytest.raises(tuf.RSTUFError):
58+
tuf.post_bootstrap(self.server, payload)
59+
60+
def test_wait_for_success(self, monkeypatch):
61+
get_task_state = call_recorder(lambda *a: "SUCCESS")
62+
monkeypatch.setattr(tuf, "get_task_state", get_task_state)
63+
tuf.wait_for_success(self.server, self.task_id)
64+
65+
assert get_task_state.calls == [call(self.server, self.task_id)]
66+
67+
@pytest.mark.parametrize(
68+
"state, iterations",
69+
[
70+
("PENDING", 20),
71+
("RUNNING", 20),
72+
("RECEIVED", 20),
73+
("STARTED", 20),
74+
("FAILURE", 1),
75+
("ERRORED", 1),
76+
("REVOKED", 1),
77+
("REJECTED", 1),
78+
("bogus", 1),
79+
],
80+
)
81+
def test_wait_for_success_error(self, state, iterations, monkeypatch):
82+
monkeypatch.setattr(tuf.time, "sleep", lambda *a: None)
83+
84+
get_task_state = call_recorder(lambda *a: state)
85+
monkeypatch.setattr(tuf, "get_task_state", get_task_state)
86+
87+
with pytest.raises(tuf.RSTUFError):
88+
tuf.wait_for_success(self.server, self.task_id)
89+
90+
assert get_task_state.calls == [call(self.server, self.task_id)] * iterations

warehouse/cli/tuf.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import json
14+
15+
import click
16+
17+
from warehouse.cli import warehouse
18+
from warehouse.tuf import post_bootstrap, wait_for_success
19+
20+
21+
@warehouse.group()
22+
def tuf():
23+
"""Manage TUF."""
24+
25+
26+
@tuf.command()
27+
@click.argument("payload", type=click.File("rb", lazy=True), required=True)
28+
@click.option("--api-server", required=True)
29+
def bootstrap(payload, api_server):
30+
"""Use payload file to bootstrap RSTUF server."""
31+
task_id = post_bootstrap(api_server, json.load(payload))
32+
wait_for_success(api_server, task_id)
33+
print(f"Bootstrap completed using `{payload.name}`. 🔐 🎉")

warehouse/tuf/__init__.py

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
"""
14+
RSTUF API client library
15+
"""
16+
17+
import time
18+
19+
from typing import Any
20+
21+
import requests
22+
23+
24+
class RSTUFError(Exception):
25+
pass
26+
27+
28+
def get_task_state(server: str, task_id: str) -> str:
29+
resp = requests.get(f"{server}/api/v1/task?task_id={task_id}")
30+
resp.raise_for_status()
31+
return resp.json()["data"]["state"]
32+
33+
34+
def post_bootstrap(server: str, payload: Any) -> str:
35+
resp = requests.post(f"{server}/api/v1/bootstrap", json=payload)
36+
resp.raise_for_status()
37+
38+
# TODO: Ask upstream to not return 200 on error
39+
resp_json = resp.json()
40+
resp_data = resp_json.get("data")
41+
if not resp_data:
42+
raise RSTUFError(f"Error in RSTUF job: {resp_json}")
43+
44+
return resp_data["task_id"]
45+
46+
47+
def wait_for_success(server: str, task_id: str):
48+
"""Poll RSTUF task state API until success or error."""
49+
50+
retries = 20
51+
delay = 1
52+
53+
for _ in range(retries):
54+
state = get_task_state(server, task_id)
55+
56+
match state:
57+
case "SUCCESS":
58+
break
59+
60+
case "PENDING" | "RUNNING" | "RECEIVED" | "STARTED":
61+
time.sleep(delay)
62+
continue
63+
64+
case "FAILURE":
65+
raise RSTUFError("RSTUF job failed, please check payload and retry")
66+
67+
case "ERRORED" | "REVOKED" | "REJECTED":
68+
raise RSTUFError("RSTUF internal problem, please check RSTUF health")
69+
70+
case _:
71+
raise RSTUFError(f"RSTUF job returned unexpected state: {state}")
72+
73+
else:
74+
raise RSTUFError("RSTUF job failed, please check payload and retry")

0 commit comments

Comments
 (0)