Repository: CentOS/duffy (language: python)

Issue:
Node models
This is part of #47.

# Story

As a user of CentOS CI,

1. I want to be able to use VMs,
because I don't want to waste resources.

1. I want to be able to use bare metal nodes,
because I have requirements not met by VMs.

# Acceptance Criteria

- [ ] An abstract base model for nodes exist
- [ ] Specific models for bare metal and VM nodes exist

# Background

In the previous implementation, the `Host` model described bare metal nodes (specifically SeaMicro chassis), it mixed generic and specific information. We want to support different types, so need base classes modelling shared properties as well as child classes modelling the differences. Hierarchically, it could be e.g.:

* unspecified node -> bare metal -> bare metal in a sea micro chassis
* unspecified node -> VM/cloud node -> libvirt/kvm node

(No idea if we need that level of granularity.)


Discussion / hints:

Fix patch:
diff --git a/developer-docs/README.md b/developer-docs/README.md
index a7ad41e6..65cb103b 100644
--- a/developer-docs/README.md
+++ b/developer-docs/README.md
@@ -1,2 +1,2 @@
-## Model for database
+## Entity Relationship Diagram for Database Models
 ![DB Model](duffy-db-models.svg)
diff --git a/developer-docs/duffy-db-models.svg b/developer-docs/duffy-db-models.svg
index efb50bde..2a6b328d 100644
--- a/developer-docs/duffy-db-models.svg
+++ b/developer-docs/duffy-db-models.svg
@@ -1,4 +1,4 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- Do not edit this file with editors other than diagrams.net -->
 <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="761px" height="661px" viewBox="-0.5 -0.5 761 661" content="&lt;mxfile host=&quot;app.diagrams.net&quot; modified=&quot;2021-10-28T15:24:46.753Z&quot; agent=&quot;5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36&quot; etag=&quot;EnKcrsS8DMjropPJB0TB&quot; version=&quot;15.6.1&quot; type=&quot;google&quot;&gt;&lt;diagram id=&quot;R2lEEEUBdFMjLlhIrx00&quot; name=&quot;Page-1&quot;&gt;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&lt;/diagram&gt;&lt;/mxfile&gt;" style="background-color: rgb(255, 255, 255);"><defs><clipPath id="mx-clip-510-30-30-30-0"><rect x="510" y="30" width="30" height="30"/></clipPath><clipPath id="mx-clip-546-30-214-30-0"><rect x="546" y="30" width="214" height="30"/></clipPath><clipPath id="mx-clip-510-60-30-30-0"><rect x="510" y="60" width="30" height="30"/></clipPath><clipPath id="mx-clip-546-60-214-30-0"><rect x="546" y="60" width="214" height="30"/></clipPath><clipPath id="mx-clip-546-90-214-30-0"><rect x="546" y="90" width="214" height="30"/></clipPath><clipPath id="mx-clip-0-30-30-30-0"><rect x="0" y="30" width="30" height="30"/></clipPath><clipPath id="mx-clip-36-30-214-30-0"><rect x="36" y="30" width="214" height="30"/></clipPath><clipPath id="mx-clip-36-60-214-30-0"><rect x="36" y="60" width="214" height="30"/></clipPath><clipPath id="mx-clip-270-170-30-30-0"><rect x="270" y="170" width="30" height="30"/></clipPath><clipPath id="mx-clip-306-170-144-30-0"><rect x="306" y="170" width="144" height="30"/></clipPath><clipPath id="mx-clip-270-200-30-30-0"><rect x="270" y="200" width="30" height="30"/></clipPath><clipPath id="mx-clip-306-200-144-30-0"><rect x="306" y="200" width="144" height="30"/></clipPath><clipPath id="mx-clip-250-480-30-30-0"><rect x="250" y="480" width="30" height="30"/></clipPath><clipPath id="mx-clip-286-480-144-30-0"><rect x="286" y="480" width="144" height="30"/></clipPath><clipPath id="mx-clip-286-510-144-30-0"><rect x="286" y="510" width="144" height="30"/></clipPath><clipPath id="mx-clip-286-540-144-30-0"><rect x="286" y="540" width="144" height="30"/></clipPath><clipPath id="mx-clip-286-570-144-30-0"><rect x="286" y="570" width="144" height="30"/></clipPath><clipPath id="mx-clip-286-600-144-30-0"><rect x="286" y="600" width="144" height="30"/></clipPath><clipPath id="mx-clip-510-600-50-30-0"><rect x="510" y="600" width="50" height="30"/></clipPath><clipPath id="mx-clip-566-600-164-30-0"><rect x="566" y="600" width="164" height="30"/></clipPath><clipPath id="mx-clip-566-630-164-30-0"><rect x="566" y="630" width="164" height="30"/></clipPath><clipPath id="mx-clip-510-430-50-30-0"><rect x="510" y="430" width="50" height="30"/></clipPath><clipPath id="mx-clip-566-430-164-30-0"><rect x="566" y="430" width="164" height="30"/></clipPath><clipPath id="mx-clip-566-460-164-30-0"><rect x="566" y="460" width="164" height="30"/></clipPath><clipPath id="mx-clip-100-300-60-30-0"><rect x="100" y="300" width="60" height="30"/></clipPath><clipPath id="mx-clip-166-300-194-30-0"><rect x="166" y="300" width="194" height="30"/></clipPath><clipPath id="mx-clip-100-330-60-30-0"><rect x="100" y="330" width="60" height="30"/></clipPath><clipPath id="mx-clip-166-330-194-30-0"><rect x="166" y="330" width="194" height="30"/></clipPath><clipPath id="mx-clip-166-360-194-30-0"><rect x="166" y="360" width="194" height="30"/></clipPath><clipPath id="mx-clip-166-390-194-30-0"><rect x="166" y="390" width="194" height="30"/></clipPath><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Permanent+Marker);&#xa;</style></defs><g><path d="M 510 30 L 510 0 L 760 0 L 760 30" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 510 30 L 510 120 L 760 120 L 760 30" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 30 L 760 30" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 540 30 L 540 120" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="634.5" y="19.5">Project</text></g><path d="M 510 30 M 760 30 M 760 60 L 510 60" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 30 M 540 30 M 540 60 M 510 60" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-510-30-30-30-0)" text-anchor="middle" font-size="12px"><text x="524.5" y="49.5">PK</text></g><path d="M 540 30 M 760 30 M 760 60 M 540 60" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-546-30-214-30-0)" font-size="12px"><text x="547.5" y="49.5">id int NOT NULL </text></g><path d="M 510 60 M 760 60 M 760 90 M 510 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 60 M 540 60 M 540 90 M 510 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-510-60-30-30-0)" text-anchor="middle" font-size="12px"><text x="524.5" y="79.5">FK1</text></g><path d="M 540 60 M 760 60 M 760 90 M 540 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-546-60-214-30-0)" font-size="12px"><text x="547.5" y="79.5">customer_id int NOT NULL</text></g><path d="M 510 90 M 760 90 M 760 120 M 510 120" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 90 M 540 90 M 540 120 M 510 120" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 540 90 M 760 90 M 760 120 M 540 120" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-546-90-214-30-0)" font-size="12px"><text x="547.5" y="109.5">order_date date NOT NULL</text></g><path d="M 0 30 L 0 0 L 250 0 L 250 30" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 0 30 L 0 90 L 250 90 L 250 30" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 0 30 L 250 30" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 30 30 L 30 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="124.5" y="19.5">User</text></g><path d="M 0 30 M 250 30 M 250 60 L 0 60" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 0 30 M 30 30 M 30 60 M 0 60" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-0-30-30-30-0)" text-anchor="middle" font-size="12px"><text x="14.5" y="49.5">PK</text></g><path d="M 30 30 M 250 30 M 250 60 M 30 60" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-36-30-214-30-0)" font-size="12px"><text x="37.5" y="49.5">id int NOT NULL </text></g><path d="M 0 60 M 250 60 M 250 90 M 0 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 0 60 M 30 60 M 30 90 M 0 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 30 60 M 250 60 M 250 90 M 30 90" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-36-60-214-30-0)" font-size="12px"><text x="37.5" y="79.5">ssh_key text NOT NULL</text></g><path d="M 250 45 L 270 45 Q 280 45 290 45 L 470 45 Q 480 45 490 45 L 510 45" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 258 41 L 258 49 M 250 49 L 258 45 L 250 41" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 502 49 L 502 41 M 510 41 L 502 45 L 510 49" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 170 L 270 140 L 450 140 L 450 170" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 170 L 270 230 L 450 230 L 450 170" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 170 L 450 170" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 300 170 L 300 230" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="359.5" y="159.5">Session</text></g><path d="M 270 170 M 450 170 M 450 200 L 270 200" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 170 M 300 170 M 300 200 M 270 200" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-270-170-30-30-0)" text-anchor="middle" font-size="12px"><text x="284.5" y="189.5">PK</text></g><path d="M 300 170 M 450 170 M 450 200 M 300 200" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-306-170-144-30-0)" font-size="12px"><text x="307.5" y="189.5">id int NOT NULL</text></g><path d="M 270 200 M 450 200 M 450 230 M 270 230" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 200 M 300 200 M 300 230 M 270 230" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-270-200-30-30-0)" text-anchor="middle" font-size="12px"><text x="284.5" y="219.5">FK</text></g><path d="M 300 200 M 450 200 M 450 230 M 300 230" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-306-200-144-30-0)" font-size="12px"><text x="307.5" y="219.5">project_id int NOT NULL</text></g><path d="M 510 45 L 490 45 Q 480 45 480 55 L 480 175 Q 480 185 470 185 L 450 185" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 458 181 L 458 189 M 450 189 L 458 185 L 450 181" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 480 L 250 450 L 430 450 L 430 480" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 480 L 250 640 L 430 640 L 430 480" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 480 L 430 480" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 280 480 L 280 640" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="339.5" y="469.5">Node</text></g><path d="M 250 480 M 430 480 M 430 510 L 250 510" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 480 M 280 480 M 280 510 M 250 510" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-250-480-30-30-0)" text-anchor="middle" font-size="12px"><text x="264.5" y="499.5">PK</text></g><path d="M 280 480 M 430 480 M 430 510 M 280 510" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-286-480-144-30-0)" font-size="12px"><text x="287.5" y="499.5">id int NOT NULL</text></g><path d="M 250 510 M 430 510 M 430 540 M 250 540" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 510 M 280 510 M 280 540 M 250 540" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 280 510 M 430 510 M 430 540 M 280 540" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-286-510-144-30-0)" font-size="12px"><text x="287.5" y="529.5">hostname text NOT NULL</text></g><path d="M 250 540 M 430 540 M 430 570 M 250 570" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 540 M 280 540 M 280 570 M 250 570" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 280 540 M 430 540 M 430 570 M 280 570" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-286-540-144-30-0)" font-size="12px"><text x="287.5" y="559.5">ipaddr text NOT NULL</text></g><path d="M 250 570 M 430 570 M 430 600 M 250 600" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 570 M 280 570 M 280 600 M 250 600" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 280 570 M 430 570 M 430 600 M 280 600" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-286-570-144-30-0)" font-size="12px"><text x="287.5" y="589.5">state enum NOT NULL</text></g><path d="M 250 600 M 430 600 M 430 630 M 250 630" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 600 M 280 600 M 280 630 M 250 630" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 280 600 M 430 600 M 430 630 M 280 630" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-286-600-144-30-0)" font-size="12px"><text x="287.5" y="619.5">comment text</text></g><path d="M 510 600 L 510 570 L 730 570 L 730 600" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 600 L 510 660 L 730 660 L 730 600" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 600 L 730 600" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 600 L 560 660" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="619.5" y="589.5">PhysicalNode</text></g><path d="M 510 600 M 730 600 M 730 630 L 510 630" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 600 M 560 600 M 560 630 M 510 630" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-510-600-50-30-0)" text-anchor="middle" font-size="12px"><text x="534.5" y="619.5">PK, FK</text></g><path d="M 560 600 M 730 600 M 730 630 M 560 630" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-566-600-164-30-0)" font-size="12px"><text x="567.5" y="619.5">id int NOT NULL</text></g><path d="M 510 630 M 730 630 M 730 660 M 510 660" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 630 M 560 630 M 560 660 M 510 660" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 630 M 730 630 M 730 660 M 560 660" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-566-630-164-30-0)" font-size="12px"><text x="567.5" y="649.5">chassis text NOT NULL</text></g><path d="M 510 430 L 510 400 L 730 400 L 730 430" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 430 L 510 490 L 730 490 L 730 430" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 430 L 730 430" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 430 L 560 490" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="619.5" y="419.5">VirtualNode</text></g><path d="M 510 430 M 730 430 M 730 460 L 510 460" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 430 M 560 430 M 560 460 M 510 460" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-510-430-50-30-0)" text-anchor="middle" font-size="12px"><text x="534.5" y="449.5">PK, FK</text></g><path d="M 560 430 M 730 430 M 730 460 M 560 460" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-566-430-164-30-0)" font-size="12px"><text x="567.5" y="449.5">id int NOT NULL</text></g><path d="M 510 460 M 730 460 M 730 490 M 510 490" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 460 M 560 460 M 560 490 M 510 490" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 460 M 730 460 M 730 490 M 560 490" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-566-460-164-30-0)" font-size="12px"><text x="567.5" y="479.5">flavour enum NOT NULL</text></g><path d="M 510 615 L 490 615 Q 480 615 478.36 605.14 L 461.64 504.86 Q 460 495 450 495 L 430 495" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 506 619 L 506 611 M 502 619 L 502 611" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 434 491 L 434 499 M 438 491 L 438 499" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 510 445 L 490 445 Q 480 445 476.29 454.28 L 463.71 485.72 Q 460 495 450 495 L 430 495" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 506 449 L 506 441 M 502 449 L 502 441" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 434 491 L 434 499 M 438 491 L 438 499" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 300 L 100 270 L 360 270 L 360 300" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 300 L 100 420 L 360 420 L 360 300" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 300 L 360 300" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 160 300 L 160 420" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="229.5" y="289.5">SessionsNodes</text></g><path d="M 100 300 M 360 300 M 360 330 M 100 330" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 300 M 160 300 M 160 330 M 100 330" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-100-300-60-30-0)" text-anchor="middle" font-size="12px"><text x="129.5" y="319.5">PK,FK1</text></g><path d="M 160 300 M 360 300 M 360 330 M 160 330" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-166-300-194-30-0)" font-size="12px"><text x="167.5" y="319.5">session_id int NOT NULL</text></g><path d="M 100 330 M 360 330 M 360 360 L 100 360" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 330 M 160 330 M 160 360 M 100 360" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-100-330-60-30-0)" text-anchor="middle" font-size="12px"><text x="129.5" y="349.5">PK,FK2</text></g><path d="M 160 330 M 360 330 M 360 360 M 160 360" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-166-330-194-30-0)" font-size="12px"><text x="167.5" y="349.5">node_id int NOT NULL</text></g><path d="M 100 360 M 360 360 M 360 390 M 100 390" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 360 M 160 360 M 160 390 M 100 390" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 160 360 M 360 360 M 360 390 M 160 390" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-166-360-194-30-0)" font-size="12px"><text x="167.5" y="379.5">distro_type TEXT NOT NULL</text></g><path d="M 100 390 M 360 390 M 360 420 M 100 420" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 100 390 M 160 390 M 160 420 M 100 420" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 160 390 M 360 390 M 360 420 M 160 420" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-166-390-194-30-0)" font-size="12px"><text x="167.5" y="409.5">distro_version TEXT NOT NULL</text></g><path d="M 270 185 L 80 185 Q 70 185 70 195 L 70 305 Q 70 315 80 315 L 100 315" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 266 189 L 266 181 M 262 189 L 262 181" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 92 319 L 92 311 M 100 311 L 92 315 L 100 319" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 250 495 L 90 495 Q 80 495 80 485 L 80 355 Q 80 345 90 345 L 100 345" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 246 499 L 246 491 M 242 499 L 242 491" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 92 349 L 92 341 M 100 341 L 92 345 L 100 349" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="801px" height="881px" viewBox="-0.5 -0.5 801 881" content="&lt;mxfile host=&quot;app.diagrams.net&quot; modified=&quot;2021-11-03T16:45:25.553Z&quot; agent=&quot;5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36&quot; etag=&quot;pQQY75-YomnmasuwDqZQ&quot; version=&quot;15.6.8&quot; type=&quot;google&quot;&gt;&lt;diagram id=&quot;R2lEEEUBdFMjLlhIrx00&quot; name=&quot;Page-1&quot;&gt;7V1bc5s6EP41fkzHgPHlsXaS9kzjNm2Tnp6njmIUmxYjD8iJ3V9/JBA2RsIR5iaMZzKtJYQQ+62+1a4udIzJcvPBA6vFFFnQ6ehda9Mxrju6rum9IfmP5mzDnP5QCzPmnm2xQvuM7/ZfyDK7LHdtW9A/KIgRcrC9OsycIdeFM3yQBzwPvR4We0bO4VNXYA65jO8z4PC5/9oWXoS5Q32wz/8I7fkierLWH4VXliAqzN7EXwALvR5kwQ2+RS5mTbyH3hK40MXkyhR4f6DXMW8WGNM3fd/Rb8nfMy39bo7Q3IFgZfvvZmhJsmc+KXL7DJa2Q8Ucq2jMKiKPM246xsRDCIe/lpsJdChWEQxhm25Tru7k4NF6JW6YXL1s7/50sfvx1fiBp9P5/eDpSg9reQHOmsn33kO/KW6hiPA2kjuR1or+xOCJZo19DDzM1MPokgwCOAa2S97NuNaCtOOAlW8HxcOche1Yd2CL1jiqKEqNn+0NtL6F2kHLEkW5I5XRJK2cyvk7awy9DBx77pLfM/Ly9IljD/qkLXfAx6wELx4msRfoYbiJZTFxfYBoCbG3JUXYVbPH1GIbdR6Wft0rnm6yvEVM6UYsDzBFmu+q3uNDfjCIMsBlcHCl4kTeHNvA+UawBO48gOwQESpWy0OrB+DNIWYZK2RTgd68wLAXBLK3HWeCHESBdZELo2LBy5lj8kded9J9Z3ZM0oAJSWv7NPmjxT08Qa6PPaIg9FGQ4PQKKVZjjFbsOQ58jprhMVnS308IY9Kp0lA9qtZvQ82gNSSRNcpCtsd3xE9ZsEXkXZ+dgM4WtmVBN+yRlILBHm8BlEL572SeBCPZDyXxMKTxiAFgZJQ/q2wvlcy1AYfovgswHKO1a/kcqLt2no6zyeFMik/0zvuuHdiZz18e6L+Pd3ednZ1SSQEi4g3Ljv0VmNnu/C68s5/QELMMDdmk91i9UI2Rqq4Clem3mfS7JZJ+v27SH+RCtkLKjyqw4DNYO1gek740JudO/EMOaxcsaVPXrj0jXhqRT5L/lVMFKfKvQFdaZwJ0fuD/6IdeZNudNEM1H03nh/ItstdZnDT5MZ8qXprOD9/PyU3bqW6p5jofAvxomJTXW+49ZQCuQNuZD8h8g9+G02QWtyYzTdbu1+j8YFfBzigr/4G0/GsjxREnb99f/PoDaaMCxyKIMDXbtygerhqo0L3R5j++Pj1uuh8H0/faZPvzL7jSJKgQWnMYGQYiABtvv0EHYBu5N/srofkIh/saeePxAi8dNgSArvWeTr+R5M03AtADmgJ3G/kI4kvkQd72JwMoSPxHE+/MKHm9iV+83kapjY1/Rs8lv2N3kdT+JprY3eNatzaVW5AKGhVLpzoKPlp7MyhhGnFkGtIKMpqlgpbt5FpXoCa7TC8A6AUetPeID3qPwkFMpJbaoWdj9BP6F746u2uvglxFvURFerKiUDRcRSc4r2L15q3Bd+j7RHVTaahV/msKOnE9G5bkwIrx4q1JPSOz4wZESxiQAsdxPKzHFTuPAyvEtjRTE9m1kx3YCpxV0YBDFpGRNCIVjNXECGgcAikObK2gZPZUq0EtfcimleXhiFHUG8GRSVSbwJGy9q88bPlg+23dHAktO6ovjSIPaVQOnZ0aK8yXfDh9FS5R+9VI3szBkxnQUoUnDd7acQCV59p6dEIOWqwYcGcL5AVuzrXtEdypBxJeeiJVxLrX7/VyFbWIaQHwZuzpej5/lJFL3B09ahSV8Uc1jU1+RI7K8ER/VEs6tsmKSvZHe7x/8xlZMJU4muKMyk6+Z3ZRd0gzwMyBpInWSvNRTd6PUXH8Vb+P2pMfW6vio5q8zTgnH3Wnuq1aEyUWBe9FNXJMV6AvnEE7ChzjHdcWqeqqUBdVtj+o7nRnJ/3anW4z76qpOlxu2T7dyu0PYlHwC6gWyMdsKWyuNbANc+YzKEX7iF6VLQ/nR/Sj2ok+77ovlYm+ldsdxKLgJ/3tFbAsr200X8SuhnOl+WYsNGgizYu2QVTL85FHcZY8X8kyg0bwfJ8P1vmYPJFkQXe9bA3N17KEoRk0rwkWHV14viCeN+vmeU2woOlsiH6vuhem1yJcY0DP0HIZno90sIu5eTQvO3eaW0tax/59Pmh/v9j69gw4ZzHrzutI7nOm+oIgjXBHSmnT631V9jCrPr3eL2QPc6W7jfriPcyksm7tyxwLnGbvn7SXuVgOVoWCpfdMq224C5xmz6Adm0Ms4yZ2UKzFlqmuCnVRZWe26u5advIX7cyulvz5sHztpF/S2vb+Sfu2z9MA8IH22QL4vu23bg19Bq1oHfEP+HHCD9vD64unluapmaLTpir11AbNMNb1e2oD+YlYVTy1AW+sz9FTG1RiqPMhwRvQRhrOAj2oDKgVaEhzoRjNiyjOlfU7Ntm5snbHZnjGs1A7vVWXIIf83NCzA17Q2iOZbVoIkAErZWhRYhlWsXt0l8C1vrg7n0F0IXWwHt9gezS09+YO20jwyuywTZ74NNATkJ964lM/WVHJO2xHEoa2QRoVDXVarFFmcgtwko3K1ih+0oqdIebT2ISfalFaFJzQEkdhG6Jp5L5I28yyTMvospFDbtiw0+9c0QkRuKUNG0Z85CmITtx+0rJhrHJsYnTSjo5qceCjRH5Ijc0M7hcYo8iA3uYQuXivEhno8tBsxp6I+uO52RlThG2lPXU3quMps+5gRZGUedLmiIqB4KNFLhlGXggz8zaG2glT6/KhJxUZs4ljTFkHokRw8x75oXBYd6+5KjMlvxLYsokWol94u6LrGB5ufj40ii4rQkwdfuRDJxd+LIYfhR8bq7h/5o2wKE2Q8ojUR5B8GIQRJGl1+JGDlnGkPGjKcKRmcIjE5ycItyzQHLnAic9NHJ4uKzgzdn/bHQrwCcpBjLcsEA3WGHUOJzXYl1O6Hfkvp6TOeCQ/opJ6Tu7ht1d4oGUnSqIza9+cKIkCM29OlGSb/yBvB7axAsyG6GnTI1ri2xsG093Uk2sT5XuGmdC9sAWFTn3szt1tpHKe+gkhdZQ6mhx5W6llT2yWVmpZUlsPvv7xZx+1+eeH6fpqNf41f51e8SGOSbjmO9X4tGiubHeEMevJwx5vg8RHGidnUAvDqxlhjEoDv0f1WskDjYUtFuyAbuby3aN626oTEoSS4EMlpHjw5cV2hJRzqsc5H40gFM0lMFMS41d5mrGwxecRlDmqtBe65wM/6ccYdx8///P18UZ13q9EL1rH8/m+hn3h+Szzk9XyfN7FOgrzvPx3+86d56M11PEIP/Rnnr3CYXi/qYeeFa4JrWN2rcfhnjU0e5Zx2L/QQ3kCscc8yTfjsNERD6XHYUnSQwjHVcoDq8UUWZCW+B8=&lt;/diagram&gt;&lt;/mxfile&gt;" style="background-color: rgb(255, 255, 255);"><defs><clipPath id="mx-clip-530-50-30-30-0"><rect x="530" y="50" width="30" height="30"/></clipPath><clipPath id="mx-clip-566-50-214-30-0"><rect x="566" y="50" width="214" height="30"/></clipPath><clipPath id="mx-clip-566-80-214-30-0"><rect x="566" y="80" width="214" height="30"/></clipPath><clipPath id="mx-clip-20-50-30-30-0"><rect x="20" y="50" width="30" height="30"/></clipPath><clipPath id="mx-clip-56-50-214-30-0"><rect x="56" y="50" width="214" height="30"/></clipPath><clipPath id="mx-clip-56-80-214-30-0"><rect x="56" y="80" width="214" height="30"/></clipPath><clipPath id="mx-clip-290-190-30-30-0"><rect x="290" y="190" width="30" height="30"/></clipPath><clipPath id="mx-clip-326-190-144-30-0"><rect x="326" y="190" width="144" height="30"/></clipPath><clipPath id="mx-clip-290-220-30-30-0"><rect x="290" y="220" width="30" height="30"/></clipPath><clipPath id="mx-clip-326-220-144-30-0"><rect x="326" y="220" width="144" height="30"/></clipPath><clipPath id="mx-clip-270-500-30-30-0"><rect x="270" y="500" width="30" height="30"/></clipPath><clipPath id="mx-clip-306-500-144-30-0"><rect x="306" y="500" width="144" height="30"/></clipPath><clipPath id="mx-clip-306-530-144-30-0"><rect x="306" y="530" width="144" height="30"/></clipPath><clipPath id="mx-clip-306-560-144-30-0"><rect x="306" y="560" width="144" height="30"/></clipPath><clipPath id="mx-clip-306-590-144-30-0"><rect x="306" y="590" width="144" height="30"/></clipPath><clipPath id="mx-clip-306-620-144-30-0"><rect x="306" y="620" width="144" height="30"/></clipPath><clipPath id="mx-clip-530-620-50-30-0"><rect x="530" y="620" width="50" height="30"/></clipPath><clipPath id="mx-clip-586-620-164-30-0"><rect x="586" y="620" width="164" height="30"/></clipPath><clipPath id="mx-clip-530-650-50-30-0"><rect x="530" y="650" width="50" height="30"/></clipPath><clipPath id="mx-clip-586-650-164-30-0"><rect x="586" y="650" width="164" height="30"/></clipPath><clipPath id="mx-clip-530-450-50-30-0"><rect x="530" y="450" width="50" height="30"/></clipPath><clipPath id="mx-clip-586-450-164-30-0"><rect x="586" y="450" width="164" height="30"/></clipPath><clipPath id="mx-clip-586-480-164-30-0"><rect x="586" y="480" width="164" height="30"/></clipPath><clipPath id="mx-clip-120-320-60-30-0"><rect x="120" y="320" width="60" height="30"/></clipPath><clipPath id="mx-clip-186-320-194-30-0"><rect x="186" y="320" width="194" height="30"/></clipPath><clipPath id="mx-clip-120-350-60-30-0"><rect x="120" y="350" width="60" height="30"/></clipPath><clipPath id="mx-clip-186-350-194-30-0"><rect x="186" y="350" width="194" height="30"/></clipPath><clipPath id="mx-clip-186-380-194-30-0"><rect x="186" y="380" width="194" height="30"/></clipPath><clipPath id="mx-clip-186-410-194-30-0"><rect x="186" y="410" width="194" height="30"/></clipPath><clipPath id="mx-clip-560-770-30-30-0"><rect x="560" y="770" width="30" height="30"/></clipPath><clipPath id="mx-clip-596-770-144-30-0"><rect x="596" y="770" width="144" height="30"/></clipPath><clipPath id="mx-clip-596-800-144-30-0"><rect x="596" y="800" width="144" height="30"/></clipPath><clipPath id="mx-clip-596-830-144-30-0"><rect x="596" y="830" width="144" height="30"/></clipPath><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Permanent+Marker);&#xa;</style></defs><g><path d="M 530 50 L 530 20 L 780 20 L 780 50" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="all"/><path d="M 530 50 L 530 110 L 780 110 L 780 50" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 50 L 780 50" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 50 L 560 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="654.5" y="39.5">Project</text></g><path d="M 530 50 M 780 50 M 780 80 L 530 80" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 50 M 560 50 M 560 80 M 530 80" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-530-50-30-30-0)" text-anchor="middle" font-size="12px"><text x="544.5" y="69.5">PK</text></g><path d="M 560 50 M 780 50 M 780 80 M 560 80" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-566-50-214-30-0)" font-size="12px"><text x="567.5" y="69.5">id int NOT NULL </text></g><path d="M 530 80 M 780 80 M 780 110 M 530 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 80 M 560 80 M 560 110 M 530 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 80 M 780 80 M 780 110 M 560 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-566-80-214-30-0)" font-size="12px"><text x="567.5" y="99.5">name unicodetext NOT NULL</text></g><path d="M 20 50 L 20 20 L 270 20 L 270 50" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 20 50 L 20 110 L 270 110 L 270 50" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 20 50 L 270 50" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 50 50 L 50 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="144.5" y="39.5">User</text></g><path d="M 20 50 M 270 50 M 270 80 L 20 80" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 20 50 M 50 50 M 50 80 M 20 80" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-20-50-30-30-0)" text-anchor="middle" font-size="12px"><text x="34.5" y="69.5">PK</text></g><path d="M 50 50 M 270 50 M 270 80 M 50 80" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-56-50-214-30-0)" font-size="12px"><text x="57.5" y="69.5">id int NOT NULL </text></g><path d="M 20 80 M 270 80 M 270 110 M 20 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 20 80 M 50 80 M 50 110 M 20 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 50 80 M 270 80 M 270 110 M 50 110" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-56-80-214-30-0)" font-size="12px"><text x="57.5" y="99.5">ssh_key text NOT NULL</text></g><path d="M 270 65 L 290 65 Q 300 65 310 65 L 490 65 Q 500 65 510 65 L 530 65" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 278 61 L 278 69 M 270 69 L 278 65 L 270 61" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 522 69 L 522 61 M 530 61 L 522 65 L 530 69" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 290 190 L 290 160 L 470 160 L 470 190" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 290 190 L 290 250 L 470 250 L 470 190" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 290 190 L 470 190" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 320 190 L 320 250" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="379.5" y="179.5">Session</text></g><path d="M 290 190 M 470 190 M 470 220 L 290 220" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 290 190 M 320 190 M 320 220 M 290 220" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-290-190-30-30-0)" text-anchor="middle" font-size="12px"><text x="304.5" y="209.5">PK</text></g><path d="M 320 190 M 470 190 M 470 220 M 320 220" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-326-190-144-30-0)" font-size="12px"><text x="327.5" y="209.5">id int NOT NULL</text></g><path d="M 290 220 M 470 220 M 470 250 M 290 250" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 290 220 M 320 220 M 320 250 M 290 250" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-290-220-30-30-0)" text-anchor="middle" font-size="12px"><text x="304.5" y="239.5">FK</text></g><path d="M 320 220 M 470 220 M 470 250 M 320 250" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-326-220-144-30-0)" font-size="12px"><text x="327.5" y="239.5">project_id int NOT NULL</text></g><path d="M 530 65 L 510 65 Q 500 65 500 75 L 500 195 Q 500 205 490 205 L 470 205" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 478 201 L 478 209 M 470 209 L 478 205 L 470 201" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 500 L 270 470 L 450 470 L 450 500" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 500 L 270 660 L 450 660 L 450 500" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 500 L 450 500" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 300 500 L 300 660" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="359.5" y="489.5">Node</text></g><path d="M 270 500 M 450 500 M 450 530 L 270 530" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 500 M 300 500 M 300 530 M 270 530" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-270-500-30-30-0)" text-anchor="middle" font-size="12px"><text x="284.5" y="519.5">PK</text></g><path d="M 300 500 M 450 500 M 450 530 M 300 530" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-306-500-144-30-0)" font-size="12px"><text x="307.5" y="519.5">id int NOT NULL</text></g><path d="M 270 530 M 450 530 M 450 560 M 270 560" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 530 M 300 530 M 300 560 M 270 560" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 300 530 M 450 530 M 450 560 M 300 560" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-306-530-144-30-0)" font-size="12px"><text x="307.5" y="549.5">hostname text NOT NULL</text></g><path d="M 270 560 M 450 560 M 450 590 M 270 590" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 560 M 300 560 M 300 590 M 270 590" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 300 560 M 450 560 M 450 590 M 300 590" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-306-560-144-30-0)" font-size="12px"><text x="307.5" y="579.5">ipaddr text NOT NULL</text></g><path d="M 270 590 M 450 590 M 450 620 M 270 620" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 590 M 300 590 M 300 620 M 270 620" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 300 590 M 450 590 M 450 620 M 300 620" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-306-590-144-30-0)" font-size="12px"><text x="307.5" y="609.5">state enum NOT NULL</text></g><path d="M 270 620 M 450 620 M 450 650 M 270 650" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 620 M 300 620 M 300 650 M 270 650" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 300 620 M 450 620 M 450 650 M 300 650" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-306-620-144-30-0)" font-size="12px"><text x="307.5" y="639.5">comment unicodetext</text></g><path d="M 530 620 L 530 590 L 750 590 L 750 620" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 620 L 530 680 L 750 680 L 750 620" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 620 L 750 620" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 580 620 L 580 680" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="639.5" y="609.5">PhysicalNode</text></g><path d="M 530 620 M 750 620 M 750 650 L 530 650" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 620 M 580 620 M 580 650 M 530 650" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-530-620-50-30-0)" text-anchor="middle" font-size="12px"><text x="554.5" y="639.5">PK, FK</text></g><path d="M 580 620 M 750 620 M 750 650 M 580 650" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-586-620-164-30-0)" font-size="12px"><text x="587.5" y="639.5">id int NOT NULL</text></g><path d="M 530 650 M 750 650 M 750 680 M 530 680" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 650 M 580 650 M 580 680 M 530 680" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-530-650-50-30-0)" text-anchor="middle" font-size="12px"><text x="554.5" y="669.5">FK</text></g><path d="M 580 650 M 750 650 M 750 680 M 580 680" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-586-650-164-30-0)" font-size="12px"><text x="587.5" y="669.5">chassis_id int NOT NULL</text></g><path d="M 530 450 L 530 420 L 750 420 L 750 450" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 450 L 530 510 L 750 510 L 750 450" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 450 L 750 450" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 580 450 L 580 510" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="639.5" y="439.5">VirtualNode</text></g><path d="M 530 450 M 750 450 M 750 480 L 530 480" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 450 M 580 450 M 580 480 M 530 480" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-530-450-50-30-0)" text-anchor="middle" font-size="12px"><text x="554.5" y="469.5">PK, FK</text></g><path d="M 580 450 M 750 450 M 750 480 M 580 480" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-586-450-164-30-0)" font-size="12px"><text x="587.5" y="469.5">id int NOT NULL</text></g><path d="M 530 480 M 750 480 M 750 510 M 530 510" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 480 M 580 480 M 580 510 M 530 510" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 580 480 M 750 480 M 750 510 M 580 510" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-586-480-164-30-0)" font-size="12px"><text x="587.5" y="499.5">flavour enum NOT NULL</text></g><path d="M 530 635 L 510 635 Q 500 635 498.36 625.14 L 481.64 524.86 Q 480 515 470 515 L 450 515" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 526 639 L 526 631 M 522 639 L 522 631" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 454 511 L 454 519 M 458 511 L 458 519" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 465 L 510 465 Q 500 465 496.29 474.28 L 483.71 505.72 Q 480 515 470 515 L 450 515" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 526 469 L 526 461 M 522 469 L 522 461" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 454 511 L 454 519 M 458 511 L 458 519" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 320 L 120 290 L 380 290 L 380 320" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 320 L 120 440 L 380 440 L 380 320" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 320 L 380 320" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 180 320 L 180 440" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="249.5" y="309.5">SessionsNodes</text></g><path d="M 120 320 M 380 320 M 380 350 M 120 350" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 320 M 180 320 M 180 350 M 120 350" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-120-320-60-30-0)" text-anchor="middle" font-size="12px"><text x="149.5" y="339.5">PK,FK1</text></g><path d="M 180 320 M 380 320 M 380 350 M 180 350" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-186-320-194-30-0)" font-size="12px"><text x="187.5" y="339.5">session_id int NOT NULL</text></g><path d="M 120 350 M 380 350 M 380 380 L 120 380" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 350 M 180 350 M 180 380 M 120 380" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-120-350-60-30-0)" text-anchor="middle" font-size="12px"><text x="149.5" y="369.5">PK,FK2</text></g><path d="M 180 350 M 380 350 M 380 380 M 180 380" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-186-350-194-30-0)" font-size="12px"><text x="187.5" y="369.5">node_id int NOT NULL</text></g><path d="M 120 380 M 380 380 M 380 410 M 120 410" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 380 M 180 380 M 180 410 M 120 410" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 180 380 M 380 380 M 380 410 M 180 410" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-186-380-194-30-0)" font-size="12px"><text x="187.5" y="399.5">distro_type TEXT NOT NULL</text></g><path d="M 120 410 M 380 410 M 380 440 M 120 440" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 120 410 M 180 410 M 180 440 M 120 440" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 180 410 M 380 410 M 380 440 M 180 440" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-186-410-194-30-0)" font-size="12px"><text x="187.5" y="429.5">distro_version TEXT NOT NULL</text></g><path d="M 290 205 L 100 205 Q 90 205 90 215 L 90 325 Q 90 335 100 335 L 120 335" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 286 209 L 286 201 M 282 209 L 282 201" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 112 339 L 112 331 M 120 331 L 112 335 L 120 339" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 270 515 L 110 515 Q 100 515 100 505 L 100 375 Q 100 365 110 365 L 120 365" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 266 519 L 266 511 M 262 519 L 262 511" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 112 369 L 112 361 M 120 361 L 112 365 L 120 369" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 770 L 560 740 L 740 740 L 740 770" fill="rgba(255, 255, 255, 1)" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 770 L 560 860 L 740 860 L 740 770" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 770 L 740 770" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 590 770 L 590 860" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="649.5" y="759.5">Chassis</text></g><path d="M 560 770 M 740 770 M 740 800 L 560 800" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 770 M 590 770 M 590 800 M 560 800" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" pointer-events="none" clip-path="url(#mx-clip-560-770-30-30-0)" text-anchor="middle" font-size="12px"><text x="574.5" y="789.5">PK</text></g><path d="M 590 770 M 740 770 M 740 800 M 590 800" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" font-weight="bold" text-decoration="underline" pointer-events="none" clip-path="url(#mx-clip-596-770-144-30-0)" font-size="12px"><text x="597.5" y="789.5">id int NOT NULL</text></g><path d="M 560 800 M 740 800 M 740 830 M 560 830" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 800 M 590 800 M 590 830 M 560 830" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 590 800 M 740 800 M 740 830 M 590 830" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-596-800-144-30-0)" font-size="12px"><text x="597.5" y="819.5">name text NOT NULL UNIQUE</text></g><path d="M 560 830 M 740 830 M 740 860 M 560 860" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 560 830 M 590 830 M 590 860 M 560 860" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 590 830 M 740 830 M 740 860 M 590 860" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><g fill="rgba(0, 0, 0, 1)" font-family="Helvetica" pointer-events="none" clip-path="url(#mx-clip-596-830-144-30-0)" font-size="12px"><text x="597.5" y="849.5">description text</text></g><path d="M 560 785 L 520 785 Q 510 785 510 775 L 510 675 Q 510 665 520 665 L 530 665" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><path d="M 556 789 L 556 781 M 552 789 L 552 781" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/><ellipse cx="518" cy="665" rx="3" ry="3" fill="none" stroke="rgba(0, 0, 0, 1)" pointer-events="none"/><path d="M 530 661 L 522 665 L 530 669" fill="none" stroke="rgba(0, 0, 0, 1)" stroke-miterlimit="10" pointer-events="none"/></g></svg>
