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.
- checksums.yaml +7 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- 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
|