feat: add stack management and container delete endpoints
Build and Push / build (release) Successful in 29s

- Add stack compose YAML get/update endpoints
- Add stack environment variable get/update endpoints
- Add stack deploy and rebuild endpoints
- Add container delete endpoint with force option
- Add Portainer client methods for new operations
- Add comprehensive test coverage (30 new tests)
- Remove REQUESTED_SERVICES.md (now implemented)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 10:19:50 +01:00
co-authored by Claude Opus 4.5
parent b5bdff3d54
commit 167a3a43a8
7 changed files with 1104 additions and 448 deletions
+376
View File
@@ -402,3 +402,379 @@ class TestInfrastructureGetService:
response = client.get("/infrastructure/services/nonexistent")
assert response.status_code == 404
class TestInfrastructureDeleteContainer:
"""Test DELETE /infrastructure/containers/{container_id} endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.return_value = True
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/abc123")
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_with_force(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should pass force parameter."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.return_value = True
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/abc123?force=true")
assert response.status_code == 204
mock_portainer.delete_container.assert_called_once_with(1, "abc123", force=True)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_delete_container_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Delete container should return 404 if not found."""
mock_portainer = AsyncMock()
mock_portainer.get_endpoints.return_value = [{"Id": 1}]
mock_portainer.delete_container.side_effect = Exception("404 no such container")
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.delete("/infrastructure/containers/nonexistent")
assert response.status_code == 404
class TestInfrastructureStackCompose:
"""Test /infrastructure/stacks/{stackId}/compose endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_compose_returns_yaml(self, mock_get_npm, mock_get_portainer, client):
"""Get stack compose should return YAML content."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack_file.return_value = "version: '3'\nservices:\n web:\n image: nginx"
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/compose")
assert response.status_code == 200
assert "text/yaml" in response.headers.get("content-type", "")
assert "version:" in response.text
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get stack compose should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/nonexistent/compose")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.update_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/compose",
content="version: '3'\nservices:\n web:\n image: nginx:latest",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_400_for_empty(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 400 for empty content."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/compose",
content="",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 400
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_compose_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Update stack compose should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/nonexistent/compose",
content="version: '3'",
headers={"Content-Type": "text/yaml"}
)
assert response.status_code == 404
class TestInfrastructureStackEnv:
"""Test /infrastructure/stacks/{stackId}/env endpoints."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_dict(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return environment variables as dict."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack.return_value = {
"Id": 1,
"Name": "mystack",
"Env": [
{"name": "DB_HOST", "value": "localhost"},
{"name": "DB_PORT", "value": "5432"}
]
}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/env")
assert response.status_code == 200
data = response.json()
assert data == {"DB_HOST": "localhost", "DB_PORT": "5432"}
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_empty_dict(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return empty dict if no env vars."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.get_stack.return_value = {"Id": 1, "Name": "mystack", "Env": []}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/mystack/env")
assert response.status_code == 200
assert response.json() == {}
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_get_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Get stack env should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.get("/infrastructure/stacks/nonexistent/env")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_env_returns_204(self, mock_get_npm, mock_get_portainer, client):
"""Update stack env should return 204 on success."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.update_stack_env.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/mystack/env",
json={"DB_HOST": "newhost", "DB_PORT": "5433"}
)
assert response.status_code == 204
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_put_stack_env_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Update stack env should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.put(
"/infrastructure/stacks/nonexistent/env",
json={"KEY": "value"}
)
assert response.status_code == 404
class TestInfrastructureStackDeploy:
"""Test /infrastructure/stacks/{stackId}/deploy endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should return 202 Accepted."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/deploy")
assert response.status_code == 202
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_does_not_pull_images(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should not pull images."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/deploy")
assert response.status_code == 202
mock_portainer.redeploy_stack.assert_called_once_with(
stack_id=1,
endpoint_id=1,
pull_image=False
)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_deploy_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Deploy stack should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/nonexistent/deploy")
assert response.status_code == 404
class TestInfrastructureStackRebuild:
"""Test /infrastructure/stacks/{stackId}/rebuild endpoint."""
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_returns_202(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should return 202 Accepted."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_pulls_images(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should pull images."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "mystack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
mock_portainer.redeploy_stack.assert_called_once_with(
stack_id=1,
endpoint_id=1,
pull_image=True
)
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_returns_404_for_missing(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should return 404 if stack not found."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = []
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/nonexistent/rebuild")
assert response.status_code == 404
@patch("src.controllers.infrastructure_controller.get_portainer_client")
@patch("src.controllers.infrastructure_controller.get_npm_client")
def test_rebuild_stack_case_insensitive(self, mock_get_npm, mock_get_portainer, client):
"""Rebuild stack should match stack name case-insensitively."""
mock_portainer = AsyncMock()
mock_portainer.get_stacks.return_value = [
{"Id": 1, "Name": "MyStack", "EndpointId": 1}
]
mock_portainer.redeploy_stack.return_value = {"Id": 1}
mock_get_portainer.return_value = mock_portainer
mock_npm = AsyncMock()
mock_get_npm.return_value = mock_npm
response = client.post("/infrastructure/stacks/mystack/rebuild")
assert response.status_code == 202
+210
View File
@@ -561,6 +561,216 @@ class TestPortainerClientWrapperMethods:
assert result is None
class TestPortainerClientStackFile:
"""Test stack file operations."""
@pytest.mark.asyncio
async def test_get_stack_file_returns_content(self):
"""get_stack_file should return compose YAML content."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
compose_content = "version: '3'\nservices:\n web:\n image: nginx"
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"StackFileContent": compose_content}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_stack_file(1)
assert result == compose_content
@pytest.mark.asyncio
async def test_get_stack_file_returns_empty_string_if_missing(self):
"""get_stack_file should return empty string if content missing."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_stack_file(1)
assert result == ""
class TestPortainerClientRedeployStack:
"""Test stack redeploy operations."""
@pytest.mark.asyncio
async def test_redeploy_stack_without_pull(self):
"""redeploy_stack should redeploy without pulling images by default."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file:
mock_file.return_value = "version: '3'"
with patch.object(client, "get_stack", new_callable=AsyncMock) as mock_stack:
mock_stack.return_value = {"Id": 1, "Env": [{"name": "KEY", "value": "val"}]}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.redeploy_stack(1, 1, pull_image=False)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["pullImage"] is False
assert result == {"Id": 1}
@pytest.mark.asyncio
async def test_redeploy_stack_with_pull(self):
"""redeploy_stack should pull images when requested."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file:
mock_file.return_value = "version: '3'"
with patch.object(client, "get_stack", new_callable=AsyncMock) as mock_stack:
mock_stack.return_value = {"Id": 1, "Env": []}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.redeploy_stack(1, 1, pull_image=True)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["pullImage"] is True
class TestPortainerClientUpdateStackEnv:
"""Test stack environment variable updates."""
@pytest.mark.asyncio
async def test_update_stack_env_sends_env_vars(self):
"""update_stack_env should update environment variables."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
env_vars = [{"name": "DB_HOST", "value": "localhost"}]
with patch.object(client, "get_stack_file", new_callable=AsyncMock) as mock_file:
mock_file.return_value = "version: '3'"
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"Id": 1}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.put.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.update_stack_env(1, 1, env_vars)
call_args = mock_client.put.call_args
assert call_args[1]["json"]["env"] == env_vars
class TestPortainerClientDeleteContainer:
"""Test container deletion."""
@pytest.mark.asyncio
async def test_delete_container_returns_true(self):
"""delete_container should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.delete.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.delete_container(1, "abc123")
assert result is True
@pytest.mark.asyncio
async def test_delete_container_with_force(self):
"""delete_container should pass force parameter."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.delete.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.delete_container(1, "abc123", force=True)
call_args = mock_client.delete.call_args
assert call_args[1]["params"]["force"] == "true"
class TestPortainerClientRestartContainer:
"""Test container restart."""
@pytest.mark.asyncio
async def test_restart_container_returns_true(self):
"""restart_container should return True on success."""
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.restart_container(1, "abc123")
assert result is True
mock_client.post.assert_called_once()
class TestPortainerClientSingleton:
"""Test singleton pattern."""