Skip to content
Merged
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
10 changes: 10 additions & 0 deletions exordos/manifests/core.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ version: "{{ version }}"
api_version: "v1"

resources:

# IAM
$core.iam.permissions:
ua_agent_create:
name: "agent.ua.create"
description: "Register a Universal Agent"
ua_agent_issue_key:
name: "agent.ua.issue_key"
description: "Issue or fetch a Universal Agent node encryption key"

$core.vs.profiles:
develop:
name: "develop"
Expand Down
12 changes: 4 additions & 8 deletions exordos_core/compute/dm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import typing as tp
import uuid as sys_uuid

from gcl_sdk.agents.universal.api import crypto as ua_crypto
from gcl_sdk.agents.universal.dm import models as ua_models
from gcl_sdk.infra.dm import models as infra_models
import netaddr
Expand Down Expand Up @@ -427,13 +426,10 @@ def insert(self, session=None):
volume = Volume.restore_from_simple_view(**view)
volume.insert(session=session)

# Generate private key for the node
_, key_base64 = ua_crypto.generate_key_base64()
private_key = ua_models.NodeEncryptionKey(
uuid=self.uuid,
private_key=key_base64,
)
private_key.insert(session=session)
# A key may already exist for this uuid (e.g. it's also
# registered as a local hypervisor's node, which provisions its
# own key the same way) - reuse it instead of conflicting.
ua_models.NodeEncryptionKey.get_or_create(self.uuid, session=session)

