From 1d200485bcbd52d59676e508fcc57756cac9d4eb Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Mon, 25 Aug 2025 18:25:36 +0200 Subject: [PATCH 1/7] chore: adds -s to pytest to not capture output from tests --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 18a3212..7b2c667 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ install: sync test: @echo "Running tests..." - uv run pytest tests/ -v + uv run pytest -s tests/ -v test-file: @echo "Usage: make test-file SPEC=tests/test_cli.py" From 4bdd1397be57f242921a9bf6b188962e0cf4991e Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Mon, 25 Aug 2025 18:25:56 +0200 Subject: [PATCH 2/7] test: adds unit tests for models --- ecsify/models/config.py | 1 - ecsify/models/service.py | 7 +- ecsify/models/task.py | 13 +-- tests/unit/models/test_config.py | 0 tests/unit/models/test_service.py | 75 +++++++++++++ tests/unit/models/test_task.py | 180 ++++++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 tests/unit/models/test_config.py create mode 100644 tests/unit/models/test_service.py create mode 100644 tests/unit/models/test_task.py diff --git a/ecsify/models/config.py b/ecsify/models/config.py index 079dbb8..e5dd646 100644 --- a/ecsify/models/config.py +++ b/ecsify/models/config.py @@ -18,4 +18,3 @@ class ECSifyConfig(BaseModel): class Config: extra = "forbid" - extra = "forbid" diff --git a/ecsify/models/service.py b/ecsify/models/service.py index 33505dd..7aaf657 100644 --- a/ecsify/models/service.py +++ b/ecsify/models/service.py @@ -2,16 +2,15 @@ Service definition models """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class ServiceDefinition(BaseModel): """ECS Service configuration""" + model_config = ConfigDict(extra="forbid") + name: str = Field(..., min_length=1) cluster: str = Field(..., min_length=1) replicas: int = Field(default=1, ge=1) task_family: str = Field(..., min_length=1) - - class Config: - extra = "forbid" diff --git a/ecsify/models/task.py b/ecsify/models/task.py index 5347c5b..f1b2eb6 100644 --- a/ecsify/models/task.py +++ b/ecsify/models/task.py @@ -4,12 +4,14 @@ from typing import Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class ContainerSpec(BaseModel): """Container specification within a task definition""" + model_config = ConfigDict(extra="forbid") + name: str image: str port: int = 80 @@ -18,18 +20,13 @@ class ContainerSpec(BaseModel): command: Optional[List[str]] = None env: Optional[Dict[str, str]] = None - class Config: - extra = "forbid" - class TaskDefinition(BaseModel): """ECS Task Definition configuration""" + model_config = ConfigDict(extra="forbid") + family: str = Field(..., min_length=1) container: ContainerSpec execution_role_arn: Optional[str] = None task_role_arn: Optional[str] = None - - class Config: - extra = "forbid" - extra = "forbid" diff --git a/tests/unit/models/test_config.py b/tests/unit/models/test_config.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/models/test_service.py b/tests/unit/models/test_service.py new file mode 100644 index 0000000..7531b4e --- /dev/null +++ b/tests/unit/models/test_service.py @@ -0,0 +1,75 @@ +""" +Unit tests for service models +""" + +import pytest +from pydantic import ValidationError + +from ecsify.models.service import ServiceDefinition + + +class TestServiceDefinitionBehavior: + """Test ServiceDefinition behaviors""" + + class TestWhenCreatingWithValidData: + """When the given service data is valid""" + + def test_it_should_succeed(self): + """It should succeed""" + valid_data = { + "name": "my-service", + "cluster": "my-cluster", + "replicas": 2, + "task_family": "my-task-family", + } + + service = ServiceDefinition(**valid_data) + + assert service.name == "my-service" + assert service.cluster == "my-cluster" + assert service.replicas == 2 + assert service.task_family == "my-task-family" + + class TestWhenMissingRequiredField: + """When a required field (name) is missing""" + + def test_it_should_raise_validation_error(self): + """It should raise ValidationError""" + invalid_data = { + # "name" is missing + "cluster": "my-cluster", + "replicas": 2, + "task_family": "my-task-family", + } + + with pytest.raises(ValidationError) as exc_info: + ServiceDefinition(**invalid_data) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("name",) and error["type"] == "missing" + for error in errors + ) + + class TestWhenReplicasIsBelowMinimum: + """When replicas is set below the minimum of 1""" + + def test_it_should_raise_validation_error(self): + """It should raise ValidationError""" + invalid_data = { + "name": "my-service", + "cluster": "my-cluster", + "replicas": 0, # Invalid, should be at least 1 + "task_family": "my-task-family", + } + + with pytest.raises(ValidationError) as exc_info: + ServiceDefinition(**invalid_data) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("replicas",) and error["type"] == "greater_than_equal" + for error in errors + ) diff --git a/tests/unit/models/test_task.py b/tests/unit/models/test_task.py new file mode 100644 index 0000000..32a3fda --- /dev/null +++ b/tests/unit/models/test_task.py @@ -0,0 +1,180 @@ +""" +Unit tests for task definition models +""" + +import pytest +from pydantic import ValidationError + +from ecsify.models.task import ContainerSpec, TaskDefinition + + +class TestContainerSpecBehavior: + """Test ContainerSpec behaviors""" + + class TestWhenCreatingWithValidData: + """When the given container data is valid""" + + def test_it_should_succeed(self): + """It should succeed""" + valid_data = { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 512, + } + + container = ContainerSpec(**valid_data) + + assert container.name == "web" + assert container.image == "nginx:latest" + assert container.cpu == 256 + + class TestWhenMissingRequiredField: + """When a required field (name) is missing""" + + def test_it_should_raise_validation_error(self): + """It should raise ValidationError""" + invalid_data = { + # "name" is missing + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 512, + } + + with pytest.raises(ValidationError) as exc_info: + ContainerSpec(**invalid_data) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("name",) and error["type"] == "missing" + for error in errors + ) + + class TestWhenOmitingOptionalFields: + """When optional fields are omitted""" + + def test_it_should_use_defaults(self): + """It should use default values""" + minimal_data = { + "name": "web", + "image": "nginx:latest", + } + + container = ContainerSpec(**minimal_data) + + assert container.port == 80 + assert container.cpu == 256 + assert container.memory == 512 + assert container.command is None + assert container.env is None + + class TestWhenCreatingWithIncorrectData: + """When the given container data is invalid""" + + class TestWhenCPUIsBelowMinimum: + """When CPU is below minimum""" + + def test_it_should_raise_validation_error(self): + """It should raise ValidationError""" + invalid_data = { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 64, # Invalid: less than minimum + "memory": 512, + } + + with pytest.raises(ValidationError) as exc_info: + ContainerSpec(**invalid_data) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("cpu",) and error["type"] == "greater_than_equal" + for error in errors + ) + + class TestWhenMemoryIsBelowMinimum: + """When Memory is below minimum""" + + def test_it_should_raise_validation_error(self): + """It should raise ValidationError""" + invalid_data = { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 64, # Invalid: less than minimum + } + + with pytest.raises(ValidationError) as exc_info: + ContainerSpec(**invalid_data) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("memory",) + and error["type"] == "greater_than_equal" + for error in errors + ) + + +class TestTaskDefinitionBehavior: + """Test TaskDefinition behaviors""" + + class TestWhenCreatingWithValidData: + """When the given task definition data is valid""" + + def test_it_should_succeed(self): + """It should succeed""" + valid_data = { + "family": "my-task-family", + "container": { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 512, + }, + "execution_role_arn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + "task_role_arn": "arn:aws:iam::123456789012:role/myTaskRole", + } + + task_def = TaskDefinition(**valid_data) + + assert task_def.family == "my-task-family" + assert task_def.container.name == "web" + assert ( + task_def.execution_role_arn + == "arn:aws:iam::123456789012:role/ecsTaskExecutionRole" + ) + + class TestWhenMissingRequiredField: + """When a required field (family) is missing""" + + def test_it_should_raise_validation_error(self): + """It should raise ValidationError""" + invalid_data = { + # "family" is missing + "container": { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 512, + }, + "execution_role_arn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + } + + with pytest.raises(ValidationError) as exc_info: + TaskDefinition(**invalid_data) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("family",) and error["type"] == "missing" + for error in errors + ) From f4c24b6141740762bd374c8ffefccb64051a60ef Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Mon, 25 Aug 2025 18:28:48 +0200 Subject: [PATCH 3/7] chore: updates ROADMAP --- ROADMAP.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b2f5ae..e154f70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,11 +107,11 @@ ecsify/ ### 1.2 Pydantic Data Models **Priority**: Critical | **Estimated Time**: 3 hours -- [ ] Implement `models/config.py` with `ECSifyConfig` root model -- [ ] Implement `models/task.py` with `TaskDefinition` and `ContainerSpec` models -- [ ] Implement `models/service.py` with `ServiceDefinition` model -- [ ] Add comprehensive field validation (CPU/memory constraints, name patterns) -- [ ] Add unit tests for all models with valid/invalid data scenarios +- [x] Implement `models/config.py` with `ECSifyConfig` root model +- [x] Implement `models/task.py` with `TaskDefinition` and `ContainerSpec` models +- [x] Implement `models/service.py` with `ServiceDefinition` model +- [ ] Add comprehensive field validation (CPU/memory constraints, name patterns) (future) +- [x] Add unit tests for all models with valid/invalid data scenarios **Validation Rules**: - Task family names: alphanumeric + hyphens only @@ -123,11 +123,12 @@ ecsify/ ### 1.3 YAML Parsing & Validation **Priority**: Critical | **Estimated Time**: 2 hours -- [ ] Implement `parsers/yaml_parser.py` for loading YAML files -- [ ] Implement `parsers/validator.py` for Pydantic validation -- [ ] Add comprehensive error handling for YAML syntax errors -- [ ] Add validation error reporting with line numbers and field paths -- [ ] Create unit tests with sample valid/invalid YAML files +- [ ] Create command validate to validate the ecsify.yaml + - [ ] Implement `parsers/yaml_parser.py` for loading YAML files + - [ ] Implement `parsers/validator.py` for Pydantic validation + - [ ] Add comprehensive error handling for YAML syntax errors + - [ ] Add validation error reporting with line numbers and field paths + - [ ] Create unit tests with sample valid/invalid YAML files **Features**: - Load `ecsify.yaml` from current directory From 570cd40d7c39b55559d142ad3736945027b8596f Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Tue, 26 Aug 2025 12:58:56 +0200 Subject: [PATCH 4/7] chore: adapts makefile to run tests from tasks --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 7b2c667..3a5d520 100644 --- a/Makefile +++ b/Makefile @@ -36,13 +36,13 @@ sync: install: sync uv pip install -e . -test: +test-all: @echo "Running tests..." uv run pytest -s tests/ -v -test-file: - @echo "Usage: make test-file SPEC=tests/test_cli.py" - uv run pytest $(SPEC) -v +test: + @echo "Usage: make test SPEC=tests/test_cli.py" + uv run pytest -s $(SPEC) -v test-coverage: @echo "Running tests with coverage..." From e2b57094e3817245870557a85d60e8e78847da86 Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Tue, 26 Aug 2025 12:59:12 +0200 Subject: [PATCH 5/7] test: adds unit tests for parsers --- ecsify/models/config.py | 7 +- tests/test_cli.py | 31 +++++++-- tests/unit/parsers/test_validator.py | 90 ++++++++++++++++++++++++++ tests/unit/parsers/test_yaml_parser.py | 88 +++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 tests/unit/parsers/test_validator.py create mode 100644 tests/unit/parsers/test_yaml_parser.py diff --git a/ecsify/models/config.py b/ecsify/models/config.py index e5dd646..8e777e8 100644 --- a/ecsify/models/config.py +++ b/ecsify/models/config.py @@ -4,7 +4,7 @@ from typing import List -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ecsify.models.service import ServiceDefinition from ecsify.models.task import TaskDefinition @@ -13,8 +13,7 @@ class ECSifyConfig(BaseModel): """Root configuration model for ECSify YAML files""" + model_config = ConfigDict(extra="forbid") + tasks: List[TaskDefinition] services: List[ServiceDefinition] - - class Config: - extra = "forbid" diff --git a/tests/test_cli.py b/tests/test_cli.py index bdd6bac..1b10a9a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,15 +2,38 @@ Basic CLI tests """ +from click.testing import CliRunner + +from ecsify.cli import main + def test_cli_import(): """Test that CLI module can be imported""" - from ecsify import cli - assert cli is not None + assert main is not None def test_version_command(): """Test version command functionality""" - # Placeholder test - assert True + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + + output = result.output.lower() + + assert result.exit_code == 0 + assert "ecsify" in output + assert "version" in output + assert "version" in output + + +def test_validate_command(): + """Test validate command functionality""" + runner = CliRunner() + result = runner.invoke(main, ["validate", "--help"]) + + output = result.output.lower() + + assert result.exit_code == 0 + assert "ecsify" in output + assert "version" in output + assert "version" in output diff --git a/tests/unit/parsers/test_validator.py b/tests/unit/parsers/test_validator.py new file mode 100644 index 0000000..bf31e90 --- /dev/null +++ b/tests/unit/parsers/test_validator.py @@ -0,0 +1,90 @@ +""" +Unit tests for config validator +""" + +import pytest + +from ecsify.parsers.validator import ECSifyConfig +from ecsify.utils.exceptions import ValidationError + + +class TestECSifyConfigValidation: + """Test ECSifyConfig Validation behaviors""" + + class TestWhenGivenValidConfig: + """When the given config is valid""" + + def test_it_should_succeed(self): + """It should succeed""" + + valid_config = { + "tasks": [ + { + "family": "web-task", + "container": { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 512, + }, + "execution_role_arn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + "task_role_arn": "arn:aws:iam::123456789012:role/myTaskRole", + } + ], + "services": [ + { + "name": "web", + "cluster": "default", + "replicas": 2, + "task_family": "web-task", + } + ], + } + + config = ECSifyConfig.model_validate(valid_config) + + assert len(config.tasks) == 1 + assert config.tasks[0].family == "web-task" + assert len(config.services) == 1 + assert config.services[0].name == "web" + + class TestWhenGivenInvalidConfig: + """When the given config is invalid""" + + def test_it_should_fail(self): + """It should fail""" + + valid_config = { + "tasks": [ + { + "container": { + "name": "web", + "image": "nginx:latest", + "port": 80, + "cpu": 256, + "memory": 512, + }, + "execution_role_arn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + "task_role_arn": "arn:aws:iam::123456789012:role/myTaskRole", + } + ], + "services": [ + { + "name": "web", + "cluster": "default", + "replicas": 2, + "task_family": "web-task", + } + ], + } + + with pytest.raises(ValidationError) as exc_info: + ECSifyConfig.model_validate(valid_config) + + errors = exc_info.value.errors() + + assert any( + error["loc"] == ("tasks", 0, "family") and error["type"] == "missing" + for error in errors + ) diff --git a/tests/unit/parsers/test_yaml_parser.py b/tests/unit/parsers/test_yaml_parser.py new file mode 100644 index 0000000..f8d734e --- /dev/null +++ b/tests/unit/parsers/test_yaml_parser.py @@ -0,0 +1,88 @@ +""" +Unit tests for Yaml parser +""" + +import pytest + +from ecsify.parsers.yaml_parser import load_yaml_file +from ecsify.utils.exceptions import ValidationError + + +class TestLoadYAMLFile: + """Test Yaml Parser behaviors""" + + class TestWhenFileExistsAndIsValid: + """When the given file exists and is valid""" + + def test_it_should_succeed(self, tmp_path): + """It should succeed""" + valid_yaml_content = """ + tasks: + - family: web-task + container: + name: web + image: nginx:latest + port: 80 + cpu: 256 + memory: 512 + execution_role_arn: arn:aws:iam::123456789012:role/ecsTaskExecutionRole + task_role_arn: arn:aws:iam::123456789012:role/myTaskRole + services: + - name: web + cluster: default + replicas: 2 + task_family: web-task + """ + + yaml_file = tmp_path / "config.yaml" + yaml_file.write_text(valid_yaml_content) + + result = load_yaml_file(str(yaml_file)) + + assert "tasks" in result + assert len(result["tasks"]) == 1 + + class TestWhenFileDoesNotExist: + """When the given file does not exist""" + + def test_it_should_raise_file_not_found_error(self): + """It should raise ValidationError""" + non_existent_file = "non_existent.yaml" + + with pytest.raises(ValidationError) as exc_info: + load_yaml_file(non_existent_file) + + assert ( + str(exc_info.value) == "Configuration file not found: non_existent.yaml" + ) + + class TestWhenFileIsInvalidYAML: + """When the given file contains invalid YAML""" + + def test_it_should_raise_yaml_syntax_error(self, tmp_path): + """It should raise ValidationError""" + invalid_yaml_content = """ + tasks: + - family: web-task + container + name: web + image: nginx:latest + port: 80 + cpu: 256 + memory: 512 + execution_role_arn: arn:aws:iam::123456789012:role/ecsTaskExecutionRole + task_role_arn: arn:aws:iam::123456789012:role/myTaskRole + services: + - name: web + cluster: default + replicas: 2 + task_family: web-task + """ + + yaml_file = tmp_path / "config.yaml" + yaml_file.write_text(invalid_yaml_content) + + with pytest.raises(ValidationError) as exc_info: + load_yaml_file(yaml_file) + + assert "YAML syntax error in" in str(exc_info.value) From 222b5a0ef605536632c2da209db9bfe996f7609a Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Mon, 1 Sep 2025 16:43:20 +0200 Subject: [PATCH 6/7] docs: adds simple mermaid flow diagram --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 60c6a29..c933478 100644 --- a/README.md +++ b/README.md @@ -171,4 +171,7 @@ The project uses modern Python packaging standards with `pyproject.toml`: 3. **Phase 3**: UX improvements 4. **Phase 4**: Advanced features +## Sample Flow for apply command +[![](https://mermaid.ink/img/pako:eNp9U8Fy2jAQ_ZUdnYEBYorjQzuNTUjSadMpyaE1OSj2YjTIkkeSQ1yGf68sATVMpzp4vLtv3z49STuSyRxJRFZcbrM1VQaekqUAuz6nzxoVqFpowEyzVQO0qngD_dUhHjS05C_Q73-Em_Q7VRrPCp7mxtXjXcxrbSwfvjNt9Ke9r8a2Cj9RO1DSTvQAJgrIfMdLB_lNOuAsjRVSg9CSnhD-mzjEbfoDC-YGPlG9gQRXTDDDpAAmYBYvDj0zj-4S3LrUfLdA9cYyvBA878i4O8oQuIUD3KPa1YdXJvLL8d26RgMKK84yqi_zAs1Wqo31oVuRVctBeeS5uaQ5vFJORXZyYd519D59rvJW4cnUf8isJBMGjHTb-I9c6xrk0jIryXnLVTvuM_fv3NiHNLHbkk17eUSLPPP83mN88OCCL-ljbaragK7Lkqom6s493Jxu6ng2gpZnW3Hq879nrfCN6YttLAw1tbZaSI8UiuUkMqrGHilRlbQNya6FL4lZo2Unkf3NqdosyVLsbU9FxS8py2ObknWxJtGKcm0jb0nCaKFoecoqFDmqWNbCkGg0mjgSEu3IO4mC4HownEyvJuFVGIyGwbhHGhJNx4NwGlx_GAeTcDQehdN9j_x2U4e2YAkwZ0aqr_7xuje8_wNBqyYO?type=png)](https://mermaid.live/edit#pako:eNp9U8Fy2jAQ_ZUdnYEBYorjQzuNTUjSadMpyaE1OSj2YjTIkkeSQ1yGf68sATVMpzp4vLtv3z49STuSyRxJRFZcbrM1VQaekqUAuz6nzxoVqFpowEyzVQO0qngD_dUhHjS05C_Q73-Em_Q7VRrPCp7mxtXjXcxrbSwfvjNt9Ke9r8a2Cj9RO1DSTvQAJgrIfMdLB_lNOuAsjRVSg9CSnhD-mzjEbfoDC-YGPlG9gQRXTDDDpAAmYBYvDj0zj-4S3LrUfLdA9cYyvBA878i4O8oQuIUD3KPa1YdXJvLL8d26RgMKK84yqi_zAs1Wqo31oVuRVctBeeS5uaQ5vFJORXZyYd519D59rvJW4cnUf8isJBMGjHTb-I9c6xrk0jIryXnLVTvuM_fv3NiHNLHbkk17eUSLPPP83mN88OCCL-ljbaragK7Lkqom6s493Jxu6ng2gpZnW3Hq879nrfCN6YttLAw1tbZaSI8UiuUkMqrGHilRlbQNya6FL4lZo2Unkf3NqdosyVLsbU9FxS8py2ObknWxJtGKcm0jb0nCaKFoecoqFDmqWNbCkGg0mjgSEu3IO4mC4HownEyvJuFVGIyGwbhHGhJNx4NwGlx_GAeTcDQehdN9j_x2U4e2YAkwZ0aqr_7xuje8_wNBqyYO) + See ROADMAP.md for detailed implementation plan. \ No newline at end of file From d235620d8576a926059832895546e3b2fcd241e2 Mon Sep 17 00:00:00 2001 From: Paco Sanchez Date: Mon, 1 Sep 2025 16:44:33 +0200 Subject: [PATCH 7/7] feat: adds validate command --- README.md | 5 +++ ecsify/aws/ecs_client.py | 2 +- ecsify/cli.py | 29 ++++++++++++- ecsify/deployment/deployer.py | 2 +- ecsify/parsers/validator.py | 3 +- ecsify/parsers/yaml_parser.py | 2 +- tests/test_cli.py | 59 ++++++++++++++++++++++---- tests/unit/parsers/test_validator.py | 9 ++-- tests/unit/parsers/test_yaml_parser.py | 2 +- 9 files changed, 94 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c933478..21dcd8f 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,13 @@ python -m ecsify --help python -m ecsify version python -m ecsify apply python -m ecsify apply --dry-run --env prod --service inventory +python -m ecsify validate +python -m ecsify validate --file ecsify.prod.yaml # Using uv (if installed with uv) uv run ecsify apply uv run ecsify apply --dry-run --env prod --service inventory +uv run ecsify validate --file examples/ecsify.yaml # JSON output for automation python -m ecsify apply --json @@ -107,6 +110,8 @@ python -m ecsify apply --json - `--service`: Deploy only a specific service - `--file`: Custom configuration file to use - `--json`: Output in JSON format for automation +- `validate`: Validate ecsify.yaml configuration files + - `--file` / `-f`: Custom configuration file to validate (default: ecsify.yaml) - `version`: Show ECSify version ### Development Commands diff --git a/ecsify/aws/ecs_client.py b/ecsify/aws/ecs_client.py index 2639c5e..db15f3c 100644 --- a/ecsify/aws/ecs_client.py +++ b/ecsify/aws/ecs_client.py @@ -14,7 +14,7 @@ class ECSClient: """AWS ECS client wrapper with error handling""" - def __init__(self): + def __init__(self) -> None: self.session = get_aws_session() self.ecs = self.session.client("ecs") diff --git a/ecsify/cli.py b/ecsify/cli.py index 0fed99c..9a25360 100644 --- a/ecsify/cli.py +++ b/ecsify/cli.py @@ -2,10 +2,15 @@ CLI entrypoint for ECSify """ +import sys + import click from rich.console import Console from rich.panel import Panel +from ecsify.parsers.validator import validate_config +from ecsify.parsers.yaml_parser import load_yaml_file +from ecsify.utils.exceptions import ValidationError from ecsify.utils.logger import get_logger console = Console() @@ -29,7 +34,7 @@ def main(ctx: click.Context) -> None: @click.option("--dry-run", is_flag=True, help="Show deployment plan without executing") @click.option("--env", help="Environment configuration to use (dev, staging, prod)") @click.option("--service", help="Deploy only a specific service") -@click.option("--file", help="Custom configuration file to use") +@click.option("--file", default="ecsify.yaml", help="Custom configuration file to use") @click.option("--json", is_flag=True, help="Output in JSON format for automation") def apply(dry_run: bool, env: str, service: str, file: str, json: bool) -> None: """Deploy services to AWS ECS""" @@ -66,5 +71,27 @@ def version() -> None: console.print("[bold blue]ECSify version 0.1.0[/bold blue]") +@main.command() +@click.option( + "--file", "-f", default="ecsify.yaml", help="Custom configuration file to use" +) +def validate(file: str) -> None: + """Validates ecsify.yaml files""" + + try: + config_data = load_yaml_file(file) + validate_config(config_data) + console.print(f"[bold green]✅ Configuration is valid: {file}[/bold green]") + + except FileNotFoundError as e: + console.print(f"[bold red]❌ {e}[/bold red]") + sys.exit(1) + except ValidationError as e: + console.print(f"[bold red]❌ Validation failed for {file}[/bold red]") + console.print(f"[yellow]⚠️ {e}[/yellow]") + sys.exit(1) + + if __name__ == "__main__": main() # pylint: disable=no-value-for-parameter + main() # pylint: disable=no-value-for-parameter diff --git a/ecsify/deployment/deployer.py b/ecsify/deployment/deployer.py index 9005045..b6013e1 100644 --- a/ecsify/deployment/deployer.py +++ b/ecsify/deployment/deployer.py @@ -12,7 +12,7 @@ class Deployer: """Main deployment orchestrator""" - def __init__(self): + def __init__(self) -> None: self.ecs_client = ECSClient() def deploy(self, config: ECSifyConfig, dry_run: bool = False) -> bool: diff --git a/ecsify/parsers/validator.py b/ecsify/parsers/validator.py index a5193b5..a0eae53 100644 --- a/ecsify/parsers/validator.py +++ b/ecsify/parsers/validator.py @@ -29,4 +29,5 @@ def validate_config(config_data: Dict[str, Any]) -> ECSifyConfig: logger.info("Configuration validation passed") return config except Exception as e: - raise ValidationError(f"Configuration validation failed: {e}") from e + print("ERROR", e) + raise ValidationError(str(e)) from e diff --git a/ecsify/parsers/yaml_parser.py b/ecsify/parsers/yaml_parser.py index 0d39790..a50f223 100644 --- a/ecsify/parsers/yaml_parser.py +++ b/ecsify/parsers/yaml_parser.py @@ -29,7 +29,7 @@ def load_yaml_file(file_path: str) -> Dict[str, Any]: path = Path(file_path) if not path.exists(): - raise ValidationError(f"Configuration file not found: {file_path}") + raise FileNotFoundError(f"Configuration file not found: {file_path}") try: with open(path, "r", encoding="utf-8") as file: diff --git a/tests/test_cli.py b/tests/test_cli.py index 1b10a9a..3a924ac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,8 @@ Basic CLI tests """ +from unittest.mock import patch + from click.testing import CliRunner from ecsify.cli import main @@ -26,14 +28,53 @@ def test_version_command(): assert "version" in output -def test_validate_command(): - """Test validate command functionality""" - runner = CliRunner() - result = runner.invoke(main, ["validate", "--help"]) +class TestValidateCommandBehavior: + """Test validate command behaviour""" - output = result.output.lower() + class TestWhenYamlIsValid: + """When the given YAML file is valid""" - assert result.exit_code == 0 - assert "ecsify" in output - assert "version" in output - assert "version" in output + @patch("ecsify.cli.validate_config") + @patch("ecsify.cli.load_yaml_file") + def test_it_should_pass_validation(self, mock_yaml_parser, validator_mock): + """It should pass validation""" + mock_yaml_parser.return_value = {"services": {}} + validator_mock.return_value = True + + runner = CliRunner() + result = runner.invoke(main, ["validate"]) + + output = result.output.lower() + + assert result.exit_code == 0 + assert "configuration is valid" in output + + class TestWhenYamlDoesNotExist: + """When the given YAML file does not exist""" + + def test_it_should_return_file_not_found_error(self): + """It should return file not found error and exit with code 1""" + + runner = CliRunner() + result = runner.invoke(main, ["validate", "-f", "nonexistent.yaml"]) + + output = result.output.lower() + + assert result.exit_code != 0 + assert "file not found" in output + + class TestWhenYamlIsInvalid: + """When the given YAML file is invalid""" + + @patch("ecsify.cli.load_yaml_file") + def test_it_should_return_validation_error(self, mock_yaml_parser): + """It should return validation error""" + mock_yaml_parser.return_value = {"services": {}} + + runner = CliRunner() + result = runner.invoke(main, ["validate"]) + + output = result.output.lower() + + assert result.exit_code != 0 + assert "validation failed" in output diff --git a/tests/unit/parsers/test_validator.py b/tests/unit/parsers/test_validator.py index bf31e90..176d665 100644 --- a/tests/unit/parsers/test_validator.py +++ b/tests/unit/parsers/test_validator.py @@ -4,7 +4,7 @@ import pytest -from ecsify.parsers.validator import ECSifyConfig +from ecsify.parsers.validator import validate_config from ecsify.utils.exceptions import ValidationError @@ -42,7 +42,7 @@ def test_it_should_succeed(self): ], } - config = ECSifyConfig.model_validate(valid_config) + config = validate_config(valid_config) assert len(config.tasks) == 1 assert config.tasks[0].family == "web-task" @@ -80,9 +80,10 @@ def test_it_should_fail(self): } with pytest.raises(ValidationError) as exc_info: - ECSifyConfig.model_validate(valid_config) + validate_config(valid_config) - errors = exc_info.value.errors() + pydantic_error = exc_info.value.__cause__ + errors = pydantic_error.errors() assert any( error["loc"] == ("tasks", 0, "family") and error["type"] == "missing" diff --git a/tests/unit/parsers/test_yaml_parser.py b/tests/unit/parsers/test_yaml_parser.py index f8d734e..2513f68 100644 --- a/tests/unit/parsers/test_yaml_parser.py +++ b/tests/unit/parsers/test_yaml_parser.py @@ -49,7 +49,7 @@ def test_it_should_raise_file_not_found_error(self): """It should raise ValidationError""" non_existent_file = "non_existent.yaml" - with pytest.raises(ValidationError) as exc_info: + with pytest.raises(FileNotFoundError) as exc_info: load_yaml_file(non_existent_file) assert (