Skip to content

Commit

Permalink
Merge 8baa92c into 7118358
Browse files Browse the repository at this point in the history
  • Loading branch information
ekampf committed Sep 5, 2024
2 parents 7118358 + 8baa92c commit b7d17dc
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 3 deletions.
113 changes: 110 additions & 3 deletions app/api/client_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,80 @@
from gql import gql
from gql.transport.exceptions import TransportQueryError

from app.api.exceptions import GraphQLMutationError
from app.api.protocol import TwingateClientProtocol
from app.crds import GroupSpec

_GROUP_FRAGMENT = """
fragment GroupFields on Group {
id
name
createdAt
updatedAt
}
"""

QUERY_GET_GROUP_ID_BY_NAME = gql(
"""
_GROUP_FRAGMENT
+ """
query GetGroupByName($name: String!) {
groups(filter: {name: {eq: $name}}) {
edges {
node {
id
name
...GroupFields
}
}
}
}
"""
)

MUT_CREATE_GROUP = gql(
_GROUP_FRAGMENT
+ """
mutation CreateGroup($name: String!, $userIds: [ID]) {
groupCreate(
name: $name
userIds: $userIds
) {
ok
error
entity {
...GroupFields
}
}
}
"""
)

MUT_UPDATE_GROUP = gql(
_GROUP_FRAGMENT
+ """
mutation UpdateGroup($id: ID!, $name: String!, $userIds: [ID]) {
groupUpdate(
id: $id,
name: $name
userIds: $userIds
) {
ok
error
entity {
...GroupFields
}
}
}
"""
)

MUT_DELETE_GROUP = gql("""
mutation DeleteGroup($id: ID!) {
groupDelete(id: $id) {
ok
error
}
}
""")


class TwingateGroupAPIs:
def get_group_id(self: TwingateClientProtocol, group_name: str) -> str | None:
Expand All @@ -31,3 +88,53 @@ def get_group_id(self: TwingateClientProtocol, group_name: str) -> str | None:
except (TransportQueryError, IndexError, KeyError):
logging.exception("Failed to get resource")
return None

def group_create(
self: TwingateClientProtocol,
group: GroupSpec,
user_ids: list[str] | None = None,
) -> str:
user_ids = user_ids or []
result = self.execute_mutation(
"groupCreate",
MUT_CREATE_GROUP,
variable_values={
"name": group.name,
"userIds": user_ids,
},
)
return result["entity"]["id"]

def group_update(
self: TwingateClientProtocol,
group: GroupSpec,
user_ids: list[str] | None = None,
) -> str:
user_ids = user_ids or []
result = self.execute_mutation(
"groupUpdate",
MUT_UPDATE_GROUP,
variable_values={
"id": group.id,
"name": group.name,
"userIds": user_ids,
},
)
return result["entity"]["id"]

def group_delete(self: TwingateClientProtocol, group_id: str):
try:
result = self.execute_mutation(
"groupDelete",
MUT_DELETE_GROUP,
variable_values={"id": group_id},
)

return bool(result["ok"])
except GraphQLMutationError as gql_err:
if "does not exist" in gql_err.error:
return True

raise
except TransportQueryError:
return False
20 changes: 20 additions & 0 deletions app/crds.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,23 @@ class TwingateConnectorCRD(BaseK8sModel):


# endregion

# region TwingateGroup


class GroupSpec(BaseModel):
model_config = ConfigDict(
frozen=True, populate_by_name=True, alias_generator=to_camel
)

id: str | None = None
name: str | None = None


class TwingateGroupCRD(BaseK8sModel):
model_config = ConfigDict(frozen=True, populate_by_name=True, extra="allow")

spec: GroupSpec


# endregion
1 change: 1 addition & 0 deletions app/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# ruff: noqa: F403,F403
from .handlers_connectors import *
from .handlers_groups import *
from .handlers_resource import *
from .handlers_resource_access import *
from .handlers_services import *
71 changes: 71 additions & 0 deletions app/handlers/handlers_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from datetime import timedelta

import kopf

from app.api import TwingateAPIClient
from app.api.exceptions import GraphQLMutationError
from app.crds import TwingateGroupCRD
from app.handlers import fail, success


@kopf.on.resume("twingategroup")
@kopf.on.create("twingategroup")
@kopf.on.update("twingategroup")
def twingate_group_create_update(body, spec, logger, memo, patch, **kwargs):
logger.info("twingate_group_reconciler: %s", spec)
settings = memo.twingate_settings
client = TwingateAPIClient(settings)

if diff := kwargs.get("diff"):
logger.info("Diff: %s", diff)
# If ID changed from `None to value we just created it no need to update
# diff is `(('add', ('spec', 'id'), None, 'R3JvdXA6MjAxNjc4OQ=='),)`
if (
len(diff) == 1
and diff[0][0] == "add"
and diff[0][1] == ("spec", "id")
and diff[0][2] is None
):
return success()

crd = TwingateGroupCRD(**body)
group_id = crd.spec.id
if group_id:
logger.info("Updating group with name='%s'", crd.spec.name)
try:
client.group_update(crd.spec)
except GraphQLMutationError as gqlerr:
logger.error("Failed to update group: %s", gqlerr)
if "does not exist" in gqlerr.message:
patch.spec["id"] = None
return fail(error=gqlerr.message)
else:
logger.info(
"Creating group with name='%s'",
crd.spec.name,
)
group_id = client.group_create(crd.spec)
patch.spec["id"] = group_id

return success(
twingate_id=group_id,
)


@kopf.timer(
"twingategroup", interval=timedelta(hours=10).seconds, initial_delay=60, idle=60
)
def twingate_group_reconciler(body, spec, logger, memo, patch, **_):
return twingate_group_create_update(body, spec, logger, memo, patch)


@kopf.on.delete("twingategroup")
def twingate_group_delete(spec, status, memo, logger, **kwargs):
logger.info("Got a delete request: %s. Status: %s", spec, status)
if not status:
return

if group_id := spec.get("id"):
logger.info("Deleting group %s", group_id)
client = TwingateAPIClient(memo.twingate_settings)
client.group_delete(group_id)
26 changes: 26 additions & 0 deletions app/tests/test_crds_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest

from app.crds import TwingateGroupCRD


@pytest.fixture
def sample_group_object():
return {
"apiVersion": "twingate.com/v1beta",
"kind": "TwingateConnector",
"metadata": {
"name": "my-group",
"namespace": "default",
"uid": "ad0298c5-b84f-4617-b4a2-d3cbbe9f6a4c",
},
"spec": {
"name": "My Group",
},
}


def test_group_deserialization(sample_group_object):
group = TwingateGroupCRD(**sample_group_object)

assert group.metadata.name == "my-group"
assert group.spec.name == "My Group"
36 changes: 36 additions & 0 deletions deploy/twingate-operator/crds/twingate.com.twingategroup.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: twingategroups.twingate.com
spec:
group: twingate.com
versions:
- name: v1beta
served: true
storage: true
schema:
openAPIV3Schema:
type: object
description: "TwingateGroup represents a Group in Twingate."
properties:
spec:
type: object
description: "TwingateGroupSpec defines the desired state of TwingateGroup"
required: ["name"]
properties:
id:
type: string
nullable: true
name:
type: string
description: "Name of the group."
status:
type: object
x-kubernetes-preserve-unknown-fields: true
scope: Namespaced
names:
plural: twingategroups
singular: twingategroup
kind: TwingateGroup
shortNames:
- tgg
6 changes: 6 additions & 0 deletions examples/group.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: twingate.com/v1beta
kind: TwingateGroup
metadata:
name: example
spec:
name: Example Group
33 changes: 33 additions & 0 deletions tests_integration/test_crds_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import subprocess

import pytest

from tests_integration.utils import kubectl_create, kubectl_delete


def test_success(unique_resource_name):
result = kubectl_create(f"""
apiVersion: twingate.com/v1beta
kind: TwingateGroup
metadata:
name: {unique_resource_name}
spec:
name: My K8S Group
""")

assert result.returncode == 0
kubectl_delete(f"tgg/{unique_resource_name}")


def test_name_required(unique_resource_name):
with pytest.raises(subprocess.CalledProcessError) as ex:
kubectl_create(f"""
apiVersion: twingate.com/v1beta
kind: TwingateGroup
metadata:
name: {unique_resource_name}
spec: {{}}
""")

stderr = ex.value.stderr.decode()
assert "spec.name: Required" in stderr

0 comments on commit b7d17dc

Please sign in to comment.