Skip to content
Open
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
12 changes: 12 additions & 0 deletions .github/actions/setup-workspace/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ runs:
shell: bash
run: |
just tidy

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"

- name: Install requirements
shell: bash
run: uv sync --locked --all-extras --dev
8 changes: 6 additions & 2 deletions .github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest

- name: Run linters
run: pre-commit run --all-files
run: pre-commit run --all-files --verbose

test:
name: Run unit tests
Expand All @@ -47,11 +47,15 @@ jobs:
- name: Run setup
uses: ./.github/actions/setup-workspace

- name: Run tests
- name: Run Go tests
run: |
go clean -testcache
go test -v -cover -race ./...

- name: Run Python tests
run: |
uv run pytest -vv

build:
name: Build
runs-on: ubuntu-latest
Expand Down
60 changes: 55 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,17 +1,67 @@
# Python-generated files
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[oc]
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
*.egg-info
.python-version
MANIFEST

# Testing
.coverage
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Virtual environments
*venv

Expand Down
14 changes: 13 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ repos:
hooks:
- id: codespell
args: [-w]
files: ^.*\.(md|py|jinja|yaml|yml|sh|feature)$
files: ^.*\.(md|go|py|jinja|yaml|yml|sh|feature)$

- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
Expand All @@ -52,6 +52,18 @@ repos:
args: [--config, ".markdownlint.yaml"]
exclude: docs/cli/

- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
args: ["--profile", "black", "--skip-glob", "/builds/*"]

# Python code formatting
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black

# Security
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
Expand Down
4 changes: 4 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from backend.service import main

if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Main backend source code.

This package contains the core functionality of the backend service.
The main logger initialization is also done here to ensure consistent
logging across the application.
"""
Empty file added backend/core/__init__.py
Empty file.
65 changes: 65 additions & 0 deletions backend/core/config/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import logging
import os

from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)

# Constants for backward compatibility
LOG_LEVEL = "LOG_LEVEL"


class Config(BaseModel):
"""Application configuration loaded from environment variables."""

# Application settings
app_name: str = Field(default="DevOps API")
app_version: str = Field(default="0.0.1")
debug: bool = Field(default=False)

# Server settings
host: str = Field(default="0.0.0.0")
port: int = Field(default=8000)
reload: bool = Field(default=False)

# Logging settings
log_level: str = Field(default="INFO")

@classmethod
def from_env(cls) -> "Config":
"""Create a Config instance from environment variables."""

def parse_bool(value: str) -> bool:
"""Parse a string as a boolean."""
return value.lower() in ("true", "1", "t", "yes", "y", "on")

def parse_list(value: str) -> list[str]:
"""Parse a comma-separated string as a list."""
if not value:
return []
return [item.strip() for item in value.split(",")]

def parse_log_level(value: str) -> str:
"""Parse and validate log level."""
level = value.upper()
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if level not in valid_levels:
return "INFO"
return level # type: ignore

return cls(
app_name=os.getenv("APP_NAME", "DevOps API"),
app_version=os.getenv("APP_VERSION", "0.0.1"),
debug=parse_bool(os.getenv("DEBUG", "false")),
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
reload=parse_bool(os.getenv("RELOAD", "false")),
log_level=parse_log_level(os.getenv("LOG_LEVEL", "INFO")),
)


def load_environment() -> Config:
"""Load the application configuration from environment variables."""
config = Config.from_env()
logger.info("Environment configuration loaded successfully!")
return config
22 changes: 22 additions & 0 deletions backend/core/config/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import yaml
from pathlib import Path

from pydantic import BaseModel


class SoftwareComponent(BaseModel):
"""Model representing a software component with name and version."""

name: str
version: str
description: str | None = None
repository: str | None = None

@classmethod
def from_definition_file(cls, filepath: Path) -> "SoftwareComponent":
"""Load a Software Component from a definition file."""
with open(filepath, "r") as f:
if filepath.suffix.lower() not in (".yml", ".yaml"):
raise ValueError("Unsupported file format. Use .json or .yaml/.yml")
data = yaml.safe_load(f)
return cls(**data)
1 change: 1 addition & 0 deletions backend/core/obs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Observability module."""
31 changes: 31 additions & 0 deletions backend/core/obs/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import logging
import os
from typing import Final