\ No newline at end of file
diff --git a/duffy/database/__init__.py b/duffy/database/__init__.py
new file mode 100644
index 00000000..17e03308
--- /dev/null
+++ b/duffy/database/__init__.py
@@ -0,0 +1,57 @@
+try:
+    from asyncio import current_task
+except ImportError:  # pragma: no cover
+    # Python < 3.7
+    current_task = None
+
+from sqlalchemy import MetaData
+from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session
+from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
+
+
+# use custom metadata to specify naming convention
+def constraint_column_names(constraint, table):
+    return "_".join(c.name for c in constraint.columns)
+
+
+def constraint_column_labels(constraint, table):  # pragma: no cover
+    return "_".join(c._label for c in constraint.columns)
+
+
+naming_convention = {
+    "column_names": constraint_column_names,
+    "column_labels": constraint_column_labels,
+    "ix": "%(column_labels)s_index",
+    "uq": "%(table_name)s_%(column_names)s_key",
+    "ck": "%(table_name)s_%(constraint_name)s_check",
+    "fk": "%(table_name)s_%(column_names)s_%(referred_table_name)s_fkey",
+    "pk": "%(table_name)s_pkey",
+}
+metadata = MetaData(naming_convention=naming_convention)
+
+Base = declarative_base(metadata=metadata)
+
+# Global session managers: DBSession() and AsyncDBSession() return the thread-local session object
+# appropriate for the current web request.
+maker = sessionmaker(future=True)
+DBSession = scoped_session(maker)
+
+if current_task:  # pragma: no cover
+    async_maker = sessionmaker(class_=AsyncSession, future=True)
+    AsyncDBSession = async_scoped_session(async_maker, scopefunc=current_task)
+else:
+    # Python < 3.7
+    AsyncDBSession = None  # pragma: no cover
+
+
+def init_model(engine, async_engine=None):
+    DBSession.remove()
+    DBSession.configure(bind=engine)
+
+    if async_engine:  # pragma: no cover
+        if AsyncDBSession:
+            AsyncDBSession.remove()
+            AsyncDBSession.configure(bind=async_engine)
+        else:
+            # Python < 3.7
+            raise RuntimeError("Async DB sessions need Python >= 3.7")
diff --git a/duffy/database/model.py b/duffy/database/model.py
new file mode 100644
index 00000000..3b98b0de
--- /dev/null
+++ b/duffy/database/model.py
@@ -0,0 +1,93 @@
+from sqlalchemy import Column, ForeignKey, Integer, Table, Text, UnicodeText
+from sqlalchemy.orm import relationship
+
+from . import Base
+from .util import DeclEnum
+
+
+class User(Base):
+    __tablename__ = "users"
+    id = Column(Integer, primary_key=True, nullable=False)
+    ssh_key = Column(Text, nullable=False)
+
+
+users_projects_table = Table(
+    "users_projects",
+    Base.metadata,
+    Column("user_id", ForeignKey("users.id"), nullable=False),
+    Column("project_id", ForeignKey("projects.id"), nullable=False),
+)
+
+
+class Project(Base):
+    __tablename__ = "projects"
+    id = Column(Integer, primary_key=True, nullable=False)
+    name = Column(UnicodeText, unique=True, nullable=False)
+    users = relationship(User, secondary=users_projects_table, backref="projects")
+
+
+class Session(Base):
+    __tablename__ = "sessions"
+    id = Column(Integer, primary_key=True, nullable=False)
+    project_id = Column(Integer, ForeignKey(Project.id), nullable=False)
+    project = relationship(Project, backref="sessions")
+
+
+class NodeState(DeclEnum):
+    ready = "ready"
+    active = "active"
+    contextualizing = "contextualizing"
+    deployed = "deployed"
+    deprovisioning = "deprovisioning"
+    done = "done"
+    failing = "failing"
+    failed = "failed"
+
+
+class Node(Base):
+    __tablename__ = "nodes"
+    __mapper_args__ = {"polymorphic_on": "type"}
+    id = Column(Integer, primary_key=True, nullable=False)
+    type = Column(Text, nullable=False)
+    hostname = Column(Text, nullable=False)
+    ipaddr = Column(Text, nullable=False)
+    state = Column(NodeState.db_type(), nullable=False)
+    comment = Column(UnicodeText, nullable=True)
+
+
+class VirtualNodeFlavour(DeclEnum):
+    small = "small"
+    medium = "medium"
+    large = "large"
+
+
+class VirtualNode(Node):
+    __tablename__ = "virtualnodes"
+    __mapper_args__ = {"polymorphic_identity": "virtual"}
+    id = Column(Integer, ForeignKey(Node.id), primary_key=True, nullable=False)
+    flavour = Column(VirtualNodeFlavour.db_type(), nullable=False)
+
+
+class Chassis(Base):
+    __tablename__ = "chassis"
+    id = Column(Integer, primary_key=True)
+    name = Column(UnicodeText, nullable=False, unique=True)
+    description = Column(UnicodeText, nullable=True)
+
+
+class PhysicalNode(Node):
+    __tablename__ = "physicalnodes"
+    __mapper_args__ = {"polymorphic_identity": "physical"}
+    id = Column(Integer, ForeignKey(Node.id), primary_key=True, nullable=False)
+    chassis_id = Column(Integer, ForeignKey(Chassis.id), nullable=False)
+    chassis = relationship(Chassis)
+
+
+class SessionNode(Base):
+    __tablename__ = "sessions_nodes"
+    session_id = Column(Integer, ForeignKey(Session.id), primary_key=True, nullable=False)
+    session = relationship(Session)
+    node_id = Column(Integer, ForeignKey(Node.id), primary_key=True, nullable=False)
+    node = relationship(Node)
+    distro_type = Column(UnicodeText, nullable=False)
+    distro_version = Column(Text, nullable=False)
diff --git a/duffy/database/util.py b/duffy/database/util.py
new file mode 100644
index 00000000..1dbbd013
--- /dev/null
+++ b/duffy/database/util.py
@@ -0,0 +1,107 @@
+from sqlalchemy.types import Enum, SchemaType, TypeDecorator
+
+from ..util import camel_case_to_lower_with_underscores
+
+# adapted from http://techspot.zzzeek.org/2011/01/14/the-enum-recipe
+# changes: Python 3.x only, use __init_subclass__() rather than a custom
+# metaclass, improve auto-generated db-side type name
+
+
+class EnumSymbol:
+    """A fixed symbol tied to a parent DeclEnum class."""
+
+    def __init__(self, cls, name, value, description):
+        self.cls = cls
+        self.name = name
+        self.value = value
+        self.description = description
+
+    def __lt__(self, other):
+        return self.value < other.value
+
+    def __reduce__(self):
+        """Allow unpickling to return the symbol
+        linked to the DeclEnum class."""
+        return getattr, (self.cls, self.name)
+
+    def __iter__(self):
+        return iter([self.value, self.description])
+
+    def __repr__(self):
+        return f"<{self.name}>"
+
+
+class DeclEnum:
+    """A declarative enumeration type for SQLAlchemy models."""
+
+    _reg = {}
+
+    def __init_subclass__(cls, **kwargs):
+        super().__init_subclass__(**kwargs)
+
+        cls._reg = reg = cls._reg.copy()
+
+        for k in dir(cls):
+            if k.startswith("__"):
+                continue
+            v = getattr(cls, k)
+            sym = None
+            if isinstance(v, tuple):
+                sym = EnumSymbol(cls, k, *v)
+            elif isinstance(v, str):
+                sym = EnumSymbol(cls, k, k, v)
+            if sym:
+                reg[sym.value] = sym
+                setattr(cls, k, sym)
+
+        try:
+            del cls._db_type
+        except AttributeError:
+            pass
+
+    @classmethod
+    def db_type(cls):
+        if not hasattr(cls, "_db_type"):
+            cls._db_type = DeclEnumType(cls)
+        return cls._db_type
+
+    @classmethod
+    def from_string(cls, value):
+        try:
+            return cls._reg[value]
+        except KeyError:
+            raise ValueError(f"Invalid value for {cls.__name__!r}: {value!r}")
+
+    @classmethod
+    def values(cls):
+        return cls._reg.keys()
+
+
+class DeclEnumType(SchemaType, TypeDecorator):
+    """A persistable column type tied to a DeclEnum type."""
+
+    def __init__(self, enum):
+        self.enum = enum
+        self.impl = Enum(*enum.values(), name=self._type_name(enum.__name__))
+
+    @classmethod
+    def _type_name(cls, clsname):
+        return "ck_" + camel_case_to_lower_with_underscores(clsname)
+
+    def _set_table(self, table, column):
+        self.impl._set_table(table, column)
+
+    def copy(self):
+        return DeclEnumType(self.enum)
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        if isinstance(value, str):
+            value = self.process_result_value(value, dialect)
+        return value.value
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        return self.enum.from_string(value.strip())
diff --git a/duffy/util.py b/duffy/util.py
new file mode 100644
index 00000000..a65853ee
--- /dev/null
+++ b/duffy/util.py
@@ -0,0 +1,24 @@
+def camel_case_to_lower_with_underscores(camelcased: str) -> str:
+    """Convert CamelCased names to lower_case_with_underscores."""
+    chunk_positions = []
+    prev_split_pos = 0
+
+    for pos, (prev_is_upper, char_is_upper, next_is_upper) in enumerate(
+        zip(
+            (x.isupper() for x in camelcased[:-2]),
+            (x.isupper() for x in camelcased[1:-1]),
+            (x.isupper() for x in camelcased[2:]),
+        ),
+        start=1,
+    ):
+        if char_is_upper and (not prev_is_upper or not next_is_upper):
+            chunk_positions.append((prev_split_pos, pos))
+            prev_split_pos = pos
+
+    chunk_positions.append((prev_split_pos, len(camelcased)))
+
+    lowercased = camelcased.lower()
+
+    return "_".join(
+        lowercased[prev_split_pos:split_pos] for prev_split_pos, split_pos in chunk_positions
+    )
diff --git a/pyproject.toml b/pyproject.toml
index bd176f48..1ff440d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,9 @@ flake8 = "^3.9.2"
 addopts = "--black --cov-config .coveragerc --cov=duffy --cov-report term --cov-report xml --cov-report html --flake8 --isort"
 flake8-max-line-length = 100
 
