slipway 0.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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. metadata +173 -0
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cli/errors'
4
+ require_relative 'labels'
5
+
6
+ module Slipway
7
+ # A kubectl label selector.
8
+ class Selector
9
+ # `values` is sorted and holds one value for = and !=, none for existence checks.
10
+ Requirement = Data.define(:key, :operator, :values) do
11
+ def match?(labels)
12
+ case operator
13
+ when :equals, :in then labels.key?(key) && values.include?(labels[key])
14
+ when :not_equals, :not_in then !labels.key?(key) || !values.include?(labels[key])
15
+ when :exists then labels.key?(key)
16
+ when :does_not_exist then !labels.key?(key)
17
+ end
18
+ end
19
+
20
+ # Also the sort key among requirements of one key.
21
+ def to_s
22
+ case operator
23
+ when :equals then "#{key}=#{values.first}"
24
+ when :not_equals then "#{key}!=#{values.first}"
25
+ when :in then "#{key} in (#{values.join(',')})"
26
+ when :not_in then "#{key} notin (#{values.join(',')})"
27
+ when :exists then key
28
+ when :does_not_exist then "!#{key}"
29
+ end
30
+ end
31
+ end
32
+
33
+ class Parser
34
+ TOKEN = /==|!=|[=!(),]|[^\s=!(),]+/
35
+ SYMBOLS = %w[( ) , = == != !].freeze
36
+ KEYWORDS = { 'in' => :in, 'notin' => :not_in }.freeze
37
+ EXACT = { '=' => :equals, '==' => :equals, '!=' => :not_equals }.freeze
38
+
39
+ def initialize(expression)
40
+ @expression = expression
41
+ @tokens = expression.scan(TOKEN)
42
+ @position = 0
43
+ end
44
+
45
+ def parse
46
+ return [] if @tokens.empty?
47
+
48
+ requirements = [requirement]
49
+ until peek.nil?
50
+ expect(',', "expected ',' or the end of the selector")
51
+ fail!("expected a requirement after ','") if peek.nil?
52
+ requirements << requirement
53
+ end
54
+ requirements.sort_by { [it.key, it.to_s] }
55
+ end
56
+
57
+ private
58
+
59
+ def requirement
60
+ return build(key_after_bang, :does_not_exist, []) if peek == '!'
61
+
62
+ key = key_token
63
+ case peek
64
+ when nil, ',' then build(key, :exists, [])
65
+ when *EXACT.keys then build(key, EXACT.fetch(advance), [exact_value])
66
+ when *KEYWORDS.keys then build(key, KEYWORDS.fetch(advance), set_values)
67
+ else fail!("unexpected #{peek.inspect} after #{key.inspect}")
68
+ end
69
+ end
70
+
71
+ def key_after_bang
72
+ advance
73
+ fail!("expected a key after '!'") unless identifier?(peek) && !KEYWORDS.key?(peek)
74
+ advance
75
+ end
76
+
77
+ def key_token
78
+ return advance if identifier?(peek) && !KEYWORDS.key?(peek)
79
+
80
+ fail!(peek.nil? ? 'expected a key' : "expected a key, found #{peek.inspect}")
81
+ end
82
+
83
+ # kubectl reads +key=+ and +key=,other+ as a comparison with the empty value.
84
+ def exact_value
85
+ return '' if peek.nil? || peek == ','
86
+ return advance if identifier?(peek)
87
+
88
+ fail!("expected a value, found #{peek.inspect}")
89
+ end
90
+
91
+ # An empty slot, as in +()+ or +(a,)+, is the empty value.
92
+ def set_values
93
+ expect('(', "expected '(' after #{@tokens[@position - 1].inspect}")
94
+ values = []
95
+ loop do
96
+ values << (identifier?(peek) ? advance : '')
97
+ break if accept(')')
98
+
99
+ expect(',', "expected ',' or ')' in the value set")
100
+ end
101
+ values.uniq.sort
102
+ end
103
+
104
+ def build(key, operator, values)
105
+ fail!("invalid label key #{key.inspect}") unless Labels.valid_key?(key)
106
+ values.each { fail!("invalid label value #{it.inspect}") unless Labels.valid_value?(it) }
107
+ Requirement.new(key:, operator:, values:)
108
+ end
109
+
110
+ def identifier?(token) = !token.nil? && !SYMBOLS.include?(token)
111
+
112
+ def peek = @tokens[@position]
113
+
114
+ def advance
115
+ token = peek
116
+ @position += 1
117
+ token
118
+ end
119
+
120
+ def accept(token)
121
+ return unless peek == token
122
+
123
+ advance
124
+ end
125
+
126
+ def expect(token, reason)
127
+ fail!(reason) unless accept(token)
128
+ end
129
+
130
+ def fail!(reason)
131
+ raise CLI::UsageError, "invalid selector #{@expression.inspect}: #{reason}"
132
+ end
133
+ end
134
+
135
+ # nil or a blank string selects everything.
136
+ def self.parse(expression) = new(Parser.new(expression.to_s).parse)
137
+
138
+ def initialize(requirements)
139
+ @requirements = requirements.freeze
140
+ end
141
+
142
+ def match?(labels) = @requirements.all? { it.match?(labels) }
143
+
144
+ def to_s = @requirements.join(',')
145
+ end
146
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'error'
5
+ require_relative 'cli/theme'
6
+ require_relative 'cli/style'
7
+ require_relative 'names'
8
+
9
+ module Slipway
10
+ # The settings in effect for one run. Settings.load resolves each from the flag typed on the
11
+ # command line, then its `SLIPWAY_*` variable, then the configuration file, then its default,
12
+ # and raises Settings::Error naming the file or the variable whose value is refused.
13
+ # `variables` maps each key whose value came from its variable to the variable's name.
14
+ Settings = Data.define(:color, :theme, :editor, :group, :network_timeout, :parallel, :protocols, :path, :exists,
15
+ :variables)
16
+
17
+ class Settings
18
+ class Error < Slipway::Error; end
19
+
20
+ # A git transport name, spelled as a URL scheme. The names reach GIT_ALLOW_PROTOCOL joined by
21
+ # colons, so a name holding a colon would allow a transport nobody listed.
22
+ PROTOCOL = /\A[a-z][a-z0-9+.-]*\z/
23
+ INTEGER = ->(text) { Integer(text, 10, exception: false) }
24
+ PARALLEL = 1..16
25
+ # Capped at a day: Thread#join, which enforces the deadline, takes a timeout past about 1.8e10
26
+ # seconds as already passed.
27
+ NETWORK_TIMEOUT = 1..86_400
28
+ # Colon-separated like GIT_ALLOW_PROTOCOL; an empty field is kept so the check refuses it.
29
+ LIST = ->(text) { text.split(':', -1) }
30
+ # Once GIT_ALLOW_PROTOCOL is set it is git's whole policy, and git's own refusal of ext no
31
+ # longer applies, so these stay refused even when listed.
32
+ UNSAFE_PROTOCOLS = {
33
+ 'ext' => 'runs a command named in the URL',
34
+ 'fd' => 'reads from file descriptors'
35
+ }.freeze
36
+
37
+ # `parse` reads the string an environment variable holds; what it cannot read comes back as
38
+ # nil and fails the check with the key's expectation, or with `variable_expectation` when the
39
+ # variable is written in another form than the file's value. `refusal` says why a value of the
40
+ # right form is still refused, or returns nil.
41
+ Setting = Data.define(:key, :variable, :default, :description, :valid, :expectation, :parse,
42
+ :variable_expectation, :refusal) do
43
+ def initialize(parse: :itself.to_proc, variable_expectation: nil, refusal: ->(_) {}, **) = super
44
+
45
+ def attribute = key.gsub(/(?=[A-Z])/, '_').downcase.to_sym
46
+
47
+ def check(value, prefix, subject: "\"#{key}\" ", expectation: self.expectation)
48
+ problem = valid.call(value) ? refusal.call(value) : expectation
49
+ return value if problem.nil?
50
+
51
+ raise Error, "#{prefix}: #{subject}#{problem}"
52
+ end
53
+
54
+ def from_variable(text)
55
+ check(parse.call(text), variable, subject: '', expectation: variable_expectation || expectation)
56
+ end
57
+
58
+ def documentation = [description, default && "Default: #{Array(default).join(', ')}."].compact.join(' ')
59
+ end
60
+
61
+ ALL = [
62
+ Setting.new(key: 'color', variable: 'SLIPWAY_COLOR', default: CLI::Style::DEFAULT_MODE,
63
+ description: 'When to color output: auto, always or never.',
64
+ valid: ->(value) { CLI::Style::MODES.include?(value) },
65
+ expectation: "must be one of #{CLI::Style::MODES.join(', ')}"),
66
+ Setting.new(key: 'editor', variable: 'SLIPWAY_EDITOR', default: nil,
67
+ description: 'Command line of the editor that slipway edit opens.',
68
+ valid: ->(value) { value.is_a?(String) }, expectation: 'must be a string'),
69
+ Setting.new(key: 'group', variable: 'SLIPWAY_GROUP', default: 'default',
70
+ description: 'Group used when -n is not given.',
71
+ valid: ->(value) { Names.valid?(value) },
72
+ expectation: "must be a valid group name: #{Names::RULE}"),
73
+ Setting.new(key: 'networkTimeout', variable: 'SLIPWAY_NETWORK_TIMEOUT', default: 60,
74
+ description: "Seconds, from #{NETWORK_TIMEOUT.min} to #{NETWORK_TIMEOUT.max}, a git network " \
75
+ 'command may run before it is killed with the processes it started.',
76
+ valid: ->(value) { value.is_a?(Integer) && NETWORK_TIMEOUT.cover?(value) },
77
+ expectation: "must be an integer from #{NETWORK_TIMEOUT.min} to #{NETWORK_TIMEOUT.max}",
78
+ parse: INTEGER),
79
+ Setting.new(key: 'parallel', variable: 'SLIPWAY_PARALLEL', default: 4,
80
+ description: "How many git network commands run at once, from #{PARALLEL.min} to #{PARALLEL.max}.",
81
+ valid: ->(value) { value.is_a?(Integer) && PARALLEL.cover?(value) },
82
+ expectation: "must be an integer from #{PARALLEL.min} to #{PARALLEL.max}", parse: INTEGER),
83
+ Setting.new(key: 'protocols', variable: 'SLIPWAY_PROTOCOLS', default: %w[ssh https].freeze,
84
+ description: 'Transports git may use in network commands, as a list; any other transport is ' \
85
+ 'refused, and so are ext and fd. Add file for local mirrors.',
86
+ valid: lambda { |value|
87
+ value.is_a?(Array) && !value.empty? && value.all? { it.is_a?(String) && PROTOCOL.match?(it) }
88
+ },
89
+ expectation: 'must be a list of lowercase git transport names, such as ssh, https or file',
90
+ variable_expectation: 'must be lowercase git transport names separated by colons, such as ssh:https',
91
+ parse: LIST,
92
+ refusal: lambda { |names|
93
+ unsafe = names.find { UNSAFE_PROTOCOLS.key?(it) }
94
+ "must not include #{unsafe}, which #{UNSAFE_PROTOCOLS[unsafe]}" if unsafe
95
+ }),
96
+ Setting.new(key: 'theme', variable: 'SLIPWAY_THEME', default: CLI::Theme::DEFAULT_NAME,
97
+ description: 'Color theme: dark or light.',
98
+ valid: ->(value) { CLI::Theme::NAMES.include?(value) },
99
+ expectation: "must be one of #{CLI::Theme::NAMES.join(', ')}")
100
+ ].freeze
101
+
102
+ KEYS = ALL.map(&:key).freeze
103
+ DOCUMENTATION = ALL.to_h { [it.key, it.documentation] }.freeze
104
+
105
+ class Document
106
+ Contents = Data.define(:values, :exists)
107
+
108
+ def self.read(path, explicit:)
109
+ text = File.read(path)
110
+ rescue Errno::ENOENT
111
+ raise Error, "#{path}: no such file" if explicit
112
+
113
+ Contents.new(values: {}, exists: false)
114
+ rescue SystemCallError => e
115
+ raise Error.from_system_call(e, path)
116
+ else
117
+ Contents.new(values: new(path, text).values, exists: true)
118
+ end
119
+
120
+ def initialize(path, text)
121
+ @path = path
122
+ @text = text
123
+ end
124
+
125
+ def values
126
+ document = parse
127
+ return {} if document.nil?
128
+ raise Error, "#{@path}: expected a mapping of keys to values" unless document.is_a?(Hash)
129
+
130
+ document.to_h { |key, value| [key.to_s, setting(key).check(value, @path)] }
131
+ end
132
+
133
+ private
134
+
135
+ def parse
136
+ Psych.safe_load(@text, filename: @path, symbolize_names: false)
137
+ rescue Psych::SyntaxError => e
138
+ raise Error, "#{@path}: #{e.message.delete_prefix("(#{@path}): ")}"
139
+ rescue Psych::Exception => e
140
+ raise Error, "#{@path}: #{e.message}"
141
+ end
142
+
143
+ def setting(key)
144
+ ALL.find { it.key == key } ||
145
+ raise(Error, "#{@path}: unknown key \"#{key}\" (known keys: #{KEYS.join(', ')})")
146
+ end
147
+ end
148
+
149
+ def self.load(paths, env:, flags: {})
150
+ path = paths.config_file
151
+ contents = Document.read(path, explicit: paths.config_explicit?)
152
+ resolved = ALL.to_h { |setting| [setting.attribute, resolve(setting, flags, env, contents.values)] }
153
+ # Each key whose value its environment variable supplied, with that variable's name, so a
154
+ # message can point at the variable rather than at a file it outranks.
155
+ variables = ALL.select { from_variable?(it, flags, env) }.to_h { [it.key, it.variable] }.freeze
156
+ new(path:, exists: contents.exists, variables:, **resolved)
157
+ end
158
+
159
+ def self.resolve(setting, flags, env, file)
160
+ flag = flags[setting.attribute]
161
+ return flag unless flag.nil?
162
+ return setting.from_variable(env[setting.variable]) if from_variable?(setting, flags, env)
163
+
164
+ file.fetch(setting.key, setting.default)
165
+ end
166
+
167
+ def self.from_variable?(setting, flags, env) = flags[setting.attribute].nil? && !env[setting.variable].to_s.empty?
168
+ private_class_method :resolve, :from_variable?
169
+
170
+ def exists? = exists
171
+
172
+ def to_h = ALL.to_h { [it.key, public_send(it.attribute)] }
173
+ end
174
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git/runner'
4
+
5
+ module Slipway
6
+ # A repository can be dirty and behind at once; the STATUS column shows one word, for the
7
+ # fact that needs attention first.
8
+ module State
9
+ # In precedence order.
10
+ ROLES = {
11
+ 'Missing' => :status_danger,
12
+ 'NotARepo' => :status_danger,
13
+ 'Unsafe' => :status_danger,
14
+ 'Conflicted' => :status_danger,
15
+ 'Detached' => :status_warning,
16
+ 'Unborn' => :status_warning,
17
+ 'Dirty' => :status_warning,
18
+ 'Gone' => :status_warning,
19
+ 'Diverged' => :status_warning,
20
+ 'Ahead' => :status_warning,
21
+ 'Behind' => :status_warning,
22
+ 'Clean' => :status_success,
23
+ 'Unknown' => :muted
24
+ }.freeze
25
+
26
+ # What each word means, in the same order. The help of get and describe prints these, and the
27
+ # README table says the same with its code spans marked.
28
+ MEANINGS = {
29
+ 'Missing' => 'The registered path is relative, or is not a directory on this machine.',
30
+ 'NotARepo' => 'The directory exists but no repository contains it.',
31
+ 'Unsafe' => 'git refused the repository because another user owns it (safe.directory); describe, diff and ' \
32
+ 'fetch print the git command that trusts it.',
33
+ 'Conflicted' => 'The working tree has unmerged paths.',
34
+ 'Detached' => 'HEAD points at a commit rather than a branch.',
35
+ 'Unborn' => 'The branch has no commits yet.',
36
+ 'Dirty' => 'Staged, modified or untracked files are present.',
37
+ 'Gone' => 'An upstream is configured but its remote-tracking ref is gone, as of the last fetch --prune (or a ' \
38
+ 'fetch with fetch.prune set).',
39
+ 'Diverged' => 'The branch is both ahead of and behind its upstream, as of the last fetch (FETCHED).',
40
+ 'Ahead' => 'Commits not yet pushed to the upstream, as of the last fetch (FETCHED).',
41
+ 'Behind' => 'Commits on the upstream not yet pulled, as of the last fetch (FETCHED).',
42
+ 'Clean' => 'Nothing to do.',
43
+ 'Unknown' => 'git could not answer: it is not installed, it did not finish within ' \
44
+ "#{Git::Runner::DEFAULT_TIMEOUT.to_i} seconds, or it failed for a reason slipway does not " \
45
+ 'classify. Each distinct reason is printed once on stderr per run.'
46
+ }.freeze
47
+
48
+ # A conflict outranks everything, then the states in which a commit could be lost, then
49
+ # uncommitted work, then the position against upstream.
50
+ def self.derive(status)
51
+ return 'Conflicted' if status.conflicted.positive?
52
+ return 'Detached' if status.detached?
53
+ return 'Unborn' if status.unborn?
54
+ return 'Dirty' unless status.clean?
55
+ return 'Gone' if status.upstream_gone?
56
+
57
+ position(status)
58
+ end
59
+
60
+ def self.for_error(error)
61
+ case error
62
+ when Git::MissingPath then 'Missing'
63
+ when Git::NotARepository then 'NotARepo'
64
+ when Git::UnsafeRepository then 'Unsafe'
65
+ else 'Unknown'
66
+ end
67
+ end
68
+
69
+ def self.role(word) = ROLES.fetch(word)
70
+
71
+ def self.position(status)
72
+ ahead = status.ahead.to_i.positive?
73
+ behind = status.behind.to_i.positive?
74
+ return 'Diverged' if ahead && behind
75
+ return 'Ahead' if ahead
76
+ return 'Behind' if behind
77
+
78
+ 'Clean'
79
+ end
80
+ private_class_method :position
81
+ end
82
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'error'
5
+ require_relative 'names'
6
+ require_relative 'resources'
7
+ require_relative 'manifest'
8
+
9
+ module Slipway
10
+ # The registry on disk: one manifest per resource, `groups/NAME.yaml` and
11
+ # `projects/GROUP/NAME.yaml` under `root`. A write goes to a temporary file renamed over the
12
+ # target, so a reader sees the old manifest or the new one and never part of one. Nothing is
13
+ # locked: two processes that write the same resource at once leave the one that renamed last.
14
+ # A file is trusted only when its kind, name and group match where it was found.
15
+ class Store
16
+ class NotFound < Error
17
+ # kubectl's form for every kind: `projects "hldr" not found`, `groups "work" not found`.
18
+ def self.of(kind, name) = new("#{kind.plural} #{name.inspect} not found")
19
+ end
20
+
21
+ class Conflict < Error; end
22
+
23
+ DEFAULT_GROUP = Resources::DEFAULT_GROUP
24
+ PROTECTED_GROUP = 'the default group cannot be deleted'
25
+
26
+ attr_reader :root
27
+
28
+ def initialize(root:, clock: -> { Time.now.utc })
29
+ @root = root
30
+ @clock = clock
31
+ end
32
+
33
+ # A file that does not hold a valid manifest raises, or is handed to the block and skipped.
34
+ def list(kind, group: nil, &on_problem)
35
+ files = kind.namespaced? ? project_files(group) : group_files
36
+ resources = files.filter_map { read_or_report(kind, it, &on_problem) }
37
+ kind.namespaced? ? resources.sort_by { [it.group, it.name] } : resources.sort_by(&:name)
38
+ end
39
+
40
+ # A nil `group` means the default group for projects.
41
+ def find(kind, name, group: nil)
42
+ file = path_for(kind, name, group)
43
+ raise NotFound.of(kind, name) unless File.file?(file)
44
+
45
+ read(kind, file)
46
+ end
47
+
48
+ # A nil `group` means the default group for projects.
49
+ def exist?(kind, name, group: nil) = File.file?(path_for(kind, name, group))
50
+
51
+ # Unique because project names repeat across groups; completion wants each once.
52
+ def names(kind, group: nil) = list(kind, group:).map(&:name).uniq.sort
53
+
54
+ def create(resource)
55
+ kind = Resources.of(resource)
56
+ target = path_of(kind, resource)
57
+ ensure_group!(resource.group) if kind.namespaced?
58
+ raise Conflict, "#{kind.singular} #{resource.name.inspect} already exists" if File.exist?(target)
59
+
60
+ stamped = resource.created_at ? resource : resource.with(created_at: @clock.call.getutc.floor)
61
+ write(target, stamped)
62
+ stamped
63
+ end
64
+
65
+ def save(resource)
66
+ kind = Resources.of(resource)
67
+ target = path_of(kind, resource)
68
+ ensure_group!(resource.group) if kind.namespaced?
69
+ write(target, resource)
70
+ resource
71
+ end
72
+
73
+ # Deleting a group also removes the registrations of its projects.
74
+ def delete(kind, name, group: nil)
75
+ file = path_for(kind, name, group)
76
+ raise Error, PROTECTED_GROUP if !kind.namespaced? && name == DEFAULT_GROUP
77
+ raise NotFound.of(kind, name) unless File.file?(file)
78
+
79
+ File.delete(file)
80
+ kind.namespaced? ? prune(File.dirname(file)) : FileUtils.rm_rf(File.join(root, 'projects', name))
81
+ end
82
+
83
+ def project_count(group_name) = project_files(group_name).size
84
+
85
+ def group_available?(name)
86
+ Names.validate!(name, what: 'group name')
87
+ name == DEFAULT_GROUP || File.file?(group_file(name))
88
+ end
89
+
90
+ private
91
+
92
+ def ensure_group!(name)
93
+ raise NotFound.of(Resources::GROUPS, name) unless group_available?(name)
94
+
95
+ create(Group.new(name: DEFAULT_GROUP)) if name == DEFAULT_GROUP && !File.file?(group_file(name))
96
+ name
97
+ end
98
+
99
+ def path_for(kind, name, group)
100
+ Names.validate!(name, what: "#{kind.singular} name")
101
+ kind.namespaced? ? project_file(group || DEFAULT_GROUP, name) : group_file(name)
102
+ end
103
+
104
+ def path_of(kind, resource) = path_for(kind, resource.name, kind.namespaced? ? resource.group : nil)
105
+
106
+ def project_file(group, name)
107
+ File.join(root, 'projects', Names.validate!(group, what: 'group name'), "#{name}.yaml")
108
+ end
109
+
110
+ def group_file(name) = File.join(root, 'groups', "#{name}.yaml")
111
+
112
+ # A group's project directory exists only while it holds a project. rmdir refuses a
113
+ # directory that is not empty, which is the check itself; there is no locking to lose.
114
+ def prune(directory)
115
+ Dir.rmdir(directory)
116
+ rescue Errno::ENOTEMPTY, Errno::ENOENT
117
+ nil
118
+ end
119
+
120
+ # The root is a path the user chose, so it is the glob's base rather than part of the
121
+ # pattern: a bracket or a star in it must not be read as a wildcard.
122
+ def group_files = Dir.glob(File.join('groups', '*.yaml'), base: root).map { File.join(root, it) }
123
+
124
+ def project_files(group)
125
+ pattern = group ? Names.validate!(group, what: 'group name') : '*'
126
+ Dir.glob(File.join('projects', pattern, '*.yaml'), base: root).map { File.join(root, it) }
127
+ end
128
+
129
+ def read_or_report(kind, file)
130
+ read(kind, file)
131
+ rescue Error => e
132
+ raise unless block_given?
133
+
134
+ yield e
135
+ nil
136
+ end
137
+
138
+ # A manifest is only trusted when its kind, name and group agree with where it was found.
139
+ def read(kind, file)
140
+ resource = Manifest.parse_yaml(read_text(file), source: file)
141
+ return resource if resource.is_a?(kind.klass) && path_of(kind, resource) == file
142
+
143
+ raise Manifest::Invalid.new(file, "describes #{resource.kind.downcase} #{resource.name.inspect}, " \
144
+ 'which does not belong at this path')
145
+ end
146
+
147
+ def read_text(file)
148
+ File.read(file)
149
+ rescue SystemCallError => e
150
+ raise Error.from_system_call(e, file)
151
+ end
152
+
153
+ # A temporary file in the target directory renamed over the target, so a reader never sees a partial file.
154
+ def write(target, resource)
155
+ require 'tempfile'
156
+ FileUtils.mkdir_p(root, mode: 0o700)
157
+ directory = File.dirname(target)
158
+ FileUtils.mkdir_p(directory)
159
+ Tempfile.create([".#{File.basename(target)}.", '.tmp'], directory) do |tmp|
160
+ tmp.write(Manifest.dump(resource))
161
+ tmp.fsync
162
+ # Tempfile creates the file 0600; a manifest gets the mode File.write would give it.
163
+ tmp.chmod(0o666 & ~File.umask)
164
+ File.rename(tmp.path, target)
165
+ end
166
+ rescue SystemCallError => e
167
+ raise Error.from_system_call(e, target)
168
+ end
169
+ end
170
+ end