feat: add stack management and container delete endpoints
Build and Push / build (release) Successful in 29s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user