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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'manual'
|
|
5
|
+
require_relative '../inspector'
|
|
6
|
+
require_relative '../plan'
|
|
7
|
+
|
|
8
|
+
module Slipway
|
|
9
|
+
module Commands
|
|
10
|
+
class Diff < Base
|
|
11
|
+
# Raised once every project has printed: the lines say what differs, so it adds no error
|
|
12
|
+
# line, only the exit status a script tests.
|
|
13
|
+
class Differs < Error
|
|
14
|
+
def initialize = super(problems: [])
|
|
15
|
+
|
|
16
|
+
def exit_status = 3
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A project that could not be compared, because its manifest could not be read or git could
|
|
20
|
+
# not answer for it, must not pass for one in sync. The reason is already on stderr, so this
|
|
21
|
+
# adds only exit status 1.
|
|
22
|
+
class Unanswered < Error
|
|
23
|
+
def initialize = super(problems: [])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
DESCRIPTION = "Show where each selected project differs from its manifest.\n\n" \
|
|
27
|
+
'Compares every project of the current group, the projects named, the ones a label selector ' \
|
|
28
|
+
'matches, or with --all-groups every project, with its manifest. Each project that differs ' \
|
|
29
|
+
'prints its name and one line per difference: Missing, Remote, Branch, Revision or Behind, ' \
|
|
30
|
+
'then the blocker that keeps sync from fast-forwarding the branch, if any. A line is followed ' \
|
|
31
|
+
'by the git command that shows or resolves it when there is one; slipway never runs it. A ' \
|
|
32
|
+
"project that matches its manifest prints nothing.\n\n" \
|
|
33
|
+
'The repositories are read as they are on disk: diff contacts no remote and writes nothing, ' \
|
|
34
|
+
'so Behind is as of the last fetch, which slipway fetch refreshes. A project pinned by ' \
|
|
35
|
+
'spec.revision is compared with that commit instead of its upstream, and is blocked when the ' \
|
|
36
|
+
'repository lacks the commit, HEAD is past it or its upstream does not hold it. A paused or ' \
|
|
37
|
+
'FetchOnly project shows a blocker only when git cannot read its repository.'
|
|
38
|
+
USAGE = '[NAME... | project/NAME...]'
|
|
39
|
+
EXIT_STATUSES = Manual::EXIT_STATUSES.merge(
|
|
40
|
+
'0' => 'Every selected project matches its manifest.',
|
|
41
|
+
'1' => 'Runtime error, such as a missing resource or an unreadable manifest, or a project whose state is ' \
|
|
42
|
+
'Unknown because git failed or did not finish.',
|
|
43
|
+
'3' => 'At least one selected project differs from its manifest or is blocked, including a directory that ' \
|
|
44
|
+
'holds no repository (NotARepo) and a repository git refuses (Unsafe).'
|
|
45
|
+
).sort_by { |status, _| Integer(status) }.to_h.freeze
|
|
46
|
+
GLOSSARIES = [
|
|
47
|
+
CLI::Glossary.new(title: 'Drift', entries: Drift::TYPE_MEANINGS),
|
|
48
|
+
CLI::Glossary.new(
|
|
49
|
+
title: 'Blockers',
|
|
50
|
+
intro: 'A blocker says why the checked-out branch cannot be fast-forwarded, onto its upstream or, for a ' \
|
|
51
|
+
'project pinned by spec.revision, onto the pin. The first three mean git could not read the ' \
|
|
52
|
+
'repository and apply to every project; the others apply only under FastForward to a project that ' \
|
|
53
|
+
'is not paused, and RevisionNotFound, PastRevision and OffUpstream only to a pinned one.',
|
|
54
|
+
entries: Drift::BLOCKER_MEANINGS
|
|
55
|
+
)
|
|
56
|
+
].freeze
|
|
57
|
+
INDENT = ' '
|
|
58
|
+
COMMAND_INDENT = ' '
|
|
59
|
+
ALL_GROUPS = Options::ALL_GROUPS.with(description: 'If present, compare every project across all groups with ' \
|
|
60
|
+
'its manifest. The group in the current configuration is ' \
|
|
61
|
+
'ignored even if specified with --group.')
|
|
62
|
+
|
|
63
|
+
def self.command(factory)
|
|
64
|
+
CLI::Command.new(
|
|
65
|
+
name: 'diff', summary: 'Show where projects differ from their manifests', section: 'Repository Commands',
|
|
66
|
+
description: DESCRIPTION, examples:, usage: USAGE, exit_statuses: EXIT_STATUSES, glossaries: GLOSSARIES,
|
|
67
|
+
positionals: [Options.project_positional(factory)],
|
|
68
|
+
options: [Options::SELECTOR, ALL_GROUPS],
|
|
69
|
+
handler: new(factory)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.examples
|
|
74
|
+
[
|
|
75
|
+
CLI::Example.new(comment: 'Show where the projects of the current group differ from their manifests',
|
|
76
|
+
command: 'diff'),
|
|
77
|
+
CLI::Example.new(comment: 'Show every project that differs, in every group', command: 'diff -A'),
|
|
78
|
+
CLI::Example.new(comment: 'Compare two projects of the work group', command: 'diff api web -n work'),
|
|
79
|
+
CLI::Example.new(comment: 'Compare the projects labeled lang=rust', command: 'diff -l lang=rust')
|
|
80
|
+
]
|
|
81
|
+
end
|
|
82
|
+
private_class_method :examples
|
|
83
|
+
|
|
84
|
+
def kinds = [Resources::PROJECTS]
|
|
85
|
+
|
|
86
|
+
def run(runtime, context, args, opts)
|
|
87
|
+
scope = scope(runtime, context, opts)
|
|
88
|
+
names = scope.project_targets(args, verb: 'diff')
|
|
89
|
+
failure = nil
|
|
90
|
+
scope.select(Resources::PROJECTS, names) do |projects|
|
|
91
|
+
next scope.report_none(Resources::PROJECTS) if projects.empty?
|
|
92
|
+
|
|
93
|
+
failure = compare(context, Base.examine(runtime, context, projects))
|
|
94
|
+
end
|
|
95
|
+
failure = Unanswered.new if scope.skipped?
|
|
96
|
+
raise failure if failure
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def compare(context, inspections)
|
|
102
|
+
plans = inspections.map { Plan.for(it) }
|
|
103
|
+
inspections.zip(plans).each { |inspection, plan| report(context, inspection.project, plan) }
|
|
104
|
+
return Unanswered.new if inspections.any? { it.state == Inspector::UNKNOWN }
|
|
105
|
+
|
|
106
|
+
Differs.new unless plans.all?(&:converged?)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Messages and commands arrive redacted; git's text in them is made plain here.
|
|
110
|
+
def report(context, project, plan)
|
|
111
|
+
return if plan.converged?
|
|
112
|
+
|
|
113
|
+
lines = plan.items.flat_map do |item|
|
|
114
|
+
word = context.paint(item.blocker ? :status_danger : :status_warning, "#{item.type}:")
|
|
115
|
+
line = "#{INDENT}#{word} #{Output.plain(item.message)}"
|
|
116
|
+
item.command ? [line, COMMAND_INDENT + context.paint(:muted, Output.plain(item.command))] : [line]
|
|
117
|
+
end
|
|
118
|
+
context.puts("#{Resources::PROJECTS.singular}/#{project.name}", *lines)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../editor'
|
|
5
|
+
require_relative '../manifest'
|
|
6
|
+
require_relative '../store'
|
|
7
|
+
|
|
8
|
+
module Slipway
|
|
9
|
+
module Commands
|
|
10
|
+
class Edit < Base
|
|
11
|
+
DESCRIPTION = "Edit a resource from the default editor.\n\n" \
|
|
12
|
+
'The edit command allows you to directly edit any resource in the registry. It will open ' \
|
|
13
|
+
'the editor named by the SLIPWAY_EDITOR environment variable, then the editor key of the ' \
|
|
14
|
+
"configuration file, then VISUAL or EDITOR, or fall back to 'vi'.\n\n" \
|
|
15
|
+
'The kind, name, group and creation timestamp of a resource cannot be changed. If an error ' \
|
|
16
|
+
'occurs while saving, the file is reopened with the relevant failures as comments at the ' \
|
|
17
|
+
"top; saving it again without changes cancels the edit.\n\n" \
|
|
18
|
+
"#{Options::TYPES_SENTENCE}".freeze
|
|
19
|
+
USAGE = '(TYPE NAME | TYPE/NAME)'
|
|
20
|
+
HEADER = <<~TEXT
|
|
21
|
+
# Please edit the object below. Lines beginning with a '#' will be ignored,
|
|
22
|
+
# and an empty file will abort the edit. If an error occurs while saving this
|
|
23
|
+
# file will be reopened with the relevant failures.
|
|
24
|
+
#
|
|
25
|
+
TEXT
|
|
26
|
+
UNCHANGED = 'Edit cancelled, no changes made.'
|
|
27
|
+
EMPTY = 'Edit cancelled, saved file was empty.'
|
|
28
|
+
ABORTED = 'Edit cancelled, no valid changes were saved.'
|
|
29
|
+
IMMUTABLE = 'kind, metadata.name and metadata.group cannot be changed'
|
|
30
|
+
SOURCE = 'edited manifest'
|
|
31
|
+
COMMENT = /\A\s*#/
|
|
32
|
+
|
|
33
|
+
class Aborted < Slipway::Error; end
|
|
34
|
+
|
|
35
|
+
def self.command(factory)
|
|
36
|
+
CLI::Command.new(
|
|
37
|
+
name: 'edit', summary: 'Edit a resource from the default editor', section: 'Basic Commands',
|
|
38
|
+
description: DESCRIPTION, examples:, usage: USAGE,
|
|
39
|
+
positionals: [Options::TYPE, Options.name_positional(factory, variadic: false, required: false)],
|
|
40
|
+
handler: new(factory)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.examples
|
|
45
|
+
[
|
|
46
|
+
CLI::Example.new(comment: "Edit the project named 'hldr'", command: 'edit project hldr'),
|
|
47
|
+
CLI::Example.new(comment: 'Edit a project in the work group', command: 'edit project job -n work'),
|
|
48
|
+
CLI::Example.new(comment: "Edit the group named 'work'", command: 'edit group work')
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
private_class_method :examples
|
|
52
|
+
|
|
53
|
+
def kinds = Resources::KINDS
|
|
54
|
+
|
|
55
|
+
def run(runtime, context, args, opts)
|
|
56
|
+
scope = scope(runtime, context, opts)
|
|
57
|
+
kind, name = scope.target(args)
|
|
58
|
+
resource = runtime.store.find(kind, name, group: scope.group)
|
|
59
|
+
editor = Editor.new(env: context.env, preferred: runtime.settings.editor)
|
|
60
|
+
edited = Session.new(editor, kind, resource).edit
|
|
61
|
+
return context.warn(UNCHANGED) if edited.nil?
|
|
62
|
+
|
|
63
|
+
finish(runtime, context, kind, resource, edited)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# kubectl reports `skipped` when the text changed but the object did not.
|
|
69
|
+
def finish(runtime, context, kind, original, edited)
|
|
70
|
+
return result_line(context, kind, original.name, 'skipped', :apply_unchanged) if edited == original
|
|
71
|
+
|
|
72
|
+
runtime.store.save(edited)
|
|
73
|
+
result_line(context, kind, original.name, 'edited', :apply_configured)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Session
|
|
77
|
+
def initialize(editor, kind, resource)
|
|
78
|
+
@editor = editor
|
|
79
|
+
@kind = kind
|
|
80
|
+
@resource = resource
|
|
81
|
+
@original = Manifest.dump(resource)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def edit
|
|
85
|
+
text = @original
|
|
86
|
+
problem = nil
|
|
87
|
+
loop do
|
|
88
|
+
edited = strip(@editor.edit(buffer(text, problem), filename: "#{@resource.name}.yaml"))
|
|
89
|
+
raise Aborted, EMPTY if edited.strip.empty?
|
|
90
|
+
raise Aborted, ABORTED if problem && edited == text
|
|
91
|
+
return nil if edited == @original
|
|
92
|
+
|
|
93
|
+
return parse(edited)
|
|
94
|
+
rescue Manifest::Invalid => e
|
|
95
|
+
problem = e.problem
|
|
96
|
+
text = edited
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# The failure is worded as kubectl words it.
|
|
103
|
+
def buffer(text, problem)
|
|
104
|
+
return "#{HEADER}#{text}" if problem.nil?
|
|
105
|
+
|
|
106
|
+
"#{HEADER}# #{@kind.plural} #{@resource.name.inspect} was not valid:\n# * #{problem}\n#\n#{text}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def strip(text) = text.lines.grep_v(COMMENT).join
|
|
110
|
+
|
|
111
|
+
# The stored creationTimestamp is kept whatever the buffer says, as apply keeps it.
|
|
112
|
+
def parse(text)
|
|
113
|
+
parsed = Manifest.parse_yaml(text, source: SOURCE, default_group: group)
|
|
114
|
+
raise Manifest::Invalid.new(SOURCE, IMMUTABLE) unless same_identity?(parsed)
|
|
115
|
+
|
|
116
|
+
parsed.with(created_at: @resource.created_at)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def group = @kind.namespaced? ? @resource.group : Store::DEFAULT_GROUP
|
|
120
|
+
|
|
121
|
+
def same_identity?(parsed)
|
|
122
|
+
parsed.instance_of?(@kind.klass) && parsed.name == @resource.name &&
|
|
123
|
+
(!@kind.namespaced? || parsed.group == @resource.group)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private_constant :Session
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../cli'
|
|
4
|
+
require_relative '../resources'
|
|
5
|
+
require_relative '../schema'
|
|
6
|
+
require_relative '../output'
|
|
7
|
+
require_relative 'options'
|
|
8
|
+
|
|
9
|
+
module Slipway
|
|
10
|
+
module Commands
|
|
11
|
+
# Reads only Schema, so it builds no runtime and answers even where the configuration file is
|
|
12
|
+
# broken or git is missing.
|
|
13
|
+
class Explain
|
|
14
|
+
DESCRIPTION = "Describe fields and structure of the resource types.\n\n" \
|
|
15
|
+
'This command describes the fields of each resource type, as a manifest holds them. Fields ' \
|
|
16
|
+
'are identified by a dotted path after the type word, such as project.spec.syncPolicy. Each ' \
|
|
17
|
+
'field shows its type, -required- when a manifest must have it, and a description with the ' \
|
|
18
|
+
'rule its value follows and its default; --recursive prints the whole tree of names and ' \
|
|
19
|
+
"types instead.\n\n" \
|
|
20
|
+
"#{Options::TYPES_SENTENCE} Field names are case-sensitive, as in a manifest.".freeze
|
|
21
|
+
USAGE = 'TYPE[.FIELD...]'
|
|
22
|
+
SEPARATOR = '.'
|
|
23
|
+
RECURSIVE = CLI::Option.new(long: 'recursive',
|
|
24
|
+
description: 'If present, print the name of all the fields recursively. ' \
|
|
25
|
+
'Otherwise, print the available fields with their description.')
|
|
26
|
+
|
|
27
|
+
def self.command(_factory)
|
|
28
|
+
CLI::Command.new(
|
|
29
|
+
name: 'explain', summary: 'Get documentation for a resource', section: 'Basic Commands',
|
|
30
|
+
description: DESCRIPTION, examples:, usage: USAGE, options: [RECURSIVE], handler: new,
|
|
31
|
+
positionals: [CLI::Positional.new(name: 'TYPE', completer: ->(_given, current) { paths(current) })]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.examples
|
|
36
|
+
[
|
|
37
|
+
CLI::Example.new(comment: 'Get the documentation of the resource and its fields',
|
|
38
|
+
command: 'explain projects'),
|
|
39
|
+
CLI::Example.new(comment: 'Get all the fields in the resource', command: 'explain projects --recursive'),
|
|
40
|
+
CLI::Example.new(comment: 'Get the documentation of a specific field of a resource',
|
|
41
|
+
command: 'explain project.spec.syncPolicy')
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
private_class_method :examples
|
|
45
|
+
|
|
46
|
+
# The type words, then the fields one level under the path typed so far, as whole paths.
|
|
47
|
+
# A path with fields under it goes on after a dot, so the shell adds no space after it. A
|
|
48
|
+
# path naming no type or field has no candidates, never an error in the shell.
|
|
49
|
+
def self.paths(current)
|
|
50
|
+
parent, dot, = current.rpartition(SEPARATOR)
|
|
51
|
+
offered = (dot.empty? ? types : children(parent)).select { |path, _, _| path.start_with?(current) }
|
|
52
|
+
candidates = offered.to_h { |path, description, _| [path, description] }
|
|
53
|
+
offered.any? { |_, _, continues| continues } ? CLI::Completer::NoSpace.new(candidates) : candidates
|
|
54
|
+
rescue Slipway::Error
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.types = Options::TYPE_DESCRIPTIONS.map { |word, description| [word, description, true] }
|
|
59
|
+
|
|
60
|
+
def self.children(path)
|
|
61
|
+
_, field = lookup(path)
|
|
62
|
+
field.fields.map { ["#{path}#{SEPARATOR}#{it.name}", "<#{it.type}>", !it.fields.empty?] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The kind the type word names and the field the path leads to; raises UsageError for a field
|
|
66
|
+
# that does not exist, in kubectl's words.
|
|
67
|
+
def self.lookup(path)
|
|
68
|
+
# split turns an empty word into no words at all, not into one empty type word.
|
|
69
|
+
type, *names = path.split(SEPARATOR, -1)
|
|
70
|
+
kind = Resources.resolve(type || '')
|
|
71
|
+
field = names.reduce(Schema::KINDS.fetch(kind.title)) do |parent, name|
|
|
72
|
+
parent.field(name) || raise(CLI::UsageError, "field #{name.inspect} does not exist")
|
|
73
|
+
end
|
|
74
|
+
[kind, field]
|
|
75
|
+
end
|
|
76
|
+
private_class_method :types, :children
|
|
77
|
+
|
|
78
|
+
def kinds = Resources::KINDS
|
|
79
|
+
|
|
80
|
+
def call(context, args, opts)
|
|
81
|
+
kind, field = Explain.lookup(args.fetch(0))
|
|
82
|
+
Output::Explain.new(context).print(kind: kind.title, field: document(field),
|
|
83
|
+
named: args.fetch(0).include?(SEPARATOR), recursive: opts[:recursive])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def document(field)
|
|
89
|
+
Output::Explain::Field.new(name: field.name, type: field.type, required: field.required,
|
|
90
|
+
description: description(field), fields: field.fields.map { document(it) })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The Kubernetes API reference words a default the same way: "Defaults to 1."
|
|
94
|
+
def description(field) = field.default.nil? ? field.meaning : "#{field.meaning} Defaults to #{field.default}."
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'results'
|
|
5
|
+
require_relative '../fetcher'
|
|
6
|
+
require_relative '../outcome'
|
|
7
|
+
|
|
8
|
+
module Slipway
|
|
9
|
+
module Commands
|
|
10
|
+
class Fetch < Base
|
|
11
|
+
# Raised once every line has printed: the lines already say what went wrong, so it adds no
|
|
12
|
+
# error line, only exit status 1.
|
|
13
|
+
class Failed < Error
|
|
14
|
+
def initialize = super(problems: [])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
DESCRIPTION = "Fetch from the remote of each selected project.\n\n" \
|
|
18
|
+
'Runs git fetch in every project of the current group, in the projects named, in the ones ' \
|
|
19
|
+
'a label selector matches, or with --all-groups in every project. Git fetches from the ' \
|
|
20
|
+
'remote of the checked-out branch, else from the only remote, else from origin, as a git ' \
|
|
21
|
+
'fetch typed in the repository would, and updates the references that remote\'s fetch ' \
|
|
22
|
+
'refspecs name (remote-tracking references by default) and its tags, never the ' \
|
|
23
|
+
'checked-out branch or the working tree. The parallel setting caps how many projects ' \
|
|
24
|
+
"fetch at once, and the results print in the order the projects are listed.\n\n" \
|
|
25
|
+
'Each project prints one line, one of the results listed below, with a reason in parentheses ' \
|
|
26
|
+
'and the details under it. A project whose repository cannot be read, or in which git finds ' \
|
|
27
|
+
'no remote to fetch from, is skipped before git fetch runs. A project whose spec.paused is ' \
|
|
28
|
+
"true is paused: no git command runs in it.\n\n" \
|
|
29
|
+
'Git never prompts: a fetch that needs a password, a passphrase or a host key is denied, one ' \
|
|
30
|
+
'that runs past the networkTimeout setting is killed, and only the transports in the ' \
|
|
31
|
+
'protocols setting are allowed. The exit status is 1 when any project was denied or failed.'
|
|
32
|
+
USAGE = '[NAME... | project/NAME...]'
|
|
33
|
+
# In the order the closing summary counts them.
|
|
34
|
+
ROLES = { Outcome::FETCHED => :result_changed, Outcome::UNCHANGED => :result_unchanged,
|
|
35
|
+
Outcome::SKIPPED => :result_skipped, Outcome::PAUSED => :result_paused,
|
|
36
|
+
Outcome::DENIED => :result_denied, Outcome::FAILED => :result_failed }.freeze
|
|
37
|
+
|
|
38
|
+
RESULTS = CLI::Glossary.new(
|
|
39
|
+
title: 'Results',
|
|
40
|
+
intro: 'When more than one project ran, a count of the results closes the run on stderr. With ' \
|
|
41
|
+
'--dry-run no remote is contacted: a project that would be fetched reads fetched, and every ' \
|
|
42
|
+
'line and the count end in (dry run). Ctrl-C stops the git processes slipway started and exits with ' \
|
|
43
|
+
'status 130.',
|
|
44
|
+
entries: {
|
|
45
|
+
Outcome::FETCHED => "The remote moved refs, and up to #{Fetcher::REF_LIMIT} follow: origin/main " \
|
|
46
|
+
'a1b2c3d..e4f5a6b for a ref that moved, origin/feature d09a085 (new) for a new one ' \
|
|
47
|
+
'and origin/feature deleted (was d09a085) for one --prune removed. A last line, and ' \
|
|
48
|
+
'N more, counts the rest. Tags appear under their bare name. With git before 2.41, ' \
|
|
49
|
+
'every fetch that succeeds reads fetched, without the refs.',
|
|
50
|
+
Outcome::UNCHANGED => 'The remote answered and had nothing new.',
|
|
51
|
+
"#{Outcome::SKIPPED} (Reason)" => 'No fetch ran: git could not read the repository (Missing, NotARepo, ' \
|
|
52
|
+
'Unsafe, Unknown), git has no remote to pick because there is no ' \
|
|
53
|
+
'upstream, no origin and either no remote or more than one ' \
|
|
54
|
+
"(#{Fetcher::NO_REMOTE}), or the branch tracks a local branch " \
|
|
55
|
+
'(LocalUpstream).',
|
|
56
|
+
Outcome::PAUSED => 'spec.paused is true, so no git command ran in the project.',
|
|
57
|
+
"#{Outcome::DENIED} (Reason)" => 'Git needed a password, a passphrase or a host key (AuthRequired). Run ' \
|
|
58
|
+
'the git -C PATH fetch printed below it once in a terminal to see what ' \
|
|
59
|
+
'git needs.',
|
|
60
|
+
"#{Outcome::FAILED} (Reason)" => 'The fetch ran past networkTimeout (Timeout), used a transport ' \
|
|
61
|
+
'protocols leaves out (ProtocolNotAllowed), or git failed for another ' \
|
|
62
|
+
'reason (Unknown).'
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
ALL_GROUPS = Options::ALL_GROUPS.with(description: 'If present, fetch every project across all groups. The ' \
|
|
66
|
+
'group in the current configuration is ignored even if ' \
|
|
67
|
+
'specified with --group.')
|
|
68
|
+
PRUNE = CLI::Option.new(long: 'prune', description: 'Before fetching, remove any remote-tracking references ' \
|
|
69
|
+
'that no longer exist on the remote.')
|
|
70
|
+
|
|
71
|
+
def self.command(factory)
|
|
72
|
+
CLI::Command.new(
|
|
73
|
+
name: 'fetch', summary: 'Fetch from the remote of each project', section: 'Repository Commands',
|
|
74
|
+
description: DESCRIPTION, examples:, usage: USAGE, glossaries: [RESULTS],
|
|
75
|
+
positionals: [Options.project_positional(factory)],
|
|
76
|
+
options: [Options::SELECTOR, ALL_GROUPS, PRUNE, Options::DRY_RUN],
|
|
77
|
+
handler: new(factory)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.examples
|
|
82
|
+
[
|
|
83
|
+
CLI::Example.new(comment: 'Fetch every project in the current group', command: 'fetch'),
|
|
84
|
+
CLI::Example.new(comment: 'Fetch every project in every group', command: 'fetch -A'),
|
|
85
|
+
CLI::Example.new(comment: 'Fetch two projects of the work group', command: 'fetch api web -n work'),
|
|
86
|
+
CLI::Example.new(comment: 'Fetch the projects labeled lang=rust and prune deleted branches',
|
|
87
|
+
command: 'fetch -l lang=rust --prune'),
|
|
88
|
+
CLI::Example.new(comment: 'List the projects a fetch would reach, without contacting any remote',
|
|
89
|
+
command: 'fetch -A --dry-run')
|
|
90
|
+
]
|
|
91
|
+
end
|
|
92
|
+
private_class_method :examples
|
|
93
|
+
|
|
94
|
+
def kinds = [Resources::PROJECTS]
|
|
95
|
+
|
|
96
|
+
def run(runtime, context, args, opts)
|
|
97
|
+
scope = scope(runtime, context, opts)
|
|
98
|
+
names = scope.project_targets(args, verb: 'fetch')
|
|
99
|
+
dry_run = opts[:dry_run] == true
|
|
100
|
+
fetcher = Fetcher.new(runtime, prune: opts[:prune] == true, dry_run:)
|
|
101
|
+
results = Results.new(context, ROLES, dry_run:)
|
|
102
|
+
scope.select(Resources::PROJECTS, names) do |projects|
|
|
103
|
+
next scope.report_none(Resources::PROJECTS) if projects.empty?
|
|
104
|
+
|
|
105
|
+
results.stream(projects, workers: runtime.settings.parallel, work: fetcher.method(:attempt))
|
|
106
|
+
results.summarize
|
|
107
|
+
end
|
|
108
|
+
raise Failed if results.any?(Outcome::DENIED, Outcome::FAILED)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../error'
|
|
4
|
+
require_relative '../git/errors'
|
|
5
|
+
require_relative '../git/url'
|
|
6
|
+
require_relative '../names'
|
|
7
|
+
require_relative '../output'
|
|
8
|
+
require_relative '../paths'
|
|
9
|
+
require_relative '../resources'
|
|
10
|
+
require_relative '../scanner'
|
|
11
|
+
require_relative '../store'
|
|
12
|
+
|
|
13
|
+
module Slipway
|
|
14
|
+
module Commands
|
|
15
|
+
# Registers the repositories Scanner finds for create project --from-dir. Each is keyed by
|
|
16
|
+
# its path within the group, so a second run over the same directory adds only new clones.
|
|
17
|
+
class FromDir
|
|
18
|
+
# `created` is false for a path the group already held; `project` is then the stored one.
|
|
19
|
+
Entry = Data.define(:project, :created)
|
|
20
|
+
|
|
21
|
+
NONE_FOUND = 'no git repositories found under %s to depth %d'
|
|
22
|
+
TAKEN = 'project %p already exists at %s'
|
|
23
|
+
ORIGIN_FIELD = 'origin'
|
|
24
|
+
NOT_UTF8 = 'path is not valid UTF-8'
|
|
25
|
+
SEPARATOR = /[^a-z0-9-]+/
|
|
26
|
+
EDGE_DASHES = /\A-+|-+\z/
|
|
27
|
+
|
|
28
|
+
attr_reader :problems
|
|
29
|
+
|
|
30
|
+
def initialize(runtime, context, group:, labels:, dry_run:)
|
|
31
|
+
@store = runtime.store
|
|
32
|
+
@git = runtime.git
|
|
33
|
+
@home = File.expand_path(runtime.paths.home)
|
|
34
|
+
@context = context
|
|
35
|
+
@group = group
|
|
36
|
+
@labels = labels
|
|
37
|
+
@dry_run = dry_run
|
|
38
|
+
@problems = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def register(directory, depth:)
|
|
42
|
+
raise Store::NotFound.of(Resources::GROUPS, @group) unless @store.group_available?(@group)
|
|
43
|
+
|
|
44
|
+
scan = Scanner.scan(File.absolute_path(Paths.expand(directory, home: @home)), depth:)
|
|
45
|
+
@problems.concat(scan.problems.map { "#{stored_path(it.path)}: #{it.detail}" })
|
|
46
|
+
raise Error, format(NONE_FOUND, directory, depth) if scan.repositories.empty? && scan.problems.empty?
|
|
47
|
+
|
|
48
|
+
index_registered
|
|
49
|
+
scan.repositories.filter_map { entry(it) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# A failure is kept with the path it concerns, so the other repositories are still registered.
|
|
55
|
+
def entry(path)
|
|
56
|
+
shown = stored_path(path)
|
|
57
|
+
raise Error, NOT_UTF8 unless path.valid_encoding?
|
|
58
|
+
|
|
59
|
+
existing = @by_path[identity(path)]
|
|
60
|
+
return Entry.new(project: existing, created: false) if existing
|
|
61
|
+
|
|
62
|
+
Entry.new(project: create(path, shown), created: true)
|
|
63
|
+
rescue Error => e
|
|
64
|
+
@problems << problem(shown, e)
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A git error starts with the absolute path, which the line already names as the manifest would.
|
|
69
|
+
def problem(shown, error)
|
|
70
|
+
detail = error.is_a?(Git::Error) ? error.message.delete_prefix("#{error.path}: ") : error.message
|
|
71
|
+
["#{shown}: #{detail}", error.hint].compact.join('. ')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create(path, shown)
|
|
75
|
+
project = Project.new(name: claim(name_for(path)), group: @group, labels: @labels, path: shown,
|
|
76
|
+
remote: remote(path, shown))
|
|
77
|
+
project = @store.create(project) unless @dry_run
|
|
78
|
+
remember(project)
|
|
79
|
+
project
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def name_for(path)
|
|
83
|
+
Names.validate!(File.basename(path).downcase.gsub(SEPARATOR, '-').gsub(EDGE_DASHES, ''), what: 'project name')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The listing skips a manifest it cannot read, whose name the store still holds.
|
|
87
|
+
def claim(name)
|
|
88
|
+
taken = @by_name[name]
|
|
89
|
+
raise Error, format(TAKEN, name, taken.path) if taken
|
|
90
|
+
|
|
91
|
+
unreadable = @store.exist?(Resources::PROJECTS, name, group: @group)
|
|
92
|
+
raise Store::Conflict, "project #{name.inspect} already exists" if unreadable
|
|
93
|
+
|
|
94
|
+
name
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A manifest that cannot be read is reported the way get reports it.
|
|
98
|
+
def index_registered
|
|
99
|
+
@by_path = {}
|
|
100
|
+
@by_name = {}
|
|
101
|
+
@store.list(Resources::PROJECTS, group: @group) { Output.warning(@context, it.message) }.each { remember(it) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Slipway never expands a ~user path, so such a project holds its name but no directory.
|
|
105
|
+
def remember(project)
|
|
106
|
+
path = Paths.expand(project.path, home: @home)
|
|
107
|
+
@by_path[identity(File.absolute_path(path))] = project if File.absolute_path?(path)
|
|
108
|
+
@by_name[project.name] = project
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A clone reached through a symbolic link, or registered from a directory reached through
|
|
112
|
+
# one, is still the same clone; a stored path that no longer resolves keeps its spelling.
|
|
113
|
+
def identity(path)
|
|
114
|
+
File.realpath(path)
|
|
115
|
+
rescue SystemCallError
|
|
116
|
+
path
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A remote that is refused once its credentials are dropped is left out rather than
|
|
120
|
+
# failing the registration: the path alone is enough to track the clone. git config reads a
|
|
121
|
+
# repository it distrusts or cannot open as one without an origin, so a missing origin is
|
|
122
|
+
# trusted only once git has read the repository.
|
|
123
|
+
def remote(path, shown)
|
|
124
|
+
url = @git.remote_url(path)
|
|
125
|
+
@git.status(path) if url.nil?
|
|
126
|
+
url && Git::Url.validate!(Git::Url.without_credentials(url), field: ORIGIN_FIELD)
|
|
127
|
+
rescue Git::Url::Invalid => e
|
|
128
|
+
Output.warning(@context, "#{shown}: spec.remote left out: #{e.message}")
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Under HOME the path is written with ~/, so the manifest names the same clone on another machine.
|
|
133
|
+
def stored_path(path)
|
|
134
|
+
return '~' if path == @home
|
|
135
|
+
return path unless path.start_with?("#{@home}/")
|
|
136
|
+
|
|
137
|
+
"~/#{path.delete_prefix("#{@home}/")}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|