Source code for tuoni.TuoniNote
import re
from urllib.parse import quote, urlsplit
from uuid import UUID
from tuoni.TuoniEvent import TuoniEvent
from tuoni.TuoniExceptions import ExceptionTuoniDeleted
[docs]
class TuoniNote:
"""A note attached to a command, including its audit events.
Edit ``label`` or ``content``, then call :meth:`update` to save both fields.
The server trims labels and preserves content whitespace. Archiving is
permanent, but archived notes remain readable.
Attributes:
note_id (str): The note's UUID.
target (dict): Command target with ``targetType``, ``commandId`` and
an ``href`` identifying the command. Requests always use this
client's server, including when the server returns absolute links.
label (str): Nonblank label, limited to 128 characters by the server.
content (str): Nonblank content, limited to 2,000 characters.
status (str): ``ACTIVE`` or ``ARCHIVED``.
createEvent (TuoniEvent): Note creation event.
lastUpdateEvent (TuoniEvent): Most recent update or archive event.
archiveEvent (TuoniEvent | None): Archive event, if archived.
"""
def __init__(self, conf, c2):
"""Wrap a server note without making requests.
Args:
conf (dict): Note response from the server.
c2 (TuoniC2): Client used for subsequent requests.
"""
self.c2 = c2
self._load_conf(conf)
def _load_conf(self, conf):
self.note_id = conf["id"]
self.target = conf["target"]
self.label = conf["label"]
self.content = conf["content"]
self.status = conf["status"]
self.createEvent = TuoniEvent(conf["createEvent"], self.c2)
self.lastUpdateEvent = TuoniEvent(conf["lastUpdateEvent"], self.c2)
self.archiveEvent = (
TuoniEvent(conf["archiveEvent"], self.c2)
if conf.get("archiveEvent") is not None else None
)
def _get_uri(self):
if self.note_id is None:
raise ExceptionTuoniDeleted("")
href = self.target.get("href")
parts = urlsplit(href) if isinstance(href, str) else None
path = ""
if parts is not None and not parts.query and not parts.fragment:
if not parts.scheme and not parts.netloc:
path = parts.path
elif parts.scheme in {"http", "https"} and parts.netloc:
# Absolute server links may include a servlet context prefix.
# The client already owns its base URL; use only the API path.
start = parts.path.find("/api/v2/agents/")
if start >= 0:
path = parts.path[start:]
match = re.fullmatch(
r"/api/v2/agents/([0-9a-fA-F-]{36})/commands/([0-9]+)", path
)
if (self.target.get("targetType") != "COMMAND" or match is None
or int(match[2]) != self.target.get("commandId")):
raise ValueError("Note target must contain a matching command API path")
# Validate the agent identifier as well as the path shape before using it.
UUID(match[1])
return f"{path}/notes/{quote(str(self.note_id), safe='')}"
[docs]
def reload(self):
"""Reload this note, including an archived note, from the server.
Requires ``VIEW_RESOURCES``. Replaces local fields and returns None.
"""
data = self.c2.request_get(self._get_uri())
self._load_conf(data)
[docs]
def update(self):
"""Save the current label and content, then refresh local fields.
Requires ``SEND_COMMANDS`` and note ownership, unless ``MANAGE_USERS``
grants an ownership override. The server rejects invalid fields with
HTTP 400 and archived or concurrently changed notes with HTTP 409.
Request failures use the existing Tuoni request exceptions; local
fields are refreshed only on success. Returns None.
Examples:
>>> note.content = "Reviewed the command output."
>>> note.update()
"""
data = self.c2.request_patch(
self._get_uri(), {"label": self.label, "content": self.content}
)
self._load_conf(data)
[docs]
def archive(self):
"""Permanently archive this note and refresh its status and events.
Requires ``SEND_COMMANDS`` and ownership or a ``MANAGE_USERS`` ownership
override. Repeated archiving is supported. The identifier is retained
so the note can still be reloaded. Returns None.
"""
data = self.c2.request_delete(self._get_uri())
self._load_conf(data)