from backend.core.config.env import LOG_LEVEL

logger = logging.getLogger(__name__)


LOG_FORMAT: Final[str] = "[%(asctime)s][%(levelname)s] %(name)s: %(message)s"
TIMESTAMP_FORMAT: Final[str] = "%Y-%m-%d %H:%M:%S"


def setup_logger():
"""Set up logging configuration."""
levels = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
base_log_level = logging.INFO
if level_from_env := os.getenv(LOG_LEVEL):
base_log_level = levels.get(level_from_env.upper(), base_log_level)
logging.basicConfig(
format=LOG_FORMAT, datefmt=TIMESTAMP_FORMAT, level=base_log_level
)
logger.debug(
f"Logging initialized with level: {logging.getLevelName(base_log_level)}"
)
1 change: 1 addition & 0 deletions backend/core/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Service utility helpers."""
3 changes: 3 additions & 0 deletions backend/core/utils/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from typing import Dict

JsonResponse = Dict[str, object]
93 changes: 93 additions & 0 deletions backend/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import logging
import time
from http import HTTPStatus

import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from backend.core.config.env import load_environment
from backend.core.obs.logging import setup_logger
from backend.core.utils.types import JsonResponse

logger = logging.getLogger(__name__)
config = load_environment()
app = FastAPI(
title=config.app_name,
version=config.app_version,
description="Backend service for software component management",
contact={
"name": "Chino Franco",
"email": "chino.franco@gmail.com",
"github": "https://github.com/jgfranco17",
},
)
startup_time = time.time()


@app.get("/", status_code=HTTPStatus.OK, tags=["SYSTEM"])
def root():
"""Project main page."""
return {
"message": f"Welcome to the {config.app_name}!",
"version": config.app_version,
"debug": config.debug,
}


@app.get("/healthz", status_code=HTTPStatus.OK, tags=["SYSTEM"])
def health_check() -> JsonResponse:
"""Health check for the API."""
return {"status": "ok", "uptime": time.time() - startup_time}


@app.get("/config", status_code=HTTPStatus.OK, tags=["SYSTEM"])
def get_config() -> JsonResponse:
"""Get the current application configuration."""
return {
"app_name": config.app_name,
"app_version": config.app_version,
"debug": config.debug,
"host": config.host,
"port": config.port,
"log_level": config.log_level,
}


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""General exception handler."""
return JSONResponse(
status_code=exc.status_code,
content={
"message": exc.detail,
"request": {
"method": request.method,
"url": str(request.url),
"status": exc.status_code,
},
},
)


app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
Copy link

Copilot AI Oct 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using wildcard * for CORS origins in production is a security risk. Consider restricting to specific domains or making this configurable.

Suggested change
allow_origins=["*"],
allow_origins=getattr(config, "allowed_origins", ["https://example.com"]),

Copilot uses AI. Check for mistakes.
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


def start():
"""Main entry point for the application."""
setup_logger()
logger.info(f"Starting {config.app_name} v{config.app_version}...")
uvicorn.run(
app,
host=config.host,
port=config.port,
reload=config.reload,
log_level=config.log_level.lower(),
)
2 changes: 1 addition & 1 deletion cli/core/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type MockExpectedInput struct {
}

func (e *UnexpectedCommandError) Error() string {
return fmt.Sprintf("Unexpeced command: wanted %s but got %s", e.Want, e.Got)
return fmt.Sprintf("Unexpected command: wanted %s but got %s", e.Want, e.Got)
}

type MockExecutor struct {
Expand Down
Loading
Loading