kerbi 0.0.1 → 0.0.5

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cli/base_handler.rb +194 -0
  3. data/lib/cli/base_serializer.rb +120 -0
  4. data/lib/cli/config_handler.rb +51 -0
  5. data/lib/cli/entry_serializers.rb +99 -0
  6. data/lib/cli/project_handler.rb +2 -2
  7. data/lib/cli/release_handler.rb +41 -0
  8. data/lib/cli/release_serializer.rb +46 -0
  9. data/lib/cli/root_handler.rb +34 -13
  10. data/lib/cli/state_handler.rb +88 -0
  11. data/lib/cli/values_handler.rb +4 -3
  12. data/{boilerplate → lib/code-gen/new-project}/Gemfile.erb +0 -0
  13. data/lib/code-gen/new-project/kerbifile.rb.erb +9 -0
  14. data/lib/code-gen/new-project/values.yaml.erb +1 -0
  15. data/lib/config/cli_schema.rb +343 -28
  16. data/lib/config/config_file.rb +60 -0
  17. data/lib/config/globals.rb +4 -0
  18. data/lib/config/run_opts.rb +162 -0
  19. data/lib/config/state_consts.rb +11 -0
  20. data/lib/kerbi.rb +35 -10
  21. data/lib/main/code_gen.rb +1 -1
  22. data/lib/main/errors.rb +115 -0
  23. data/lib/main/mixer.rb +20 -10
  24. data/lib/mixins/cli_state_helpers.rb +108 -0
  25. data/lib/mixins/cm_backend_testing.rb +109 -0
  26. data/lib/mixins/entry_tag_logic.rb +183 -0
  27. data/lib/state/base_backend.rb +93 -0
  28. data/lib/state/config_map_backend.rb +173 -0
  29. data/lib/state/entry.rb +173 -0
  30. data/lib/state/entry_set.rb +137 -0
  31. data/lib/state/metadata.yaml.erb +11 -0
  32. data/lib/state/mixers.rb +23 -0
  33. data/lib/state/resources.yaml.erb +17 -0
  34. data/lib/utils/cli.rb +108 -9
  35. data/lib/utils/helm.rb +10 -12
  36. data/lib/utils/k8s_auth.rb +87 -0
  37. data/lib/utils/misc.rb +36 -1
  38. data/lib/utils/mixing.rb +1 -1
  39. data/lib/utils/values.rb +13 -22
  40. data/spec/cli/config_handler_spec.rb +38 -0
  41. data/spec/cli/release_handler_spec.rb +127 -0
  42. data/spec/cli/root_handler_spec.rb +100 -0
  43. data/spec/cli/state_handler_spec.rb +108 -0
  44. data/spec/cli/values_handler_spec.rb +17 -0
  45. data/spec/fixtures/expectations/common/bad-tag.txt +1 -0
  46. data/spec/fixtures/expectations/config/bad-set.txt +1 -0
  47. data/spec/fixtures/expectations/config/set.txt +1 -0
  48. data/spec/fixtures/expectations/config/show-default.yaml +6 -0
  49. data/spec/fixtures/expectations/release/delete.txt +1 -0
  50. data/spec/fixtures/expectations/release/init-already-existed.txt +2 -0
  51. data/spec/fixtures/expectations/release/init-both-created.txt +2 -0
  52. data/spec/fixtures/expectations/release/list.txt +5 -0
  53. data/spec/fixtures/expectations/release/status-all-working.txt +5 -0
  54. data/spec/fixtures/expectations/release/status-data-unreadable.txt +5 -0
  55. data/spec/fixtures/expectations/release/status-not-provisioned.txt +5 -0
  56. data/spec/fixtures/expectations/root/template-inlines.yaml +31 -0
  57. data/spec/fixtures/expectations/root/template-production.yaml +31 -0
  58. data/spec/fixtures/expectations/root/template-read-inlines.yaml +31 -0
  59. data/spec/fixtures/expectations/root/template-read.yaml +31 -0
  60. data/spec/fixtures/expectations/root/template-write.yaml +31 -0
  61. data/spec/fixtures/expectations/root/template.yaml +31 -0
  62. data/spec/fixtures/expectations/root/values.json +28 -0
  63. data/spec/fixtures/expectations/state/delete.txt +1 -0
  64. data/spec/fixtures/expectations/state/demote.txt +1 -0
  65. data/spec/fixtures/expectations/state/list.json +51 -0
  66. data/spec/fixtures/expectations/state/list.txt +6 -0
  67. data/spec/fixtures/expectations/state/list.yaml +35 -0
  68. data/spec/fixtures/expectations/state/promote.txt +1 -0
  69. data/spec/fixtures/expectations/state/prune-candidates.txt +1 -0
  70. data/spec/fixtures/expectations/state/retag.txt +1 -0
  71. data/spec/fixtures/expectations/state/set.txt +1 -0
  72. data/spec/fixtures/expectations/state/show.json +13 -0
  73. data/spec/fixtures/expectations/state/show.txt +13 -0
  74. data/spec/fixtures/expectations/state/show.yaml +8 -0
  75. data/spec/fixtures/expectations/values/order-of-precedence.yaml +4 -0
  76. data/spec/main/configmap_backend_spec.rb +110 -0
  77. data/spec/main/project_code_gen_spec.rb +8 -2
  78. data/spec/main/state_entry_set_spec.rb +112 -0
  79. data/spec/main/state_entry_spec.rb +109 -0
  80. data/spec/mini-projects/hello-kerbi/common/metadata.yaml.erb +5 -0
  81. data/spec/mini-projects/hello-kerbi/consts.rb +5 -0
  82. data/spec/mini-projects/hello-kerbi/helpers.rb +8 -0
  83. data/spec/mini-projects/hello-kerbi/kerbifile.rb +18 -0
  84. data/spec/mini-projects/hello-kerbi/pod-and-service.yaml.erb +23 -0
  85. data/spec/mini-projects/hello-kerbi/values/production.yaml +2 -0
  86. data/spec/mini-projects/hello-kerbi/values/v2.yaml +2 -0
  87. data/spec/mini-projects/hello-kerbi/values/values.yaml +4 -0
  88. data/spec/spec_helper.rb +143 -1
  89. data/spec/utils/helm_spec.rb +89 -109
  90. data/spec/utils/k8s_auth_spec.rb +32 -0
  91. data/spec/utils/misc_utils_spec.rb +9 -0
  92. data/spec/utils/values_utils_spec.rb +12 -19
  93. metadata +143 -16
  94. data/boilerplate/kerbifile.rb.erb +0 -9
  95. data/boilerplate/values.yaml.erb +0 -1
  96. data/lib/cli/base.rb +0 -83
  97. data/lib/config/cli_opts.rb +0 -50
  98. data/lib/config/manager.rb +0 -36
  99. data/lib/main/state_manager.rb +0 -47
  100. data/lib/utils/kubectl.rb +0 -58
  101. data/spec/main/examples_spec.rb +0 -12
  102. data/spec/main/state_manager_spec.rb +0 -84
  103. data/spec/utils/state_utils.rb +0 -15
