confctl 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,9 @@ module ConfCtl
16
16
  # @return [String]
17
17
  attr_reader :toplevel
18
18
 
19
+ # @return [String]
20
+ attr_reader :auto_rollback
21
+
19
22
  # @return [Array<String>]
20
23
  attr_reader :swpin_names
21
24
 
@@ -29,22 +32,28 @@ module ConfCtl
29
32
  # @return [Boolean]
30
33
  attr_accessor :current
31
34
 
35
+ # @return [String, nil]
36
+ attr_reader :kernel_version
37
+
32
38
  # @param host [String]
33
39
  def initialize(host)
34
40
  @host = host
35
41
  end
36
42
 
37
43
  # @param toplevel [String]
44
+ # @param auto_rollback [String]
38
45
  # @param swpin_paths [Hash]
39
46
  # @param swpin_specs [Hash]
40
47
  # @param date [Time]
41
- def create(toplevel, swpin_paths, swpin_specs, date: nil)
48
+ def create(toplevel, auto_rollback, swpin_paths, swpin_specs, date: nil)
42
49
  @toplevel = toplevel
50
+ @auto_rollback = auto_rollback
43
51
  @swpin_names = swpin_paths.keys
44
52
  @swpin_paths = swpin_paths
45
53
  @swpin_specs = swpin_specs
46
54
  @date = date || Time.now
47
55
  @name = date.strftime('%Y-%m-%d--%H-%M-%S')
56
+ @kernel_version = extract_kernel_version
48
57
  end
49
58
 
50
59
  # @param name [String]
@@ -53,6 +62,7 @@ module ConfCtl
53
62
 
54
63
  cfg = JSON.parse(File.read(config_path))
55
64
  @toplevel = cfg['toplevel']
65
+ @auto_rollback = cfg['auto_rollback']
56
66
 
57
67
  @swpin_names = []
58
68
  @swpin_paths = {}
@@ -69,6 +79,7 @@ module ConfCtl
69
79
  end
70
80
 
71
81
  @date = Time.iso8601(cfg['date'])
82
+ @kernel_version = extract_kernel_version
72
83
  rescue StandardError => e
73
84
  raise Error, "invalid generation '#{name}': #{e.message}"
74
85
  end
@@ -76,6 +87,7 @@ module ConfCtl
76
87
  def save
77
88
  FileUtils.mkdir_p(dir)
78
89
  File.symlink(toplevel, toplevel_path)
90
+ File.symlink(auto_rollback, auto_rollback_path)
79
91
 
80
92
  swpin_paths.each do |name, path|
81
93
  File.symlink(path, swpin_path(name))
