Page MenuHomeDevCentral

D2793.id7096.diff
No OneTemporary

D2793.id7096.diff

diff --git a/.arclint b/.arclint
--- a/.arclint
+++ b/.arclint
@@ -47,6 +47,7 @@
],
"include": [
"(\\.py$)",
+ "(^roles/saas-rabbitmq/server/content.sls$)",
"(^roles/viperserv/eggdrop/cron.sls$)",
"(^roles/webserver-legacy/php-builder/source.sls$)",
"(^roles/webserver-legacy/php-sites/cleanup.sls$)"
diff --git a/_modules/rabbitmq.py b/_modules/rabbitmq.py
--- a/_modules/rabbitmq.py
+++ b/_modules/rabbitmq.py
@@ -19,6 +19,11 @@
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+def get_password(credential):
+ secret = __salt__["vault.read_secret"](credential)
+ return compute_password_hash(secret["password"])
+
+
def compute_password_hash(password):
salt = secrets.randbits(32)
return _compute_password_hash_with_salt(salt, password)
diff --git a/_modules/rabbitmq_api.py b/_modules/rabbitmq_api.py
new file mode 100644
--- /dev/null
+++ b/_modules/rabbitmq_api.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+
+# -------------------------------------------------------------
+# Salt - RabbitMQ management HTTP API client
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# Description: Connect to RabbitMQ management HTTP API
+# License: BSD-2-Clause
+# -------------------------------------------------------------
+
+
+import json
+import logging
+
+
+import requests
+from requests.auth import HTTPBasicAuth
+
+
+log = logging.getLogger(__name__)
+
+
+HTTP_SUCCESS_CODES = [200, 201, 204]
+HTTP_CONTENT_CODES = [200]
+
+
+def _request(cluster, method, path, data=None):
+ args = __opts__["rabbitmq"][cluster]
+
+ url = args["url"] + "/" + path
+
+ if args["auth"] == "basic":
+ auth = HTTPBasicAuth(args["user"], args["password"])
+ else:
+ raise RuntimeError(
+ f"RabbitMQ HTTP API authentication scheme not supported: {args['auth']}"
+ )
+
+ headers = {
+ "User-agent": "Salt-RabbitMQ/1.0",
+ }
+
+ if data is not None:
+ data = json.dumps(data)
+
+ log.debug(f"HTTP request {method} to {url}")
+ log.trace(f"Payload: {data}")
+ r = requests.request(method, url, headers=headers, auth=auth, data=data)
+
+ if r.status_code not in HTTP_SUCCESS_CODES:
+ log.error(f"HTTP status code {r.status_code}, 2xx expected.")
+ raise RuntimeError(f"Status code is {r.status_code}")
+
+ if r.status_code not in HTTP_CONTENT_CODES:
+ log.trace(
+ f"HTTP response is {r.status_code}. The API doesn't include any content for this code."
+ )
+ return True
+
+ return r.json()
+
+
+# -------------------------------------------------------------
+# Execution module methods
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+ARGS_VHOST = ["description", "tags", "tracing"]
+ARGS_EXCHANGE = ["type", "auto_delete", "durable", "internal", "arguments"]
+ARGS_QUEUE = ["auto_delete", "durable", "arguments", "node"]
+
+
+def overview(cluster):
+ return _request(cluster, "GET", "overview")
+
+
+def list_vhosts(cluster):
+ return _request(cluster, "GET", "vhosts")
+
+
+def get_vhost(cluster, vhost):
+ vhost = requests.utils.quote(vhost, safe="")
+ return _request(cluster, "GET", f"vhosts/{vhost}")
+
+
+def update_vhost(cluster, vhost, **kwargs):
+ vhost = requests.utils.quote(vhost, safe="")
+ data = {}
+ for arg in ARGS_VHOST:
+ if arg in kwargs:
+ data[arg] = kwargs[arg]
+
+ return _request(cluster, "PUT", f"vhosts/{vhost}", data)
+
+
+def delete_vhost(cluster, vhost):
+ vhost = requests.utils.quote(vhost, safe="")
+ return _request(cluster, "DELETE", f"vhosts/{vhost}")
+
+
+def vhost_exists(cluster, vhost):
+ return vhost in [result["name"] for result in list_vhosts(cluster)]
+
+
+def list_exchanges(cluster, vhost):
+ vhost = requests.utils.quote(vhost, safe="")
+ return _request(cluster, "GET", f"exchanges/{vhost}")
+
+
+def get_exchange(cluster, vhost, exchange):
+ vhost = requests.utils.quote(vhost, safe="")
+ exchange = requests.utils.quote(exchange, safe="")
+ return _request(cluster, "GET", f"exchanges/{vhost}/{exchange}")
+
+
+def update_exchange(cluster, vhost, exchange, **kwargs):
+ vhost = requests.utils.quote(vhost, safe="")
+ exchange = requests.utils.quote(exchange, safe="")
+ data = {}
+ for arg in ARGS_EXCHANGE:
+ if arg in kwargs:
+ data[arg] = kwargs[arg]
+
+ return _request(cluster, "PUT", f"exchanges/{vhost}/{exchange}", data)
+
+
+def delete_exchange(cluster, vhost, exchange):
+ vhost = requests.utils.quote(vhost, safe="")
+ exchange = requests.utils.quote(exchange, safe="")
+ return _request(cluster, "DELETE", f"exchanges/{vhost}/{exchange}")
+
+
+def exchange_exists(cluster, vhost, exchange):
+ vhost = requests.utils.quote(vhost, safe="")
+ return exchange in [result["name"] for result in list_exchanges(cluster, vhost)]
+
+
+def list_queues(cluster, vhost):
+ vhost = requests.utils.quote(vhost, safe="")
+ return _request(cluster, "GET", f"queues/{vhost}")
+
+
+def get_queue(cluster, vhost, queue):
+ vhost = requests.utils.quote(vhost, safe="")
+ queue = requests.utils.quote(queue, safe="")
+ return _request(cluster, "GET", f"queues/{vhost}/{queue}")
+
+
+def update_queue(cluster, vhost, queue, **kwargs):
+ vhost = requests.utils.quote(vhost, safe="")
+ queue = requests.utils.quote(queue, safe="")
+ data = {}
+ for arg in ARGS_QUEUE:
+ if arg in kwargs:
+ data[arg] = kwargs[arg]
+
+ return _request(cluster, "PUT", f"queues/{vhost}/{queue}", data)
+
+
+def delete_queue(cluster, vhost, queue):
+ vhost = requests.utils.quote(vhost, safe="")
+ queue = requests.utils.quote(queue, safe="")
+ return _request(cluster, "DELETE", f"queues/{vhost}/{queue}")
+
+
+def queue_exists(cluster, vhost, queue):
+ vhost = requests.utils.quote(vhost, safe="")
+ return queue in [result["name"] for result in list_queues(cluster, vhost)]
diff --git a/_states/rabbitmq.py b/_states/rabbitmq.py
new file mode 100644
--- /dev/null
+++ b/_states/rabbitmq.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+
+# -------------------------------------------------------------
+# Salt - RabbitMQ management HTTP API state module
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# Description: Configure RabbitMQ through management HTTP API
+# License: BSD-2-Clause
+# -------------------------------------------------------------
+
+
+import logging
+
+
+log = logging.getLogger(__name__)
+
+
+# -------------------------------------------------------------
+# Vhost
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def vhost_present(name, cluster, description="", tags=[], tracing=False):
+ ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+ expected = {
+ "description": description,
+ "tags": tags,
+ "tracing": tracing,
+ }
+ actual = {}
+ is_existing = False
+
+ if __salt__["rabbitmq_api.vhost_exists"](cluster, name):
+ vhost = __salt__["rabbitmq_api.get_vhost"](cluster, name)
+ is_existing = True
+ actual = {
+ "description": vhost["metadata"]["description"],
+ "tags": vhost["metadata"]["tags"],
+ "tracing": vhost["tracing"],
+ }
+
+ if actual == expected:
+ ret["result"] = True
+ ret["comment"] = f"Vhost {name} is up to date"
+ return ret
+
+ ret["changes"] = _changes(actual, expected)
+ update_verb = "updated" if is_existing else "created"
+
+ if __opts__["test"]:
+ ret["result"] = None
+ ret["comment"] = f"Vhost {name} will be {update_verb}"
+ return ret
+
+ try:
+ __salt__["rabbitmq_api.update_vhost"](cluster, name, **expected)
+ except Exception as e:
+ log.error("Can't update RabbitMQ vhost: " + e)
+ ret["comment"] = e
+ return ret
+
+ ret["result"] = True
+ ret["comment"] = f"Vhost {update_verb}"
+
+ return ret
+
+
+def vhost_absent(name, cluster):
+ ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+ if not __salt__["rabbitmq_api.vhost_exists"](cluster, name):
+ ret["result"] = True
+ ret["comment"] = f"Vhost {name} is absent"
+ return ret
+
+ if __opts__["test"]:
+ ret["result"] = None
+ ret["comment"] = f"Vhost {name} will be deleted"
+ return ret
+
+ try:
+ __salt__["rabbitmq_api.delete_vhost"](cluster, name)
+ except Exception as e:
+ log.error("Can't delete RabbitMQ vhost: " + e)
+ ret["comment"] = e
+ return ret
+
+ ret["result"] = True
+ ret["comment"] = "Vhost deleted"
+
+ return ret
+
+
+# -------------------------------------------------------------
+# Exchange
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def exchange_present(
+ name,
+ cluster,
+ vhost,
+ type,
+ auto_delete=False,
+ durable=False,
+ internal=False,
+ arguments={},
+):
+ ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+ expected = {
+ "type": type,
+ "auto_delete": auto_delete,
+ "durable": durable,
+ "internal": internal,
+ "arguments": arguments,
+ }
+ actual = {}
+ is_existing = False
+
+ if __salt__["rabbitmq_api.exchange_exists"](cluster, vhost, name):
+ exchange = __salt__["rabbitmq_api.get_exchange"](cluster, vhost, name)
+ is_existing = True
+ actual = {
+ "type": exchange["type"],
+ "auto_delete": exchange["auto_delete"],
+ "durable": exchange["durable"],
+ "internal": exchange["internal"],
+ "arguments": exchange["arguments"],
+ }
+
+ if actual == expected:
+ ret["result"] = True
+ ret["comment"] = f"Exchange {name} is up to date"
+ return ret
+
+ ret["changes"] = _changes(actual, expected)
+ update_verb = "deleted then created back" if is_existing else "created"
+
+ if __opts__["test"]:
+ ret["result"] = None
+ ret["comment"] = f"Exchange {name} will be {update_verb}"
+ return ret
+
+ try:
+ if is_existing:
+ operation = "delete"
+ __salt__["rabbitmq_api.delete_exchange"](cluster, vhost, name)
+
+ operation = "create"
+ __salt__["rabbitmq_api.update_exchange"](cluster, vhost, name, **expected)
+ except Exception as e:
+ e = str(e)
+ log.error(f"Can't {operation} RabbitMQ exchange: {e}")
+ ret["comment"] = e
+ return ret
+
+ ret["result"] = True
+ ret["comment"] = f"Exchange {update_verb}"
+
+ return ret
+
+
+# -------------------------------------------------------------
+# Queue
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def queue_present(
+ name, cluster, vhost, auto_delete=False, durable=False, arguments={}, node=None
+):
+ ret = {"name": name, "result": False, "changes": {}, "comment": ""}
+
+ expected = {
+ "auto_delete": auto_delete,
+ "durable": durable,
+ "arguments": arguments,
+ }
+ if node is not None:
+ expected["node"] = node
+
+ actual = {}
+ is_existing = False
+
+ if __salt__["rabbitmq_api.queue_exists"](cluster, vhost, name):
+ queue = __salt__["rabbitmq_api.get_queue"](cluster, vhost, name)
+ is_existing = True
+ actual = {
+ "auto_delete": queue["auto_delete"],
+ "durable": queue["durable"],
+ "arguments": queue["arguments"],
+ }
+ if node is not None:
+ actual["node"] = queue["node"]
+
+ if actual == expected:
+ ret["result"] = True
+ ret["comment"] = f"queue {name} is up to date"
+ return ret
+
+ ret["changes"] = _changes(actual, expected)
+ update_verb = "deleted then created back" if is_existing else "created"
+
+ if __opts__["test"]:
+ ret["result"] = None
+ ret["comment"] = f"queue {name} will be {update_verb}"
+ return ret
+
+ try:
+ if is_existing:
+ operation = "delete"
+ __salt__["rabbitmq_api.delete_queue"](cluster, vhost, name)
+
+ operation = "create"
+ __salt__["rabbitmq_api.update_queue"](cluster, vhost, name, **expected)
+ except Exception as e:
+ e = str(e)
+ log.error(f"Can't {operation} RabbitMQ queue: {e}")
+ ret["comment"] = e
+ return ret
+
+ ret["result"] = True
+ ret["comment"] = f"queue {update_verb}"
+
+ return ret
+
+
+# -------------------------------------------------------------
+# Helper functions
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def _changes(actual, expected):
+ """Compute a changes dictionary between actual and expected state dictionaries."""
+ changes = {}
+ for k, v in expected.items():
+ if k not in actual:
+ changes[k] = {"old": None, "new": expected[k]}
+ elif actual[k] != expected[k]:
+ changes[k] = {"old": actual[k], "new": expected[k]}
+
+ return changes
diff --git a/pillar/saas/rabbitmq.sls b/pillar/saas/rabbitmq.sls
new file mode 100644
--- /dev/null
+++ b/pillar/saas/rabbitmq.sls
@@ -0,0 +1,105 @@
+# -------------------------------------------------------------
+# Salt — RabbitMQ
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+# -------------------------------------------------------------
+# RabbitMQ clusters
+#
+# Each cluster is defined by a deployment method (e.g. docker),
+# and the node we can use to configure it.
+#
+# The cluster configuration is a collection of vhosts and users:
+#
+# vhosts:
+# <vhost name>: <configuration>
+#
+# users:
+# <user>: <password FULL secret path in Vault>
+#
+# In addition, a root account is managed by deployment states.
+#
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+#
+# The vhost configuration allows to define the exchanges and queues,
+# and the permissions users have on them.
+#
+# exchanges:
+# type is 'direct', 'topic' or 'fanout'
+#
+# queues:
+# Application can create their own ephemeral queue.
+# For that, it needs configure permission on the vhost.
+#
+# If an application needs a stable one, it should be configured here,
+# so we can drop the configure permission.
+#
+# permissions:
+# See https://www.rabbitmq.com/access-control.html#authorisation
+# for the needed permissions for an AMQP operation
+#
+# To give access to server-generated queue names, use amq\.gen.*
+# To not give any access, use blank string
+#
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+rabbitmq_clusters:
+ white-rabbit:
+ deployment: docker
+ node: docker-002
+ container: white-rabbit
+ url: https://white-rabbit.nasqueron.org/
+
+ vhosts:
+
+ ###
+ ### Nasqueron dev services:
+ ### - Notifications center
+ ###
+
+ dev: &nasqueron-dev-services-vhost
+ description: Nasqueron dev services
+
+ exchanges:
+ # Producer: Notifications center
+ # Consumers: any notifications client
+ notifications:
+ type: topic
+
+ queues:
+ # Used by Wearg to stream notifications to IRC
+ wearg-notifications: {}
+
+ bindings:
+ - exchange: notifications
+ queue: wearg-notifications
+ routing_key: '#'
+
+ permissions:
+ # Notifications center (paas-docker role / notifications container)
+ notifications:
+ configure: ''
+ read: ''
+ write: '^notifications$'
+
+ # Wearg (viperserv role)
+ wearg:
+ configure: ''
+ read: '^wearg\-notifications$'
+ write: ''
+
+ # Notifications CLI clients
+ notifications-ysul: &notifications-client-permissions
+ configure: '^amq\.gen.*$'
+ read: '^(amq\.gen.*|notifications)$'
+ write: '^amq\.gen.*$'
+ notifications-windriver: *notifications-client-permissions
+
+ users:
+ # Notifications center server and clients
+ notifications: ops/secrets/nasqueron.notifications.broker
+ wearg: apps/viperserv/broker
+ notifications-ysul: ops/secrets/nasqueron/notifications/notifications-cli/ysul
+ notifications-windriver: ops/secrets/nasqueron/notifications/notifications-cli/windriver
diff --git a/roles/saas-rabbitmq/init.sls b/roles/saas-rabbitmq/init.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/init.sls
@@ -0,0 +1,9 @@
+# -------------------------------------------------------------
+# Salt — RabbitMQ
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+include:
+ - .server
diff --git a/roles/saas-rabbitmq/server/content.sls b/roles/saas-rabbitmq/server/content.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/server/content.sls
@@ -0,0 +1,141 @@
+#!py
+
+# -------------------------------------------------------------
+# Salt — RabbitMQ
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# If eligible, licensed under BSD-2-Clause
+# -------------------------------------------------------------
+
+
+# -------------------------------------------------------------
+# Configuration provider
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def run():
+ config = {}
+
+ for cluster, cluster_args in __pillar__["rabbitmq_clusters"].items():
+ config |= configure_cluster(cluster, cluster_args)
+
+ return config
+
+
+# -------------------------------------------------------------
+# Cluster configuration
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_cluster(cluster, cluster_args):
+ config = {}
+
+ for user, credential in cluster_args["users"].items():
+ id = f"rabbitmq_cluster_{cluster}_user_{user}"
+ config[id] = configure_user(cluster, user, credential)
+
+ for vhost, vhost_args in cluster_args["vhosts"].items():
+ id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}"
+ config[id] = configure_vhost(cluster, vhost, vhost_args)
+
+ for exchange, exchange_args in vhost_args.get("exchanges", {}).items():
+ id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_exchange_{exchange}"
+ config[id] = configure_exchange(cluster, vhost, exchange, exchange_args)
+
+ for queue, queue_args in vhost_args.get("queues", {}).items():
+ id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_queue_{queue}"
+ config[id] = configure_queue(cluster, vhost, queue, queue_args)
+
+ i = 0
+ for binding in vhost_args.get("bindings", []):
+ i += 1
+ id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_binding_{i}"
+ config[id] = configure_binding(cluster, vhost, binding)
+
+ for user, permission in vhost_args.get("permissions", {}).items():
+ id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_permissions_user_{user}"
+ config[id] = configure_user_permission(cluster, vhost, user, permission)
+
+ return config
+
+
+# -------------------------------------------------------------
+# RabbitMQ vhosts
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_vhost(cluster, vhost, vhost_args):
+ return {
+ "rabbitmq.vhost_present": [
+ {"name": vhost},
+ {"cluster": cluster},
+ {"description": vhost_args.get("description", "")},
+ ]
+ }
+
+
+# -------------------------------------------------------------
+# RabbitMQ exchanges and queues
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_exchange(cluster, vhost, exchange, exchange_args):
+ return {
+ "rabbitmq.exchange_present": [
+ {"name": exchange},
+ {"cluster": cluster},
+ {"vhost": vhost},
+ {"type": exchange_args["type"]},
+ ]
+ }
+
+
+def configure_queue(cluster, vhost, queue, queue_args):
+ return {
+ "rabbitmq.queue_present": [
+ {"name": queue},
+ {"cluster": cluster},
+ {"vhost": vhost},
+ ]
+ }
+
+
+def configure_binding(cluster, vhost, binding):
+ params = [
+ {"queue": binding["queue"]},
+ {"cluster": cluster},
+ {"vhost": vhost},
+ {"exchange": binding["exchange"]},
+ ]
+
+ if "routing_key" in binding:
+ params.append({"routing_key": binding["routing_key"]})
+
+ return {"rabbitmq.queue_binding": params}
+
+
+# -------------------------------------------------------------
+# RabbitMQ users and permissions
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+def configure_user(cluster, user, credential):
+ return {
+ "rabbitmq.user_present": [
+ {"name": user},
+ {"cluster": cluster},
+ {"credential": credential},
+ ]
+ }
+
+
+def configure_user_permission(cluster, vhost, user, privilege):
+ return {
+ "rabbitmq.user_permissions": [
+ {"cluster": cluster},
+ {"vhost": vhost},
+ {"user": user},
+ {"permissions": privilege},
+ ]
+ }
diff --git a/roles/saas-rabbitmq/server/init.sls b/roles/saas-rabbitmq/server/init.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/server/init.sls
@@ -0,0 +1,12 @@
+# -------------------------------------------------------------
+# Salt — RabbitMQ
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+include:
+ - .software
+
+ # Content includes vhosts, exchanges, queues, users, privileges
+ - .content
diff --git a/roles/saas-rabbitmq/server/software.sls b/roles/saas-rabbitmq/server/software.sls
new file mode 100644
--- /dev/null
+++ b/roles/saas-rabbitmq/server/software.sls
@@ -0,0 +1,8 @@
+# -------------------------------------------------------------
+# Salt — RabbitMQ
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+# This state is left intentionally blank.
diff --git a/services.sls b/services.sls
new file mode 100644
--- /dev/null
+++ b/services.sls
@@ -0,0 +1,14 @@
+# -------------------------------------------------------------
+# Salt configuration for Nasqueron servers :: services
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# Description: List of the roles configured through services API.
+# They are typically run on the Salt primary server,
+# especially as they can need Vault credentials,
+# but they don't touch any file *directly*.
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+base:
+ 'local':
+ - roles/saas-rabbitmq

File Metadata

Mime Type
text/plain
Expires
Sun, Nov 24, 13:06 (8 h, 42 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2260210
Default Alt Text
D2793.id7096.diff (23 KB)

Event Timeline