Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ sync:
install: sync
uv pip install -e .

test:
test-all:
@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"
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..."
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -171,4 +176,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.
21 changes: 11 additions & 10 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ecsify/aws/ecs_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
29 changes: 28 additions & 1 deletion ecsify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"""
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion ecsify/deployment/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 3 additions & 5 deletions ecsify/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,9 +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"
extra = "forbid"
7 changes: 3 additions & 4 deletions ecsify/models/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 5 additions & 8 deletions ecsify/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
3 changes: 2 additions & 1 deletion ecsify/parsers/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion ecsify/parsers/yaml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 68 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,79 @@
Basic CLI tests
"""

from unittest.mock import patch

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


class TestValidateCommandBehavior:
"""Test validate command behaviour"""

class TestWhenYamlIsValid:
"""When the given YAML file is valid"""

@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
Empty file.
Loading