Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement GraphQL endpoint to determine if an environment is halted or is on expert mode #8796

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
7 changes: 7 additions & 0 deletions changelogs/unreleased/8736-implement-graphql-endpoint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
description: Implemented a GraphQL endpoint.
Added a query to fetch environments that supports filtering, sorting and paging.
issue-nr: 8736
change-type: minor
destination-branches: [master]
sections:
feature: "{{description}}"
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ typing_inspect==0.9.0
build==1.2.2.post1
ruamel.yaml==0.18.10
setproctitle==1.3.5
SQLAlchemy==2.0.38
strawberry-sqlalchemy-mapper==0.5.0

# Optional import in code
graphviz==0.20.3
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"ruamel.yaml~=0.17",
"toml~=0.10 ",
"setproctitle~=1.3",
"SQLAlchemy~=2.0",
"strawberry-sqlalchemy-mapper==0.5.0",
]


Expand Down
20 changes: 19 additions & 1 deletion src/inmanta/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
from inmanta.types import JsonType, PrimitiveTypes, ResourceIdStr, ResourceType, ResourceVersionIdStr
from inmanta.util import parse_timestamp
from sqlalchemy import URL, AsyncAdaptedQueuePool
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine

"""
Global reference to the SQL Alchemy engine
Expand All @@ -79,6 +79,8 @@
"""
ENGINE: AsyncEngine | None = None

SESSION_FACTORY: async_sessionmaker[AsyncSession] | None = None


LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -6632,6 +6634,7 @@ async def start_engine(
)

global ENGINE
global SESSION_FACTORY

if ENGINE is not None:
raise Exception("Engine already running: cannot call start_engine twice.")
Expand All @@ -6646,6 +6649,7 @@ async def start_engine(
echo=echo,
pool_pre_ping=True,
)
SESSION_FACTORY = async_sessionmaker(ENGINE)
except Exception as e:
await stop_engine()
raise e
Expand Down Expand Up @@ -6675,9 +6679,11 @@ async def stop_engine() -> None:
Stop the sql alchemy engine.
"""
global ENGINE
global SESSION_FACTORY
if ENGINE is not None:
await ENGINE.dispose(close=True)
ENGINE = None
SESSION_FACTORY = None


def get_pool() -> AsyncAdaptedQueuePool:
Expand All @@ -6690,3 +6696,15 @@ def get_pool() -> AsyncAdaptedQueuePool:
def get_engine() -> AsyncEngine:
assert ENGINE is not None, "SQL Alchemy engine was not initialized"
return ENGINE


def get_session_factory() -> async_sessionmaker[AsyncSession]:
assert SESSION_FACTORY is not None, "SQL Alchemy engine and session factory were not initialized"
return SESSION_FACTORY


@asynccontextmanager
async def get_session() -> AsyncIterator[AsyncSession]:
assert SESSION_FACTORY is not None, "SQL Alchemy engine and session factory were not initialized"
async with SESSION_FACTORY() as session:
yield session
640 changes: 640 additions & 0 deletions src/inmanta/data/sqlalchemy.py

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/inmanta/graphql/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
Copyright 2025 Inmanta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Contact: [email protected]
"""
30 changes: 30 additions & 0 deletions src/inmanta/graphql/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
Copyright 2025 Inmanta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Contact: [email protected]
"""

from typing import Any

import inmanta.graphql.schema
from inmanta.protocol import methods_v2
from inmanta.protocol.decorators import handle
from inmanta.server import SLICE_GRAPHQL, protocol


class GraphQLSlice(protocol.ServerSlice):

def __init__(self) -> None:
super().__init__(name=SLICE_GRAPHQL)

@handle(methods_v2.graphql)
async def graphql(self, query: str) -> Any: # Actual return type: strawberry.types.execution.ExecutionResult
return await inmanta.graphql.schema.get_schema().execute(query)
117 changes: 117 additions & 0 deletions src/inmanta/graphql/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
Copyright 2025 Inmanta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Contact: [email protected]
"""

import typing

import inmanta.data.sqlalchemy as models
import strawberry
from inmanta.data import get_session, get_session_factory
from sqlalchemy import Select, asc, desc, select
from strawberry import relay
from strawberry.schema.config import StrawberryConfig
from strawberry.types import Info
from strawberry.types.info import ContextType
from strawberry_sqlalchemy_mapper import StrawberrySQLAlchemyLoader, StrawberrySQLAlchemyMapper

mapper: StrawberrySQLAlchemyMapper[typing.Any] = StrawberrySQLAlchemyMapper()
SCHEMA: strawberry.Schema | None = None


