diff --git a/src/foxops/__main__.py b/src/foxops/__main__.py index 00eb6805..2146917d 100644 --- a/src/foxops/__main__.py +++ b/src/foxops/__main__.py @@ -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__) @@ -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) diff --git a/src/foxops/dependencies.py b/src/foxops/dependencies.py index a1d0b646..308a4cfa 100644 --- a/src/foxops/dependencies.py +++ b/src/foxops/dependencies.py @@ -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, @@ -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), diff --git a/src/foxops/engine/models/template_config.py b/src/foxops/engine/models/template_config.py index 175d838e..5c13f404 100644 --- a/src/foxops/engine/models/template_config.py +++ b/src/foxops/engine/models/template_config.py @@ -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: @@ -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" @@ -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" @@ -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" @@ -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( @@ -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() @@ -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()) diff --git a/src/foxops/routers/template.py b/src/foxops/routers/template.py new file mode 100644 index 00000000..1eeee948 --- /dev/null +++ b/src/foxops/routers/template.py @@ -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) diff --git a/src/foxops/services/template.py b/src/foxops/services/template.py new file mode 100644 index 00000000..0f971361 --- /dev/null +++ b/src/foxops/services/template.py @@ -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]: + 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()} diff --git a/tests/engine/models/test_template_config.py b/tests/engine/models/test_template_config.py index d4585aea..601361b6 100644 --- a/tests/engine/models/test_template_config.py +++ b/tests/engine/models/test_template_config.py @@ -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"] == "" diff --git a/ui/src/components/Layout/Layout.tsx b/ui/src/components/Layout/Layout.tsx index 0b538ef6..35c63506 100644 --- a/ui/src/components/Layout/Layout.tsx +++ b/ui/src/components/Layout/Layout.tsx @@ -86,7 +86,8 @@ const ErrorWrapper = styled.div({ display: 'flex', alignItems: 'start', justifyContent: 'right', - paddingRight: '1rem' + paddingRight: '1rem', + pointerEvents: 'none' }) const LoadbarWrapper = styled.div({ diff --git a/ui/src/components/common/JsonEditor/JsonEditor.tsx b/ui/src/components/common/JsonEditor/JsonEditor.tsx index d6c530eb..9a5647af 100644 --- a/ui/src/components/common/JsonEditor/JsonEditor.tsx +++ b/ui/src/components/common/JsonEditor/JsonEditor.tsx @@ -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 @@ -14,7 +14,7 @@ export interface JsonEditorProps { } export const JsonEditor = ({ - defaultValue, + value, height = 300, onChange = () => {}, invalid, @@ -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', @@ -54,6 +54,8 @@ export const JsonEditor = ({ monaco.editor.setTheme(mode === 'dark' ? 'vs-dark' : 'vs') }, [mode]) + useEffect(() => editor?.setValue(value), [value]) + return ( <> diff --git a/ui/src/routes/incarnations/Form.tsx b/ui/src/routes/incarnations/Form.tsx index f23a7e5d..d23027a9 100644 --- a/ui/src/routes/incarnations/Form.tsx +++ b/ui/src/routes/incarnations/Form.tsx @@ -24,6 +24,7 @@ import { Tabs } from 'components/common/Tabs/Tabs' import { useNavigate } from 'react-router-dom' import { Dialog } from 'components/common/Dialog/Dialog' import { useErrorStore } from 'stores/error' +import { template } from '../../services/template' const DeleteIncarnationLink = styled.span` cursor: pointer; @@ -121,14 +122,7 @@ export const IncarnationsForm = ({ commitUrl, templateDataFull }: FormProps) => { - const { - register, - handleSubmit, - formState: { errors }, - control, - watch, - getValues - } = useForm({ + const { register, handleSubmit, formState: { errors }, control, watch, setValue, getValues } = useForm({ defaultValues }) @@ -138,6 +132,8 @@ export const IncarnationsForm = ({ } const templateRepo = watch('templateRepository') + const templateVersion = watch('templateVersion') + const failed = templateRepo === '' && isEdit const navigate = useNavigate() @@ -152,6 +148,18 @@ export const IncarnationsForm = ({ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [resetDialogOpen, setResetDialogOpen] = useState(false) + const [isLoadingTemplateData, setIsLoadingTemplateData] = useState(false) + const [fetchDialogOpen, setFetchDialogOpen] = useState(false) + + const fetchTemplateData = async () => { + setFetchDialogOpen(false) + setIsLoadingTemplateData(true) + + const data = await template.getDefaultVariables(templateRepo, templateVersion) + setValue('templateData', JSON.stringify(data, null, 2)) + setIsLoadingTemplateData(false) + } + const onDelete = async () => { setDeleteDialogOpen(false) try { @@ -175,7 +183,6 @@ export const IncarnationsForm = ({ const onSubmit: SubmitHandler = async incarnation => { errorStore.clearError() try { - console.log(incarnation) await mutateAsync(incarnation) await delay(1000) queryClient.invalidateQueries(['incarnations']) @@ -219,15 +226,13 @@ export const IncarnationsForm = ({ render={({ field: { onChange, value }, fieldState: { error, invalid } - }) => ( - - )} + }) => } /> ) @@ -315,6 +320,11 @@ export const IncarnationsForm = ({ /> )} + {!isEdit && ( + + + + )} {isEdit ? ( @@ -333,7 +343,7 @@ export const IncarnationsForm = ({ content: ( Template data JSON - - {editTemplateDataController} - + {editTemplateDataController} )} @@ -478,6 +486,17 @@ export const IncarnationsForm = ({ The merge request will not be automatically merged + setFetchDialogOpen(false)} + onConfirm={fetchTemplateData} + title="Fetch template data" + > + Are you sure you want to fetch the template data? + + Doing this will overwrite the current template data. This action is not reversible. + + {failed ? ( failedFeedback diff --git a/ui/src/services/template.ts b/ui/src/services/template.ts new file mode 100644 index 00000000..a86d86ca --- /dev/null +++ b/ui/src/services/template.ts @@ -0,0 +1,9 @@ +import { api } from './api' + +export const template = { + getDefaultVariables: async (templateRepository: string, templateVersion: string) => { + const data = await api.get>(`/templates/variables?template_repository=${templateRepository}&template_version=${templateVersion}`) + return data + } +} +