- set min and max for str fields
[plcapi.git] / PLC / Nodes.py
1 #
2 # Functions for interacting with the nodes table in the database
3 #
4 # Mark Huang <mlhuang@cs.princeton.edu>
5 # Copyright (C) 2006 The Trustees of Princeton University
6 #
7 # $Id: Nodes.py,v 1.1 2006/09/06 15:36:07 mlhuang Exp $
8 #
9
10 from types import StringTypes
11 import re
12
13 from PLC.Faults import *
14 from PLC.Parameter import Parameter
15 from PLC.Debug import profile
16 from PLC.Table import Row, Table
17 from PLC.NodeNetworks import NodeNetwork, NodeNetworks
18 from PLC.BootStates import BootStates
19
20 class Node(Row):
21     """
22     Representation of a row in the nodes table. To use, optionally
23     instantiate with a dict of values. Update as you would a
24     dict. Commit to the database with flush().
25     """
26
27     fields = {
28         'node_id': Parameter(int, "Node identifier"),
29         'hostname': Parameter(str, "Fully qualified hostname", max = 255),
30         'boot_state': Parameter(str, "Boot state", max = 20),
31         'model': Parameter(str, "Make and model of the actual machine", max = 255),
32         'boot_nonce': Parameter(str, "(Admin only) Random value generated by the node at last boot", max = 128),
33         'version': Parameter(str, "Apparent Boot CD version", max = 64),
34         'ssh_rsa_key': Parameter(str, "Last known SSH host key", max = 1024),
35         'date_created': Parameter(str, "Date and time when node entry was created"),
36         'deleted': Parameter(bool, "Has been deleted"),
37         'key': Parameter(str, "(Admin only) Node key", max = 256),
38         'session': Parameter(str, "(Admin only) Node session value", max = 256),
39         }
40
41     # These fields are derived from join tables and are not actually
42     # in the nodes table.
43     join_fields = {
44         'nodenetwork_ids': Parameter([int], "List of network interfaces that this node has"),
45         }
46
47     # These fields are derived from join tables and are not returned
48     # by default unless specified.
49     extra_fields = {
50         'nodegroup_ids': Parameter([int], "List of node groups that this node is in"),
51         'conf_file_ids': Parameter([int], "List of configuration files specific to this node"),
52         'root_person_ids': Parameter([int], "(Admin only) List of people who have root access to this node"),
53         # XXX Too inefficient
54         # 'slice_ids': Parameter([int], "List of slices on this node"),
55         'pcu_ids': Parameter([int], "List of PCUs that control this node"),
56         'site_id': Parameter([int], "Site at which this node is located"),
57         }
58
59     # Primary interface values
60     primary_nodenetwork_fields = dict(filter(lambda (key, value): \
61                                              key not in ['node_id', 'is_primary', 'hostname'],
62                                              NodeNetwork.fields.items()))
63
64     extra_fields.update(primary_nodenetwork_fields)
65
66     default_fields = dict(fields.items() + join_fields.items())
67     all_fields = dict(default_fields.items() + extra_fields.items())
68
69     def __init__(self, api, fields):
70         Row.__init__(self, fields)
71         self.api = api
72
73     def validate_hostname(self, hostname):
74         # 1. Each part begins and ends with a letter or number.
75         # 2. Each part except the last can contain letters, numbers, or hyphens.
76         # 3. Each part is between 1 and 64 characters, including the trailing dot.
77         # 4. At least two parts.
78         # 5. Last part can only contain between 2 and 6 letters.
79         good_hostname = r'^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+' \
80                         r'[a-z]{2,6}$'
81         if not hostname or \
82            not re.match(good_hostname, hostname, re.IGNORECASE):
83             raise PLCInvalidArgument, "Invalid hostname"
84
85         conflicts = Nodes(self.api, [hostname])
86         for node_id, node in conflicts.iteritems():
87             if not node['deleted'] and ('node_id' not in self or self['node_id'] != node_id):
88                 raise PLCInvalidArgument, "Hostname already in use"
89
90         # Check for conflicts with a nodenetwork hostname
91         conflicts = NodeNetworks(self.api, [hostname])
92         for nodenetwork_id in conflicts:
93             if 'nodenetwork_ids' not in self or nodenetwork_id not in self['nodenetwork_ids']:
94                 raise PLCInvalidArgument, "Hostname already in use"
95
96         return hostname
97
98     def validate_boot_state(self, boot_state):
99         if boot_state not in BootStates(self.api):
100             raise PLCInvalidArgument, "Invalid boot state"
101
102         return boot_state
103
104     def flush(self, commit = True):
105         """
106         Flush changes back to the database.
107         """
108
109         self.validate()
110
111         # Fetch a new node_id if necessary
112         if 'node_id' not in self:
113             rows = self.api.db.selectall("SELECT NEXTVAL('nodes_node_id_seq') AS node_id")
114             if not rows:
115                 raise PLCDBError, "Unable to fetch new node_id"
116             self['node_id'] = rows[0]['node_id']
117             insert = True
118         else:
119             insert = False
120
121         # Filter out fields that cannot be set or updated directly
122         fields = dict(filter(lambda (key, value): key in self.fields,
123                              self.items()))
124
125         # Parameterize for safety
126         keys = fields.keys()
127         values = [self.api.db.param(key, value) for (key, value) in fields.items()]
128
129         if insert:
130             # Insert new row in nodes table
131             sql = "INSERT INTO nodes (%s) VALUES (%s)" % \
132                   (", ".join(keys), ", ".join(values))
133         else:
134             # Update existing row in nodes table
135             columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
136             sql = "UPDATE nodes SET " + \
137                   ", ".join(columns) + \
138                   " WHERE node_id = %(node_id)d"
139
140         self.api.db.do(sql, fields)
141
142         if commit:
143             self.api.db.commit()
144
145     def delete(self, commit = True):
146         """
147         Delete existing node.
148         """
149
150         assert 'node_id' in self
151
152         # Delete all nodenetworks
153         nodenetworks = NodeNetworks(self.api, self['nodenetwork_ids'])
154         for nodenetwork in nodenetworks.values():
155             nodenetwork.delete(commit = False)
156
157         # Clean up miscellaneous join tables
158         for table in ['nodegroup_nodes', 'pod_hash', 'conf_assoc',
159                       'node_root_access', 'dslice03_slicenode',
160                       'pcu_ports']:
161             self.api.db.do("DELETE FROM %s" \
162                            " WHERE node_id = %d" % \
163                            (table, self['node_id']))
164
165         # Mark as deleted
166         self['deleted'] = True
167         self.flush(commit)
168
169 class Nodes(Table):
170     """
171     Representation of row(s) from the nodes table in the
172     database.
173     """
174
175     def __init__(self, api, node_id_or_hostname_list = None, extra_fields = []):
176         self.api = api
177
178         sql = "SELECT nodes.*, node_nodenetworks.nodenetwork_id"
179
180         # For compatibility and convenience, support returning primary
181         # interface values directly in the Node structure.
182         extra_nodenetwork_fields = set(extra_fields).intersection(Node.primary_nodenetwork_fields)
183
184         # N.B.: Joined IDs may be marked as deleted in their primary tables
185         join_tables = {
186             # extra_field: (extra_table, extra_column, join_using)
187             'nodegroup_ids': ('nodegroup_nodes', 'nodegroup_id', 'node_id'),
188             'conf_file_ids': ('conf_assoc', 'conf_file_id', 'node_id'),
189             'root_person_ids': ('node_root_access', 'person_id AS root_person_id', 'node_id'),
190             'slice_ids': ('dslice03_slicenode', 'slice_id', 'node_id'),
191             'pcu_ids': ('pcu_ports', 'pcu_id', 'node_id'),
192             }
193
194         extra_fields = filter(join_tables.has_key, extra_fields)
195         extra_tables = ["%s USING (%s)" % \
196                         (join_tables[field][0], join_tables[field][2]) \
197                         for field in extra_fields]
198         extra_columns = ["%s.%s" % \
199                          (join_tables[field][0], join_tables[field][1]) \
200                          for field in extra_fields]
201
202         if extra_columns:
203             sql += ", " + ", ".join(extra_columns)
204
205         sql += " FROM nodes" \
206                " LEFT JOIN node_nodenetworks USING (node_id)"
207
208         if extra_tables:
209             sql += " LEFT JOIN " + " LEFT JOIN ".join(extra_tables)
210
211         sql += " WHERE deleted IS False"
212
213         if node_id_or_hostname_list:
214             # Separate the list into integers and strings
215             node_ids = filter(lambda node_id: isinstance(node_id, (int, long)),
216                               node_id_or_hostname_list)
217             hostnames = filter(lambda hostname: isinstance(hostname, StringTypes),
218                                node_id_or_hostname_list)
219             sql += " AND (False"
220             if node_ids:
221                 sql += " OR node_id IN (%s)" % ", ".join(map(str, node_ids))
222             if hostnames:
223                 sql += " OR hostname IN (%s)" % ", ".join(api.db.quote(hostnames)).lower()
224             sql += ")"
225
226         # So that if the node has a primary interface, it is listed
227         # first.
228         if 'nodenetwork_ids' in extra_fields:
229             sql += " ORDER BY node_nodenetworks.is_primary DESC"
230
231         rows = self.api.db.selectall(sql)
232         for row in rows:
233             if self.has_key(row['node_id']):
234                 node = self[row['node_id']]
235                 node.update(row)
236             else:
237                 self[row['node_id']] = Node(api, row)
238
239         # XXX Should instead have a site_node join table that is
240         # magically taken care of above.
241         if rows:
242             sql = "SELECT node_id, sites.site_id FROM nodegroup_nodes" \
243                   " INNER JOIN sites USING (nodegroup_id)" \
244                   " WHERE node_id IN (%s)" % ", ".join(map(str, self.keys()))
245
246             rows = self.api.db.selectall(sql, self)
247             for row in rows:
248                 assert self.has_key(row['node_id'])
249                 node = self[row['node_id']]
250                 node.update(row)
251
252         # Fill in optional primary interface fields for each node
253         if extra_nodenetwork_fields:
254             # More efficient to get all the nodenetworks at once
255             nodenetwork_ids = []
256             for node in self.values():
257                 nodenetwork_ids += node['nodenetwork_ids']
258
259             # Remove duplicates
260             nodenetwork_ids = set(nodenetwork_ids)
261
262             # Get all nodenetwork information
263             nodenetworks = NodeNetworks(self.api, nodenetwork_ids)
264
265             for node in self.values():
266                 for nodenetwork_id in node['nodenetwork_ids']:
267                     nodenetwork = nodenetworks[nodenetwork_id]
268                     if nodenetwork['is_primary']:
269                         for field in extra_nodenetwork_fields:
270                             node[field] = nodenetwork[field]
271                         break