d-installer 0.4.2

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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +29 -0
  3. data/Gemfile.lock +75 -0
  4. data/bin/d-installer +74 -0
  5. data/etc/d-installer.yaml +284 -0
  6. data/lib/dinstaller/can_ask_question.rb +48 -0
  7. data/lib/dinstaller/cmdline_args.rb +80 -0
  8. data/lib/dinstaller/cockpit_manager.rb +176 -0
  9. data/lib/dinstaller/config.rb +128 -0
  10. data/lib/dinstaller/config_reader.rb +164 -0
  11. data/lib/dinstaller/dbus/base_object.rb +58 -0
  12. data/lib/dinstaller/dbus/clients/base.rb +71 -0
  13. data/lib/dinstaller/dbus/clients/language.rb +86 -0
  14. data/lib/dinstaller/dbus/clients/manager.rb +76 -0
  15. data/lib/dinstaller/dbus/clients/software.rb +185 -0
  16. data/lib/dinstaller/dbus/clients/users.rb +112 -0
  17. data/lib/dinstaller/dbus/clients/with_progress.rb +56 -0
  18. data/lib/dinstaller/dbus/clients/with_service_status.rb +75 -0
  19. data/lib/dinstaller/dbus/clients.rb +34 -0
  20. data/lib/dinstaller/dbus/interfaces/progress.rb +113 -0
  21. data/lib/dinstaller/dbus/interfaces/service_status.rb +89 -0
  22. data/lib/dinstaller/dbus/language.rb +93 -0
  23. data/lib/dinstaller/dbus/language_service.rb +92 -0
  24. data/lib/dinstaller/dbus/manager.rb +147 -0
  25. data/lib/dinstaller/dbus/manager_service.rb +132 -0
  26. data/lib/dinstaller/dbus/question.rb +176 -0
  27. data/lib/dinstaller/dbus/questions.rb +124 -0
  28. data/lib/dinstaller/dbus/service_runner.rb +97 -0
  29. data/lib/dinstaller/dbus/service_status.rb +87 -0
  30. data/lib/dinstaller/dbus/software/manager.rb +131 -0
  31. data/lib/dinstaller/dbus/software/proposal.rb +82 -0
  32. data/lib/dinstaller/dbus/software.rb +31 -0
  33. data/lib/dinstaller/dbus/software_service.rb +86 -0
  34. data/lib/dinstaller/dbus/storage/proposal.rb +170 -0
  35. data/lib/dinstaller/dbus/storage.rb +30 -0
  36. data/lib/dinstaller/dbus/users.rb +132 -0
  37. data/lib/dinstaller/dbus/users_service.rb +92 -0
  38. data/lib/dinstaller/dbus/with_service_status.rb +48 -0
  39. data/lib/dinstaller/dbus/y2dir/manager/modules/Package.rb +51 -0
  40. data/lib/dinstaller/dbus/y2dir/manager/modules/PackagesProposal.rb +62 -0
  41. data/lib/dinstaller/dbus/y2dir/modules/Autologin.rb +214 -0
  42. data/lib/dinstaller/dbus/y2dir/software/modules/SpaceCalculation.rb +44 -0
  43. data/lib/dinstaller/dbus.rb +11 -0
  44. data/lib/dinstaller/errors.rb +28 -0
  45. data/lib/dinstaller/installation_phase.rb +106 -0
  46. data/lib/dinstaller/language.rb +73 -0
  47. data/lib/dinstaller/luks_activation_question.rb +92 -0
  48. data/lib/dinstaller/manager.rb +261 -0
  49. data/lib/dinstaller/network.rb +53 -0
  50. data/lib/dinstaller/package_callbacks.rb +69 -0
  51. data/lib/dinstaller/progress.rb +149 -0
  52. data/lib/dinstaller/question.rb +103 -0
  53. data/lib/dinstaller/questions_manager.rb +145 -0
  54. data/lib/dinstaller/security.rb +96 -0
  55. data/lib/dinstaller/service_status_recorder.rb +58 -0
  56. data/lib/dinstaller/software.rb +232 -0
  57. data/lib/dinstaller/storage/actions.rb +90 -0
  58. data/lib/dinstaller/storage/callbacks/activate.rb +93 -0
  59. data/lib/dinstaller/storage/callbacks/activate_luks.rb +93 -0
  60. data/lib/dinstaller/storage/callbacks/activate_multipath.rb +77 -0
  61. data/lib/dinstaller/storage/callbacks.rb +30 -0
  62. data/lib/dinstaller/storage/manager.rb +104 -0
  63. data/lib/dinstaller/storage/proposal.rb +197 -0
  64. data/lib/dinstaller/storage.rb +30 -0
  65. data/lib/dinstaller/users.rb +156 -0
  66. data/lib/dinstaller/with_progress.rb +63 -0
  67. data/lib/dinstaller.rb +5 -0
  68. data/share/dbus.conf +38 -0
  69. data/share/dbus.service +5 -0
  70. data/share/systemd.service +14 -0
  71. metadata +295 -0
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) [2022] SUSE LLC
4
+ #
5
+ # All Rights Reserved.
6
+ #
7
+ # This program is free software; you can redistribute it and/or modify it
8
+ # under the terms of version 2 of the GNU General Public License as published
9
+ # by the Free Software Foundation.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14
+ # more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along
17
+ # with this program; if not, contact SUSE LLC.
18
+ #
19
+ # To contact SUSE LLC about this file by physical or electronic mail, you may
20
+ # find current contact information at www.suse.com.
21
+
22
+ require "yast"
23
+ require "bootloader/proposal_client"
24
+ require "bootloader/finish_client"
25
+ require "dinstaller/config"
26
+ require "dinstaller/network"
27
+ require "dinstaller/security"
28
+ require "dinstaller/storage"
29
+ require "dinstaller/questions_manager"
30
+ require "dinstaller/with_progress"
31
+ require "dinstaller/installation_phase"
32
+ require "dinstaller/service_status_recorder"
33
+ require "dinstaller/dbus/clients/language"
34
+ require "dinstaller/dbus/clients/software"
35
+ require "dinstaller/dbus/clients/users"
36
+
37
+ Yast.import "Stage"
38
+
39
+ module DInstaller
40
+ # This class represents the top level installer manager.
41
+ #
42
+ # It is responsible for orchestrating the installation process. For module
43
+ # specific stuff it delegates it to the corresponding module class (e.g.,
44
+ # {DInstaller::Network}, {DInstaller::Storage::Proposal}, etc.) or asks
45
+ # other services via D-Bus (e.g., `org.opensuse.DInstaller.Software`).
46
+ class Manager
47
+ include WithProgress
48
+
49
+ # @return [Logger]
50
+ attr_reader :logger
51
+
52
+ # @return [QuestionsManager]
53
+ attr_reader :questions_manager
54
+
55
+ # @return [InstallationPhase]
56
+ attr_reader :installation_phase
57
+
58
+ # Constructor
59
+ #
60
+ # @param logger [Logger]
61
+ def initialize(config, logger)
62
+ @config = config
63
+ @logger = logger
64
+ @questions_manager = QuestionsManager.new(logger)
65
+ @installation_phase = InstallationPhase.new
66
+ @service_status_recorder = ServiceStatusRecorder.new
67
+ end
68
+
69
+ # Runs the startup phase
70
+ def startup_phase
71
+ installation_phase.startup
72
+
73
+ probe_single_product unless config.multi_product?
74
+
75
+ logger.info("Startup phase done")
76
+ end
77
+
78
+ # Runs the config phase
79
+ def config_phase
80
+ installation_phase.config
81
+
82
+ storage.probe(questions_manager)
83
+ security.probe
84
+ network.probe
85
+
86
+ logger.info("Config phase done")
87
+ rescue StandardError => e
88
+ logger.error "Startup error: #{e.inspect}. Backtrace: #{e.backtrace}"
89
+ # TODO: report errors
90
+ end
91
+
92
+ # Runs the install phase
93
+ # rubocop:disable Metrics/AbcSize
94
+ def install_phase
95
+ installation_phase.install
96
+
97
+ start_progress(9)
98
+
99
+ progress.step("Reading software repositories") do
100
+ software.probe
101
+ Yast::Installation.destdir = "/mnt"
102
+ end
103
+
104
+ progress.step("Partitioning") do
105
+ # lets propose it here to be sure that software proposal reflects product selection
106
+ # FIXME: maybe repropose after product selection change?
107
+ # first make bootloader proposal to be sure that required packages are installed
108
+ proposal = ::Bootloader::ProposalClient.new.make_proposal({})
109
+ logger.info "Bootloader proposal #{proposal.inspect}"
110
+ storage.install
111
+ # propose software after /mnt is already separated, so it uses proper
112
+ # target
113
+ software.propose
114
+
115
+ # call inst bootloader to get properly initialized bootloader
116
+ # sysconfig before package installation
117
+ Yast::WFM.CallFunction("inst_bootloader", [])
118
+ end
119
+
120
+ progress.step("Installing Software") { software.install }
121
+
122
+ on_target do
123
+ progress.step("Writing Users") { users.write }
124
+
125
+ progress.step("Writing Network Configuration") { network.install }
126
+
127
+ progress.step("Installing Bootloader") do
128
+ security.write
129
+ ::Bootloader::FinishClient.new.write
130
+ end
131
+
132
+ progress.step("Saving Language Settings") { language.finish }
133
+
134
+ progress.step("Writing repositories information") { software.finish }
135
+
136
+ progress.step("Finishing installation") { finish_installation }
137
+ end
138
+
139
+ logger.info("Install phase done")
140
+ end
141
+ # rubocop:enable Metrics/AbcSize
142
+
143
+ # Software client
144
+ #
145
+ # @return [DBus::Clients::Software]
146
+ def software
147
+ @software ||= DBus::Clients::Software.new.tap do |client|
148
+ client.on_service_status_change do |status|
149
+ service_status_recorder.save(client.service.name, status)
150
+ end
151
+ end
152
+ end
153
+
154
+ # Language manager
155
+ #
156
+ # @return [DBus::Clients::Language]
157
+ def language
158
+ @language ||= DBus::Clients::Language.new
159
+ end
160
+
161
+ # Users client
162
+ #
163
+ # @return [DBus::Clients::Users]
164
+ def users
165
+ @users ||= DBus::Clients::Users.new.tap do |client|
166
+ client.on_service_status_change do |status|
167
+ service_status_recorder.save(client.service.name, status)
168
+ end
169
+ end
170
+ end
171
+
172
+ # Network manager
173
+ #
174
+ # @return [Network]
175
+ def network
176
+ @network ||= Network.new(logger)
177
+ end
178
+
179
+ # Storage manager
180
+ #
181
+ # @return [Storage::Manager]
182
+ def storage
183
+ @storage ||= Storage::Manager.new(logger, config)
184
+ end
185
+
186
+ # Security manager
187
+ #
188
+ # @return [Security]
189
+ def security
190
+ @security ||= Security.new(logger, config)
191
+ end
192
+
193
+ # Actions to perform when a product is selected
194
+ #
195
+ # @note The config phase is executed.
196
+ def select_product(product)
197
+ config.pick_product(product)
198
+ config_phase
199
+ end
200
+
201
+ # Name of busy services
202
+ #
203
+ # @see ServiceStatusRecorder
204
+ #
205
+ # @return [Array<String>]
206
+ def busy_services
207
+ service_status_recorder.busy_services
208
+ end
209
+
210
+ # Registers a callback to be called when the status of a service changes
211
+ #
212
+ # @see ServiceStatusRecorder
213
+ def on_services_status_change(&block)
214
+ service_status_recorder.on_service_status_change(&block)
215
+ end
216
+
217
+ private
218
+
219
+ attr_reader :config
220
+
221
+ # @return [ServiceStatusRecorder]
222
+ attr_reader :service_status_recorder
223
+
224
+ # Performs required steps after installing the system
225
+ #
226
+ # For now, this only unmounts the installed system and copies installation logs. Note that YaST
227
+ # performs many more steps like copying configuration files, creating snapshots, etc. Adding
228
+ # more features to D-Installer could require to recover some of that YaST logic.
229
+ def finish_installation
230
+ Yast::WFM.CallFunction("copy_logs_finish", ["Write"])
231
+ storage.finish
232
+ end
233
+
234
+ # Runs a block in the target system
235
+ def on_target(&block)
236
+ old_handle = Yast::WFM.SCRGetDefault
237
+ handle = Yast::WFM.SCROpen("chroot=#{Yast::Installation.destdir}:scr", false)
238
+ Yast::WFM.SCRSetDefault(handle)
239
+
240
+ begin
241
+ block.call
242
+ rescue StandardError => e
243
+ logger.error "Error while running on target tasks: #{e.inspect}"
244
+ ensure
245
+ Yast::WFM.SCRSetDefault(old_handle)
246
+ Yast::WFM.SCRClose(handle)
247
+ end
248
+ end
249
+
250
+ # Runs the config phase for the first product found
251
+ #
252
+ # Adjust the configuration and run the config phase.
253
+ #
254
+ # This method is expected to be used on single-product scenarios.
255
+ def probe_single_product
256
+ selected = config.data["products"].keys.first
257
+ config.pick_product(selected)
258
+ config_phase
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) [2022] SUSE LLC
4
+ #
5
+ # All Rights Reserved.
6
+ #
7
+ # This program is free software; you can redistribute it and/or modify it
8
+ # under the terms of version 2 of the GNU General Public License as published
9
+ # by the Free Software Foundation.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14
+ # more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along
17
+ # with this program; if not, contact SUSE LLC.
18
+ #
19
+ # To contact SUSE LLC about this file by physical or electronic mail, you may
20
+ # find current contact information at www.suse.com.
21
+
22
+ require "singleton"
23
+ require "yast"
24
+ require "y2network/proposal_settings"
25
+ Yast.import "Lan"
26
+
27
+ module DInstaller
28
+ # Backend class to handle network configuration
29
+ class Network
30
+ def initialize(logger)
31
+ @logger = logger
32
+ end
33
+
34
+ # Probes the network configuration
35
+ def probe
36
+ logger.info "Probing network"
37
+ Yast::Lan.read_config
38
+ settings = Y2Network::ProposalSettings.instance
39
+ settings.refresh_packages
40
+ settings.apply_defaults
41
+ end
42
+
43
+ # Writes the network configuration to the installed system
44
+ def install
45
+ Yast::WFM.CallFunction("save_network", [])
46
+ end
47
+
48
+ private
49
+
50
+ # @return [Logger]
51
+ attr_reader :logger
52
+ end
53
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) [2021] SUSE LLC
4
+ #
5
+ # All Rights Reserved.
6
+ #
7
+ # This program is free software; you can redistribute it and/or modify it
8
+ # under the terms of version 2 of the GNU General Public License as published
9
+ # by the Free Software Foundation.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14
+ # more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along
17
+ # with this program; if not, contact SUSE LLC.
18
+ #
19
+ # To contact SUSE LLC about this file by physical or electronic mail, you may
20
+ # find current contact information at www.suse.com.
21
+
22
+ require "yast"
23
+
24
+ Yast.import "Pkg"
25
+
26
+ # YaST specific code lives under this namespace
27
+ module DInstaller
28
+ # This class represents the installer status
29
+ class PackageCallbacks
30
+ class << self
31
+ def setup(pkg_count, progress)
32
+ new(pkg_count, progress).setup
33
+ end
34
+ end
35
+
36
+ def initialize(pkg_count, progress)
37
+ @total = pkg_count
38
+ @installed = 0
39
+ @progress = progress
40
+ end
41
+
42
+ def setup
43
+ Yast::Pkg.CallbackDonePackage(
44
+ fun_ref(method(:package_installed), "string (integer, string)")
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ # @return [DInstaller::Progress]
51
+ attr_reader :progress
52
+
53
+ def fun_ref(method, signature)
54
+ Yast::FunRef.new(method, signature)
55
+ end
56
+
57
+ # TODO: error handling
58
+ def package_installed(_error, _reason)
59
+ @installed += 1
60
+ progress.step(msg)
61
+
62
+ ""
63
+ end
64
+
65
+ def msg
66
+ "Installing packages (#{@total - @installed} remains)"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) [2022] SUSE LLC
4
+ #
5
+ # All Rights Reserved.
6
+ #
7
+ # This program is free software; you can redistribute it and/or modify it
8
+ # under the terms of version 2 of the GNU General Public License as published
9
+ # by the Free Software Foundation.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14
+ # more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along
17
+ # with this program; if not, contact SUSE LLC.
18
+ #
19
+ # To contact SUSE LLC about this file by physical or electronic mail, you may
20
+ # find current contact information at www.suse.com.
21
+
22
+ module DInstaller
23
+ # Class to manage progress
24
+ #
25
+ # It allows to configure callbacks to be called on each step and also when the progress finishes.
26
+ #
27
+ # @example
28
+ #
29
+ # progress = Progress.new(3) # 3 steps
30
+ # progress.on_change { puts progress.message } # configures callbacks
31
+ # progress.on_finish { puts "finished" } # configures callbacks
32
+ #
33
+ # progress.step("Doing step1") { step1 } # calls on_change callbacks and executes step1
34
+ # progress.step("Doing step2") { step2 } # calls on_change callbacks and executes step2
35
+ #
36
+ # progress.current_step #=> <Step>
37
+ # progress.current_step.id #=> 2
38
+ # progress.current_step.description #=> "Doing step2"
39
+ #
40
+ # progress.finished? #=> false
41
+
42
+ # progress.step("Doing step3") do # calls on_change callbacks, executes the given
43
+ # progress.current_step.description # block and calls on_finish callbacks
44
+ # end #=> "Doing step3"
45
+ #
46
+ # progress.finished? #=> true
47
+ # progress.current_step #=> nil
48
+ class Progress
49
+ # Step of the progress
50
+ class Step
51
+ # Id of the step
52
+ #
53
+ # @return [Integer]
54
+ attr_reader :id
55
+
56
+ # Description of the step
57
+ #
58
+ # @return [String]
59
+ attr_reader :description
60
+
61
+ # Constructor
62
+ #
63
+ # @param id [Integer]
64
+ # @param description [String]
65
+ def initialize(id, description)
66
+ @id = id
67
+ @description = description
68
+ end
69
+ end
70
+
71
+ # Total number of steps
72
+ #
73
+ # @return [Integer]
74
+ attr_reader :total_steps
75
+
76
+ # Constructor
77
+ #
78
+ # @param toal_steps [Integer] total number of steps
79
+ def initialize(total_steps)
80
+ @total_steps = total_steps
81
+ @current_step = nil
82
+ @counter = 0
83
+ @finished = false
84
+ @on_change_callbacks = []
85
+ @on_finish_callbacks = []
86
+ end
87
+
88
+ # Current progress step, if any
89
+ #
90
+ # @return [Step, nil] nil if the progress is already finished or not stated yet.
91
+ def current_step
92
+ return nil if finished?
93
+
94
+ @current_step
95
+ end
96
+
97
+ # Runs a progress step
98
+ #
99
+ # It calls the `on_change` callbacks and then runs the given block, if any. It also calls
100
+ # `on_finish` callbacks after the last step.
101
+ #
102
+ # @param description [String] description of the step
103
+ # @param block [Proc]
104
+ #
105
+ # @return [Object, nil] result of the given block or nil if no block is given
106
+ def step(description, &block)
107
+ return if finished?
108
+
109
+ @counter += 1
110
+ @current_step = Step.new(@counter, description)
111
+ @on_change_callbacks.each(&:call)
112
+
113
+ result = block_given? ? block.call : nil
114
+
115
+ finish if @counter == total_steps
116
+
117
+ result
118
+ end
119
+
120
+ # Whether the last step was already done
121
+ #
122
+ # @return [Boolean]
123
+ def finished?
124
+ total_steps == 0 || @finished
125
+ end
126
+
127
+ # Finishes the progress and runs the callbacks
128
+ #
129
+ # This method can be called to force the progress to finish before of running all the steps.
130
+ def finish
131
+ @finished = true
132
+ @on_finish_callbacks.each(&:call)
133
+ end
134
+
135
+ # Adds a callback to be called when progress changes
136
+ #
137
+ # @param block [Proc]
138
+ def on_change(&block)
139
+ @on_change_callbacks << block
140
+ end
141
+
142
+ # Adds a callback to be called when progress finishes
143
+ #
144
+ # @param block [Proc]
145
+ def on_finish(&block)
146
+ @on_finish_callbacks << block
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) [2022] SUSE LLC
4
+ #
5
+ # All Rights Reserved.
6
+ #
7
+ # This program is free software; you can redistribute it and/or modify it
8
+ # under the terms of version 2 of the GNU General Public License as published
9
+ # by the Free Software Foundation.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14
+ # more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License along
17
+ # with this program; if not, contact SUSE LLC.
18
+ #
19
+ # To contact SUSE LLC about this file by physical or electronic mail, you may
20
+ # find current contact information at www.suse.com.
21
+
22
+ module DInstaller
23
+ # This class represents a question
24
+ #
25
+ # Questions are used when some information needs to be asked. For example, a question could be
26
+ # created for asking whether to continue or not when an error is detected.
27
+ #
28
+ # Questions are managed by a questions manager, see {QuestionsManager}.
29
+ class Question
30
+ # Each question is identified by an unique id
31
+ #
32
+ # @return [Integer]
33
+ attr_reader :id
34
+
35
+ # Text of the question
36
+ #
37
+ # @return [String]
38
+ attr_reader :text
39
+
40
+ # Options the question offers
41
+ #
42
+ # The question must be answered with one of that options.
43
+ #
44
+ # @return [Array<Symbol>]
45
+ attr_reader :options
46
+
47
+ # Default option to use as answer
48
+ #
49
+ # @return [Symbol, nil]
50
+ attr_reader :default_option
51
+
52
+ # Answer of the question
53
+ #
54
+ # @return [Symbol, nil] nil if the question is not answered yet
55
+ attr_reader :answer
56
+
57
+ def initialize(text, options: [], default_option: nil)
58
+ @id = IdGenerator.next
59
+ @text = text
60
+ @options = options
61
+ @default_option = default_option
62
+ end
63
+
64
+ # Answers the question with an option
65
+ #
66
+ # @raise [ArgumentError] if the given value is not a valid answer.
67
+ #
68
+ # @param value [Symbol]
69
+ def answer=(value)
70
+ raise ArgumentError, "Invalid answer. Options: #{options}" unless valid_answer?(value)
71
+
72
+ @answer = value
73
+ end
74
+
75
+ # Whether the question is already answered
76
+ #
77
+ # @return [Boolean]
78
+ def answered?
79
+ !answer.nil?
80
+ end
81
+
82
+ private
83
+
84
+ # Checks whether the given value is a valid answer
85
+ #
86
+ # @param value [Symbol]
87
+ # @return [Boolean]
88
+ def valid_answer?(value)
89
+ options.include?(value)
90
+ end
91
+
92
+ # Helper class for generating unique ids
93
+ class IdGenerator
94
+ # Generates the next id to be used
95
+ #
96
+ # @return [Integer]
97
+ def self.next
98
+ @last_id ||= 0
99
+ @last_id += 1
100
+ end
101
+ end
102
+ end
103
+ end