def get_expert_mode(root: "Environment") -> bool:
if not hasattr(root, "settings"):
return False
return bool(root.settings.get("enable_lsm_expert_mode", False))


@mapper.type(models.Project)
class Project:
pass


@mapper.type(models.Environment)
class Environment:
is_expert_mode: bool = strawberry.field(resolver=get_expert_mode)


def get_schema() -> strawberry.Schema:
if SCHEMA is None:
initialize_schema()
assert SCHEMA
return SCHEMA


class StrawberryFilter:
def get_filter_dict(self) -> dict[str, typing.Any]:
return {key: value for key, value in self.__dict__.items() if value is not strawberry.UNSET}


class StrawberryOrder:
pass


@strawberry.input
class EnvironmentFilter(StrawberryFilter):
id: typing.Optional[str] = strawberry.UNSET


@strawberry.input(one_of=True)
class EnvironmentOrder(StrawberryOrder):
id: typing.Optional[str] = strawberry.UNSET
name: typing.Optional[str] = strawberry.UNSET


def add_filter_and_sort(
stmt: Select[typing.Any],
filter: typing.Optional[StrawberryFilter] = strawberry.UNSET,
order_by: typing.Optional[StrawberryOrder] = strawberry.UNSET,
) -> Select[typing.Any]:
if filter and filter is not strawberry.UNSET:
stmt = stmt.filter_by(**filter.get_filter_dict())
if order_by and order_by is not strawberry.UNSET:
for key in order_by.__dict__.keys():
order = getattr(order_by, key)
if order is not strawberry.UNSET:
if order == "asc":
stmt = stmt.order_by(asc(key))
elif order == "desc":
stmt = stmt.order_by(desc(key))
else:
raise Exception(f"Invalid order {order} for field {key}. Only 'asc' or 'desc' is allowed.")
return stmt


def initialize_schema() -> None:
global SCHEMA
loader = StrawberrySQLAlchemyLoader(async_bind_factory=get_session_factory())

class CustomInfo(Info):
@property
def context(self) -> ContextType: # type: ignore[type-var]
return typing.cast(ContextType, {"sqlalchemy_loader": loader})

@strawberry.type
class Query:
@relay.connection(mapper.connection_types["EnvironmentConnection"]) # type: ignore[misc]
async def environments(
self,
filter: typing.Optional[EnvironmentFilter] = strawberry.UNSET,
order_by: typing.Optional[EnvironmentOrder] = strawberry.UNSET,
) -> typing.Iterable[models.Environment]:
async with get_session() as session:
stmt = select(models.Environment)
stmt = add_filter_and_sort(stmt, filter, order_by)
_environments = await session.scalars(stmt)
return _environments.all()

SCHEMA = strawberry.Schema(query=Query, config=StrawberryConfig(info_class=CustomInfo))
21 changes: 20 additions & 1 deletion src/inmanta/protocol/methods_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import datetime
import uuid
from typing import Literal, Optional, Union
from typing import Any, Literal, Optional, Union

import inmanta.types
from inmanta.const import AgentAction, ApiDocsFormat, Change, ClientType, ParameterSource, ResourceState
Expand Down Expand Up @@ -1604,3 +1604,22 @@ def discovered_resources_get_batch(
:raise NotFound: This exception is raised when the referenced environment is not found
:raise BadRequest: When the parameters used for filtering, sorting or paging are not valid
"""


@typedmethod(
path="/graphql",
operation="POST",
client_types=[ClientType.api],
api_version=2,
strict_typing=False,
)
def graphql(query: str) -> Any: # Actual return type: strawberry.types.execution.ExecutionResult
"""
GraphQL endpoint for Inmanta.
Supports paging, filtering and sorting on certain attributes.

Available queries:
- "environments" - Gets a list of created environments.
Supports filter and sorting on id and name.
"""
pass
1 change: 1 addition & 0 deletions src/inmanta/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@
SLICE_NOTIFICATION = "core.notification"
SLICE_ENVIRONMENT_METRICS = "core.environment-metrics"
SLICE_USER = "core.user"
SLICE_GRAPHQL = "core.graphql"
2 changes: 2 additions & 0 deletions src/inmanta_ext/core/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Contact: [email protected]
"""

from inmanta.graphql.graphql import GraphQLSlice
from inmanta.server import agentmanager, server
from inmanta.server.extensions import ApplicationContext
from inmanta.server.services import (
Expand Down Expand Up @@ -54,3 +55,4 @@ def setup(application: ApplicationContext) -> None:
application.register_slice(notificationservice.NotificationService())
application.register_slice(userservice.UserService())
application.register_slice(environment_metrics_service.EnvironmentMetricsService())
application.register_slice(GraphQLSlice())
Loading