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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'command_line'
4
+ require_relative 'drift'
5
+ require_relative 'git'
6
+ require_relative 'outcome'
7
+ require_relative 'paths'
8
+ require_relative 'rollout_history'
9
+
10
+ module Slipway
11
+ # Moves a project's branch to a revision of its rollout history and holds the project there
12
+ # with spec.revision, as kubectl rollout undo rewrites a Deployment's template to an earlier
13
+ # revision. The manifest is written only once git has moved the branch.
14
+ class Rollback
15
+ # Where the branch is and where the chosen revision is, as the checks saw them. `distance`
16
+ # counts the commits the move drops (ahead) or gains (behind).
17
+ Step = Data.define(:project, :status, :head, :revision, :distance) do
18
+ def back? = distance.ahead.positive?
19
+
20
+ def forward? = !back? && distance.behind.positive?
21
+
22
+ def count = back? ? distance.ahead : distance.behind
23
+ end
24
+
25
+ # A check that failed, turned into the project's skipped line.
26
+ class Refused < StandardError
27
+ attr_reader :reason, :values
28
+
29
+ def initialize(reason, values)
30
+ @reason = reason
31
+ @values = values
32
+ super(reason)
33
+ end
34
+ end
35
+
36
+ ROLLED_BACK = 'rolled back'
37
+ ACTION = Git::Repository::ROLLBACK_ACTION
38
+ ABBREV = Git::Porcelain::ABBREVIATION
39
+ HELD = 'held there by spec.revision'
40
+ NOT_PINNED = 'NotPinned'
41
+ # Each leaves the branch and the manifest as they were. A conflict and an operation in progress
42
+ # stop sync too, and read as diff and sync word them.
43
+ REFUSALS = {
44
+ 'Conflicted' => Drift::BLOCKERS.fetch('Conflicted'),
45
+ 'Detached' => 'HEAD is detached at %<head>s; undo moves only a checked-out branch',
46
+ 'Unborn' => 'no commits yet; nothing to roll back',
47
+ 'NoUpstream' => '%<branch>s tracks no upstream; undo drops only commits an upstream holds',
48
+ 'Gone' => 'upstream %<upstream>s no longer exists; undo drops only commits an upstream holds',
49
+ 'InProgress' => Drift::BLOCKERS.fetch('InProgress'),
50
+ 'NoHistory' => 'no rollout history found for %<branch>s',
51
+ 'NoPrevious' => 'no last revision to roll back to',
52
+ 'UnknownRevision' => 'unable to find specified revision %<number>d in history',
53
+ 'RevisionNotFound' => 'commit %<commit>s of revision %<number>d is not in this repository',
54
+ 'Diverged' => 'revision %<number>d is not on the history of %<branch>s; undo moves a branch only along it',
55
+ 'LocalCommits' => '%<branch>s has commits that are not on %<upstream>s; undo would drop them',
56
+ 'Dirty' => '%<changes>s; undo moves a branch back only without staged changes, and forward only without ' \
57
+ 'staged or unstaged changes',
58
+ 'OffUpstream' => 'revision %<number>d is not on %<upstream>s; undo moves a branch forward only along its upstream'
59
+ }.freeze
60
+ # Git's own refusals, worded for a move either way.
61
+ RELAYED = {
62
+ 'WouldLoseChanges' => 'the move would overwrite local changes; commit or move them and run undo again',
63
+ 'WouldOverwrite' => 'the move would overwrite untracked or ignored files; move them and run undo again'
64
+ }.freeze
65
+ KEEPS_LOCKS = 'undo never removes a lock'
66
+ # Plain status hides an ignored file, and the move refuses one in its way.
67
+ COMMANDS = { 'Conflicted' => %w[status], 'Detached' => %w[status], 'Gone' => %w[branch -vv],
68
+ 'InProgress' => %w[status], 'LocalCommits' => %w[log --oneline @{upstream}..HEAD],
69
+ 'Dirty' => %w[status], 'WouldLoseChanges' => %w[status],
70
+ 'WouldOverwrite' => %w[status --ignored] }.freeze
71
+
72
+ # `group` is the one a command without -n selects, so the unpin command can leave it out; nil
73
+ # when -n was typed, so the unpin command names the group.
74
+ def initialize(runtime, dry_run:, group:)
75
+ @runtime = runtime
76
+ @dry_run = dry_run
77
+ @group = group
78
+ end
79
+
80
+ # `number` is a revision the history lists; nil or 0 is the one before the current one.
81
+ def undo(project, number: nil)
82
+ inspection = @runtime.inspector.examine(project)
83
+ return Outcome.unreadable(project, inspection) if inspection.error
84
+
85
+ ready(inspection)
86
+ step = plan(inspection, number)
87
+ @dry_run ? settled(step) : act(step)
88
+ rescue Refused => e
89
+ skipped(project, e.reason, format(REFUSALS.fetch(e.reason), **e.values))
90
+ end
91
+
92
+ private
93
+
94
+ # The checks sync makes before it moves a branch, less the one for local changes, which
95
+ # depends on the direction of the move.
96
+ def ready(inspection)
97
+ status = inspection.status
98
+ refuse('Conflicted', paths: unmerged(status.conflicted)) if status.conflicted.positive?
99
+ refuse('Detached', head: status.head) if status.detached?
100
+ refuse('Unborn') if status.unborn?
101
+ refuse('NoUpstream', branch: status.branch) if status.upstream.nil?
102
+ refuse('Gone', upstream: status.upstream) if status.upstream_gone?
103
+ operation = @runtime.git.in_progress(path(inspection.project))
104
+ refuse('InProgress', operation:) if operation
105
+ end
106
+
107
+ def plan(inspection, number)
108
+ project = inspection.project
109
+ status = inspection.status
110
+ head = inspection.commit.sha
111
+ history = RolloutHistory.new(@runtime.git.reflog(path(project), status.branch))
112
+ revision = target(history, head, number, status.branch)
113
+ distance = @runtime.git.distance(path(project), revision.sha, tracking: true)
114
+ refuse('RevisionNotFound', commit: short(revision.sha), number: revision.number) if distance.nil?
115
+ Step.new(project:, status:, head:, revision:, distance:).tap { check(it) }
116
+ end
117
+
118
+ def target(history, head, number, branch)
119
+ revision = number.to_i.zero? ? history.previous(head) : history.revision(number)
120
+ return revision if revision
121
+
122
+ refuse('NoHistory', branch:) if history.empty?
123
+ number.to_i.zero? ? refuse('NoPrevious') : refuse('UnknownRevision', number:)
124
+ end
125
+
126
+ # A move back must not drop a commit only this repository holds, nor a staged change, since
127
+ # reset --keep resets every index entry; git itself refuses one that would lose an unstaged
128
+ # change. A move forward is a fast-forward and needs what sync's fast-forward needs.
129
+ def check(step)
130
+ status = step.status
131
+ values = { number: step.revision.number, branch: status.branch, upstream: status.upstream }
132
+ back_ready(step, values) if step.back?
133
+ forward_ready(step, values) if step.forward?
134
+ end
135
+
136
+ def back_ready(step, values)
137
+ status = step.status
138
+ refuse('Diverged', **values) if step.distance.behind.positive?
139
+ refuse('LocalCommits', **values) if status.ahead.to_i.positive?
140
+ refuse('Dirty', changes: "#{status.staged} staged") if status.staged.positive?
141
+ end
142
+
143
+ def forward_ready(step, values)
144
+ status = step.status
145
+ changes = { 'staged' => status.staged, 'unstaged' => status.unstaged }
146
+ .filter_map { |kind, number| "#{number} #{kind}" if number.positive? }
147
+ refuse('Dirty', changes: changes.join(', ')) unless changes.empty?
148
+ refuse('OffUpstream', **values) if step.distance.off_upstream.to_i.positive?
149
+ end
150
+
151
+ def act(step)
152
+ held(step, move(step))
153
+ rescue Git::Blocked => e
154
+ relayed(step.project, e)
155
+ rescue Git::Error => e
156
+ Outcome.failure(step.project, e)
157
+ end
158
+
159
+ def move(step)
160
+ path = path(step.project)
161
+ sha = step.revision.sha
162
+ if step.back? then @runtime.git.roll_back(path, to: sha)
163
+ elsif step.forward? then @runtime.git.fast_forward(path, onto: sha, reflog_action: ACTION)
164
+ end
165
+ end
166
+
167
+ # The branch has moved by now, so a manifest that cannot be written says how to hold it there:
168
+ # the same command with the revision named finds nothing to move and only writes the pin.
169
+ def held(step, move)
170
+ from = move&.from || step.head
171
+ count = move&.count || 0
172
+ pin(step)
173
+ settled(step, from:, count:)
174
+ rescue Error => e
175
+ Outcome.new(project: step.project, word: Outcome::FAILED, reason: NOT_PINNED,
176
+ details: [position(step, from, count), unpinned(step, e)])
177
+ end
178
+
179
+ def unpinned(step, error)
180
+ retry_command = "#{RolloutHistory.command('undo', step.project, @group)} --to-revision=#{step.revision.number}"
181
+ "spec.revision was not written: #{error.message}; run '#{retry_command}' to hold it there"
182
+ end
183
+
184
+ def pin(step)
185
+ project = step.project
186
+ sha = step.revision.sha
187
+ @runtime.store.save(project.with(revision: sha)) unless project.revision == sha
188
+ end
189
+
190
+ def settled(step, from: step.head, count: step.count)
191
+ project = step.project
192
+ changed = count.positive? || project.revision != step.revision.sha
193
+ Outcome.new(project:, word: changed ? ROLLED_BACK : Outcome::UNCHANGED,
194
+ details: ["#{position(step, from, count)}; #{HELD}", following(project, step.status)])
195
+ end
196
+
197
+ def position(step, from, count)
198
+ revision = step.revision
199
+ branch = step.status.branch
200
+ target = short(revision.sha)
201
+ return "#{branch} is already at #{target} (revision #{revision.number})" if count.zero?
202
+
203
+ way = step.back? ? 'back' : 'forward'
204
+ "#{branch} #{short(from)}..#{target} (#{commits(count)} #{way} to revision #{revision.number})"
205
+ end
206
+
207
+ def following(project, status)
208
+ "'#{RolloutHistory.command('unpin', project, @group)}' follows #{status.upstream} again"
209
+ end
210
+
211
+ def relayed(project, error)
212
+ reason = error.reason
213
+ detail = RELAYED.fetch(reason) do
214
+ message = error.message.delete_prefix("#{error.path}: ")
215
+ reason == 'Busy' ? "#{message}; #{KEEPS_LOCKS}" : message
216
+ end
217
+ skipped(project, reason, detail)
218
+ end
219
+
220
+ def skipped(project, reason, detail)
221
+ words = COMMANDS[reason]
222
+ Outcome.new(project:, word: Outcome::SKIPPED, reason:,
223
+ details: [detail, words && CommandLine.git(project, *words)].compact)
224
+ end
225
+
226
+ def refuse(reason, **values) = raise(Refused.new(reason, values))
227
+
228
+ def unmerged(count) = count == 1 ? '1 unmerged path' : "#{count} unmerged paths"
229
+
230
+ def commits(count) = count == 1 ? '1 commit' : "#{count} commits"
231
+
232
+ def short(sha) = sha[0, ABBREV]
233
+
234
+ def path(project) = Paths.expand(project.path, home: @runtime.paths.home)
235
+ end
236
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git'
4
+ require_relative 'resources'
5
+
6
+ module Slipway
7
+ # The revisions of a branch, read from the moves slipway left in its reflog. Git keeps the
8
+ # history, so slipway stores none of its own.
9
+ class RolloutHistory
10
+ # Every move slipway makes runs with GIT_REFLOG_ACTION set to "slipway <action>", and git
11
+ # writes "<action>: <what it did>".
12
+ ACTION = /\Aslipway ([^:]+):/
13
+ SYNC = 'sync'
14
+ UNDO = 'rollout undo'
15
+
16
+ # A commit the branch pointed at just before or just after a move slipway made. `action` names
17
+ # that move ("sync", "rollout undo"), or is nil for where the branch stood before slipway moved
18
+ # it; `from` is the commit the move started from, nil when the reflog no longer holds it.
19
+ Revision = Data.define(:number, :sha, :time, :action, :from)
20
+
21
+ # The rollout command that acts on `project`. It names the project's group unless that is
22
+ # `group`, the one a command typed without -n selects.
23
+ def self.command(verb, project, group)
24
+ command = "slipway rollout #{verb} #{Resources::PROJECTS.singular}/#{project.name}"
25
+ project.group == group ? command : "#{command} -n #{project.group}"
26
+ end
27
+
28
+ attr_reader :revisions
29
+
30
+ # `entries` are Git::ReflogEntry values, newest first as git lists them. Revisions count from
31
+ # the oldest entry git still keeps.
32
+ def initialize(entries)
33
+ @revisions = []
34
+ [nil, *entries.reverse].each_cons(2) { |before, entry| record(entry, before) }
35
+ @revisions.freeze
36
+ end
37
+
38
+ def empty? = revisions.empty?
39
+
40
+ def revision(number) = revisions.find { it.number == number }
41
+
42
+ # The revision before the current one. A HEAD at the newest revision makes that one the
43
+ # current; a HEAD that moved on without slipway makes the newest the one before.
44
+ def previous(head)
45
+ newest = revisions.last
46
+ newest&.sha == head ? revisions[-2] : newest
47
+ end
48
+
49
+ # The revision an undo returned to: the newest earlier one at the same commit.
50
+ def undone_to(revision) = revisions.first(revision.number - 1).reverse.find { it.sha == revision.sha }
51
+
52
+ # The newest revision at `sha`, the one spec.revision holds.
53
+ def pinned(sha) = revisions.reverse.find { it.sha == sha }
54
+
55
+ private
56
+
57
+ def record(entry, before)
58
+ action = entry.subject[ACTION, 1]
59
+ return unless action
60
+
61
+ add(before, nil, nil) if before && @revisions.last&.sha != before.sha
62
+ add(entry, action, before&.sha)
63
+ end
64
+
65
+ def add(entry, action, from)
66
+ @revisions << Revision.new(number: @revisions.size + 1, sha: entry.sha, time: entry.time, action:, from:)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'paths'
4
+ require_relative 'settings'
5
+ require_relative 'store'
6
+ require_relative 'git'
7
+ require_relative 'inspector'
8
+
9
+ module Slipway
10
+ # Tests build one with fakes and a temporary store instead of calling build.
11
+ class Runtime
12
+ CONFIG_FLAG = '--config'
13
+ CONFIG_INLINE = "#{CONFIG_FLAG}=".freeze
14
+
15
+ attr_reader :settings, :paths, :store, :git, :inspector, :clock, :env
16
+
17
+ def self.build(context, opts)
18
+ paths = Paths.new(context.env, config: opts[:config])
19
+ settings = Settings.load(paths, env: context.env, flags: opts.slice(:color, :group))
20
+ clock = -> { Time.now.utc }
21
+ # The variable outranks the file, so a transport added to the file would change nothing.
22
+ protocols_source = settings.variables.fetch('protocols') { %("protocols" in #{settings.path}) }
23
+ git = Git::Repository.new(network_timeout: settings.network_timeout, protocols: settings.protocols,
24
+ protocols_source:)
25
+ new(settings:, paths:, store: Store.new(root: paths.data_home, clock:), git:,
26
+ inspector: Inspector.new(git:, clock:, home: paths.home), clock:, env: context.env)
27
+ end
28
+
29
+ # Read before the command line is parsed, so a `--color` typed later still outranks it.
30
+ # A broken file yields no defaults; the command that runs next raises the same error with
31
+ # its exit status.
32
+ def self.color_defaults(context, argv)
33
+ paths = Paths.new(context.env, config: config_flag(argv))
34
+ settings = Settings.load(paths, env: context.env)
35
+ [settings.color, settings.theme]
36
+ rescue Settings::Error
37
+ [nil, nil]
38
+ end
39
+
40
+ def self.config_flag(argv)
41
+ words = argv.take_while { it != '--' }
42
+ words.each_with_index do |word, index|
43
+ return words[index + 1] if word == CONFIG_FLAG
44
+ return word.delete_prefix(CONFIG_INLINE) if word.start_with?(CONFIG_INLINE)
45
+ end
46
+ nil
47
+ end
48
+ private_class_method :config_flag
49
+
50
+ def initialize(settings:, paths:, store:, git:, inspector:, clock:, env:)
51
+ @settings = settings
52
+ @paths = paths
53
+ @store = store
54
+ @git = git
55
+ @inspector = inspector
56
+ @clock = clock
57
+ @env = env
58
+ end
59
+
60
+ def group_for(opts) = opts[:group] || settings.group
61
+
62
+ # Leaves the environment out so a debugging print never dumps every variable of the process.
63
+ def inspect = "#<#{self.class.name} data_home=#{store.root.inspect} settings=#{settings.to_h.inspect}>"
64
+ end
65
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+
5
+ module Slipway
6
+ # Finds the git repositories at and under a directory.
7
+ class Scanner
8
+ # `problems` holds a Problem for each directory that could not be read.
9
+ Result = Data.define(:repositories, :problems)
10
+ # `detail` is the system's reason alone, so the caller names the path the way it shows paths.
11
+ Problem = Data.define(:path, :detail)
12
+
13
+ DEFAULT_DEPTH = 1
14
+ MAX_DEPTH = 8
15
+ GIT_ENTRY = '.git'
16
+
17
+ # `root` is an absolute path; `depth` counts the levels under it that are searched.
18
+ def self.scan(root, depth: DEFAULT_DEPTH)
19
+ raise Error, "#{root}: no such directory" unless File.directory?(root)
20
+
21
+ new.walk(root, depth)
22
+ end
23
+
24
+ def initialize
25
+ @repositories = []
26
+ @problems = []
27
+ end
28
+
29
+ def walk(root, depth)
30
+ visit(root, depth)
31
+ Result.new(repositories: @repositories, problems: @problems)
32
+ end
33
+
34
+ private
35
+
36
+ # A .git file marks a linked worktree or a submodule, so any entry counts. Nothing under a
37
+ # repository is searched: nested checkouts and vendored trees belong to it.
38
+ def visit(directory, depth)
39
+ return @repositories << directory if File.exist?(File.join(directory, GIT_ENTRY))
40
+ return if depth.zero?
41
+
42
+ subdirectories(directory).each { visit(it, depth - 1) }
43
+ end
44
+
45
+ # A symbolic link is never followed, so the search stays under the root and cannot loop.
46
+ def subdirectories(directory)
47
+ paths = Dir.children(directory).sort.map { File.join(directory, it) }
48
+ paths.select { File.directory?(it) && !File.symlink?(it) }
49
+ rescue SystemCallError => e
50
+ @problems << Problem.new(path: directory, detail: e.message.sub(Error::SYSTEM_SUFFIX, ''))
51
+ []
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'names'
4
+ require_relative 'labels'
5
+ require_relative 'resources'
6
+ require_relative 'git/branch_name'
7
+ require_relative 'git/url'
8
+
9
+ module Slipway
10
+ # The fields of each kind of manifest. The manifest reader takes its closed set of fields, their
11
+ # defaults and the words of its refusals from here, and the README table of a project's spec is
12
+ # held to it.
13
+ module Schema
14
+ STRING = 'string'
15
+ BOOLEAN = 'boolean'
16
+ OBJECT = 'Object'
17
+ LABEL_MAP = 'map[string]string'
18
+ # What a refusal says a value of each type has to be.
19
+ NOUNS = { STRING => 'a string', BOOLEAN => 'a boolean', OBJECT => 'a mapping', LABEL_MAP => 'a mapping' }.freeze
20
+
21
+ # `type` is the word kubectl explain prints between angle brackets. `rule` completes a sentence
22
+ # that starts with the field, the way a refusal quotes it; `enum` closes the field to a set of
23
+ # words and makes the rule when none is given. An object's `fields` are its children in the
24
+ # order a manifest writes them, and the object is required when one of them is.
25
+ Field = Data.define(:name, :type, :description, :required, :default, :enum, :rule, :fields) do
26
+ def initialize(name:, type:, description:, required: false, default: nil, enum: nil, rule: nil, fields: [])
27
+ super(name:, type:, description:, required: required || fields.any?(&:required), default:, enum:,
28
+ rule: rule || (enum && "must be #{enum.join(' or ')}"), fields:)
29
+ end
30
+
31
+ def field(name) = fields.find { it.name == name }
32
+
33
+ # nil when a name on the way is not a field.
34
+ def dig(*names) = names.reduce(self) { |field, name| field&.field(name) }
35
+
36
+ # The description followed by the rule as a sentence of its own.
37
+ def meaning = [description, rule && "#{rule[0].upcase}#{rule[1..]}."].compact.join(' ')
38
+ end
39
+
40
+ NAME_RULE = "must be #{Names::RULE}".freeze
41
+ LABELS_FIELD = Field.new(name: 'labels', type: LABEL_MAP,
42
+ description: 'Key and value pairs that a label selector (-l) matches.',
43
+ rule: "keys must be #{Labels::KEY_RULE}; values must be #{Labels::VALUE_RULE}")
44
+ TIMESTAMP_FIELD = Field.new(name: 'creationTimestamp', type: STRING, rule: 'must be an RFC 3339 timestamp',
45
+ description: 'When the resource was created, in UTC. slipway sets it on ' \
46
+ 'creation; written by hand, it has to be quoted.')
47
+ KIND_DESCRIPTION = 'The kind of resource the manifest describes.'
48
+ private_constant :NAME_RULE, :LABELS_FIELD, :TIMESTAMP_FIELD, :KIND_DESCRIPTION
49
+
50
+ PROJECT = Field.new(
51
+ name: Resources::PROJECTS.title, type: OBJECT,
52
+ description: 'A registered git repository: where it is on this machine and where it is expected to be, as ' \
53
+ 'the remote, the branch and a commit to hold it at.',
54
+ fields: [
55
+ Field.new(name: 'kind', type: STRING, required: true, enum: [Resources::PROJECTS.title],
56
+ description: KIND_DESCRIPTION),
57
+ Field.new(
58
+ name: 'metadata', type: OBJECT,
59
+ description: 'Identifies the project: its name and group, its labels, and when it was created.',
60
+ fields: [
61
+ Field.new(name: 'name', type: STRING, required: true, rule: NAME_RULE,
62
+ description: 'The name of the project, unique within its group.'),
63
+ Field.new(name: 'group', type: STRING, rule: NAME_RULE,
64
+ description: 'The group the project belongs to; every group but default has to be created ' \
65
+ 'first. When it is left out, the project goes to the group in effect: -n, then ' \
66
+ 'SLIPWAY_GROUP, then the group key, then default.'),
67
+ LABELS_FIELD, TIMESTAMP_FIELD
68
+ ]
69
+ ),
70
+ Field.new(
71
+ name: 'spec', type: OBJECT, description: 'Where the repository is, and the state it is expected to be in.',
72
+ fields: [
73
+ Field.new(name: 'path', type: STRING, required: true, rule: 'must not be empty',
74
+ description: 'The directory of the repository: an absolute path, or one starting with ~/, ' \
75
+ 'which is expanded against HOME when used so that the manifest means the same ' \
76
+ 'on every machine. A relative path is reported as Missing.'),
77
+ Field.new(name: 'description', type: STRING, description: 'What the project is, in free text.'),
78
+ Field.new(name: 'remote', type: STRING, rule: "must be #{Git::Url::RULE}",
79
+ description: 'The URL the origin remote is expected to have; diff reports another one as ' \
80
+ 'Remote drift, and no command changes a remote. A password, or any user name ' \
81
+ 'over http and https, where it often carries a token, is refused; use a ' \
82
+ 'credential helper.'),
83
+ Field.new(name: 'branch', type: STRING, rule: "must be #{Git::BranchName::RULE}",
84
+ description: 'The branch expected to be checked out; diff reports another one as Branch ' \
85
+ 'drift, and no command switches branches.'),
86
+ Field.new(name: 'revision', type: STRING,
87
+ rule: 'must be a full object name, 40 or 64 lowercase hexadecimal characters',
88
+ description: 'The commit the project is held at, named in full because an abbreviation can ' \
89
+ 'become ambiguous. sync fast-forwards the branch up to it instead of the ' \
90
+ 'upstream, never past it and never back to it. rollout undo writes it and ' \
91
+ 'rollout unpin removes it.'),
92
+ Field.new(name: 'syncPolicy', type: STRING, enum: SyncPolicy::ALL, default: SyncPolicy::FAST_FORWARD,
93
+ description: 'What sync may do to the repository: FastForward lets it fast-forward the ' \
94
+ 'checked-out branch, and FetchOnly lets it fetch only.'),
95
+ Field.new(name: 'paused', type: BOOLEAN, default: false,
96
+ description: 'When true, fetch and sync leave the project alone: they report it as ' \
97
+ 'paused and run no git command there. rollout pause sets it and rollout ' \
98
+ 'resume removes it.')
99
+ ]
100
+ )
101
+ ]
102
+ )
103
+
104
+ GROUP = Field.new(
105
+ name: Resources::GROUPS.title, type: OBJECT,
106
+ description: 'A namespace that holds projects, the way a Kubernetes namespace holds pods. Deleting a group ' \
107
+ 'removes the registrations of its projects.',
108
+ fields: [
109
+ Field.new(name: 'kind', type: STRING, required: true, enum: [Resources::GROUPS.title],
110
+ description: KIND_DESCRIPTION),
111
+ Field.new(
112
+ name: 'metadata', type: OBJECT,
113
+ description: 'Identifies the group: its name, its labels, and when it was created.',
114
+ fields: [
115
+ Field.new(name: 'name', type: STRING, required: true, rule: NAME_RULE,
116
+ description: 'The name of the group, unique in the registry.'),
117
+ LABELS_FIELD, TIMESTAMP_FIELD
118
+ ]
119
+ ),
120
+ Field.new(name: 'spec', type: OBJECT, description: 'What the group is for.',
121
+ fields: [Field.new(name: 'description', type: STRING,
122
+ description: 'What the group holds, in free text.')])
123
+ ]
124
+ )
125
+
126
+ KINDS = [PROJECT, GROUP].to_h { [it.name, it] }.freeze
127
+ end
128
+ end