Skip to content

Commit 76c2f2b

Browse files
committed
add swarm endpoint
1 parent 3e5ac42 commit 76c2f2b

File tree

4 files changed

+115
-13
lines changed

4 files changed

+115
-13
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ docker compose pull container_name && docker compose up -d container_name
5555
```bash
5656
docker compose up -d --build container_name
5757
```
58+
- `/swarm`: Rebuild your container in a docker swarm. This is equivalent to:
59+
```bash
60+
docker service update --image container_image --with-registry-auth --force container_name
61+
```
62+
Where the `container_image` gets automatically looked up from the `container_name`.
5863

5964
These endpoints are async. Meaning after request validation will return while the docker commands will process in the background.
6065

app/main.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
import uvicorn
77
from fastapi import FastAPI, HTTPException, Request
88
from src.execute import run_command
9-
from src.types import RequestData, ReturnMessage
9+
from src.types import (
10+
RequestData,
11+
ReturnMessage,
12+
ServiceJsonType,
13+
SwarmRequestData,
14+
)
1015
from src.validate import ValidateRequest
1116

1217
UVICORN_PORT = int(environ.get("UVICORN_PORT", 8000))
@@ -29,14 +34,31 @@ async def validate_request(
2934
headers = dict(request.headers)
3035

3136
container_name, compose_file = await ValidateRequest(
32-
headers, data, request_body
33-
).validate()
37+
headers, request_body
38+
).validate(data)
3439
except ValueError as err:
3540
raise HTTPException(status_code=403, detail=str(err)) from err
3641

3742
return container_name, compose_file
3843

3944

45+
async def validate_swarm_request(
46+
data: SwarmRequestData, request: Request
47+
) -> ServiceJsonType:
48+
"""validate request, return container_name and compose_file"""
49+
try:
50+
request_body = await request.body()
51+
headers = dict(request.headers)
52+
53+
service_json: ServiceJsonType = await ValidateRequest(
54+
headers, request_body
55+
).validate_swarm(data)
56+
except ValueError as err:
57+
raise HTTPException(status_code=403, detail=str(err)) from err
58+
59+
return service_json
60+
61+
4062
@app.post("/pull")
4163
async def pull_container(data: RequestData, request: Request) -> ReturnMessage:
4264
"""endpoint to pull container"""
@@ -81,6 +103,33 @@ async def execute_docker_commands():
81103
)
82104

83105

106+
@app.post("/swarm")
107+
async def rebuild_sarm_container(
108+
data: SwarmRequestData, request: Request
109+
) -> ReturnMessage:
110+
"""endpoint for swarm container rebuild"""
111+
112+
service_json: ServiceJsonType = await validate_swarm_request(data, request)
113+
image = service_json.Image
114+
service_name = service_json.Name
115+
registry_auth = "--with-registry-auth" if data.with_registry_auth else ""
116+
117+
async def execute_docker_commands():
118+
await run_command(
119+
(
120+
"docker service update "
121+
f"--image {image} {registry_auth} --force {service_name}"
122+
)
123+
)
124+
125+
asyncio.create_task(execute_docker_commands())
126+
return ReturnMessage(
127+
message="pulling",
128+
container_name=service_name,
129+
compose_file=False,
130+
)
131+
132+
84133
# entry point
85134
if __name__ == "__main__":
86135
uvicorn.run(app, host="0.0.0.0", port=UVICORN_PORT)

app/src/types.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""describes static types"""
22

3+
from typing import Optional
4+
35
from pydantic import BaseModel
46

57

@@ -8,10 +10,28 @@ class ReturnMessage(BaseModel):
810

911
message: str
1012
container_name: str
11-
compose_file: str
13+
compose_file: str | bool
1214

1315

1416
class RequestData(BaseModel):
1517
"""describes post request data"""
1618

1719
container_name: str
20+
21+
22+
class SwarmRequestData(BaseModel):
23+
"""describes post request data to swarm endpoint"""
24+
25+
container_name: str
26+
with_registry_auth: Optional[bool] = None
27+
28+
29+
class ServiceJsonType(BaseModel):
30+
"""describes a response type for services"""
31+
32+
ID: str
33+
Image: str
34+
Mode: str
35+
Name: str
36+
Ports: str
37+
Replicas: str

app/src/validate.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from os import environ
99

1010
from src.execute import run_command
11-
from src.types import RequestData
11+
from src.types import RequestData, ServiceJsonType, SwarmRequestData
1212

1313
logging.basicConfig(level=logging.INFO)
1414

@@ -19,24 +19,33 @@ class ValidateRequest:
1919
TIME_WINDOW = 300 # 5 minutes
2020
SECRET_KEY = environ["SECRET_KEY"]
2121

22-
def __init__(
23-
self, headers: dict, data: RequestData, request_body: bytes
24-
) -> None:
22+
def __init__(self, headers: dict, request_body: bytes) -> None:
2523
self.headers = headers
26-
self.data = data
2724
self.request_body = request_body
2825

29-
async def validate(self) -> tuple[str, str]:
26+
async def validate(self, data: RequestData) -> tuple[str, str]:
3027
"""validate request"""
3128
self.validate_timestamp()
3229
self.validate_signature()
33-
container_name = self.get_container_name()
30+
container_name = self.get_container_name(data)
3431
await self.validate_container_name(container_name)
3532
compose_file = await self.get_compose_file(container_name)
3633
logging.info("validation passed")
3734

3835
return container_name, compose_file
3936

37+
async def validate_swarm(self, data: SwarmRequestData) -> ServiceJsonType:
38+
"""validate swarm request"""
39+
self.validate_timestamp()
40+
self.validate_signature()
41+
container_name = self.get_container_name(data)
42+
service_json: ServiceJsonType = await self.validate_swarm_service(
43+
container_name
44+
)
45+
logging.info("validation passed")
46+
47+
return service_json
48+
4049
def validate_timestamp(self) -> None:
4150
"""raise valueerror on invalid timestamp"""
4251
timestamp = self.headers.get("x-timestamp")
@@ -64,9 +73,9 @@ def validate_signature(self):
6473
if not hmac.compare_digest(computed_signature, signature):
6574
raise ValueError("invalid signature")
6675

67-
def get_container_name(self) -> str:
76+
def get_container_name(self, data: RequestData | SwarmRequestData) -> str:
6877
"""extract container name from data"""
69-
container_name = self.data.container_name
78+
container_name = data.container_name
7079
if not container_name:
7180
raise ValueError("no container name defined")
7281

@@ -87,6 +96,25 @@ async def validate_container_name(self, container_name: str):
8796

8897
raise ValueError("container_name not found")
8998

99+
async def validate_swarm_service(
100+
self, container_name: str
101+
) -> ServiceJsonType:
102+
"""validate swarm service name"""
103+
services = await run_command("docker service ls --format=json")
104+
for service in services.split("\n"):
105+
if not service:
106+
continue
107+
108+
try:
109+
service_json = ServiceJsonType.model_validate_json(service)
110+
except ValueError:
111+
continue
112+
113+
if service_json.Name == container_name:
114+
return service_json
115+
116+
raise ValueError("container_name not found")
117+
90118
async def get_compose_file(self, container_name: str) -> str:
91119
"""get absolute compose file path from container config"""
92120
try:

0 commit comments

Comments
 (0)