Merge branch '20984-instance-capacity'
[arvados.git] / services / api / app / models / node.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require 'tempfile'
6
7 class Node < ArvadosModel
8   include HasUuid
9   include KindAndEtag
10   include CommonApiTemplate
11
12   # Posgresql JSONB columns should NOT be declared as serialized, Rails 5
13   # already know how to properly treat them.
14   attribute :properties, :jsonbHash, default: {}
15   attribute :info, :jsonbHash, default: {}
16
17   before_validation :ensure_ping_secret
18   after_update :dns_server_update
19
20   # Only a controller can figure out whether or not the current API tokens
21   # have access to the associated Job.  They're expected to set
22   # job_readable=true if the Job UUID can be included in the API response.
23   belongs_to :job, {
24                foreign_key: 'job_uuid',
25                primary_key: 'uuid',
26                optional: true,
27              }
28   attr_accessor :job_readable
29
30   UNUSED_NODE_IP = '127.40.4.0'
31   MAX_VMS = 3
32
33   api_accessible :user, :extend => :common do |t|
34     t.add :hostname
35     t.add :domain
36     t.add :ip_address
37     t.add :last_ping_at
38     t.add :slot_number
39     t.add :status
40     t.add :api_job_uuid, as: :job_uuid
41     t.add :crunch_worker_state
42     t.add :properties
43   end
44   api_accessible :superuser, :extend => :user do |t|
45     t.add :first_ping_at
46     t.add :info
47     t.add lambda { |x| Rails.configuration.Containers.SLURM.Managed.ComputeNodeNameservers.keys }, :as => :nameservers
48   end
49
50   after_initialize do
51     @bypass_arvados_authorization = false
52   end
53
54   def domain
55     super || Rails.configuration.Containers.SLURM.Managed.ComputeNodeDomain
56   end
57
58   def api_job_uuid
59     job_readable ? job_uuid : nil
60   end
61
62   def crunch_worker_state
63     return 'down' if slot_number.nil?
64     case self.info.andand['slurm_state']
65     when 'alloc', 'comp', 'mix', 'drng'
66       'busy'
67     when 'idle'
68       'idle'
69     else
70       'down'
71     end
72   end
73
74   def status
75     if !self.last_ping_at
76       if db_current_time - self.created_at > 5.minutes
77         'startup-fail'
78       else
79         'pending'
80       end
81     elsif db_current_time - self.last_ping_at > 1.hours
82       'missing'
83     else
84       'running'
85     end
86   end
87
88   def ping(o)
89     raise "must have :ip and :ping_secret" unless o[:ip] and o[:ping_secret]
90
91     if o[:ping_secret] != self.info['ping_secret']
92       logger.info "Ping: secret mismatch: received \"#{o[:ping_secret]}\" != \"#{self.info['ping_secret']}\""
93       raise ArvadosModel::UnauthorizedError.new("Incorrect ping_secret")
94     end
95
96     current_time = db_current_time
97     self.last_ping_at = current_time
98
99     @bypass_arvados_authorization = true
100
101     # Record IP address
102     if self.ip_address.nil?
103       logger.info "#{self.uuid} ip_address= #{o[:ip]}"
104       self.ip_address = o[:ip]
105       self.first_ping_at = current_time
106     end
107
108     # Record instance ID if not already known
109     if o[:ec2_instance_id]
110       if !self.info['ec2_instance_id']
111         self.info['ec2_instance_id'] = o[:ec2_instance_id]
112       elsif self.info['ec2_instance_id'] != o[:ec2_instance_id]
113         logger.debug "Multiple nodes have credentials for #{self.uuid}"
114         raise "#{self.uuid} is already running at #{self.info['ec2_instance_id']} so rejecting ping from #{o[:ec2_instance_id]}"
115       end
116     end
117
118     assign_slot
119
120     # Record other basic stats
121     ['total_cpu_cores', 'total_ram_mb', 'total_scratch_mb'].each do |key|
122       if value = (o[key] or o[key.to_sym])
123         self.properties[key] = value.to_i
124       else
125         self.properties.delete(key)
126       end
127     end
128
129     save!
130   end
131
132   def assign_slot
133     return if self.slot_number.andand > 0
134     while true
135       self.slot_number = self.class.available_slot_number
136       if self.slot_number.nil?
137         raise "No available node slots"
138       end
139       begin
140         save!
141         return assign_hostname
142       rescue ActiveRecord::RecordNotUnique
143         # try again
144       end
145     end
146   end
147
148   protected
149
150   def assign_hostname
151     if self.hostname.nil? and Rails.configuration.Containers.SLURM.Managed.AssignNodeHostname
152       self.hostname = self.class.hostname_for_slot(self.slot_number)
153     end
154   end
155
156   def self.available_slot_number
157     # Join the sequence 1..max with the nodes table. Return the first
158     # (i.e., smallest) value that doesn't match the slot_number of any
159     # existing node.
160     connection.exec_query('SELECT n FROM generate_series(1, $1) AS slot(n)
161                           LEFT JOIN nodes ON n=slot_number
162                           WHERE slot_number IS NULL
163                           LIMIT 1',
164                           # query label:
165                           'Node.available_slot_number',
166                           # bind vars:
167                           [MAX_VMS],
168                          ).rows.first.andand.first
169   end
170
171   def ensure_ping_secret
172     self.info['ping_secret'] ||= rand(2**256).to_s(36)
173   end
174
175   def dns_server_update
176     if saved_change_to_ip_address? && ip_address
177       Node.where('id != ? and ip_address = ?',
178                  id, ip_address).each do |stale_node|
179         # One or more(!) stale node records have the same IP address
180         # as the new node. Clear the ip_address field on the stale
181         # nodes. Otherwise, we (via SLURM) might inadvertently connect
182         # to the new node using the old node's hostname.
183         stale_node.update!(ip_address: nil)
184       end
185     end
186     if hostname_before_last_save && saved_change_to_hostname?
187       self.class.dns_server_update(hostname_before_last_save, UNUSED_NODE_IP)
188     end
189     if hostname && (saved_change_to_hostname? || saved_change_to_ip_address?)
190       self.class.dns_server_update(hostname, ip_address || UNUSED_NODE_IP)
191     end
192   end
193
194   def self.dns_server_update hostname, ip_address
195     ok = true
196
197     ptr_domain = ip_address.
198       split('.').reverse.join('.').concat('.in-addr.arpa')
199
200     template_vars = {
201       hostname: hostname,
202       uuid_prefix: Rails.configuration.ClusterID,
203       ip_address: ip_address,
204       ptr_domain: ptr_domain,
205     }
206
207     if (!Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir.to_s.empty? and
208         !Rails.configuration.Containers.SLURM.Managed.DNSServerConfTemplate.to_s.empty?)
209       tmpfile = nil
210       begin
211         begin
212           template = IO.read(Rails.configuration.Containers.SLURM.Managed.DNSServerConfTemplate)
213         rescue IOError, SystemCallError => e
214           logger.error "Reading #{Rails.configuration.Containers.SLURM.Managed.DNSServerConfTemplate}: #{e.message}"
215           raise
216         end
217
218         hostfile = File.join Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir, "#{hostname}.conf"
219         Tempfile.open(["#{hostname}-", ".conf.tmp"],
220                                  Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir) do |f|
221           tmpfile = f.path
222           f.puts template % template_vars
223         end
224         File.rename tmpfile, hostfile
225       rescue IOError, SystemCallError => e
226         logger.error "Writing #{hostfile}: #{e.message}"
227         ok = false
228       ensure
229         if tmpfile and File.file? tmpfile
230           # Cleanup remaining temporary file.
231           File.unlink tmpfile
232         end
233       end
234     end
235
236     if !Rails.configuration.Containers.SLURM.Managed.DNSServerUpdateCommand.empty?
237       cmd = Rails.configuration.Containers.SLURM.Managed.DNSServerUpdateCommand % template_vars
238       if not system cmd
239         logger.error "dns_server_update_command #{cmd.inspect} failed: #{$?}"
240         ok = false
241       end
242     end
243
244     if (!Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir.to_s.empty? and
245         !Rails.configuration.Containers.SLURM.Managed.DNSServerReloadCommand.to_s.empty?)
246       restartfile = File.join(Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir, 'restart.txt')
247       begin
248         File.open(restartfile, 'w') do |f|
249           # Typically, this is used to trigger a dns server restart
250           f.puts Rails.configuration.Containers.SLURM.Managed.DNSServerReloadCommand
251         end
252       rescue IOError, SystemCallError => e
253         logger.error "Unable to write #{restartfile}: #{e.message}"
254         ok = false
255       end
256     end
257
258     ok
259   end
260
261   def self.hostname_for_slot(slot_number)
262     config = Rails.configuration.Containers.SLURM.Managed.AssignNodeHostname
263
264     return nil if !config
265
266     sprintf(config, {:slot_number => slot_number})
267   end
268
269   # At startup, make sure all DNS entries exist.  Otherwise, slurmctld
270   # will refuse to start.
271   if (!Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir.to_s.empty? and
272       !Rails.configuration.Containers.SLURM.Managed.DNSServerConfTemplate.to_s.empty? and
273       !Rails.configuration.Containers.SLURM.Managed.AssignNodeHostname.empty?)
274
275     (0..MAX_VMS-1).each do |slot_number|
276       hostname = hostname_for_slot(slot_number)
277       hostfile = File.join Rails.configuration.Containers.SLURM.Managed.DNSServerConfDir, "#{hostname}.conf"
278       if !File.exist? hostfile
279         n = Node.where(:slot_number => slot_number).first
280         if n.nil? or n.ip_address.nil?
281           dns_server_update(hostname, UNUSED_NODE_IP)
282         else
283           dns_server_update(hostname, n.ip_address)
284         end
285       end
286     end
287   end
288
289   def permission_to_update
290     @bypass_arvados_authorization or super
291   end
292
293   def permission_to_create
294     current_user and current_user.is_admin
295   end
296 end