@@ -85,6 +97,7 @@ module ConfCtl
85
97
  f.puts(JSON.pretty_generate({
86
98
  date: date.iso8601,
87
99
  toplevel:,
100
+ auto_rollback:,
88
101
  swpins: swpin_paths.to_h do |name, path|
89
102
  [name, { path:, spec: swpin_specs[name].as_json }]
90
103
  end
@@ -97,6 +110,13 @@ module ConfCtl
97
110
  def destroy
98
111
  remove_gcroot
99
112
  File.unlink(toplevel_path)
113
+
114
+ begin
115
+ File.unlink(auto_rollback_path)
116
+ rescue Errno::ENOENT
117
+ # Older generations might not have auto_rollback
118
+ end
119
+
100
120
  swpin_paths.each_key { |name| File.unlink(swpin_path(name)) }
101
121
  File.unlink(config_path)
102
122
  Dir.rmdir(dir)
@@ -104,6 +124,7 @@ module ConfCtl
104
124
 
105
125
  def add_gcroot
106
126
  GCRoot.add(gcroot_name('toplevel'), toplevel_path)
127
+ GCRoot.add(gcroot_name('auto_rollback'), auto_rollback_path)
107
128
  swpin_paths.each_key do |name|
108
129
  GCRoot.add(gcroot_name("swpin.#{name}"), toplevel_path)
109
130
  end
@@ -111,6 +132,7 @@ module ConfCtl
111
132
 
112
133
  def remove_gcroot
113
134
  GCRoot.remove(gcroot_name('toplevel'))
135
+ GCRoot.remove(gcroot_name('auto_rollback'))
114
136
  swpin_paths.each_key do |name|
115
137
  GCRoot.remove(gcroot_name("swpin.#{name}"))
116
138
  end
@@ -130,6 +152,10 @@ module ConfCtl
130
152
  @toplevel_path ||= File.join(dir, 'toplevel')
131
153
  end
132
154
 
155
+ def auto_rollback_path
156
+ @auto_rollback_path ||= File.join(dir, 'auto_rollback')
157
+ end
158
+
133
159
  def swpin_path(name)
134
160
  File.join(dir, "#{name}.swpin")
135
161
  end
@@ -141,5 +167,20 @@ module ConfCtl
141
167
  def gcroot_name(file)
142
168
  "#{escaped_host}-generation-#{name}-#{file}"
143
169
  end
170
+
171
+ def extract_kernel_version
172
+ # `kernel` is for NixOS/vpsAdminOS and also carried NixOS machines (netboot)
173
+ # `bzImage` is for carried vpsAdminOS machines (netboot)
174
+ %w[kernel bzImage].each do |v|
175
+ link = File.readlink(File.join(toplevel, v))
176
+ next unless %r{\A/nix/store/[^-]+-linux-([^/]+)} =~ link
177
+
178
+ return ::Regexp.last_match(1)
179
+ rescue Errno::ENOENT, Errno::EINVAL
180
+ next
181
+ end
182
+
183
+ nil
184
+ end
144
185
  end
145
186
  end
@@ -55,6 +55,16 @@ module ConfCtl
55
55
  index[name]
56
56
  end
57
57
 
58
+ # @param offset [Integer] 0 = current/last, 1 = first (oldest), -1 = before last
59
+ # @return [Generation::Build]
60
+ def at_offset(offset)
61
+ if offset == 0
62
+ generations.last
63
+ else
64
+ generations[offset - 1]
65
+ end
66
+ end
67
+
58
68
  def each(&)
59
69
  generations.each(&)
60
70
  end
@@ -1,19 +1,22 @@
1
1
  module ConfCtl
2
2
  class Generation::Host
3
- attr_reader :host, :profile, :id, :toplevel, :date, :current
3
+ attr_reader :host, :profile, :id, :toplevel, :date, :kernel_version, :current
4
4
 
5
- # @param host [String]
5
+ # @param machine [Machine]
6
6
  # @param profile [String]
7
7
  # @param id [Integer]
8
8
  # @param toplevel [String]
9
9
  # @param date [Time]
10
+ # @param kernel_version [String, nil]
10
11
  # @param mc [MachineControl]
11
- def initialize(host, profile, id, toplevel, date, current: false, mc: nil)
12
- @host = host
12
+ def initialize(machine, profile, id, toplevel, date, kernel_version, current: false, mc: nil)
13
+ @host = machine.name
14
+ @machine = machine
13
15
  @profile = profile
14
16
  @id = id
15
17
  @toplevel = toplevel
16
18
  @date = date
19
+ @kernel_version = kernel_version
17
20
  @current = current
18
21
  @mc = mc
19
22
  end
@@ -25,7 +28,8 @@ module ConfCtl
25
28
  def destroy
26
29
  raise 'machine control not available' if mc.nil?
27
30
 
28
- mc.execute('nix-env', '-p', profile, '--delete-generations', id.to_s)
31
+ env_cmd = @machine.carried? ? 'carrier-env' : 'nix-env'
32
+ mc.execute(env_cmd, '-p', profile, '--delete-generations', id.to_s)
29
33
  end
30
34
 
31
35
  protected
@@ -1,24 +1,33 @@
1
1
  module ConfCtl
2
2
  class Generation::HostList
3
+ # @parma machine [Machine]
3
4
  # @param mc [MachineControl]
4
5
  # @param profile [String]
5
6
  # @return [Generation::HostList]
6
- def self.fetch(mc, profile:)
7
+ def self.fetch(machine, mc, profile:)
7
8
  out, = mc.bash_script(<<~END)
8
9
  realpath #{profile}
9
10
 
10
11
  for generation in `ls -d -1 #{profile}-*-link` ; do
11
- echo "$generation;$(readlink $generation);$(stat --format=%Y $generation)"
12
+ echo -n "$generation;"
13
+ echo -n "$(readlink $generation);"
14
+ echo -n "$(stat --format=%Y $generation);"
15
+
16
+ for kernel_file in kernel bzImage ; do
17
+ [ -h "$generation/$kernel_file" ] && echo -n $(readlink "$generation/$kernel_file")
18
+ done
19
+
20
+ echo
12
21
  done
13
22
  END
14
23
 
15
- list = new(mc.machine.name)
24
+ list = new(machine.name)
16
25
  lines = out.strip.split("\n")
17
26
  current_path = lines.shift
18
27
  id_rx = /^#{Regexp.escape(profile)}-(\d+)-link$/
19
28
 
20
29
  lines.each do |line|
21
- link, path, created_at = line.split(';')
30
+ link, path, created_at, kernel = line.split(';')
22
31
 
23
32
  if id_rx =~ link
24
33
  id = ::Regexp.last_match(1).to_i
@@ -27,12 +36,18 @@ module ConfCtl
27
36
  next
28
37
  end
29
38
 
39
+ kernel_version =
40
+ if kernel && %r{\A/nix/store/[^-]+-linux-([^/]+)} =~ kernel
41
+ ::Regexp.last_match(1)
42
+ end
43
+
30
44
  list << Generation::Host.new(
31
- mc.machine.name,
45
+ machine,
32
46
  profile,
33
47
  id,
34
48
  path,
35
49
  Time.at(created_at.to_i),
50
+ kernel_version,
36
51
  current: path == current_path,
37
52
  mc:
38
53
  )
@@ -15,6 +15,9 @@ module ConfCtl
15
15
  # @return [Time]
16
16
  attr_reader :date
17
17
 
18
+ # @return [String, nil]
19
+ attr_reader :kernel_version
20
+
18
21
  # @return [Boolean]
19
22
  attr_reader :current
20
23
 
@@ -37,11 +40,13 @@ module ConfCtl
37
40
  @name = build_generation.name
38
41
  @toplevel = build_generation.toplevel
39
42
  @date = build_generation.date
43
+ @kernel_version = build_generation.kernel_version
40
44
  @current ||= build_generation.current
41
45
  elsif host_generation
42
46
  @name = host_generation.approx_name
43
47
  @toplevel = host_generation.toplevel
44
48
  @date = host_generation.date
49
+ @kernel_version = host_generation.kernel_version
45
50
  @current ||= host_generation.current
46
51
  else
47
52
  raise ArgumentError, 'set build or host'
@@ -46,6 +46,16 @@ module ConfCtl
46
46
  generations.each(&)
47
47
  end
48
48
 
49
+ # @param offset [Integer] 0 = current/last, 1 = first (oldest), -1 = before last
50
+ # @return [Generation::Unified]
51
+ def at_offset(offset)
52
+ if offset == 0
53
+ generations.last
54
+ else
55
+ generations[offset - 1]
56
+ end
57
+ end
58
+
49
59
  def delete_if(&)
50
60
  generations.delete_if(&)
51
61
  end
@@ -116,6 +116,10 @@ module ConfCtl
116
116
  end
117
117
  end
118
118
 
119
+ def auto_rollback?
120
+ meta.fetch('autoRollback', {}).fetch('enable', true)
121
+ end
122
+
119
123
  def health_checks
120
124
  return @health_checks if @health_checks
121
125
 
@@ -6,10 +6,11 @@ module ConfCtl
6
6
  attr_reader :machine
7
7
 
8
8
  # @param machine [Machine]
9
- def initialize(machine)
9
+ # @param logger [#<<]
10
+ def initialize(machine, logger: nil)
10
11
  @machine = machine
11
12
  @extra_ssh_opts = []
12
- @cmd = SystemCommand.new
13
+ @cmd = SystemCommand.new(logger:)
13
14
  end
14
15
 
15
16
  # Try to open SSH connection
@@ -90,6 +90,10 @@ module ConfCtl
90
90
  !empty?
91
91
  end
92
92
 
93
+ def to_a
94
+ @machines.values
95
+ end
96
+
93
97
  # @return [Array<HealthChecks::Base>]
94
98
  def health_checks
95
99
  checks = []
@@ -103,7 +103,7 @@ module ConfCtl
103
103
 
104
104
  if generations
105
105
  begin
106
- @generations = Generation::HostList.fetch(mc, profile: machine.profile)
106
+ @generations = Generation::HostList.fetch(machine, mc, profile: machine.profile)
107
107
  rescue TTY::Command::ExitError
108
108
  return
109
109
  end
data/lib/confctl/nix.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'confctl/utils/file'
2
+ require 'digest'
2
3
  require 'etc'
3
4
  require 'json'
4
5
  require 'securerandom'
@@ -137,19 +138,19 @@ module ConfCtl
137
138
  end
138
139
  end
139
140
 
140
- # Evaluate swpins for host
141
- # @param host [String]
142
- # @return [Hash]
143
- def eval_host_swpins(host)
141
+ # Evaluate swpins for hosts
142
+ # @param hosts [Array<String>]
143
+ # @return [Hash] host => swpins
144
+ def eval_host_swpins(hosts)
144
145
  with_argument({
145
146
  confDir: conf_dir,
146
147
  build: :evalHostSwpins,
147
- machines: [host]
148
+ machines: hosts
148
149
  }, core_swpins: true) do |arg|
149
150
  out_link = File.join(
150
151
  cache_dir,
151
152
  'build',
152
- "#{ConfCtl.safe_host_name(host)}.swpins"
153
+ "#{Digest::SHA256.hexdigest(hosts.join(','))[0..11]}.swpins"
153
154
  )
154
155
 
155
156
  cmd_args = [
@@ -164,7 +165,7 @@ module ConfCtl
164
165
 
165
166
  out, = cmd.run(*cmd_args)
166
167
 
167
- JSON.parse(File.read(out.strip))[host]
168
+ JSON.parse(File.read(out.strip))
168
169
  end
169
170
  end
170
171
 
@@ -204,15 +205,16 @@ module ConfCtl
204
205
  nb.run(&block)
205
206
 
206
207
  begin
207
- host_toplevels = JSON.parse(File.read(out_link))
208
- host_toplevels.each do |host, toplevel|
208
+ host_results = JSON.parse(File.read(out_link))
209
+ host_results.each do |host, result|
209
210
  host_generations = Generation::BuildList.new(host)
210
- generation = host_generations.find(toplevel, swpin_paths)
211
+ generation = host_generations.find(result['attribute'], swpin_paths)
211
212
 
212
213
  if generation.nil?
213
214
  generation = Generation::Build.new(host)
214
215
  generation.create(
215
- toplevel,
216
+ result['attribute'],
217
+ result['autoRollback'],
216
218
  swpin_paths,
217
219
  host_swpin_specs[host],
218
220
  date: time
@@ -232,35 +234,78 @@ module ConfCtl
232
234
  end
233
235
 
234
236
  # @param machine [Machine]
235
- # @param toplevel [String]
237
+ # @param paths [Array<String>]
236
238
  #
237
239
  # @yieldparam progress [Integer]
238
240
  # @yieldparam total [Integer]
239
241
  # @yieldparam path [String]
240
242
  #
241
243
  # @return [Boolean]
242
- def copy(machine, toplevel, &)
244
+ def copy(machine, paths, &)
243
245
  if machine.localhost?
244
246
  true
245
247
  elsif machine.carried?
246
- cp = NixCopy.new(machine.carrier_machine.target_host, toplevel)
248
+ cp = NixCopy.new(machine.carrier_machine.target_host, paths)
247
249
  cp.run!(&).success?
248
250
  else
249
- cp = NixCopy.new(machine.target_host, toplevel)
251
+ cp = NixCopy.new(machine.target_host, paths)
250
252
  cp.run!(&).success?
251
253
  end
252
254
  end
253
255
 
254
256
  # @param machine [Machine]
255
- # @param toplevel [String]
257
+ # @param generation [Generation::Build]
256
258
  # @param action [String]
257
259
  # @return [Boolean]
258
- def activate(machine, toplevel, action)
259
- args = [File.join(toplevel, 'bin/switch-to-configuration'), action]
260
+ def activate(machine, generation, action)
261
+ args = [File.join(generation.toplevel, 'bin/switch-to-configuration'), action]
260
262
 
261
263
  MachineControl.new(machine).execute!(*args).success?
262
264
  end
263
265
 
266
+ # @param machine [Machine]
267
+ # @param generation [Generation::Build]
268
+ # @param action [String]
269
+ # @return [Boolean]
270
+ def activate_with_rollback(machine, generation, action)
271
+ check_file = File.join('/run', "confctl-confirm-#{SecureRandom.hex(3)}")
272
+ timeout = machine['autoRollback']['timeout']
273
+ logger = NullLogger.new
274
+
275
+ args = [
276
+ generation.auto_rollback,
277
+ '-t', timeout,
278
+ generation.toplevel,
279
+ action,
280
+ check_file
281
+ ]
282
+
283
+ activation_success = nil
284
+
285
+ activation_thread = Thread.new do
286
+ activation_success = MachineControl.new(machine).execute!(*args).success?
287
+ end
288
+
289
+ # Wait for the configuration to be switched
290
+ t = Time.now
291
+
292
+ loop do
293
+ out, = MachineControl.new(machine, logger:).execute!('cat', check_file, '2>/dev/null')
294
+ stripped = out.strip
295
+ break if stripped == 'switched' || ((t + timeout + 10) < Time.now && stripped != 'switching')
296
+
297
+ sleep(1)
298
+ end
299
+
300
+ # Confirm it
301
+ 10.times do
302
+ break if MachineControl.new(machine, logger:).execute!('sh', '-c', "'echo confirmed > #{check_file}'").success?
303
+ end
304
+
305
+ activation_thread.join
306
+ activation_success
307
+ end
308
+
264
309
  # @param machine [Machine]
265
310
  # @param toplevel [String]
266
311
  # @return [Boolean]
@@ -1,10 +1,10 @@
1
1
  module ConfCtl
2
2
  class NixCopy
3
3
  # @param target [String]
4
- # @param store_path [String]
5
- def initialize(target, store_path)
4
+ # @param store_paths [Array<String>]
5
+ def initialize(target, store_paths)
6
6
  @target = target
7
- @store_path = store_path
7
+ @store_paths = store_paths
8
8
  @total = nil
9
9
  @progress = 0
10
10
  end
@@ -22,7 +22,7 @@ module ConfCtl
22
22
  ret = cmd.run!(
23
23
  'nix-copy-closure',
24
24
  '--to', "root@#{target}",
25
- store_path,
25
+ *store_paths,
26
26
  &line_buf.feed_block
27
27
  )
28
28
 
@@ -32,7 +32,7 @@ module ConfCtl
32
32
 
33
33
  protected
34
34
 
35
- attr_reader :target, :store_path
35
+ attr_reader :target, :store_paths
36
36
 
37
37
  def parse_line(line)
38
38
  if @total.nil? && /^copying (\d+) paths/ =~ line
@@ -0,0 +1,7 @@
1
+ module ConfCtl
2
+ class NullLogger
3
+ def <<(_str)
4
+ # do nothing
5
+ end
6
+ end
7
+ end
@@ -5,7 +5,7 @@ module ConfCtl
5
5
  handle :'git-rev'
6
6
 
7
7
  def prefetch_set(args)
8
- super(args)
8
+ super
9
9
  wrap_fetcher
10
10
  end
11
11
 
@@ -2,9 +2,10 @@ require 'tty-command'
2
2
 
3
3
  module ConfCtl
4
4
  module SystemCommand
5
+ # @param logger [#<<]
5
6
  # @return [TTY::Command]
6
- def self.new
7
- TTY::Command.new(output: Logger.instance, color: false)
7
+ def self.new(logger: nil)
8
+ TTY::Command.new(output: logger || Logger.instance, color: false)
8
9
  end
9
10
  end
10
11
  end
@@ -1,3 +1,3 @@
1
1
  module ConfCtl
2
- VERSION = '2.0.0'.freeze
2
+ VERSION = '2.1.0'.freeze
3
3
  end
@@ -0,0 +1,106 @@
1
+ #!@ruby@/bin/ruby
2
+ # Switch to a new system configuration and wait for confctl to confirm
3
+ # connectivity. If confctl is unable to reach the deployed machine, this
4
+ # script will roll back to the previous configuration.
5
+
6
+ require 'optparse'
7
+
8
+ class AutoRollback
9
+ def self.run
10
+ ar = new
11
+ ar.run
12
+ end
13
+
14
+ def run
15
+ puts 'Deploying with auto-rollback'
16
+
17
+ options = parse_options
18
+
19
+ current_system = File.readlink('/run/current-system')
20
+ puts " current system = #{current_system}"
21
+ puts " new system = #{options[:toplevel]}"
22
+ puts " action = #{options[:action]}"
23
+ puts " check file = #{options[:check_file]}"
24
+ puts " timeout = #{options[:timeout]} seconds"
25
+ puts
26
+
27
+ puts 'Switching to new configuration'
28
+ File.write(options[:check_file], 'switching')
29
+
30
+ pid = Process.spawn(
31
+ File.join(options[:toplevel], 'bin/switch-to-configuration'),
32
+ options[:action]
33
+ )
34
+
35
+ Process.wait(pid)
36
+
37
+ File.write(options[:check_file], 'switched')
38
+
39
+ puts 'Switch complete, waiting for confirmation'
40
+ t = Time.now
41
+
42
+ loop do
43
+ sleep(0.5)
44
+
45
+ if File.read(options[:check_file]).strip == 'confirmed'
46
+ puts 'Configuration confirmed'
47
+ File.unlink(options[:check_file])
48
+ exit
49
+ end
50
+
51
+ break if t + options[:timeout] < Time.now
52
+ end
53
+
54
+ puts 'Timeout occurred, rolling back'
55
+
56
+ pid = Process.spawn(
57
+ File.join(current_system, 'bin/switch-to-configuration'),
58
+ options[:action]
59
+ )
60
+
61
+ Process.wait(pid)
62
+
63
+ puts 'Rollback complete'
64
+ exit(false)
65
+ end
66
+
67
+ protected
68
+
69
+ def parse_options
70
+ options = {
71
+ timeout: 60,
72
+ toplevel: nil,
73
+ action: nil,
74
+ check_file: nil
75
+ }
76
+
77
+ opt_parser = OptionParser.new do |parser|
78
+ parser.banner = "Usage: #{$0} [options] <toplevel> <action> <check file>"
79
+
80
+ parser.on('-t', '--timeout TIMEOUT', Integer, 'Timeout in seconds') do |v|
81
+ options[:timeout] = v
82
+ end
83
+
84
+ parser.on('-h', '--help', 'Print help message and exit') do
85
+ puts parser
86
+ exit
87
+ end
88
+ end
89
+
90
+ opt_parser.parse!
91
+
92
+ if ARGV.length != 3
93
+ warn 'Invalid arguments'
94
+ warn opt_parser
95
+ exit(false)
96
+ end
97
+
98
+ options[:toplevel] = ARGV[0]
99
+ options[:action] = ARGV[1]
100
+ options[:check_file] = ARGV[2]
101
+
102
+ options
103
+ end
104
+ end
105
+
106
+ AutoRollback.run