+[tool.isort]
+line_length = 100
+
 [tool.black]
 line-length = 100
 


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/app/__init__.py b/tests/app/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/database/__init__.py b/tests/database/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/database/conftest.py b/tests/database/conftest.py
new file mode 100644
index 00000000..ddad8304
--- /dev/null
+++ b/tests/database/conftest.py
@@ -0,0 +1,43 @@
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from duffy.database import DBSession, init_model, model
+
+
+@pytest.fixture
+def db_engine():
+    db_engine = create_engine("sqlite:///:memory:", future=True, echo=True)
+    return db_engine
+
+
+@pytest.fixture
+def db_async_engine():
+    async_db_engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True, echo=True)
+    return async_db_engine
+
+
+@pytest.fixture
+def db_schemas(db_engine):
+    with db_engine.begin():
+        model.Base.metadata.create_all(db_engine)
+
+
+@pytest.fixture
+def db_model_initialized(db_engine, db_schemas):
+    init_model(engine=db_engine)
+
+
+@pytest.fixture
+def db_obj(request, db_model_initialized):
+    with DBSession.begin():
+        db_obj_dependencies = request.instance._db_obj_get_dependencies()
+        attrs = {**request.instance.attrs, **db_obj_dependencies}
+        obj = request.instance.klass(**attrs)
+        obj._db_obj_dependencies = db_obj_dependencies
+        DBSession.add(obj)
+        DBSession.flush()
+
+        yield obj
+
+        DBSession.rollback()
diff --git a/tests/database/test_model.py b/tests/database/test_model.py
new file mode 100644
index 00000000..13d5abce
--- /dev/null
+++ b/tests/database/test_model.py
@@ -0,0 +1,103 @@
+from typing import Any, Dict
+
+from sqlalchemy import select
+
+from duffy.database import DBSession, model
+
+
+class ModelTestBase:
+    klass = None
+    attrs = {}
+
+    def test_create_obj(self, db_obj):
+        pass
+
+    def test_query_obj(self, db_obj):
+        obj = DBSession.execute(select(self.klass)).scalar_one()
+        for key, value in self.attrs.items():
+            assert getattr(obj, key) == value
+        for key, value in db_obj._db_obj_dependencies.items():
+            assert getattr(obj, key) == value
+
+    def _db_obj_get_dependencies(self):
+        """Get model test dependencies.
+
+        Use this method to pull in other objects that need to be created
+        for the tested model object to be built properly.
+        """
+        return {}
+
+
+class TestUser(ModelTestBase):
+    klass = model.User
+    attrs = {"ssh_key": "1234"}
+
+    def test_projects_backref(self, db_obj):
+        db_obj.projects = [model.Project(name=f"Project {num}") for num in range(1, 6)]
+        for project in db_obj.projects:
+            assert project.users == [db_obj]
+
+
+class TestProject(ModelTestBase):
+    klass = model.Project
+    attrs = {"name": "My Fancy Project"}
+
+    def _db_obj_get_dependencies(self):
+        user = model.User(ssh_key="6789")
+        return {"users": [user]}
+
+    def test_users_backref(self, db_obj):
+        db_obj.users = [model.User(ssh_key="0xc0ffee") for i in range(5)]
+        for user in db_obj.users:
+            assert user.projects == [db_obj]
+
+
+class TestSession(ModelTestBase):
+    klass = model.Session
+
+    def _db_obj_get_dependencies(self):
+        project = model.Project(name="My Other Project")
+        return {"project": project}
+
+
+class TestChassis(ModelTestBase):
+    klass = model.Chassis
+    attrs = {"name": "hufty"}
+
+
+def _gen_node_attrs(**addl_attrs: Dict[str, Any]) -> dict:
+    attrs = {
+        "hostname": "lolcathost",
+        "ipaddr": "192.0.2.10",  # TEST-NET-1
+        "state": model.NodeState.ready,
+    }
+
+    attrs.update(addl_attrs)
+
+    return attrs
+
+
+class TestVirtualNode(ModelTestBase):
+    klass = model.VirtualNode
+    attrs = _gen_node_attrs(flavour="large", comment="Hello!")
+
+
+class TestPhysicalNode(ModelTestBase):
+    klass = model.PhysicalNode
+    attrs = _gen_node_attrs()
+
+    def _db_obj_get_dependencies(self):
+        return {"chassis": model.Chassis(name="Infinity Polydome K")}
+
+
+class TestSessionNode(ModelTestBase):
+    klass = model.SessionNode
+    attrs = {"distro_type": "CentOS", "distro_version": "8Stream"}
+
+    def _db_obj_get_dependencies(self):
+        project = model.Project(name="World Domination")
+        session = model.Session(project=project)
+        chassis = model.Chassis(name="Celestion Greenback")
+        node = model.PhysicalNode(**_gen_node_attrs(chassis=chassis))
+
+        return {"session": session, "node": node}
diff --git a/tests/database/test_util.py b/tests/database/test_util.py
new file mode 100644
index 00000000..9a6c164b
--- /dev/null
+++ b/tests/database/test_util.py
@@ -0,0 +1,90 @@
+import pickle
+
+import pytest
+from sqlalchemy import Column, Integer
+from sqlalchemy.exc import StatementError
+
+from duffy.database import Base, DBSession
+from duffy.database.util import DeclEnum
+
+
+class UselessEnum(DeclEnum):
+    enval1 = "enval1", "Enum value 1"
+    enval2 = "Enum value 2"
+
+
+class UselessThing(Base):
+    __tablename__ = "useless_things"
+
+    id = Column(Integer, primary_key=True)
+
+    useless_enum = Column(UselessEnum.db_type(), nullable=True)
+
+
+class TestEnumSymbol:
+    def test_pickle(self):
+        """Test that EnumSymbol objects can be pickled and unpickled."""
+        obj = UselessEnum.enval1
+        pickled = pickle.loads(pickle.dumps(obj))
+
+        assert obj == pickled
+
+    def test_iter(self):
+        """Test that EnumSymbol objects can be iterated."""
+        obj = UselessEnum.enval1
+        obj_iter = iter(obj)
+
+        assert next(obj_iter) == obj.value
+        assert next(obj_iter) == obj.description
+
+        with pytest.raises(StopIteration):
+            next(obj_iter)
+
+    def test_db_type(self):
+        """Test the db_type() method."""
+        db_type = UselessEnum.db_type()
+
+        assert db_type.enum == UselessEnum
+
+    def test_comparator(self):
+        """Test the __lt__() method."""
+        assert UselessEnum.enval1 < UselessEnum.enval2
+
+
+@pytest.mark.usefixtures("db_model_initialized")
+class TestEnum:
+    def test_set_enum(self):
+        """Test that an enum attribute can be set."""
+        obj = UselessThing(useless_enum=UselessEnum.enval1)
+        DBSession.add(obj)
+        DBSession.flush()
+
+    def test_set_enum_from_string(self):
+        """Test that an attribute can be set from a string."""
+        obj = UselessThing(useless_enum="enval2")
+        DBSession.add(obj)
+        DBSession.flush()
+
+    def test_set_enum_none(self):
+        """Test that an enum attribute can be set to None and retrieved."""
+        obj = UselessThing(useless_enum=None)
+        DBSession.add(obj)
+        DBSession.flush()
+
+        DBSession.expire_all()
+        obj = DBSession.query(UselessThing).filter_by(useless_enum=None).one()
+        assert obj.useless_enum is None
+
+    def test_set_invalid_enum_from_string(self):
+        """Test that setting an invalid enum string fails."""
+        obj = UselessThing(useless_enum="enval3")
+        DBSession.add(obj)
+        with pytest.raises(StatementError) as excinfo:
+            DBSession.flush()
+        assert excinfo.type == StatementError
+        assert "enval3" in str(excinfo.value)
+
+
+class TestDeclEnumType:
+    # No tests specific to `DeclEnumType`, it's tested in conjunction with its DeclEnum parent.
+    pass
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 00000000..5b3b74f7
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,15 @@
+import pytest
+
+from duffy.util import camel_case_to_lower_with_underscores
+
+
+@pytest.mark.parametrize(
+    "camelcased,converted",
+    (
+        ("AssetType", "asset_type"),
+        ("EXIFFieldType", "exif_field_type"),
+        ("BlahEXIF", "blah_exif"),
+    ),
+)
+def test_camel_case_to_lower_with_underscores(camelcased, converted):
+    assert camel_case_to_lower_with_underscores(camelcased) == converted