Repository navigation
Expand file tree
/
Copy pathnode_instance.rb
More file actions
2131 lines (1933 loc) · 108 KB
/
Copy pathnode_instance.rb
File metadata and controls
2131 lines (1933 loc) · 108 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# frozen_string_literal: true
module System
class NodeInstance < BaseRecord
include AASM
# Constants
VARIETIES = %w[cloud physical dynamic].freeze
STATUSES = %w[pending provisioning starting running stopping stopped rebooting terminated error].freeze
# Extension seam for Ai::Tools::SystemFleetTool#destroy_instance's raw-SQL
# cascade (mirrors Account.register_teardown_guard). That cascade's static
# DESTROY_INSTANCE_FKS names only tables core or this extension owns; a
# table ANOTHER extension owns, holding a node_instance FK this model
# declares no `dependent:` for, registers here from that extension's
# `config.to_prepare` block, never `after_initialize`: the store is a
# class-level ivar that a development reload wipes, and only to_prepare
# re-runs after a reload (re-registering the same pair is a no-op). Naming it statically would
# break every destroy on an install without that extension: the table
# would not exist and the DELETE would abort the transaction.
#
# Both names go into a DELETE statement (quoted there), and only a plain
# lower-case SQL identifier is accepted.
DESTROY_DEPENDENT_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/
def self.destroy_dependents
@destroy_dependents ||= {}
end
def self.register_destroy_dependent(table, foreign_key)
pair = [ table.to_s, foreign_key.to_s ]
pair.each do |name|
next if name.match?(DESTROY_DEPENDENT_IDENTIFIER)
raise ArgumentError, "destroy dependent #{name.inspect} is not a plain SQL identifier"
end
destroy_dependents[pair] = true
pair
end
def self.registered_destroy_dependents
destroy_dependents.keys
end
# Statuses that count as a replica the control plane still expects to
# serve — capacity metrics (ProjectMetricsCollector's replica_count /
# region_count) measure the fleet against this, NOT against `active`.
#
# Every non-terminal state is in: `pending`/`provisioning` are replicas on
# the way up, and `starting`/`stopping`/`rebooting` are mid-lifecycle, not
# failed — a replica rebooting is one the mission expects back in seconds,
# and treating it as lost would make every routine reboot read as capacity
# loss. `stopped` counts too: the mission still owns that replica, and the
# remediation it needs is a start, not a second provision.
#
# That is also why this is NOT the `active` scope below, despite the cost
# of a second definition. `active` omits `starting` and `rebooting` — and
# those two states are exactly what the platform's OWN remediation
# produces (FleetDecisionEngine#reboot_silent_instance issues `reboot` or
# `start`). Sizing capacity on `active` would therefore emit capacity
# drift for the instance currently being repaired, and propose a
# replacement provision alongside the in-flight reboot: a self-amplifying
# loop. It would also drift through the whole minutes-long `starting`
# window of every fresh cloud provision.
#
# Only the two states that say the control plane can NOT count on the row
# are out. `terminated` is gone. `error` is written by five paths and four
# of them are authoritative about the instance, not merely about our view
# of it: ProvisioningService#mark_instance_errored (the provision failed —
# there is no VM), the two `finalize_state_from_cloud` controller paths and
# System::InstanceControlService, reached from NodeInstanceGating via
# System::Executors::ControlInstance (the PROVIDER
# itself reports error), and the `revert_termination` event below (a
# terminate stamp whose provider call then failed — a provably unknown
# state nothing may size against). The fifth,
# FleetDecisionEngine#reap_presumed_dead!, is the weak one: it probes the
# control-plane channel, so a partitioned-but-serving VM can land here.
# It is still excluded — the honest answer to "we cannot observe this
# replica" is never "assume it is fine", which is precisely what counting
# it asserted. That case is not silent either way: the reap emits
# `system.instance_presumed_dead` at :critical on its own lane.
#
# Deliberately spelled out rather than derived as `STATUSES - [...]`: a
# status added later is excluded until someone decides it belongs, so the
# failure mode of forgetting is an over-eager drift signal (visible, and
# bounded by the target it converges on) rather than a silently inflated
# count (invisible — the exact defect IMP-797a87dbd0bd fixed).
LIVE_REPLICA_STATUSES = %w[pending provisioning starting running stopping stopped rebooting].freeze
# The statuses in which an agent is expected to be reporting, so silence is
# evidence rather than an absence of expectation. Model vocabulary — it is
# defined relative to LIVE_REPLICA_STATUSES above — and read by
# Fleet::Sensors::InstanceStatusSensor, which aliases it.
HEARTBEAT_EXPECTED_STATUSES = %w[running starting].freeze
# The `active` scope's membership, named so consumers can report on its
# COMPLEMENT rather than silently dropping it. drift_check
# (PlatformMaintenanceExecutor#drift_summary_for) answers drift only for
# these, and discloses the rest in a third bucket — an instance in
# `starting`/`stopping`/`rebooting`/`error` used to land in neither of its
# counts with nothing in the payload saying so (IMP-351be1c674e0).
ACTIVE_STATUSES = %w[pending provisioning running stopped].freeze
MAC_ADDRESS_REGEX = /\A([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})\z/
# Slice 7 — pre-warmed instance pool membership.
# NULL for non-pool instances (operator-owned, legacy path).
POOL_STATES = %w[warming ready claimed draining errored].freeze
# Phase O2 — OVS+OVN dual-profile network selector.
# Picks which on-node BridgeApplier (and downstream CNI) the agent
# runs. `lightweight` = Linux bridge only; `heavyweight` = OVS bridge
# + OVN-controller + OVN-K8s CNI in later phases. Default is
# `lightweight` because it imposes no daemon overhead and works on
# every supported host (including Pi 4 ≤4GB / Pi Zero 2W). Operator
# promotes to `heavyweight` deliberately, or autonomy/provisioning
# consults #suggest_network_profile and acts on the recommendation.
NETWORK_PROFILES = %w[lightweight heavyweight].freeze
# Memory floor (MB) under which the heavyweight profile is unsafe.
# Sourced from the OVS+OVN footprint analysis (~150-200MB RAM for
# the three additional daemons + headroom for the host kernel).
HEAVYWEIGHT_MIN_MEMORY_MB = 4096
# Pi 4 needs the 8GB SKU specifically — the 1/2/4GB SKUs cannot run
# OVS+OVN+OVN-K8s reliably alongside K3s and the other workload pods.
HEAVYWEIGHT_PI4_MIN_MEMORY_MB = 8192
# Hardware-model hints persisted in `config["hardware_model"]` (or
# discovery DMI metadata) that the suggester recognises. The full
# list is intentionally narrow — anything outside the known-capable
# set falls through to the safe `lightweight` default.
HEAVYWEIGHT_PI_MODELS = %w[raspberry_pi_5 rpi5 pi5].freeze
PI4_HARDWARE_MODELS = %w[raspberry_pi_4 rpi4 pi4].freeze
# Encryption for sensitive fields
encrypts :key
# Associations
belongs_to :node, class_name: "System::Node"
# Operator who placed the current ops hold. Optional: a hold is only ever
# set through InstanceOpsHoldService, which requires a user, but historical
# rows and released holds carry nil.
belongs_to :ops_held_by, class_name: "User", foreign_key: :ops_hold_by_id, optional: true
# Account is denormalized as a first-class column (mirroring sibling
# tables like system_provider_volumes, system_acme_certificates).
# The before_validation callback below inherits the value from the
# parent Node so callers can omit it; a validation check keeps the
# two in sync.
belongs_to :account
# Denormalised from the node on create so every fleet query can filter by
# plane without a join (Environment campaign, incr. 1).
belongs_to :environment, class_name: "Ai::Environment"
belongs_to :provider_region, class_name: "System::ProviderRegion", optional: true
belongs_to :provider_instance_type, class_name: "System::ProviderInstanceType", optional: true
# Slice 7 — optional pool membership.
belongs_to :instance_pool,
class_name: "System::InstancePool",
optional: true
# Task associations (Release 4)
# Task association + removal policy: see System::PreservesTaskHistory.
# Tasks are TRANSITIONED on removal, never deleted — a deleted task is
# indistinguishable from one that never ran.
include System::PreservesTaskHistory
# `config` is a SHARED jsonb document with a dozen unaware writers (see
# System::ConfigDocument). Write a key with #merge_config! / remove one with
# #delete_config_keys! — never a read-modify-write of the whole document,
# which silently erases whatever landed in the interval.
include System::ConfigDocument
# Volume associations (Release 4)
has_many :provider_volumes, class_name: "System::ProviderVolume"
before_validation :inherit_account_from_node
before_validation :inherit_environment_from_node, on: :create
validate :environment_belongs_to_account
validate :provider_catalog_belongs_to_account, if: :check_provider_catalog_account?
scope :in_environment, ->(env) { where(environment_id: env.is_a?(::Ai::Environment) ? env.id : env) }
# IMP-231f17d71dfa — a presumed-dead verdict is scoped to ONE error episode.
#
# Clearing it only in #record_heartbeat! leaves it set on every other way out
# of :error, and a stale verdict then refuses a LATER, unrelated error. The
# sequence that does it: reaped for silence (stamp written) -> operator
# `start` -> :running with the stamp still set -> provider reports "error" ->
# a bare update! writes :error -> provider reports "running" -> refused,
# even though this error episode was provider-reported, not silence. That is
# exactly the "errored for other reasons keep the self-heal" case the nil
# semantics promise, broken by a leftover.
#
# A before_save on the column rather than AASM hooks, deliberately: the
# hottest writer here (System::CloudSyncService#sync_region_instances) writes
# `status` with a bare update! and fires no AASM event at all, so `after`
# hooks on the transitions would miss precisely the path that matters.
before_save :retire_presumed_dead_verdict_on_leaving_error
validate :account_matches_node
# Validations
validates :name, presence: true, uniqueness: { scope: :node_id }
validates :variety, presence: true, inclusion: { in: VARIETIES }
validates :status, presence: true, inclusion: { in: STATUSES }
validates :network_profile, presence: true, inclusion: { in: NETWORK_PROFILES }
validates :mac_address, format: { with: MAC_ADDRESS_REGEX, message: "must be a valid MAC address" }, allow_nil: true
validates :latitude, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }, allow_nil: true
validates :longitude, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }, allow_nil: true
# Config accessors. Virtual attributes stored as JSONB keys on `config`
# so callers can use the dot-accessor (`instance.cloud_instance_id`) while
# the data persists in the same JSONB blob. Other config keys
# (ip_allocation_id, ip_association_id, netboot.enabled, ipmi.*) use
# the explicit `config.merge` pattern at the call site and don't need
# accessor declarations.
# provider_guest_name is the name the guest was CREATED with, captured at
# provision time. It is deliberately not `name`: PATCH .../node_instances/:id
# permits :name and renames only the row — nothing calls `qm set --name` — so
# the row's name drifts from the guest's. Terminate's recycled-vmid check
# compares against this stable value; using the mutable one would make a
# renamed instance's GENUINE migration look like a recycled id and wrongly
# report it gone (IMP-708079f866d9). nil on pre-existing rows, which the
# check treats conservatively.
store_accessor :config, :cloud_instance_id, :admin_user, :provider_guest_name
# Top-level `config` keys a REQUEST BODY may write (IMP-1b65222b8d5f).
#
# `config` is a SHARED jsonb document — see System::ConfigDocument. The
# three writers that accept one from outside (the operator REST API, the
# worker API, and the MCP `system_update_instance` action) used to take an
# ARBITRARY document and hand it to `update`, which replaced the whole
# column. Two things went wrong with that, and they are different defects:
#
# * CLOBBER. The agent's telemetry lanes write into this same column
# several times a minute per node. A body assembled from a page load
# erased whatever landed in the interval.
# * UNVALIDATED KEYS. Nothing checked what a key MEANT, so a body could
# set provenance (`network_profile_source`), a provisioning idempotency
# key (`operation_id`), or invent keys no reader knows about.
#
# So the outside writers name what they may write. This list is DELIBERATELY
# not "everything a reader looks at": a key with a platform writer belongs
# to that writer, and an operator who wants to change one goes through the
# endpoint that owns it. Every entry below is a DECLARATION — a fact the
# platform cannot measure for itself and has to be told.
#
# admin_user / cloud_instance_id / provider_guest_name
# the store_accessor trio. The worker API already permits
# cloud_instance_id as a scalar attribute, which writes the same key;
# the operator API reached all three only through the wholesale permit.
# boot_mode
# the instance's own boot-mode declaration. ProvisioningService stamps
# the value it RESOLVED, but the instance's own key is authoritative
# over the template's (#effective_boot_mode).
# gpu / hardware_model / memory_mb
# hardware hints. Agent detection writes these too, and defers to an
# operator-set value permanently (#apply_agent_hardware_hints! only
# writes a key it has not been overruled on).
# provider_* (six)
# the provisioning selectors the create form carries in `config`
# (frontend CreateInstanceModal) before the provider adapter runs.
#
# NOT here, and each for its own reason: the five telemetry lanes
# (boot_lkg, module_verify_state, module_signing_audit, runtime_metrics,
# sdwan_state) and
# agent_hardware_hints, which the node reports; network_profile_source,
# which records WHO declared the profile (a body that could set it would
# re-arm auto-classification over an operator choice — the MCP action takes
# `network_profile` instead and stamps provenance itself); operation_id,
# ProvisioningService's find-and-reuse key; and every service-owned key
# (storage_volume, netboot, fulfillment_lease, ...) whose owner writes it
# through the seam.
#
# DELIBERATE LOSS: none of the three writers can DELETE a config key any
# more. Whole-document replacement was the only removal path they had, and
# it removed platform lanes as readily as caller keys — that is the defect
# itself, not a feature. A key that genuinely needs clearing gets an
# explicit seam operation (System::ConfigDocument), not a document that
# deletes by omission.
WRITABLE_CONFIG_KEYS = %w[
admin_user
boot_mode
cloud_instance_id
gpu
hardware_model
memory_mb
provider_availability_zone_id
provider_connection_id
provider_guest_name
provider_instance_type_id
provider_network_id
provider_network_subnet_id
provider_region_id
].freeze
# The top-level keys of `document` that no request body may write. An empty
# result means the document is entirely allow-listed. Keys are compared as
# STRINGS because the column round-trips as strings and a symbol-keyed
# document would otherwise merge as a second, shadowing key.
#
# A nil/blank document is vacuously acceptable — "no config supplied" is
# not a refusal.
def self.unwritable_config_keys(document)
return [] if document.blank?
return [] unless document.respond_to?(:keys)
document.keys.map(&:to_s) - WRITABLE_CONFIG_KEYS
end
# One wording for every writer that refuses a document, so an operator who
# hits it on the REST API, the worker API or the MCP action reads the same
# list and does not have to work out which surface phrased it.
def self.config_refusal_message(keys)
"config keys are not writable through the API: #{keys.sort.join(', ')}. " \
"Writable keys: #{WRITABLE_CONFIG_KEYS.join(', ')}. " \
"Every other key in this document is written by the platform " \
"(agent telemetry, provisioning provenance) through its own owner."
end
# SSH target address for the platform→node control channel
# (SshExecutionService + internal serializer). Preference: SDWAN overlay
# (reachable across sites) > private LAN > public.
#
# F5-01: callers invoked this method since the SSH substrate was written
# but it was never defined — every real SSH execution raised
# NoMethodError, silently swallowed into Runtime::Result.err (all caller
# specs stubbed the service, so nothing caught it).
def ssh_ip_address
vpn_ip_address.presence || private_ip_address.presence || public_ip_address.presence
end
# === State machine (AASM — platform standard) ===
# Two-phase transitions: control actions ("start", "stop", "reboot",
# "terminate") move into a transitional state; the worker runtime
# finalizes via "mark_running", "mark_stopped", "mark_terminated",
# "mark_errored". Keeps the existing UI-visible state vocabulary while
# making transitions enforceable through the platform-standard pattern.
aasm column: :status, whiny_transitions: true do
state :pending, initial: true
state :provisioning
state :starting
state :running
state :stopping
state :stopped
state :rebooting
state :terminated
state :error
# Operator-initiated transitions (intermediate states)
event :start do
transitions from: [ :stopped, :error ], to: :starting
end
event :stop do
transitions from: [ :running, :starting ], to: :stopping
end
event :reboot do
transitions from: :running, to: :rebooting
end
# Terminate must be reachable from ANY non-terminal state: once the
# provider destroys the cloud resource, the DB row must always reach
# :terminated. Restricting `from` to running/stopped/error left
# instances destroyed while :pending/:provisioning/:starting stranded
# in a non-terminal status forever. See audit 2026-06-09 finding F4-02.
event :terminate do
transitions from: [ :pending, :provisioning, :starting, :running, :stopping, :stopped, :rebooting, :error ], to: :terminated
end
# Worker runtime finalizers
event :mark_provisioning do
transitions from: :pending, to: :provisioning
end
# IMP-0d7071dc03f7: :stopping is included so a failed "stop" can revert
# to running (InstanceControlService#revert_status) and so an
# out-of-band "running" report during a stalled stop reconciles
# correctly — same shape as :rebooting already being here for a
# failed reboot's revert.
#
# IMP-42cf03360656: :error is included so a heartbeat that resumes
# after a transient partition (or the presumed-dead reap's running ->
# error correction — see FleetDecisionEngine#reap_presumed_dead!) can
# self-heal the instance back to running. Without :error here,
# may_mark_running? was false for every errored row, so
# StatusController#heartbeat's `mark_running! if may_mark_running?`
# silently no-op'd and a perfectly healthy instance stayed stranded in
# :error forever — physical instances (no CloudSync overwrite to
# accidentally repair them) needed a manual operator "start".
# F1 guard (IMP 019fe4c4-b373): a heartbeat can only prove a VM is
# running — WHOSE VM it is depends entirely on the identity seed it
# booted with. When a seed-contaminated VM heartbeats as an instance
# whose provisioning never completed (no cloud_instance_id), the
# self-heal below manufactured a "running" instance the provider has
# never seen. Cloud/dynamic rows therefore need a provider identity
# before any transition to :running; physical instances have no cloud
# id by nature and keep the IMP-42cf03360656 stranded-row self-heal.
event :mark_running do
transitions from: [ :starting, :stopping, :rebooting, :provisioning, :pending, :error ], to: :running,
guard: :provider_identity_present?
end
# IMP-0d7071dc03f7: :starting is included so a failed "start" can
# revert to stopped (InstanceControlService#revert_status) — mirrors
# :running already being here for out-of-band stop detection while
# a row sits in a running-adjacent transitional state.
event :mark_stopped do
transitions from: [ :stopping, :starting, :running ], to: :stopped
end
event :mark_terminated do
transitions from: [ :terminated, :stopped, :running, :error ], to: :terminated
end
# Undo a terminate stamp whose provider action then FAILED. The terminate
# event has no transitional state — it lands directly on :terminated
# before the provider call runs — so a provider-side failure needs a way
# back out, or the row records a destroy that never happened (an orphaned,
# still-billable instance nothing revisits). Only
# InstanceControlService#revert_status calls this; a row whose terminate
# actually succeeded must stay :terminated.
event :revert_termination do
transitions from: :terminated, to: :error
end
event :mark_errored do
transitions from: [ :pending, :provisioning, :starting, :running, :stopping, :rebooting ], to: :error
end
end
# Guard for mark_running (F1): cloud/dynamic instances must carry the
# provider-confirmed identity before any heartbeat can flip them running —
# a row without cloud_instance_id provably never completed provisioning,
# so the "instance" heartbeating under its identity is some OTHER machine
# holding its seed. Physical instances have no cloud id by nature.
def provider_identity_present?
return true unless %w[cloud dynamic].include?(variety.to_s)
cloud_instance_id.present?
end
# M4 audit trail — `prepend` so our overrides take precedence over the
# bang methods AASM defines directly on the class. `super` inside our
# overrides resolves to AASM's implementation, which performs the
# actual state transition; we then write the audit row.
prepend System::LifecycleAuditable
# Scopes
# === Operator ops hold ===
#
# A hold blocks the platform from STARTING this instance while offline work
# is happening on its disks. See the migration for the incident that
# motivated it: a start that arrived 30s after a stop, while the hypervisor
# still had the guest's filesystem mounted read-write, silently truncated a
# file to zero bytes in the guest's view.
#
# Expiry ALERTS, it does not release. `held?` stays true past
# ops_hold_expires_at on purpose — a hold that lifts itself part-way through
# maintenance is worse than no hold, because the operator believes they are
# protected. Release is always explicit.
scope :ops_held, -> { where.not(ops_hold_at: nil) }
def ops_held? = ops_hold_at.present?
def ops_hold_expired?
ops_held? && ops_hold_expires_at.present? && ops_hold_expires_at.past?
end
# Human-facing single line for refusal messages and status output. The
# "who and why" is the whole reason this is a lease rather than a flag.
def ops_hold_summary
return nil unless ops_held?
who = ops_held_by&.email.presence || ops_hold_by_id.presence || "unknown"
since = ops_hold_at.iso8601
base = "held by #{who} since #{since}"
base += " — #{ops_hold_reason}" if ops_hold_reason.present?
base += " (lease expired #{ops_hold_expires_at.iso8601}; still held, release is explicit)" if ops_hold_expired?
base
end
scope :cloud, -> { where(variety: "cloud") }
scope :physical, -> { where(variety: "physical") }
scope :dynamic, -> { where(variety: "dynamic") }
scope :pending, -> { where(status: "pending") }
scope :provisioning, -> { where(status: "provisioning") }
scope :running, -> { where(status: "running") }
scope :stopped, -> { where(status: "stopped") }
scope :terminated, -> { where(status: "terminated") }
scope :errored, -> { where(status: "error") }
scope :active, -> { where(status: ACTIVE_STATUSES) }
# Capacity-metric liveness — see LIVE_REPLICA_STATUSES for why this is a
# second definition rather than a reuse of `active` (which omits the
# transitional states).
scope :live_replicas, -> { where(status: LIVE_REPLICA_STATUSES) }
# Claim-by-ID fleet flow: a physical instance pre-registered by an operator,
# still pending and not yet bound to a device, is eligible to be claimed by
# a booting device that presents its ID (baked into /boot/identity.cfg).
# Once claimed (claimed_at set) it drops out of this scope — single-bind,
# no re-claim, so a leaked ID can't hijack an already-provisioned device.
scope :claimable, -> { physical.where(status: "pending", claimed_at: nil) }
# Phase O2 — network profile scopes (used by FleetAutonomyService and
# the heavyweight-host dashboards to filter the fleet by which
# BridgeApplier the agent runs).
scope :lightweight_profile, -> { where(network_profile: "lightweight") }
scope :heavyweight_profile, -> { where(network_profile: "heavyweight") }
# Slice 7 — pool membership scopes
scope :pool_warming, -> { where(pool_state: "warming") }
scope :pool_ready, -> { where(pool_state: "ready") }
scope :pool_claimed, -> { where(pool_state: "claimed") }
scope :pool_draining, -> { where(pool_state: "draining") }
scope :pool_errored, -> { where(pool_state: "errored") }
scope :in_any_pool, -> { where.not(instance_pool_id: nil) }
scope :not_in_pool, -> { where(instance_pool_id: nil) }
# IMP-9cc83aa64bff — a terminated instance never returns, so every task
# still queued against it is unrunnable from that moment.
#
# ON THE COLUMN, NOT ONLY THE AASM EVENT. `terminated` reaches this column
# by routes the state machine never sees: System::CloudSyncService#
# sync_node_instances writes `status:` straight from a per-instance provider
# read with a bare update!, and NodeApi::StatusController#update writes any
# member of STATUSES that an agent reports. (The hourly region sweep, the path
# that usually notices a VM has gone, confirms through mark_terminated! since
# IMP-ed10c0c4577c.) An `after` hook on mark_terminated would miss both, which
# is the "sole writer" trap: the question is who writes the COLUMN, not who
# calls the method.
#
# ...BUT NOT EVERY WRITE OF `terminated` IS A TERMINATION. See
# #confirmed_termination? — the distinction that keeps this from destroying
# live work on every failed terminate.
after_commit :cancel_unrunnable_tasks!, on: :update, if: :confirmed_termination?
# IMP-c9adb5a71dca — the cordon marker (`config["cordon"]`, see
# System::InstanceCordonService) read through its single author. Every
# reader that is not the allocator's own pool_state fence — the replica
# reconciler's live count and victim order, #mark_pool_ready!,
# InstancePoolService#release!, and the Scaling panel's actual/cordoned
# counts (deployments_controller#compute_actual_replicas, which reads the
# SAME pair so the panel and the reconciler cannot disagree on a row —
# IMP-3d4058389afa) — comes through here, so the marker's
# shape is defined in ONE file. The scopes are the SQL twins of
# #cordoned?; instance_cordon_service_spec pins that they agree.
scope :cordoned, -> { ::System::InstanceCordonService.cordoned_relation(all) }
scope :not_cordoned, -> { ::System::InstanceCordonService.not_cordoned_relation(all) }
# Variety predicates
VARIETIES.each do |variety_name|
define_method("#{variety_name}?") { variety == variety_name }
end
# Slice 7 — pool state predicates
POOL_STATES.each do |pool_state_name|
define_method("pool_#{pool_state_name}?") { pool_state == pool_state_name }
end
def in_pool?
instance_pool_id.present?
end
# The guest name every vmid-keyed provider call must verify before it acts.
# provider_guest_name is the stamped value; a pool member from before it was
# captured falls back to its own row name, which is exactly what
# ProvisioningService passed the provider as the guest name at create (the
# same derivation InstancePoolService#provider_guest_confirmed_gone? records).
# Non-pool rows keep the stamped value only (nil = the provider's legacy,
# unguarded behaviour), since their row names may have been edited by hand.
def expected_guest_name
provider_guest_name.presence || (in_pool? ? name.presence : nil)
end
# True when an operator has placed a cordon on this instance
# (system_cordon_instance). See the .cordoned / .not_cordoned scopes.
def cordoned?
::System::InstanceCordonService.cordoned?(self)
end
# Server-side SDWAN reachability gate for pool promotion (native-CI pool
# reliability fix). A pool builder enrolled onto an SDWAN overlay at
# provision time (ProvisioningService#auto_enroll_sdwan_peer! creates an
# Sdwan::Peer whenever the pool declares metadata["sdwan_network_id"]) is
# NOT usable the instant it first heartbeats: its WireGuard tunnel to the
# overlay hub may not have handshaked yet, so overlay-only names — e.g. the
# CI git registry <gitea-host>, which resolves to an overlay address —
# are unreachable and a dispatched `git clone` times out. Gating readiness
# on the tunnel being live is what keeps a still-isolating builder from
# being acquired and handed a build it can't fetch.
#
# The agent already reports per-peer handshake state to
# NodeApi::SdwanController#report, which persists Sdwan::Peer#last_handshake_at
# — so the platform reliably knows, with NO agent change, whether the overlay
# tunnel is live. Read last_handshake_at directly (against the model's healthy
# window) rather than the recompute-lagged status column, mirroring
# Fleet::Sensors::SdwanReachabilitySensor.
#
# - No Sdwan::Peer rows → true. The instance is not overlay-attached
# (non-SDWAN pool); overlay reachability is not a precondition, so this
# preserves the pre-existing "ready on first heartbeat" behavior.
# - Has peer rows → true only once at least one peer has a fresh
# WireGuard handshake (last_handshake_at within Sdwan::Peer's healthy
# window) — i.e. the overlay is actually up from this node.
#
# Guarded by defined?(::Sdwan::Peer): if the SDWAN layer is somehow absent,
# degrade to "ready" (no worse than the pre-gate behavior) rather than
# stranding pool promotion.
def sdwan_overlay_ready?
return true unless defined?(::Sdwan::Peer)
peers = ::Sdwan::Peer.where(node_instance_id: id)
return true unless peers.exists?
peers.where("last_handshake_at >= ?", ::Sdwan::Peer::HEALTHY_HANDSHAKE_WINDOW.ago).exists?
end
# Module names whose actual composition on the node — reported in the
# agent heartbeat's running_module_digests — is a precondition for a pool
# member to become acquirable. A native-CI builder leased before its
# module-forge union is mounted execs a module-forge-provided build script
# that isn't on the union yet, and the agent dead-ends the ci.module_build
# task at "unknown_command" (agent tasks/loop.go). Keeping the builder
# "warming" until the module is reported running closes that race — the
# module-composition analog of #sdwan_overlay_ready?. Kept in step with the
# module-forge module the native build path gates on
# (Api::V1::System::NodeApi::ConfigController::MODULE_FORGE_MODULE_NAME).
POOL_READINESS_REQUIRED_MODULE_NAMES = %w[module-forge].freeze
# Server-side module-composition gate for pool promotion (native-CI pool
# reliability fix — improvement 019f6ecc-7e0e). running_module_digests is
# the agent heartbeat's map of node_module_id → mounted oci_digest, so the
# platform knows exactly which of a node's assigned modules are actually
# composed. A builder whose node is assigned a build-critical module (see
# POOL_READINESS_REQUIRED_MODULE_NAMES) is only ready once that module
# appears there.
#
# - Node assigned none of the required modules → true. Every non-builder
# pool is unaffected; overlay + heartbeat liveness alone decide, exactly
# as before this gate (mirrors how #sdwan_overlay_ready? degrades for a
# non-SDWAN pool).
# - Node assigned one/more → true only once EACH is reported composed in
# running_module_digests (keyed by node_module_id, matching
# Fleet::PromotionCriteria's own digest lookups).
def required_modules_composed?
return true unless node
required = node.node_modules.where(name: POOL_READINESS_REQUIRED_MODULE_NAMES)
required_ids = required.pluck(:id)
return true if required_ids.empty?
running = running_module_digests || {}
required_ids.all? { |mid| running.key?(mid.to_s) }
end
# Module drift for THIS instance: what the agent reports mounted
# (running_module_digests, keyed by node_module_id) versus the current
# version of every module its node is assigned.
#
# missing — assigned, nothing reported mounted for it
# extra — reported mounted, no longer assigned
# mismatched — reported mounted at a digest other than current
# unverifiable — assigned, but the version its plane is served carries
# no oci_digest (or the plane is served no version), so
# there is nothing to compare against. `have` is the
# mounted digest, nil when nothing is mounted for it.
#
# Offer 01a0c60b-ee60: those modules used to be dropped from the
# assignment, so a running copy fell into `extra` and DriftRemediateExecutor
# planned to DETACH an assigned module (live: a self-hosted control plane's
# only reverse proxy). They are assigned, so never `extra`; their digest is
# unknown, so never `missing`/`mismatched` either. `unverifiable` does NOT
# count toward #module_drifted?: every drift consumer remediates with
# sync_modules, which cannot supply a digest the version record lacks, so
# counting it would hold a correctly composed node in permanent drift. It
# is surfaced by name instead (system_drift_report's unverifiable bucket;
# the compliance snapshot and drift_check, which attest rather than
# remediate, name it as its own bucket and never count it converged).
# `node.node_modules` carries no `enabled` filter while the node API serves
# only enabled modules, so a disabled digestless module still mounted
# lands here rather than in `extra` (the same pre-existing divergence
# applies to a disabled module that has a digest: it counts as assigned).
#
# The one definition of module drift. It was copied into
# SystemFleetTool#drift_report and ModuleDriftSensor, and a third caller
# (PlatformMaintenanceExecutor's deployment-scoped drift_check) shipped as
# a hardcoded `false` instead — IMP-0d106a152c47. Those three now call this.
#
# A fourth copy lived in Compliance::ComplianceSnapshotService
# #collect_drift_summary and compared KEY SETS only — `missing` and `extra`
# but no `mismatched` — so an instance running a stale digest of every
# module it was assigned counted as `reconciled` in the compliance evidence
# document. IMP-29b38f6f48b2 deleted that copy; the service now calls
# #module_drifted?. All four call sites are converged.
#
# A new caller belongs here rather than in a fifth variant.
#
# Scope note: this measures the instance against its NODE's assignments,
# which is the layer `system_refresh_instance_modules` (task
# `sync_modules`) actually reconciles — so anything reported here is
# remediable. Whether the node's assignments still match its TEMPLATE is a
# separate question this does not answer.
def module_drift
# `node` is a required belongs_to — no nil branch here on purpose: a
# rescue-to-empty would report "no drift" for a broken row, which is the
# failure mode this method was written to end.
# The version this instance's PLANE is served (Environment campaign,
# incr. 4): a pinned plane's pin, else current_version — the same read
# the node API answers, so a pinned instance is not "mismatched" merely
# because a publish moved the fleet-global pointer.
assigned = {}
digestless = []
node.node_modules.includes(:current_version).each do |m|
digest = m.served_version_for(environment)&.oci_digest
if digest
assigned[m.id] = digest
else
digestless << m.id
end
end
running = running_module_digests || {}
unverifiable = digestless.index_with { |id| { have: running[id.to_s] || running[id] } }
missing = assigned.reject { |id, _| running.key?(id.to_s) || running.key?(id) }
extra = running.reject do |id, _|
assigned.key?(id) || assigned.key?(id.to_s) || unverifiable.key?(id) || unverifiable.key?(id.to_s)
end
mismatched = assigned.each_with_object({}) do |(id, want), acc|
have = running[id.to_s] || running[id]
acc[id] = { want: want, have: have } if have && have != want
end
{ missing: missing, extra: extra, mismatched: mismatched, unverifiable: unverifiable }
end
# `unverifiable` is left out on purpose — see #module_drift.
def module_drifted?
drift = module_drift
drift[:missing].any? || drift[:extra].any? || drift[:mismatched].any?
end
# Idempotent transition: warming → ready (called from provisioning
# success callback / heartbeat success). Returns true if the
# transition succeeded, false if state didn't allow it (e.g. already
# ready, claimed, draining, etc.), if the instance's SDWAN overlay
# tunnel is not yet live (#sdwan_overlay_ready?) — a builder acquired
# before its overlay handshake completes cannot reach overlay-only build
# inputs (<gitea-host>) — or if a build-critical module the node is
# assigned is not yet composed (#required_modules_composed?), so it must
# stay "warming" until it can.
#
# IMP-c9adb5a71dca — a CORDONED warming member is never admitted: the
# heartbeat that would have promoted it lands it on "draining" instead
# (InstanceCordonService.fence_admission!, which also records "ready" as
# the state an uncordon restores), and this returns false so the caller
# emits no member_ready event for a member that is not ready. The cordon
# pre-flight refuses a warming member today, so this arm is reached only
# by a marker that arrived another way — but an admission writer that
# trusted the pre-flight would be exactly the "read in none" defect the
# marker was designed against.
def mark_pool_ready!
return false unless in_pool?
return false unless pool_state == "warming"
return false unless sdwan_overlay_ready?
return false unless required_modules_composed?
if cordoned?
::System::InstanceCordonService.fence_admission!(self, from: "warming")
return false
end
update!(pool_state: "ready")
true
end
# Promotes a "warming" pool member to "ready" and emits a FleetEvent so
# the promotion is fleet-observable — the counterpart to mark_pool_ready!
# for callers that also want observability (StatusController#heartbeat is
# the only caller today: a heartbeat is the platform's sole evidence that
# a pool-provisioned instance actually enrolled and is alive, so it's the
# natural trigger to flip warming → ready). No-op (returns false, no
# event) for a non-pool instance or one not currently "warming" —
# mark_pool_ready! already provides that guard, so this is safe to call
# unconditionally on every heartbeat, not just the first.
#
# Best-effort event emission: a FleetEvent persistence hiccup must never
# raise back into the heartbeat response the agent is waiting on.
def promote_pool_ready!
return false unless mark_pool_ready!
::System::Fleet::EventBroadcaster.emit!(
account: account,
kind: "system.pool.member_ready",
severity: :low,
payload: { pool_id: instance_pool_id, instance_id: id },
source: "node_instance#promote_pool_ready!",
node_instance_id: id
)
true
rescue StandardError => e
Rails.logger.warn("[NodeInstance] pool promotion event emit failed for #{id}: #{e.class}: #{e.message}")
true
end
# Idempotent transition: any pool state → errored. Reaper recycles
# errored members into terminated state on the next tick.
def mark_pool_errored!
return false unless in_pool?
return false if %w[claimed draining].include?(pool_state)
update!(pool_state: "errored")
true
end
# Pool slot duration accessors used by the reaper to decide
# health-check + recycling cadence.
def pool_warming_duration
return nil unless pool_warming_started_at
(Time.current - pool_warming_started_at).to_i
end
def pool_idle_duration
return nil unless pool_state == "ready" && pool_warming_started_at
(Time.current - pool_warming_started_at).to_i
end
# Check if instance is active (not terminated or error)
def active?
!terminated? && !error?
end
# === Lifecycle predicates ===
# Operator-friendly aliases for AASM `may_*?` guards. They answer the
# question "is this control action available right now?" given the
# instance's current AASM state. Used by UI buttons and integration
# specs that read more naturally as `instance.can_stop?` than
# `instance.may_stop?`.
def can_start?
may_start?
end
def can_stop?
may_stop?
end
def can_reboot?
may_reboot?
end
def can_terminate?
may_terminate?
end
# === Geolocation Methods ===
def has_coordinates?
latitude.present? && longitude.present?
end
def coordinates
return nil unless has_coordinates?
{ latitude: latitude, longitude: longitude }
end
def set_coordinates!(lat, lng)
update!(latitude: lat, longitude: lng)
end
# === Network Methods ===
def has_mac_address?
mac_address.present?
end
def normalized_mac_address
return nil unless has_mac_address?
mac_address.upcase.gsub("-", ":")
end
def netboot_enabled?
physical? && private_netboot == true
end
def enable_netboot!
return false unless physical?
update!(private_netboot: true)
end
def disable_netboot!
update!(private_netboot: false)
end
# === Network profile suggestion (Phase O2) ===
#
# Pure function — reads only this row's hardware fields and returns
# the recommended `network_profile` value. Does NOT mutate state and
# does NOT consult the persisted `network_profile` column (callers
# already know what's persisted; this answers "what *should* it be").
#
# Operators / FleetAutonomyService / the provisioning service call
# this to decide whether to promote a host to the heavyweight stack.
# The persisted column is the source of truth — this is advisory.
#
# Recommendation rules (mirror Part 7 of the dual-profile plan):
# 1. amd64 with ≥4GB RAM → heavyweight
# 2. arm64 + Pi 5 hardware hint (any RAM) → heavyweight
# 3. arm64 + Pi 4 hardware hint with ≥8GB RAM → heavyweight
# 4. Anything else (Pi 4 ≤4GB, Pi Zero 2W, Alpine aarch64,
# hardware fields not available, etc.) → lightweight
#
# The "hardware fields not available" branch is the safety net: when
# we can't prove the host has headroom for the OVS+OVN daemons, we
# default to lightweight because the lightweight profile works on
# every supported host.
def suggest_network_profile
arch = (architecture || "").downcase
mb = available_memory_mb
hw = hardware_model_hint
case arch
when "amd64", "x86_64"
return "heavyweight" if mb && mb >= HEAVYWEIGHT_MIN_MEMORY_MB
when "arm64", "aarch64"
return "heavyweight" if HEAVYWEIGHT_PI_MODELS.include?(hw)
if PI4_HARDWARE_MODELS.include?(hw) && mb && mb >= HEAVYWEIGHT_PI4_MIN_MEMORY_MB
return "heavyweight"
end
end
"lightweight"
end
# IMP-57e9a90598ee — the production writer suggest_network_profile never
# had. Called from the heartbeat endpoint immediately after
# record_heartbeat! persisted the agent-observed architecture — the last
# fact the suggester needs — and BEFORE mark_running!, so it fires only
# on the heartbeat that brings a pre-running instance up.
#
# first_contact: the caller must declare whether this is the instance's
# FIRST heartbeat ever (last_heartbeat_at was nil before this request
# stamped it). The caller captures that, because by the time this runs
# record_heartbeat! has already written the column. Without this guard,
# "first heartbeat" was really "first stamp-less non-running heartbeat":
# every pre-existing instance carries no stamp, so the whole legacy fleet
# would auto-classify — and eligible hosts silently promote to
# heavyweight — on its next pass through a non-running state (a reboot, a
# presumed-dead self-heal). That is the fleet-wide profile wave this
# method's guards exist to prevent, merely amortised over reboots.
#
# Guards, each load-bearing:
# - only the instance's first contact ever (first_contact): legacy
# hosts NEVER auto-classify; operators promote them explicitly via
# system_update_instance.
# - only a pre-running instance (may_mark_running?): an established
# fleet must never profile-flip on deploy.
# - only once (the network_profile_source stamp): a classification is
# a birth event, not a reconcile loop.
# - never over an operator declaration ("operator" source wins).
# - never a demotion: the suggester's "lightweight" is its safe
# default, not evidence a heavyweight host lost headroom.
#
# (architecture needs no guard: the column is NOT NULL + CHECKed, so an
# instance without one is unrepresentable; the unknown-fact path is
# available_memory_mb returning nil, which the suggester already treats
# as "cannot prove headroom" → lightweight.)
def classify_network_profile!(first_contact:)
return false unless first_contact
return false unless may_mark_running?
return false if config&.dig("network_profile_source").present?
suggestion = suggest_network_profile
promote = suggestion == "heavyweight" && network_profile == "lightweight"
# Column and config document written SEPARATELY — see
# System::ConfigDocument. This runs INSIDE the heartbeat request cycle,
# alongside the four telemetry writers, so folding the jsonb into the
# same `update!` would have this path erase its own siblings' documents.
transaction do
update!(network_profile: promote ? "heavyweight" : network_profile)
merge_config!("network_profile_source" => "suggested_first_heartbeat")
end
if promote
::System::Fleet::EventBroadcaster.emit!(
account: account,
kind: "system.instance.network_profile_promoted",
severity: :low,
payload: {
instance_id: id,
suggested: suggestion,
architecture: architecture,
memory_mb: available_memory_mb
},
source: "node_instance#classify_network_profile!",
node_instance_id: id
)
end
promote
rescue StandardError => e
Rails.logger.warn("[NodeInstance] network profile classification failed for #{id}: #{e.class}: #{e.message}")
false
end
# === Runtime telemetry (Golden Eclipse M0.M) ===
# Used by powernode-agent heartbeat path (M0.O / M0.P / M2). Maintains