def get_agent_private_key(self):
enc_key = ua_models.NodeEncryptionKey.objects.get_one(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
# under the License.

import typing as tp
import uuid as sys_uuid

from bazooka import exceptions as bazooka_exc
from gcl_iam.tests.functional import clients as iam_clients
from gcl_sdk.agents.universal.api import crypto as ua_crypto
from gcl_sdk.agents.universal.dm import models as ua_models
import pytest
from restalchemy.dm import filters as dm_filters

from exordos_core.compute import constants as nc

Expand Down Expand Up @@ -312,3 +316,34 @@ def test_hypervisors_add_same_connection_uri(
client.delete(
client.build_resource_uri(["compute", "hypervisors", hypervisor1["uuid"]])
)

def test_node_reuses_an_existing_node_key(
self,
node_factory: tp.Callable,
user_api_client: iam_clients.GenesisCoreTestRESTClient,
auth_user_admin: iam_clients.GenesisCoreAuth,
):
# A key may already exist for a node's uuid from another source
# (e.g. it's also registered as a local hypervisor's node) -
# registering the Node must reuse it instead of conflicting on
# insert.
node_uuid = sys_uuid.uuid4()
_, private_key = ua_crypto.generate_key_base64()
existing_key = ua_models.NodeEncryptionKey(
uuid=node_uuid, private_key=private_key
)
existing_key.insert()

client = user_api_client(auth_user_admin)
node = node_factory(uuid=node_uuid)
response = client.post(
client.build_collection_uri(["compute", "nodes"]), json=node
)
assert response.status_code == 201

key = ua_models.NodeEncryptionKey.objects.get_one(
filters={"uuid": dm_filters.EQ(node_uuid)}
)
assert key.private_key == private_key

client.delete(client.build_resource_uri(["compute", "nodes", str(node_uuid)]))
105 changes: 104 additions & 1 deletion exordos_core/tests/functional/restapi/ua/test_ua_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class TestUaAgentsApi:
def _agent_factory(
uuid: tp.Optional[sys_uuid.UUID] = None,
name: tp.Optional[str] = None,
node: tp.Optional[sys_uuid.UUID] = None,
status: str = "ACTIVE",
**kwargs,
) -> sys_uuid.UUID:
Expand All @@ -41,7 +42,7 @@ def _agent_factory(
name=name,
capabilities={"capabilities": ["test_capability"]},
facts={"facts": []},
node=sys_uuid.uuid4(),
node=node or sys_uuid.uuid4(),
status=status,
**kwargs,
)
Expand Down Expand Up @@ -147,6 +148,108 @@ def test_user_with_permission_can_list(
uuids = [item["uuid"] for item in output]
assert str(agent_uuid) in uuids

def test_admin_register_agent(
self,
user_api_client: iam_clients.GenesisCoreTestRESTClient,
auth_user_admin: iam_clients.GenesisCoreAuth,
):
agent_uuid = sys_uuid.uuid4()
node_uuid = sys_uuid.uuid4()
client = user_api_client(auth_user_admin)
url = client.build_collection_uri(["ua", "agents"])

response = client.post(
url,
json={
"uuid": str(agent_uuid),
"name": "external-agent",
"node": str(node_uuid),
"capabilities": {"capabilities": ["test_capability"]},
"facts": {"facts": []},
},
)
output = response.json()

assert response.status_code == 201
assert output["uuid"] == str(agent_uuid)
assert output["node"] == str(node_uuid)

def test_issue_key_creates_a_key(
self,
user_api_client: iam_clients.GenesisCoreTestRESTClient,
auth_user_admin: iam_clients.GenesisCoreAuth,
):
agent_uuid = self._agent_factory()
client = user_api_client(auth_user_admin)
url = client.build_resource_uri(
["ua", "agents", str(agent_uuid), "actions", "issue_key", "invoke"]
)

response = client.post(url)
output = response.json()

assert response.status_code == 200
assert output["key"]

def test_issue_key_is_shared_by_agents_on_the_same_node(
self,
user_api_client: iam_clients.GenesisCoreTestRESTClient,
auth_user_admin: iam_clients.GenesisCoreAuth,
):
node_uuid = sys_uuid.uuid4()
agent1_uuid = self._agent_factory(node=node_uuid)
agent2_uuid = self._agent_factory(node=node_uuid)
client = user_api_client(auth_user_admin)

response1 = client.post(
client.build_resource_uri(
["ua", "agents", str(agent1_uuid), "actions", "issue_key", "invoke"]
)
)
response2 = client.post(
client.build_resource_uri(
["ua", "agents", str(agent2_uuid), "actions", "issue_key", "invoke"]
)
)

assert response1.json()["key"] == response2.json()["key"]

def test_issue_key_nonadmin_no_access(
self,
user_api_client: iam_clients.GenesisCoreTestRESTClient,
auth_test1_user: iam_clients.GenesisCoreAuth,
):
agent_uuid = self._agent_factory()
client = user_api_client(auth_test1_user)
url = client.build_resource_uri(
["ua", "agents", str(agent_uuid), "actions", "issue_key", "invoke"]
)

with pytest.raises(bazooka_exc.ForbiddenError):
client.post(url)

def test_issue_key_user_with_permission_can_issue(
self,
user_api_client: iam_clients.GenesisCoreTestRESTClient,
auth_test1_user: iam_clients.GenesisCoreAuth,
):
agent_uuid = self._agent_factory()
client = user_api_client(
auth_test1_user,
permissions=[
"agent.ua.read",
"agent.ua.issue_key",
],
)
url = client.build_resource_uri(
["ua", "agents", str(agent_uuid), "actions", "issue_key", "invoke"]
)

response = client.post(url)

assert response.status_code == 200
assert response.json()["key"]


class TestUaResourcesApi:
@staticmethod
Expand Down
44 changes: 44 additions & 0 deletions exordos_core/tests/unit/compute/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Copyright 2026 Genesis Corporation.
#
# All Rights Reserved.
#
# 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.
import uuid as sys_uuid
from unittest.mock import patch

from gcl_sdk.agents.universal.dm import models as ua_models
from gcl_sdk.infra.dm import models as infra_models

from exordos_core.compute.dm import models


class TestNodeInsert:
def test_reuses_an_existing_key(self):
# A key may already exist for this uuid (e.g. it's also a local
# hypervisor's node, which provisions its own key the same way) -
# go through get_or_create instead of blindly inserting a fresh
# one and conflicting on the unique node uuid.
node = models.Node(
cores=1,
ram=1024,
disk_spec=infra_models.RootDiskSpec(image="ubuntu_24.04"),
project_id=sys_uuid.uuid4(),
)

with (
patch.object(models.orm.SQLStorableMixin, "insert"),
patch.object(ua_models.NodeEncryptionKey, "get_or_create") as get_or_create,
):
node.insert()

get_or_create.assert_called_once_with(node.uuid, session=None)
9 changes: 9 additions & 0 deletions exordos_core/user_api/ua/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from gcl_iam.api import controllers as iam_controllers
from gcl_sdk.agents.universal.dm import models as ua_models
from restalchemy.api import actions
from restalchemy.api import controllers as ra_controllers
from restalchemy.api import resources

Expand All @@ -36,6 +37,14 @@ class AgentController(
convert_underscore=False,
)

@actions.post
def issue_key(self, resource: ua_models.UniversalAgent):
self._enforce("issue_key")

enc_key = ua_models.NodeEncryptionKey.get_or_create(resource.node)

return {"key": enc_key.private_key}


class ResourceController(
iam_controllers.PolicyBasedController,
Expand Down
10 changes: 9 additions & 1 deletion exordos_core/user_api/ua/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@
from exordos_core.user_api.ua import controllers


class IssueKeyAction(routes.Action):
"""Handler for /v1/ua/agents/<uuid>/actions/issue_key/invoke endpoint"""

__controller__ = controllers.AgentController


class AgentsRoute(routes.Route):
"""Handler for /v1/ua/agents/ endpoint"""

__allow_methods__ = [routes.FILTER, routes.GET]
__allow_methods__ = [routes.FILTER, routes.GET, routes.CREATE]
__controller__ = controllers.AgentController

issue_key = routes.action(IssueKeyAction, invoke=True)


class ResourcesRoute(routes.Route):
"""Handler for /v1/ua/resources/ endpoint"""
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dependencies = [
"Jinja2>=3.1.5,<4.0.0", # BSD License (BSD-3-Clause)
"izulu>=0.50.0,<1.0.0", # MIT License
"gcl_iam>=1.3.2,<2.0.0", # Apache-2.0
"gcl_sdk>=3.1.0,<4.0.0", # Apache-2.0
"gcl_sdk>=3.2.2,<4.0.0", # Apache-2.0
"gcl_certbot_plugin>=0.0.9,<1.0.0", # Apache-2.0
"pyotp>=2.9.0,<3.0.0", # MIT License
"pyyaml>=6.0.0,<7.0.0", # MIT
Expand Down
14 changes: 7 additions & 7 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.