confctl 1.0.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.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +11 -0
  3. data/.gitignore +8 -0
  4. data/.overcommit.yml +6 -0
  5. data/.rubocop.yml +67 -0
  6. data/.rubocop_todo.yml +5 -0
  7. data/.ruby-version +1 -0
  8. data/CHANGELOG.md +2 -0
  9. data/Gemfile +2 -0
  10. data/LICENSE.txt +674 -0
  11. data/README.md +522 -0
  12. data/Rakefile +40 -0
  13. data/bin/confctl +4 -0
  14. data/confctl.gemspec +33 -0
  15. data/example/.gitignore +2 -0
  16. data/example/README.md +38 -0
  17. data/example/cluster/cluster.nix +7 -0
  18. data/example/cluster/module-list.nix +3 -0
  19. data/example/cluster/nixos-machine/config.nix +15 -0
  20. data/example/cluster/nixos-machine/hardware.nix +4 -0
  21. data/example/cluster/nixos-machine/module.nix +8 -0
  22. data/example/cluster/vpsadminos-container/config.nix +22 -0
  23. data/example/cluster/vpsadminos-container/module.nix +8 -0
  24. data/example/cluster/vpsadminos-machine/config.nix +22 -0
  25. data/example/cluster/vpsadminos-machine/hardware.nix +4 -0
  26. data/example/cluster/vpsadminos-machine/module.nix +8 -0
  27. data/example/cluster/vpsfreecz-vps/config.nix +25 -0
  28. data/example/cluster/vpsfreecz-vps/module.nix +8 -0
  29. data/example/configs/confctl.nix +10 -0
  30. data/example/configs/swpins.nix +28 -0
  31. data/example/data/default.nix +5 -0
  32. data/example/data/ssh-keys.nix +7 -0
  33. data/example/environments/base.nix +13 -0
  34. data/example/modules/module-list.nix +13 -0
  35. data/example/shell.nix +11 -0
  36. data/example/swpins/channels/nixos-unstable.json +35 -0
  37. data/example/swpins/channels/vpsadminos-staging.json +35 -0
  38. data/lib/confctl/cli/app.rb +551 -0
  39. data/lib/confctl/cli/attr_filters.rb +51 -0
  40. data/lib/confctl/cli/cluster.rb +1248 -0
  41. data/lib/confctl/cli/command.rb +206 -0
  42. data/lib/confctl/cli/configuration.rb +296 -0
  43. data/lib/confctl/cli/gen_data.rb +97 -0
  44. data/lib/confctl/cli/generation.rb +335 -0
  45. data/lib/confctl/cli/log_view.rb +267 -0
  46. data/lib/confctl/cli/output_formatter.rb +288 -0
  47. data/lib/confctl/cli/swpins/base.rb +40 -0
  48. data/lib/confctl/cli/swpins/channel.rb +73 -0
  49. data/lib/confctl/cli/swpins/cluster.rb +80 -0
  50. data/lib/confctl/cli/swpins/core.rb +86 -0
  51. data/lib/confctl/cli/swpins/utils.rb +55 -0
  52. data/lib/confctl/cli/swpins.rb +5 -0
  53. data/lib/confctl/cli/tag_filters.rb +30 -0
  54. data/lib/confctl/cli.rb +5 -0
  55. data/lib/confctl/conf_cache.rb +105 -0
  56. data/lib/confctl/conf_dir.rb +88 -0
  57. data/lib/confctl/erb_template.rb +37 -0
  58. data/lib/confctl/exceptions.rb +3 -0
  59. data/lib/confctl/gcroot.rb +30 -0
  60. data/lib/confctl/generation/build.rb +145 -0
  61. data/lib/confctl/generation/build_list.rb +106 -0
  62. data/lib/confctl/generation/host.rb +35 -0
  63. data/lib/confctl/generation/host_list.rb +81 -0
  64. data/lib/confctl/generation/unified.rb +117 -0
  65. data/lib/confctl/generation/unified_list.rb +63 -0
  66. data/lib/confctl/git_repo_mirror.rb +79 -0
  67. data/lib/confctl/health_checks/base.rb +66 -0
  68. data/lib/confctl/health_checks/run_command.rb +179 -0
  69. data/lib/confctl/health_checks/systemd/properties.rb +84 -0
  70. data/lib/confctl/health_checks/systemd/property_check.rb +31 -0
  71. data/lib/confctl/health_checks/systemd/property_list.rb +20 -0
  72. data/lib/confctl/health_checks.rb +5 -0
  73. data/lib/confctl/hook.rb +35 -0
  74. data/lib/confctl/line_buffer.rb +53 -0
  75. data/lib/confctl/logger.rb +151 -0
  76. data/lib/confctl/machine.rb +107 -0
  77. data/lib/confctl/machine_control.rb +172 -0
  78. data/lib/confctl/machine_list.rb +108 -0
  79. data/lib/confctl/machine_status.rb +135 -0
  80. data/lib/confctl/module_options.rb +95 -0
  81. data/lib/confctl/nix.rb +382 -0
  82. data/lib/confctl/nix_build.rb +108 -0
  83. data/lib/confctl/nix_collect_garbage.rb +64 -0
  84. data/lib/confctl/nix_copy.rb +49 -0
  85. data/lib/confctl/nix_format.rb +124 -0
  86. data/lib/confctl/nix_literal_expression.rb +15 -0
  87. data/lib/confctl/parallel_executor.rb +43 -0
  88. data/lib/confctl/pattern.rb +9 -0
  89. data/lib/confctl/settings.rb +50 -0
  90. data/lib/confctl/std_line_buffer.rb +40 -0
  91. data/lib/confctl/swpins/change_set.rb +151 -0
  92. data/lib/confctl/swpins/channel.rb +62 -0
  93. data/lib/confctl/swpins/channel_list.rb +47 -0
  94. data/lib/confctl/swpins/cluster_name.rb +94 -0
  95. data/lib/confctl/swpins/cluster_name_list.rb +15 -0
  96. data/lib/confctl/swpins/core.rb +137 -0
  97. data/lib/confctl/swpins/deployed_info.rb +23 -0
  98. data/lib/confctl/swpins/spec.rb +20 -0
  99. data/lib/confctl/swpins/specs/base.rb +184 -0
  100. data/lib/confctl/swpins/specs/directory.rb +51 -0
  101. data/lib/confctl/swpins/specs/git.rb +135 -0
  102. data/lib/confctl/swpins/specs/git_rev.rb +24 -0
  103. data/lib/confctl/swpins.rb +17 -0
  104. data/lib/confctl/system_command.rb +10 -0
  105. data/lib/confctl/user_script.rb +13 -0
  106. data/lib/confctl/user_scripts.rb +41 -0
  107. data/lib/confctl/utils/file.rb +21 -0
  108. data/lib/confctl/version.rb +3 -0
  109. data/lib/confctl.rb +43 -0
  110. data/man/man8/confctl-options.nix.8 +1334 -0
  111. data/man/man8/confctl-options.nix.8.md +1340 -0
  112. data/man/man8/confctl.8 +660 -0
  113. data/man/man8/confctl.8.md +654 -0
  114. data/nix/evaluator.nix +160 -0
  115. data/nix/lib/default.nix +83 -0
  116. data/nix/lib/machine/default.nix +74 -0
  117. data/nix/lib/machine/info.nix +5 -0
  118. data/nix/lib/swpins/eval.nix +71 -0
  119. data/nix/lib/swpins/options.nix +94 -0
  120. data/nix/machines.nix +31 -0
  121. data/nix/modules/cluster/default.nix +459 -0
  122. data/nix/modules/confctl/cli.nix +21 -0
  123. data/nix/modules/confctl/generations.nix +84 -0
  124. data/nix/modules/confctl/nix.nix +28 -0
  125. data/nix/modules/confctl/swpins.nix +55 -0
  126. data/nix/modules/module-list.nix +19 -0
  127. data/shell.nix +42 -0
  128. data/template/confctl-options.nix/main.erb +45 -0
  129. data/template/confctl-options.nix/options.erb +15 -0
  130. metadata +353 -0
