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
+
{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
+ }
+}
+