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
3 changes: 2 additions & 1 deletion src/foxops/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from foxops.logger import get_logger, setup_logging
from foxops.middlewares import request_id_middleware, request_time_middleware
from foxops.openapi import custom_openapi
from foxops.routers import auth, incarnations, not_found, version
from foxops.routers import auth, incarnations, not_found, template, version

#: Holds the module logger instance
logger = get_logger(__name__)
Expand Down Expand Up @@ -48,6 +48,7 @@ def create_app():
# Add routes to the protected router (authentication required)
protected_router = APIRouter(dependencies=[Depends(static_token_auth_scheme)])
protected_router.include_router(incarnations.router)
protected_router.include_router(template.router)

app.include_router(public_router)
app.include_router(protected_router)
Expand Down
7 changes: 7 additions & 0 deletions src/foxops/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from foxops.logger import get_logger
from foxops.services.change import ChangeService
from foxops.services.incarnation import IncarnationService
from foxops.services.template import TemplateService
from foxops.settings import (
DatabaseSettings,
GitlabHosterSettings,
Expand Down Expand Up @@ -94,6 +95,12 @@ def get_incarnation_service(
return IncarnationService(incarnation_repository=incarnation_repository, hoster=hoster)


def get_template_service(
hoster: Hoster = Depends(get_hoster),
):
return TemplateService(hoster=hoster)


def get_change_service(
hoster: Hoster = Depends(get_hoster),
change_repository: ChangeRepository = Depends(get_change_repository),
Expand Down
42 changes: 36 additions & 6 deletions src/foxops/engine/models/template_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ def pydantic_field_model(self) -> Any:

...

@abc.abstractmethod
def mock_data(self) -> Any:
"""
Returns a mock data value for this variable.
"""

...

class BaseFlatVariableDefinition(BaseVariableDefinition):

class BaseFlatVariableDefinition(BaseVariableDefinition, abc.ABC):
default: Any | None = None

def pydantic_field_default(self) -> Any:
Expand All @@ -60,6 +68,9 @@ class StringVariableDefinition(BaseFlatVariableDefinition):
def pydantic_field_model(self) -> Any:
return Annotated[str, BeforeValidator(convert_to_string)]

def mock_data(self) -> Any:
return self.default or ""


class IntegerVariableDefinition(BaseFlatVariableDefinition):
type: Literal["int", "integer"] = "integer"
Expand All @@ -68,6 +79,9 @@ class IntegerVariableDefinition(BaseFlatVariableDefinition):
def pydantic_field_model(self) -> Any:
return int

def mock_data(self) -> Any:
return self.default or 0


class BooleanVariableDefinition(BaseFlatVariableDefinition):
type: Literal["bool", "boolean"] = "boolean"
Expand All @@ -76,10 +90,16 @@ class BooleanVariableDefinition(BaseFlatVariableDefinition):
def pydantic_field_model(self) -> Any:
return bool

def mock_data(self) -> Any:
return self.default or False


class BaseListVariableDefinition(BaseFlatVariableDefinition):
class BaseListVariableDefinition(BaseFlatVariableDefinition, abc.ABC):
type: Literal["list"] = "list"

def mock_data(self) -> Any:
return list(self.default) if self.default is not None else []


class StringListVariableDefinition(BaseListVariableDefinition):
element_type: Literal["string"] = "string"
Expand Down Expand Up @@ -134,6 +154,9 @@ def pydantic_field_model(self) -> Type[BaseModel]:
}
return create_model("ObjectVariable", **fields)

def mock_data(self) -> Any:
return {name: child.mock_data() for name, child in self.children.items()}


class TemplateRenderingConfig(BaseModel):
excluded_files: list[str] = Field(
Expand All @@ -148,13 +171,17 @@ class TemplateConfig(BaseModel):

variables: VariableDefinitions = Field(default_factory=dict)

@classmethod
def from_string(cls, string) -> Self:
yaml = YAML(typ="safe")
obj = yaml.load(string)

return cls(**obj)

@classmethod
def from_path(cls, path: Path) -> Self:
if path.is_file():
yaml = YAML(typ="safe")
obj = yaml.load(path.read_text())

return cls(**obj)
cls.from_string(path.read_text())

return cls()

Expand All @@ -169,6 +196,9 @@ def data_model(self) -> Type[BaseModel]:
}
return create_model("TemplateDataModel", **fields)

def mock_data(self) -> dict[str, Any]:
return {name: var.mock_data() for name, var in self.variables.items()}

def save(self, target: Path) -> None:
target.write_text(self.yaml())

Expand Down
15 changes: 15 additions & 0 deletions src/foxops/routers/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends

from foxops.dependencies import get_template_service
from foxops.services.template import TemplateService

router = APIRouter(prefix="/api/templates", tags=["template"])


@router.get("/variables")
async def get_template_variables(
template_repository: str,
template_version: str,
template_service: TemplateService = Depends(get_template_service),
):
return await template_service.get_template_variables(template_repository, template_version)
13 changes: 13 additions & 0 deletions src/foxops/services/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from foxops.engine.models.template_config import TemplateConfig
from foxops.hosters import Hoster


class TemplateService:
def __init__(self, hoster: Hoster) -> None:
self.hoster = hoster

async def get_template_variables(self, template_repository: str, template_version: str) -> dict[str, str]:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we add some automated tests around this?

async with self.hoster.cloned_repository(template_repository, refspec=template_version) as repo:
template_config = TemplateConfig.from_path(repo.directory / "fengine.yaml")

return {k: v.get("default", "") for k, v in template_config.model_dump().get("variables", {}).items()}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why the casting into a dict? We can have better type safety in the code by just doing

Suggested change
return {k: v.get("default", "") for k, v in template_config.model_dump().get("variables", {}).items()}
return {k: v.get("default", "") for k, v in template_config.variables.items()}

but then, also we have to be aware here that variables can be nested, complex objects. So this logic would have to be more recursive.

I would recommend adding functionality into the *VariableDefinition classes for getting an empty "example object" of that variable. This should then be easy to combine "up the tree" to ultimately get an "empty example" of a full template config.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just added a commit to this branch which added a mock_data() method on the TemplateConfig object - including a test for it :-) check it out!

78 changes: 78 additions & 0 deletions tests/engine/models/test_template_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,81 @@ def test_template_string_variables_accept_integer_inputs_and_converts_them():

# THEN
assert parsed_data.test_string == "1"


def test_mock_data():
# GIVEN
template_config = TemplateConfig.model_validate(
{
"variables": {
"test_string_with_default": {
"type": "string",
"description": "testdescription",
"default": "test",
},
"test_string_without_default": {
"type": "string",
"description": "testdescription",
},
"test_integer_with_default": {
"type": "integer",
"description": "testdescription",
"default": 1,
},
"test_integer_without_default": {
"type": "integer",
"description": "testdescription",
},
"test_boolean_with_default": {
"type": "boolean",
"description": "testdescription",
"default": True,
},
"test_boolean_without_default": {
"type": "boolean",
"description": "testdescription",
},
"test_list_with_default": {
"type": "list",
"element_type": "string",
"description": "testdescription",
"default": ["abc", "def"],
},
"test_list_without_default": {
"type": "list",
"element_type": "string",
"description": "testdescription",
},
"test_object": {
"type": "object",
"description": "testdescription",
"children": {
"test_string_with_default": {
"type": "string",
"description": "testdescription",
"default": "test",
},
"test_string_without_default": {
"type": "string",
"description": "testdescription",
},
},
},
}
}
)

# WHEN
mock_data = template_config.mock_data()

# THEN
assert mock_data["test_string_with_default"] == "test"
assert mock_data["test_string_without_default"] == ""
assert mock_data["test_integer_with_default"] == 1
assert mock_data["test_integer_without_default"] == 0
assert mock_data["test_boolean_with_default"] is True
assert mock_data["test_boolean_without_default"] is False
assert mock_data["test_list_with_default"] == ["abc", "def"]
assert mock_data["test_list_without_default"] == []
assert mock_data["test_object"]["test_string_with_default"] == "test"
assert mock_data["test_object"]["test_string_without_default"] == ""
3 changes: 2 additions & 1 deletion ui/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ const ErrorWrapper = styled.div({
display: 'flex',
alignItems: 'start',
justifyContent: 'right',
paddingRight: '1rem'
paddingRight: '1rem',
pointerEvents: 'none'
})

const LoadbarWrapper = styled.div({
Expand Down
8 changes: 5 additions & 3 deletions ui/src/components/common/JsonEditor/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useThemeModeStore } from 'stores/theme-mode'
import { InputError } from '../InputError/InputError'

export interface JsonEditorProps {
defaultValue?: string
value: string
height?: number | string
onChange?: (value: string) => void
invalid?: boolean
Expand All @@ -14,7 +14,7 @@ export interface JsonEditorProps {
}

export const JsonEditor = ({
defaultValue,
value,
height = 300,
onChange = () => {},
invalid,
Expand All @@ -30,7 +30,7 @@ export const JsonEditor = ({
setEditor(editor => {
if (editor) return editor
const _editor = monaco.editor.create(monacoEl.current!, {
value: defaultValue,
value,
language: 'json',
automaticLayout: true,
theme: mode === 'dark' ? 'vs-dark' : 'vs',
Expand All @@ -54,6 +54,8 @@ export const JsonEditor = ({
monaco.editor.setTheme(mode === 'dark' ? 'vs-dark' : 'vs')
}, [mode])

useEffect(() => editor?.setValue(value), [value])

return (
<>
<EditorWrapper ref={monacoEl} style={{ height }} invalid={invalid} />
Expand Down
Loading