@@ -0,0 +1,183 @@
1
+ module Kerbi
2
+ module Mixins
3
+
4
+ ##
5
+ # Mixin for handling mission critical state entry tag logic. The logic
6
+ # is most comprised name resolution, i.e turning special words that users
7
+ # pass in place of literal tags, into literal tags.
8
+ module EntryTagLogic
9
+ extend ActiveSupport::Concern
10
+
11
+ SPECIAL_CHAR = "@"
12
+
13
+ CANDIDATE_WORD = "candidate"
14
+ NEW_CANDIDATE_WORD = "new-candidate"
15
+ LATEST_WORD = "latest"
16
+ OLDEST_WORD = "oldest"
17
+ RANDOM_WORD = "random"
18
+
19
+ SPECIAL_READ_WORDS = [
20
+ CANDIDATE_WORD,
21
+ LATEST_WORD,
22
+ OLDEST_WORD
23
+ ]
24
+
25
+ SPECIAL_WRITE_WORDS = [
26
+ LATEST_WORD,
27
+ OLDEST_WORD,
28
+ CANDIDATE_WORD,
29
+ NEW_CANDIDATE_WORD,
30
+ RANDOM_WORD
31
+ ]
32
+
33
+ ##
34
+ # Calls #do_resolve_tag_expr with verb=write in order to turn
35
+ # an entry tag expression like @candidate-new into [cand]purple-forest-new.
36
+ #
37
+ # See documentation for #resolve_word for information on how resolution
38
+ # works at the word level.
39
+ # @param [String] tag_expr
40
+ # @return [String]
41
+ def resolve_write_tag_expr(tag_expr)
42
+ do_resolve_tag_expr(tag_expr, "write")
43
+ end
44
+
45
+ ##
46
+ # Calls #do_resolve_tag_expr with verb=read in order to turn
47
+ # an entry tag expression like @latest into 2.1.1
48
+ #
49
+ # See documentation for #resolve_word for information on how resolution
50
+ # works at the word level.
51
+ # @param [String] tag_expr
52
+ # @return [String]
53
+ def resolve_read_tag_expr(tag_expr)
54
+ do_resolve_tag_expr(tag_expr, "read")
55
+ end
56
+
57
+ ## Main logic to template state entry tag expressions (that users use
58
+ # to identify state entries with) into a final, usable tag.
59
+ #
60
+ # The method finds special words in the tag expression, which start with
61
+ # the SPECIAL_CHAR '@', and substitutes them one at a time with a computed
62
+ # value. For instance, @latest will become the actual tag of the latest state
63
+ # entry, and @random will become a random string.
64
+ #
65
+ # Depending on whether the user's request is for reading or writing an entry,
66
+ # different substitutions are available.
67
+ # @return [String]
68
+ # @param [String] tag_expr
69
+ # @param [String] verb
70
+ def do_resolve_tag_expr(tag_expr, verb)
71
+ raise "Internal error" unless %w[read write].include?(verb)
72
+ words = verb == 'read' ? SPECIAL_READ_WORDS : SPECIAL_WRITE_WORDS
73
+
74
+ resolved_tag = tag_expr
75
+ words.each do |special_word|
76
+ part = "#{SPECIAL_CHAR}#{special_word}"
77
+ if tag_expr.include?(part)
78
+ resolved_word = resolve_word(special_word, verb)
79
+ resolved_tag = resolved_tag.gsub(part, resolved_word)
80
+ end
81
+ end
82
+ resolved_tag
83
+ end
84
+
85
+ ##
86
+ # Performs a special word substitution for an individual special word,
87
+ # like 'latest', 'random', or 'candidate'. Works by looking for a
88
+ # corresponding the word-resolver method in self.
89
+ #
90
+ # E.g if you pass 'random', it expects the method resolve_random_word to
91
+ # exist.
92
+ #
93
+ # Because the same special word can have different interpretations
94
+ # depending on whether the mode (read or write), this method will
95
+ # first look for the mode-specialized version of the word-resolver function,
96
+ # e.g if passed 'candidate' in 'read' mode, it will first look out for
97
+ # the a word-resolver method called 'resolve_candidate_read_word' and call it
98
+ # instead of the less specialized 'resolve_candidate_word' method.
99
+ #
100
+ # @param [String] word a special word ('latest', 'random', 'candidate')
101
+ # @param [Object] verb whether this is a read or write operation
102
+ def resolve_word(word, verb)
103
+ word = word.gsub("-", "_")
104
+ if respond_to?((method = "resolve_#{word}_#{verb}_word"))
105
+ send(method)
106
+ elsif respond_to?((method = "resolve_#{word}_word"))
107
+ send(method)
108
+ else
109
+ raise "What is #{word}??"
110
+ end
111
+ end
112
+
113
+ ##
114
+ # Single word resolver. Looks for the latest candidate state entry
115
+ # and returns its tag or an empty string if there is no
116
+ # latest candidate state.
117
+ # @return [String]
118
+ def resolve_candidate_read_word
119
+ latest_candidate&.tag || ""
120
+ end
121
+
122
+ ##
123
+ # Single word resolver. Outputs a non-taken random tag (given by
124
+ # #generate_random_tag) prefixed with candidate flag prefix [cand]-.
125
+ # @return [String]
126
+ def resolve_candidate_write_word
127
+ resolve_candidate_read_word || resolve_new_candidate_word
128
+ end
129
+
130
+ def resolve_new_candidate_word
131
+ prefix = Kerbi::State::Entry::CANDIDATE_PREFIX
132
+ begin
133
+ tag = "#{prefix}#{self.class.generate_random_tag}"
134
+ end while candidates.find{ |e| e.tag == tag }
135
+ tag
136
+ end
137
+
138
+ ##
139
+ # Single word resolver. Looks for the latest committed state entry
140
+ # and returns its tag or an empty string if there is no
141
+ # latest committed state.
142
+ # @return [String]
143
+ def resolve_latest_word
144
+ latest&.tag || ""
145
+ end
146
+
147
+ ##
148
+ # Single word resolver. Looks for the latest committed state entry
149
+ # and returns its tag or an empty string if there is no
150
+ # latest committed state.
151
+ # @return [String]
152
+ def resolve_oldest_word
153
+ oldest&.tag || ""
154
+ end
155
+
156
+ ##
157
+ # Single word resolver. Outputs a non-taken random tag (given by
158
+ # #generate_random_tag).
159
+ # @return [String]
160
+ def resolve_random_word
161
+ begin
162
+ tag = self.class.generate_random_tag
163
+ end while entries.find{ |e|e.tag == tag }
164
+ tag
165
+ end
166
+
167
+ # private :do_resolve_tag_expr
168
+ # private :resolve_candidate_read_word
169
+
170
+ module ClassMethods
171
+ ##
172
+ # Uses the Spicy::Proton gem to generate a convenient,
173
+ # human-readable random tag for a state entry.
174
+ # @return [String]
175
+ def generate_random_tag
176
+ gen = Spicy::Proton.new
177
+ "#{gen.adjective(max: 5)}-#{gen.noun(max: 5)}"
178
+ end
179
+ end
180
+
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,93 @@
1
+ module Kerbi
2
+ module State
3
+ class BaseBackend
4
+
5
+ attr_reader :is_working
6
+
7
+ def initialize(options={})
8
+ end
9
+
10
+ # @return [Kerbi::State::EntrySet]
11
+ def entry_set
12
+ @_entry_set ||= EntrySet.new(read_entries)
13
+ end
14
+
15
+ # @return [Array<Kerbi::State::Entry>]
16
+ def entries
17
+ entry_set.entries
18
+ end
19
+
20
+ # @param [Kerbi::State::Entry] entry
21
+ def delete_entry(entry)
22
+ entries.reject! { |e| e.tag == entry.tag }
23
+ save
24
+ @_entry_set = nil
25
+ @_resource = nil
26
+ end
27
+
28
+ def save
29
+ entry_set.validate!
30
+ update_resource
31
+ @_entry_set = nil
32
+ @_resource = nil
33
+ end
34
+
35
+ def delete
36
+ delete_resource
37
+ end
38
+
39
+ def resource_signature
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def prime
44
+ begin
45
+ resource
46
+ entries
47
+ @is_working = true
48
+ rescue
49
+ @is_working = false
50
+ end
51
+ end
52
+
53
+ # @return [TrueClass|FalseClass]
54
+ def working?
55
+ prime if @is_working.nil?
56
+ @is_working
57
+ end
58
+
59
+ def self.type_signature
60
+ raise NotImplementedError
61
+ end
62
+
63
+ protected
64
+
65
+ def resource
66
+ @_resource ||= load_resource
67
+ end
68
+
69
+ def update_resource
70
+ raise NotImplementedError
71
+ end
72
+
73
+ # @return [Array<Hash>]
74
+ def read_entries
75
+ raise NotImplementedError
76
+ end
77
+
78
+ def load_resource
79
+ raise NotImplementedError
80
+ end
81
+
82
+ def delete_resource
83
+ raise NotImplementedError
84
+ end
85
+
86
+ private
87
+
88
+ def utils
89
+ Kerbi::Utils
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,173 @@
1
+ module Kerbi
2
+ module State
3
+
4
+ ##
5
+ # Treats a Kubernetes configmap in a namespace as a
6
+ # persistent store for state entries. Reads and writes
7
+ # to the configmap.
8
+ class ConfigMapBackend < Kerbi::State::BaseBackend
9
+ include Kerbi::Mixins::CmBackendTesting
10
+
11
+ attr_reader :auth_bundle
12
+ attr_reader :release_name
13
+ attr_reader :namespace
14
+
15
+ # @param [Hash] auth_bundle generated by Kerbi::Utils::K8sAuth
16
+ # @param [String] release_name Kubernetes namespace where configmap lives
17
+ def initialize(auth_bundle, release_name, namespace)
18
+ @auth_bundle = auth_bundle.freeze
19
+ @release_name = release_name.freeze
20
+ @namespace = (namespace || @release_name).freeze
21
+ end
22
+
23
+ ##
24
+ # Checks for the namespace and configmap, creating along
25
+ # the way if missing. Does not raise if already exists.
26
+ # @param [Hash] opts for things like verbose
27
+ def provision_missing_resources(**opts)
28
+ create_namespace unless (ns_existed = namespace_exists?)
29
+ echo_init("namespaces/#{namespace}", ns_existed, opts)
30
+
31
+ create_resource unless (cm_existed = resource_exists?)
32
+ echo_init("#{namespace}/configmaps/#{cm_name}", cm_existed, opts)
33
+ end
34
+
35
+ ##
36
+ # Creates the configmap with 0 entries.
37
+ def create_resource
38
+ apply_resource(template_resource([]))
39
+ end
40
+
41
+ ##
42
+ # Creates the configmap given an exact dict representation
43
+ # of its contents. This method doesn't actually get used outside
44
+ # of rspec, but it's super useful there so keeping for time being.
45
+ # @param [Hash] resource_desc
46
+ def apply_resource(resource_desc, mode: 'create')
47
+ if mode == 'create'
48
+ #noinspection RubyResolve
49
+ client("v1").create_config_map(resource_desc)
50
+ elsif mode == 'update'
51
+ #noinspection RubyResolve
52
+ client("v1").update_config_map(resource_desc)
53
+ else
54
+ raise "What kind of sick mode is #{mode}?"
55
+ end
56
+ end
57
+
58
+ ##
59
+ # Outputs the dict representation of the configmap, templated
60
+ # with the given entries.
61
+ # @param [Array<Kerbi::State::Entry>] entries
62
+ # @return [Hash]
63
+ def template_resource(entries)
64
+ values = {
65
+ consts::ENTRIES_ATTR => entries.map(&:to_h),
66
+ namespace: namespace,
67
+ cm_name: cm_name
68
+ }
69
+ Kerbi::State::ConfigMapMixer.new(values).run.first
70
+ end
71
+
72
+ ##
73
+ # Creates the required namespace resource for this configmap
74
+ # in the cluster.
75
+ def create_namespace
76
+ values = { namespace: namespace }
77
+ dict = Kerbi::State::NamespaceMixer.new(values).run.first
78
+ #noinspection RubyResolve
79
+ client("v1").create_namespace(dict)
80
+ end
81
+
82
+ def resource_name
83
+ cm_name
84
+ end
85
+
86
+ def resource_signature
87
+ "configmaps/#{namespace}/#{resource_name}"
88
+ end
89
+
90
+ protected
91
+
92
+ ##
93
+ # Reads the configmap from Kubernetes, returns its dict representation.
94
+ def load_resource
95
+ #noinspection RubyResolve
96
+ client("v1").get_config_map(cm_name, namespace).to_h
97
+ end
98
+
99
+ ##
100
+ # Reads the configmap from Kubernetes, returns its dict representation.
101
+ def delete_resource
102
+ #noinspection RubyResolve
103
+ client("v1").delete_config_map(cm_name, namespace)
104
+ end
105
+
106
+ ##
107
+ # Templates the updated version of the configmap given the entries
108
+ # in memory, and uses the new dict to overwrite the last configmap
109
+ # in the cluster.
110
+ def update_resource
111
+ new_resource = template_resource(entries)
112
+ #noinspection RubyResolve
113
+ client("v1").update_config_map(new_resource)
114
+ end
115
+
116
+ ##
117
+ # Deserializes the list of entries in the configmap. Calls
118
+ # #resources, which is memoized, so may trigger a cluster read.
119
+ # @return [Array<Hash>] entries
120
+ def read_entries
121
+ str_entries = resource[:data][consts::ENTRIES_ATTR]
122
+ JSON.parse(str_entries)
123
+ end
124
+
125
+ ## Creates an instance of Kubeclient::Client given
126
+ # the auth_bundle in the state, and a Kubernetes API name
127
+ # like appsV1 (defaults to "v1" if not passed).
128
+ # @return [Kubeclient::Client]
129
+ def client(api_name="v1")
130
+ self.class.make_client(auth_bundle, api_name)
131
+ end
132
+
133
+ def consts
134
+ Kerbi::State::Consts
135
+ end
136
+
137
+ def cm_name
138
+ self.class.mk_cm_name(release_name)
139
+ end
140
+
141
+ def self.releases(auth_bundle)
142
+ client = make_client(auth_bundle, "v1")
143
+ res_dicts = client.get_config_maps.map(&:to_h).select do |res_dict|
144
+ name = res_dict.dig(:metadata, :name)
145
+ name =~ Kerbi::State::Consts::CM_REGEX
146
+ end
147
+
148
+ res_dicts.map do |res_dict|
149
+ name, namespace = res_dict[:metadata].values_at(:name, :namespace)
150
+ release = name.match(Kerbi::State::Consts::CM_REGEX)[1]
151
+ self.new(auth_bundle, release, namespace)
152
+ end
153
+ end
154
+
155
+ def self.mk_cm_name(release_name)
156
+ "kerbi-#{release_name}-db"
157
+ end
158
+
159
+ def self.make_client(auth_bundle, api_name)
160
+ Kubeclient::Client.new(
161
+ auth_bundle[:endpoint],
162
+ api_name,
163
+ **auth_bundle[:options]
164
+ )
165
+ end
166
+
167
+ def self.type_signature
168
+ "ConfigMap"
169
+ end
170
+
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,173 @@
1
+ module Kerbi
2
+ module State
3
+
4
+ ##
5
+ # Represents a single Kerbi state entry.
6
+ class Entry
7
+
8
+ CANDIDATE_PREFIX = "[cand]-"
9
+
10
+ ATTRS = %i[tag message values default_values created_at]
11
+ SETTABLE_ATTRS = %i[message created_at]
12
+
13
+ attr_accessor :set
14
+
15
+ attr_accessor :tag
16
+ attr_accessor :message
17
+ attr_accessor :default_values
18
+ attr_accessor :values
19
+ attr_accessor :created_at
20
+
21
+ attr_reader :validation_errors
22
+
23
+ def initialize(set, dict)
24
+ @set = set
25
+ ATTRS.each do |attr|
26
+ instance_variable_set("@#{attr}", dict[attr].freeze)
27
+ end
28
+ @_was_validated = false
29
+ @validation_errors = []
30
+ end
31
+
32
+ ## A state entry is a 'candidate' if its tag has the
33
+ # candidate signature - that is it starts with [cand]-.
34
+ # @return [TrueClass, FalseClass]
35
+ def candidate?
36
+ tag.start_with?(CANDIDATE_PREFIX)
37
+ end
38
+
39
+ ##
40
+ # Convenience method that returns the negation of #candidate?
41
+ # @return [TrueClass, FalseClass]
42
+ def committed?
43
+ !candidate?
44
+ end
45
+
46
+ ##
47
+ # Ghetto attribute validation. Pushes a attr => msg hash to the
48
+ # @validation_errors for every problem found. Does not raise on
49
+ # problems.
50
+ # @return [NilClass]
51
+ def validate
52
+ @validation_errors.push(
53
+ attr: 'tag',
54
+ msg: "Cannot be empty",
55
+ value: tag
56
+ ) unless tag.present?
57
+
58
+ @_was_validated = true
59
+ end
60
+
61
+ def valid?
62
+ raise "valid? called before #validate" unless @_was_validated
63
+ validation_errors.empty?
64
+ end
65
+
66
+ ##
67
+ # Computes a delta between this state's values and its
68
+ # default values.
69
+ # @return [Hash]
70
+ def overrides_delta
71
+ if values.is_a?(Hash) & default_values.is_a?(Hash)
72
+ Kerbi::Utils::Misc.deep_hash_diff(default_values, values)
73
+ else
74
+ nil
75
+ end
76
+ end
77
+
78
+ ##
79
+ # Dynamically assign as a user.
80
+ # @param [String|Symbol] attr_name
81
+ # @param [Object] new_value
82
+ # @return [String] the old value, for convenience
83
+ def assign_attr(attr_name, new_value)
84
+ if SETTABLE_ATTRS.include?(attr_name.to_sym)
85
+ old_value = send(attr_name)
86
+ send("#{attr_name}=", new_value)
87
+ old_value
88
+ else
89
+ raise Kerbi::NoSuchStateAttrName
90
+ end
91
+ end
92
+
93
+ ##
94
+ # Replace current tag with a new one, where the new
95
+ # one can contain special interpolatable words like
96
+ # @candidate.
97
+ # @param [String] new_tag_expr
98
+ # @return [String] the old tag, for convenience
99
+ def retag(new_tag_expr)
100
+ old_tag = tag
101
+ self.tag = set.resolve_write_tag_expr(new_tag_expr)
102
+ old_tag
103
+ end
104
+
105
+ ##
106
+ # Removes the [cand]- part of the tag, making this
107
+ # entry lose its candidate status.
108
+ #
109
+ # Raises an exception if this entry was not a candidate.
110
+ # @return [String] the old tag, for convenience
111
+ def promote
112
+ raise Kerbi::StateNotPromotable unless candidate?
113
+ old_tag = tag
114
+ self.tag = tag[CANDIDATE_PREFIX.length..]
115
+ old_tag
116
+ end
117
+
118
+ ##
119
+ # Adds the [cand]- flag to this entry's tag, making this
120
+ # entry gain candidate status.
121
+ #
122
+ # Raises an exception if this entry was already a candidate.
123
+ # @return [String] the old tag, for convenience
124
+ def demote
125
+ raise Kerbi::StateNotDemotable unless committed?
126
+ old_tag = tag
127
+ self.tag = "#{CANDIDATE_PREFIX}#{tag}"
128
+ old_tag
129
+ end
130
+
131
+ ##
132
+ # Convenience method to get all overridden keys between
133
+ # the values and default_values dicts.
134
+ # @return [Array<String>]
135
+ def overridden_keys
136
+ (delta = overrides_delta) ? delta.keys.map(&:to_s) : []
137
+ end
138
+
139
+ def to_h
140
+ special_ser = {
141
+ values: values || {},
142
+ default_values: default_values || {},
143
+ created_at: created_at.to_s
144
+ }
145
+ Hash[ATTRS.map{|k|[k, send(k)]}].merge(special_ser)
146
+ end
147
+ alias_method :serialize, :to_h
148
+
149
+ def to_json
150
+ JSON.dump(serialize)
151
+ end
152
+
153
+ # @param [Hash] dict
154
+ # @return [Kerbi::State::Entry]
155
+ def self.from_dict(set, dict={})
156
+ dict.deep_symbolize_keys!
157
+ dict.slice!(*ATTRS)
158
+
159
+ self.new(
160
+ set,
161
+ **dict,
162
+ values: dict[:values] || {},
163
+ default_values: dict[:default_values] || {},
164
+ created_at: (Time.parse(dict[:created_at]) rescue nil)
165
+ )
166
+ end
167
+
168
+ def self.versioned?(expr)
169
+ Gem::Version.correct?(expr)
170
+ end
171
+ end
172
+ end
173
+ end