@@ -0,0 +1,105 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+
4
+ module ConfCtl
5
+ # Cache the list of configuration files to detect changes
6
+ #
7
+ # The assumption is that if there hasn't been any changes in configuration
8
+ # directory, we can reuse previously built artefacts, such as the list of
9
+ # machines, etc. This is much faster than invoking nix-build to query
10
+ # the machines.
11
+ class ConfCache
12
+ # @param conf_dir [ConfDir]
13
+ def initialize(conf_dir)
14
+ @conf_dir = conf_dir
15
+ @cmd = SystemCommand.new
16
+ @cache_dir = File.join(conf_dir.cache_dir, 'build')
17
+ @cache_file = File.join(@cache_dir, 'git-files.json')
18
+ @files = {}
19
+ @loaded = false
20
+ @uptodate = nil
21
+ @checked_at = nil
22
+ end
23
+
24
+ # Load file list from cache file
25
+ def load_cache
26
+ begin
27
+ data = File.read(@cache_file)
28
+ rescue Errno::ENOENT
29
+ return
30
+ end
31
+
32
+ @files = JSON.parse(data)['files']
33
+ @loaded = true
34
+ end
35
+
36
+ # Check if cached file list differs from files on disk
37
+ # @param force [Boolean] force a new check
38
+ def uptodate?(force: false)
39
+ return @uptodate if !@uptodate.nil? && !force
40
+
41
+ @uptodate = check_uptodate
42
+ @uptodate
43
+ end
44
+
45
+ # Update cache file with the current state of the configuration directory
46
+ def update
47
+ @files.clear
48
+
49
+ list_files.each do |file|
50
+ begin
51
+ st = File.lstat(file)
52
+ rescue Errno::ENOENT
53
+ next
54
+ end
55
+
56
+ @files[file] = {
57
+ 'mtime' => st.mtime.to_i,
58
+ 'size' => st.size
59
+ }
60
+ end
61
+
62
+ tmp = "#{@cache_file}.new"
63
+
64
+ FileUtils.mkpath(@cache_dir)
65
+ File.write(tmp, { 'files' => @files }.to_json)
66
+ File.rename(tmp, @cache_file)
67
+
68
+ @uptodate = true
69
+ end
70
+
71
+ # @return [Time, nil]
72
+ def mtime
73
+ File.lstat(@cache_file).mtime
74
+ rescue Errno::ENOENT
75
+ nil
76
+ end
77
+
78
+ protected
79
+
80
+ def check_uptodate
81
+ load_cache unless @loaded
82
+ return false if @files.empty?
83
+
84
+ list_files.each do |file_path|
85
+ file = @files[file_path]
86
+ return false if file.nil?
87
+
88
+ begin
89
+ st = File.lstat(file_path)
90
+ rescue Errno::ENOENT
91
+ return false
92
+ end
93
+
94
+ return false if file['mtime'] != st.mtime.to_i || file['size'] != st.size
95
+ end
96
+
97
+ true
98
+ end
99
+
100
+ def list_files
101
+ out, = @cmd.run('git', '-C', @path, 'ls-files', '-z')
102
+ out.strip.split("\0")
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,88 @@
1
+ require 'digest'
2
+ require 'singleton'
3
+
4
+ module ConfCtl
5
+ class ConfDir
6
+ include Singleton
7
+
8
+ class << self
9
+ %i[
10
+ path
11
+ hash
12
+ short_hash
13
+ cache_dir
14
+ generation_dir
15
+ log_dir
16
+ user_script_dir
17
+ changed?
18
+ unchanged?
19
+ state_mtime
20
+ update_state
21
+ ].each do |v|
22
+ define_method(v) do |*args, **kwargs, &block|
23
+ instance.send(v, *args, **kwargs, &block)
24
+ end
25
+ end
26
+ end
27
+
28
+ def initialize
29
+ @cache = ConfCache.new(self)
30
+ end
31
+
32
+ # Path to the directory containing cluster configuration
33
+ # @return [String]
34
+ def path
35
+ @path ||= File.realpath(Dir.pwd)
36
+ end
37
+
38
+ # Unique hash identifying the configuration based on its filesystem path
39
+ # @return [String]
40
+ def hash
41
+ @hash ||= Digest::SHA256.hexdigest(path)
42
+ end
43
+
44
+ # Shorter prefix of {hash}
45
+ # @return [String]
46
+ def short_hash
47
+ @short_hash ||= hash[0..7]
48
+ end
49
+
50
+ # Path to configuration-specific cache directory
51
+ # @return [String]
52
+ def cache_dir
53
+ @cache_dir ||= File.join(path, '.confctl')
54
+ end
55
+
56
+ # Path to directory with build generations
57
+ # @return [String]
58
+ def generation_dir
59
+ @generation_dir ||= File.join(cache_dir, 'generations')
60
+ end
61
+
62
+ # Path to configuration-specific log directory
63
+ # @return [String]
64
+ def log_dir
65
+ @log_dir ||= File.join(cache_dir, 'logs')
66
+ end
67
+
68
+ def user_script_dir
69
+ @user_script_dir ||= File.join(path, 'scripts')
70
+ end
71
+
72
+ def changed?
73
+ !unchanged?
74
+ end
75
+
76
+ def unchanged?
77
+ @cache.uptodate?
78
+ end
79
+
80
+ def state_mtime
81
+ @cache.mtime
82
+ end
83
+
84
+ def update_state
85
+ @cache.update
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,37 @@
1
+ require 'erb'
2
+
3
+ module ConfCtl
4
+ class ErbTemplate
5
+ def self.render(name, vars)
6
+ t = new(name, vars)
7
+ t.render
8
+ end
9
+
10
+ def self.render_to(name, vars, path)
11
+ File.write("#{path}.new", render(name, vars))
12
+ File.rename("#{path}.new", path)
13
+ end
14
+
15
+ def initialize(name, vars)
16
+ @_tpl = ERB.new(
17
+ File.read(
18
+ File.join(ConfCtl.root, 'template', "#{name}.erb")
19
+ ), trim_mode: '-'
20
+ )
21
+
22
+ vars.each do |k, v|
23
+ if v.is_a?(Proc)
24
+ define_singleton_method(k, &v)
25
+ elsif v.is_a?(Method)
26
+ define_singleton_method(k) { |*args| v.call(*args) }
27
+ else
28
+ define_singleton_method(k) { v }
29
+ end
30
+ end
31
+ end
32
+
33
+ def render
34
+ @_tpl.result(binding)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module ConfCtl
2
+ class Error < ::StandardError; end
3
+ end
@@ -0,0 +1,30 @@
1
+ require 'confctl/utils/file'
2
+ require 'etc'
3
+ require 'fileutils'
4
+
5
+ module ConfCtl
6
+ module GCRoot
7
+ extend Utils::File
8
+
9
+ def self.dir
10
+ File.join(
11
+ '/nix/var/nix/gcroots/per-user',
12
+ Etc.getlogin,
13
+ "confctl-#{ConfDir.short_hash}"
14
+ )
15
+ end
16
+
17
+ def self.exist?(name)
18
+ File.symlink?(File.join(dir, name))
19
+ end
20
+
21
+ def self.add(name, path)
22
+ FileUtils.mkdir_p(dir)
23
+ File.symlink(path, File.join(dir, name))
24
+ end
25
+
26
+ def self.remove(name)
27
+ unlink_if_exists(File.join(dir, name))
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,145 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ module ConfCtl
6
+ class Generation::Build
7
+ # @return [String]
8
+ attr_reader :host
9
+
10
+ # @return [String]
11
+ attr_reader :name
12
+
13
+ # @return [Time]
14
+ attr_reader :date
15
+
16
+ # @return [String]
17
+ attr_reader :toplevel
18
+
19
+ # @return [Array<String>]
20
+ attr_reader :swpin_names
21
+
22
+ # @return [Hash]
23
+ attr_reader :swpin_paths
24
+
25
+ # @return [Hash]
26
+ attr_reader :swpin_specs
27
+
28
+ # @param current [Boolean]
29
+ # @return [Boolean]
30
+ attr_accessor :current
31
+
32
+ # @param host [String]
33
+ def initialize(host)
34
+ @host = host
35
+ end
36
+
37
+ # @param toplevel [String]
38
+ # @param swpin_paths [Hash]
39
+ # @param swpin_specs [Hash]
40
+ # @param date [Time]
41
+ def create(toplevel, swpin_paths, swpin_specs, date: nil)
42
+ @toplevel = toplevel
43
+ @swpin_names = swpin_paths.keys
44
+ @swpin_paths = swpin_paths
45
+ @swpin_specs = swpin_specs
46
+ @date = date || Time.now
47
+ @name = date.strftime('%Y-%m-%d--%H-%M-%S')
48
+ end
49
+
50
+ # @param name [String]
51
+ def load(name)
52
+ @name = name
53
+
54
+ cfg = JSON.parse(File.read(config_path))
55
+ @toplevel = cfg['toplevel']
56
+
57
+ @swpin_names = []
58
+ @swpin_paths = {}
59
+ @swpin_specs = {}
60
+
61
+ cfg['swpins'].each do |swpin_name, swpin|
62
+ @swpin_names << swpin_name
63
+ @swpin_paths[swpin_name] = swpin['path']
64
+ @swpin_specs[swpin_name] = Swpins::Spec.for(swpin['spec']['type'].to_sym).new(
65
+ swpin_name,
66
+ swpin['spec']['nix_options'],
67
+ swpin['spec']
68
+ )
69
+ end
70
+
71
+ @date = Time.iso8601(cfg['date'])
72
+ rescue StandardError => e
73
+ raise Error, "invalid generation '#{name}': #{e.message}"
74
+ end
75
+
76
+ def save
77
+ FileUtils.mkdir_p(dir)
78
+ File.symlink(toplevel, toplevel_path)
79
+
80
+ swpin_paths.each do |name, path|
81
+ File.symlink(path, swpin_path(name))
82
+ end
83
+
84
+ File.open(config_path, 'w') do |f|
85
+ f.puts(JSON.pretty_generate({
86
+ date: date.iso8601,
87
+ toplevel:,
88
+ swpins: swpin_paths.to_h do |name, path|
89
+ [name, { path:, spec: swpin_specs[name].as_json }]
90
+ end
91
+ }))
92
+ end
93
+
94
+ add_gcroot
95
+ end
96
+
97
+ def destroy
98
+ remove_gcroot
99
+ File.unlink(toplevel_path)
100
+ swpin_paths.each_key { |name| File.unlink(swpin_path(name)) }
101
+ File.unlink(config_path)
102
+ Dir.rmdir(dir)
103
+ end
104
+
105
+ def add_gcroot
106
+ GCRoot.add(gcroot_name('toplevel'), toplevel_path)
107
+ swpin_paths.each_key do |name|
108
+ GCRoot.add(gcroot_name("swpin.#{name}"), toplevel_path)
109
+ end
110
+ end
111
+
112
+ def remove_gcroot
113
+ GCRoot.remove(gcroot_name('toplevel'))
114
+ swpin_paths.each_key do |name|
115
+ GCRoot.remove(gcroot_name("swpin.#{name}"))
116
+ end
117
+ end
118
+
119
+ def dir
120
+ @dir ||= File.join(ConfDir.generation_dir, escaped_host, name)
121
+ end
122
+
123
+ protected
124
+
125
+ def config_path
126
+ @config_path ||= File.join(dir, 'generation.json')
127
+ end
128
+
129
+ def toplevel_path
130
+ @toplevel_path ||= File.join(dir, 'toplevel')
131
+ end
132
+
133
+ def swpin_path(name)
134
+ File.join(dir, "#{name}.swpin")
135
+ end
136
+
137
+ def escaped_host
138
+ @escaped_host ||= ConfCtl.safe_host_name(host)
139
+ end
140
+
141
+ def gcroot_name(file)
142
+ "#{escaped_host}-generation-#{name}-#{file}"
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,106 @@
1
+ require 'confctl/utils/file'
2
+
3
+ module ConfCtl
4
+ class Generation::BuildList
5
+ include Utils::File
6
+
7
+ # @return [String]
8
+ attr_reader :host
9
+
10
+ # @return [Generation::Build, nil]
11
+ attr_reader :current
12
+
13
+ # @return [String]
14
+ def initialize(host)
15
+ @host = host
16
+ @generations = []
17
+ @index = {}
18
+
19
+ return unless Dir.exist?(dir)
20
+
21
+ Dir.entries(dir).each do |v|
22
+ abs_path = File.join(dir, v)
23
+ next if %w[. ..].include?(v) || !Dir.exist?(abs_path) || File.symlink?(abs_path)
24
+
25
+ gen = Generation::Build.new(host)
26
+
27
+ begin
28
+ gen.load(v)
29
+ rescue Error => e
30
+ warn "Ignoring invalid generation #{gen.dir}"
31
+ next
32
+ end
33
+
34
+ generations << gen
35
+ index[gen.name] = gen
36
+ end
37
+
38
+ generations.sort! do |a, b|
39
+ a.date <=> b.date
40
+ end
41
+
42
+ current_gen =
43
+ if File.exist?(current_symlink)
44
+ name = File.basename(File.readlink(current_symlink))
45
+ index[name] || generations.last
46
+ else
47
+ generations.last
48
+ end
49
+
50
+ change_current(current_gen) if current_gen
51
+ end
52
+
53
+ # @param name [String]
54
+ def [](name)
55
+ index[name]
56
+ end
57
+
58
+ def each(&)
59
+ generations.each(&)
60
+ end
61
+
62
+ # @return [Array<Generation::Build>]
63
+ def to_a
64
+ generations.clone
65
+ end
66
+
67
+ # @return [Integer]
68
+ def count
69
+ generations.length
70
+ end
71
+
72
+ # @param gen [Generation::Build]
73
+ def current=(gen)
74
+ change_current(gen)
75
+ generations << gen unless generations.include?(gen)
76
+ replace_symlink(current_symlink, gen.name)
77
+ end
78
+
79
+ # @param toplevel [String]
80
+ # @param swpin_paths [Hash]
81
+ # @return [Generation::Build, nil]
82
+ def find(toplevel, swpin_paths)
83
+ generations.detect do |gen|
84
+ gen.toplevel == toplevel && gen.swpin_paths == swpin_paths
85
+ end
86
+ end
87
+
88
+ protected
89
+
90
+ attr_reader :generations, :index
91
+
92
+ def dir
93
+ @dir ||= File.join(ConfDir.generation_dir, ConfCtl.safe_host_name(host))
94
+ end
95
+
96
+ def current_symlink
97
+ @current_symlink ||= File.join(dir, 'current')
98
+ end
99
+
100
+ def change_current(gen)
101
+ @current = gen
102
+ generations.each { |g| g.current = false }
103
+ gen.current = true
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,35 @@
1
+ module ConfCtl
2
+ class Generation::Host
3
+ attr_reader :host, :profile, :id, :toplevel, :date, :current
4
+
5
+ # @param host [String]
6
+ # @param profile [String]
7
+ # @param id [Integer]
8
+ # @param toplevel [String]
9
+ # @param date [Time]
10
+ # @param mc [MachineControl]
11
+ def initialize(host, profile, id, toplevel, date, current: false, mc: nil)
12
+ @host = host
13
+ @profile = profile
14
+ @id = id
15
+ @toplevel = toplevel
16
+ @date = date
17
+ @current = current
18
+ @mc = mc
19
+ end
20
+
21
+ def approx_name
22
+ @approx_name ||= date.strftime('%Y-%m-%d--%H-%M-%S')
23
+ end
24
+
25
+ def destroy
26
+ raise 'machine control not available' if mc.nil?
27
+
28
+ mc.execute('nix-env', '-p', profile, '--delete-generations', id.to_s)
29
+ end
30
+
31
+ protected
32
+
33
+ attr_reader :mc
34
+ end
35
+ end
@@ -0,0 +1,81 @@
1
+ module ConfCtl
2
+ class Generation::HostList
3
+ # @param mc [MachineControl]
4
+ # @return [Generation::HostList]
5
+ def self.fetch(mc, profile: '/nix/var/nix/profiles/system')
6
+ out, = mc.bash_script(<<-END
7
+ realpath #{profile}
8
+
9
+ for generation in `ls -d -1 #{profile}-*-link` ; do
10
+ echo "$generation;$(readlink $generation);$(stat --format=%Y $generation)"
11
+ done
12
+ END
13
+ )
14
+
15
+ list = new(mc.machine.name)
16
+ lines = out.strip.split("\n")
17
+ current_path = lines.shift
18
+ id_rx = /^#{Regexp.escape(profile)}-(\d+)-link$/
19
+
20
+ lines.each do |line|
21
+ link, path, created_at = line.split(';')
22
+
23
+ if id_rx =~ link
24
+ id = ::Regexp.last_match(1).to_i
25
+ else
26
+ warn "Invalid profile generation link '#{link}'"
27
+ next
28
+ end
29
+
30
+ list << Generation::Host.new(
31
+ mc.machine.name,
32
+ profile,
33
+ id,
34
+ path,
35
+ Time.at(created_at.to_i),
36
+ current: path == current_path,
37
+ mc:
38
+ )
39
+ end
40
+
41
+ list.sort
42
+ list
43
+ end
44
+
45
+ # @return [String]
46
+ attr_reader :host
47
+
48
+ # @param host [String]
49
+ def initialize(host)
50
+ @host = host
51
+ @generations = []
52
+ end
53
+
54
+ # @param generation [Generation::Host]
55
+ def <<(generation)
56
+ generations << generation
57
+ end
58
+
59
+ def sort
60
+ generations.sort! { |a, b| a.id <=> b.id }
61
+ end
62
+
63
+ def each(&)
64
+ generations.each(&)
65
+ end
66
+
67
+ # @return [Integer]
68
+ def count
69
+ generations.length
70
+ end
71
+
72
+ # @return [Generation::Host]
73
+ def current
74
+ generations.detect(&:current)
75
+ end
76
+
77
+ protected
78
+
79
+ attr_reader :generations
80
+ end
81
+ end