indexmap 0.8.2 → 0.9.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +287 -35
  4. data/UPGRADE.md +361 -0
  5. data/docs/architecture.md +55 -0
  6. data/docs/deployment.md +223 -0
  7. data/docs/domain-terms.md +27 -0
  8. data/docs/dynamic-sitemaps.md +302 -0
  9. data/docs/search-engine-ping.md +368 -0
  10. data/docs/storage.md +94 -0
  11. data/lib/generators/indexmap/install_generator.rb +1 -1
  12. data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
  13. data/lib/indexmap/artifact.rb +9 -0
  14. data/lib/indexmap/configuration.rb +12 -1
  15. data/lib/indexmap/creator.rb +45 -42
  16. data/lib/indexmap/index_now_configuration.rb +3 -1
  17. data/lib/indexmap/output.rb +19 -5
  18. data/lib/indexmap/parser.rb +112 -52
  19. data/lib/indexmap/pinger/base.rb +6 -5
  20. data/lib/indexmap/pinger/google.rb +12 -8
  21. data/lib/indexmap/pinger/index_now.rb +97 -52
  22. data/lib/indexmap/publication.rb +89 -0
  23. data/lib/indexmap/robots.rb +67 -0
  24. data/lib/indexmap/robots_controller.rb +13 -0
  25. data/lib/indexmap/sitemaps_controller.rb +57 -0
  26. data/lib/indexmap/storage/active_storage.rb +132 -1
  27. data/lib/indexmap/storage/filesystem.rb +182 -11
  28. data/lib/indexmap/storage/memory.rb +1 -1
  29. data/lib/indexmap/task_runner.rb +21 -28
  30. data/lib/indexmap/validator/duplicates.rb +83 -0
  31. data/lib/indexmap/validator.rb +171 -59
  32. data/lib/indexmap/version.rb +1 -1
  33. data/lib/indexmap/writer.rb +89 -34
  34. data/lib/indexmap.rb +14 -1
  35. data/lib/tasks/indexmap_tasks.rake +21 -2
  36. data/test/indexmap/correctness_test.rb +153 -0
  37. data/test/indexmap/install_generator_test.rb +36 -4
  38. data/test/indexmap/parser_test.rb +77 -0
  39. data/test/indexmap/pinger/google_test.rb +22 -0
  40. data/test/indexmap/pinger/index_now_test.rb +168 -0
  41. data/test/indexmap/publication_test.rb +280 -0
  42. data/test/indexmap/robots_test.rb +73 -0
  43. data/test/indexmap/sitemaps_controller_test.rb +50 -0
  44. data/test/indexmap/storage_test.rb +27 -0
  45. data/test/indexmap/task_runner_test.rb +24 -0
  46. data/test/indexmap/validator_test.rb +14 -0
  47. data/test/indexmap/writer_test.rb +42 -0
  48. data/test/integration/queue_checks.rb +244 -0
  49. data/test/release_task_test.rb +153 -0
  50. metadata +22 -5
  51. data/lib/indexmap/storage/file.rb +0 -11
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "uri"
4
+ require "tmpdir"
5
+ require "active_support/core_ext/file/atomic"
4
6
 
5
7
  module Indexmap
6
8
  module Storage
@@ -8,16 +10,21 @@ module Indexmap
8
10
  DEFAULT_CONTENT_TYPE = "application/xml"
9
11
 
10
12
  def initialize(path:, public_url: nil)
11
- @path = Pathname(path)
13
+ @path = Pathname(path).expand_path
14
+ @path.mkpath
15
+ @path = @path.realpath
12
16
  @public_url_base = public_url
13
17
  end
14
18
 
15
19
  def write(filename, body, content_type: DEFAULT_CONTENT_TYPE)
20
+ if Publication.immutable?(filename) && exist?(filename)
21
+ raise ConfigurationError, "Immutable sitemap already exists: #{filename}"
22
+ end
16
23
  target = path_for(filename)
17
24
  target.dirname.mkpath
18
- target.write(body.to_s)
25
+ ::File.atomic_write(target) { |file| file.write(body.to_s) }
19
26
 
20
- File.new(
27
+ Artifact.new(
21
28
  filename: normalize_filename(filename),
22
29
  body: body.to_s,
23
30
  content_type: content_type || DEFAULT_CONTENT_TYPE
@@ -33,13 +40,32 @@ module Indexmap
33
40
  end
34
41
 
35
42
  def list(prefix: nil, suffix: nil)
36
- path.glob("**/*").select(&:file?).filter_map do |file|
37
- filename = file.relative_path_from(path).to_s
38
- next if prefix && !filename.start_with?(prefix)
39
- next if suffix && !filename.end_with?(suffix)
40
-
41
- filename
42
- end.sort
43
+ prefix = prefix.to_s
44
+ unless prefix.empty? || normalize_filename(prefix) == prefix.delete_suffix("/")
45
+ raise ArgumentError, "Storage prefix must be a normalized relative path: #{prefix.inspect}"
46
+ end
47
+ directory, _, fragment = prefix.rpartition("/")
48
+ ancestor = path
49
+ directory.split("/").each do |segment|
50
+ ancestor = ancestor.join(segment)
51
+ return [] if segment.start_with?(".") || ancestor.symlink?
52
+ end
53
+ base = directory.empty? ? path : path_for(directory)
54
+ return [] unless base.directory?
55
+
56
+ pending = base.children.select { |entry| entry.basename.to_s.start_with?(fragment) }
57
+ filenames = []
58
+ until pending.empty?
59
+ entry = pending.pop
60
+ next if entry.symlink? || entry.basename.to_s.start_with?(".")
61
+
62
+ if entry.directory?
63
+ pending.concat(entry.children)
64
+ elsif (!suffix || entry.to_s.end_with?(suffix)) && entry.file?
65
+ filenames << entry.relative_path_from(path).to_s
66
+ end
67
+ end
68
+ filenames.sort
43
69
  end
44
70
 
45
71
  def delete(filename)
@@ -52,6 +78,73 @@ module Indexmap
52
78
  URI.join("#{public_url_base.to_s.delete_suffix("/")}/", normalize_filename(filename)).to_s
53
79
  end
54
80
 
81
+ def open(filename, &block)
82
+ path_for(filename).open("rb", &block)
83
+ end
84
+
85
+ def write_io(filename, io, content_type: DEFAULT_CONTENT_TYPE)
86
+ if Publication.immutable?(filename) && exist?(filename)
87
+ raise ConfigurationError, "Immutable sitemap already exists: #{filename}"
88
+ end
89
+ target = path_for(filename)
90
+ target.dirname.mkpath
91
+ ::File.atomic_write(target) { |file| IO.copy_stream(io, file) }
92
+ end
93
+
94
+ def metadata(filename)
95
+ stat = path_for(filename).stat
96
+ {etag: "#{stat.ino}-#{stat.size}-#{stat.mtime.to_r}", bytes: stat.size, last_modified: stat.mtime.utc}
97
+ end
98
+
99
+ def revision(root)
100
+ exist?(root) ? metadata(root).fetch(:etag) : nil
101
+ end
102
+
103
+ def publish(source, files:, root:, expected:)
104
+ check_revision!(root, expected)
105
+ publication = Publication.new(root: root)
106
+ publication.validate_files!(files)
107
+ with_staging(root, expected: expected) do |staged|
108
+ files.each do |filename|
109
+ source.open(filename) { |io| staged.write_io(filename, io) }
110
+ end
111
+ archive = if expected && (version = publication.current_generation(self))
112
+ publication.archive(version).tap do |filename|
113
+ self.open(root) { |io| staged.write_io(filename, io) }
114
+ end
115
+ end
116
+ with_lock(root) do
117
+ check_revision!(root, expected)
118
+ children = files.reject { |filename| filename == root }
119
+ children.each do |filename|
120
+ raise ConfigurationError, "Immutable sitemap already exists: #{filename}" if exist?(filename)
121
+ end
122
+ moved = []
123
+ root_revision = staged.revision(root)
124
+ begin
125
+ children.each do |filename|
126
+ move_staged(staged, filename)
127
+ moved << filename
128
+ end
129
+ move_staged(staged, archive, retired_at: Time.now.utc) if archive
130
+ move_staged(staged, root)
131
+ ensure
132
+ # A root rename can succeed immediately before an interrupt.
133
+ moved.each { |filename| delete(filename) } unless revision(root) == root_revision
134
+ end
135
+ end
136
+ end
137
+ rescue Errno::ENOENT
138
+ check_revision!(root, expected)
139
+ raise
140
+ end
141
+
142
+ def prune(root:, before:)
143
+ with_lock(root) do
144
+ Publication.new(root: root).prune(self, before: before) + prune_staging(root, before: before)
145
+ end
146
+ end
147
+
55
148
  def inspect
56
149
  "#<#{self.class.name} path=#{path}>"
57
150
  end
@@ -60,8 +153,86 @@ module Indexmap
60
153
 
61
154
  attr_reader :path, :public_url_base
62
155
 
156
+ def check_revision!(root, expected)
157
+ raise SupersededPublication, "Sitemap changed while building: #{root}" unless revision(root) == expected
158
+ end
159
+
160
+ def staging_path(root)
161
+ path_for(".indexmap-builds/#{Digest::SHA256.hexdigest(root)}")
162
+ end
163
+
164
+ def with_staging(root, expected:)
165
+ directory = nil
166
+ lock = nil
167
+ with_lock(root) do
168
+ check_revision!(root, expected)
169
+ base = staging_path(root)
170
+ base.mkpath
171
+ directory = Dir.mktmpdir("build-", base)
172
+ lock = ::File.open(::File.join(directory, ".lock"), "w")
173
+ lock.flock(::File::LOCK_EX)
174
+ end
175
+ yield Filesystem.new(path: directory)
176
+ ensure
177
+ begin
178
+ FileUtils.remove_entry(directory) if directory
179
+ ensure
180
+ lock&.close
181
+ end
182
+ end
183
+
184
+ def move_staged(staged, filename, retired_at: nil)
185
+ target = path_for(filename)
186
+ target.dirname.mkpath
187
+ staged.open(filename) do |io|
188
+ ::File.utime(retired_at, retired_at, io.path) if retired_at
189
+ ::File.atomic_write(target) do |temporary|
190
+ temporary.close
191
+ ::File.rename(io.path, temporary.path)
192
+ end
193
+ end
194
+ end
195
+
196
+ def prune_staging(root, before:)
197
+ base = staging_path(root)
198
+ return [] unless base.directory?
199
+
200
+ base.children.filter_map do |directory|
201
+ next if directory.symlink? || !directory.directory? || directory.mtime >= before
202
+
203
+ begin
204
+ directory.join(".lock").open("a") do |lock|
205
+ next unless lock.flock(::File::LOCK_EX | ::File::LOCK_NB)
206
+
207
+ FileUtils.remove_entry_secure(directory.to_s)
208
+ directory.relative_path_from(path).to_s
209
+ end
210
+ rescue Errno::ENOENT
211
+ # A completed build can remove its staging directory during pruning.
212
+ nil
213
+ end
214
+ end
215
+ end
216
+
217
+ def with_lock(root)
218
+ lock = path_for(".indexmap-#{Digest::SHA256.hexdigest(root)}.lock")
219
+ lock.open("a") do |file|
220
+ file.flock(::File::LOCK_EX)
221
+ yield
222
+ ensure
223
+ file.flock(::File::LOCK_UN)
224
+ end
225
+ end
226
+
63
227
  def path_for(filename)
64
- path.join(normalize_filename(filename))
228
+ target = path.join(normalize_filename(filename))
229
+ ancestor = target
230
+ ancestor = ancestor.parent until ancestor.exist? || ancestor.symlink?
231
+ resolved = ancestor.realpath
232
+ unless resolved == path || resolved.to_s.start_with?("#{path}/")
233
+ raise ArgumentError, "Storage filename escapes its root: #{filename.inspect}"
234
+ end
235
+ target
65
236
  end
66
237
 
67
238
  def normalize_filename(filename)
@@ -17,7 +17,7 @@ module Indexmap
17
17
 
18
18
  def write(filename, body, content_type: DEFAULT_CONTENT_TYPE)
19
19
  normalized = normalize_filename(filename)
20
- files[normalized] = File.new(
20
+ files[normalized] = Artifact.new(
21
21
  filename: normalized,
22
22
  body: body.to_s,
23
23
  content_type: content_type || DEFAULT_CONTENT_TYPE
@@ -4,24 +4,29 @@ require "nokogiri"
4
4
 
5
5
  module Indexmap
6
6
  class TaskRunner
7
- def initialize(configuration: Indexmap.configuration)
7
+ def initialize(configuration: Indexmap.configuration, storage: nil)
8
8
  @configuration = configuration
9
+ @storage = storage
9
10
  end
10
11
 
11
12
  def create
12
- written_files = Indexmap.create(configuration: configuration)
13
+ creator = Creator.new(output: default_output, storage: storage)
14
+ written_files = creator.create
13
15
  index_now_key_filename = write_index_now_key if configuration.index_now.write_key_file?
14
16
  configuration.run_after_create_callbacks
15
17
 
16
18
  {
17
19
  files: written_files.map(&:to_s),
18
20
  written_files: written_files,
19
- sitemaps: sitemap_details(written_files),
21
+ sitemaps: creator.sitemaps,
20
22
  index_now_key_filename: index_now_key_filename
21
23
  }
22
24
  end
23
25
 
24
26
  def format
27
+ if configuration.output_names.any? { |name| configuration.output_for(name).atomic }
28
+ raise ConfigurationError, "Format immutable sitemaps by regenerating them"
29
+ end
25
30
  sitemap_files.each do |filename|
26
31
  content = storage.read(filename)
27
32
  document = Nokogiri::XML(
@@ -39,19 +44,29 @@ module Indexmap
39
44
  end
40
45
 
41
46
  def validate
42
- Validator.new(configuration: configuration).validate!
47
+ Validator.new(configuration: configuration, storage: storage).validate!(collect: false)
43
48
  sitemap_files
44
49
  end
45
50
 
51
+ def prune(retention: 48 * 3600)
52
+ raise ConfigurationError, "Retention must be at least one hour" unless retention.is_a?(Numeric) && retention >= 3600
53
+ raise ConfigurationError, "Storage does not support pruning" unless storage.respond_to?(:prune)
54
+
55
+ configuration.output_names.flat_map do |name|
56
+ output = configuration.output_for(name)
57
+ output.atomic ? storage.prune(root: output.index_filename, before: Time.now.utc - retention) : []
58
+ end
59
+ end
60
+
46
61
  def write_index_now_key(generate_if_missing: false)
47
- pinger = Pinger::IndexNow.new(configuration: configuration)
62
+ pinger = Pinger::IndexNow.new(configuration: configuration, storage: storage)
48
63
  return pinger.ensure_key_file if generate_if_missing
49
64
 
50
65
  pinger.write_key_file
51
66
  end
52
67
 
53
68
  def storage
54
- configuration.storage
69
+ @storage ||= configuration.storage
55
70
  end
56
71
 
57
72
  private
@@ -65,27 +80,5 @@ module Indexmap
65
80
  def sitemap_files
66
81
  storage.list(prefix: "sitemap", suffix: ".xml")
67
82
  end
68
-
69
- def sitemap_details(files)
70
- files.map do |filename|
71
- {
72
- filename: filename.to_s,
73
- location: sitemap_location(filename),
74
- link_count: sitemap_link_count(filename)
75
- }
76
- end
77
- end
78
-
79
- def sitemap_location(filename)
80
- return storage.public_url(filename) if storage.respond_to?(:public_url)
81
-
82
- filename.to_s
83
- end
84
-
85
- def sitemap_link_count(filename)
86
- document = Nokogiri::XML(storage.read(filename.to_s))
87
- document.remove_namespaces!
88
- document.xpath("//loc").count
89
- end
90
83
  end
91
84
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Indexmap
4
+ class Validator
5
+ class Duplicates
6
+ def initialize(directory:)
7
+ @directory = directory
8
+ @buffer = []
9
+ @runs = []
10
+ @sequence = 0
11
+ end
12
+
13
+ def add(location)
14
+ @buffer << location
15
+ flush if @buffer.size == 10_000
16
+ end
17
+
18
+ def validate!
19
+ previous = nil
20
+ each_sorted_location do |line|
21
+ raise ValidationError, "Duplicate sitemap URLs detected: #{line.chomp}" if line == previous
22
+
23
+ previous = line
24
+ end
25
+ end
26
+
27
+ def unique_count
28
+ count = 0
29
+ previous = nil
30
+ each_sorted_location do |line|
31
+ count += 1 unless line == previous
32
+ previous = line
33
+ end
34
+ count
35
+ end
36
+
37
+ private
38
+
39
+ def each_sorted_location
40
+ flush unless @buffer.empty?
41
+ while @runs.size > 1
42
+ @runs = @runs.each_slice(2).map do |left, right|
43
+ next left unless right
44
+
45
+ target = next_path
46
+ File.open(left) do |a|
47
+ File.open(right) do |b|
48
+ File.open(target, "wb") do |output|
49
+ x = a.gets
50
+ y = b.gets
51
+ while x || y
52
+ if y.nil? || (x && x <= y)
53
+ output.write(x)
54
+ x = a.gets
55
+ else
56
+ output.write(y)
57
+ y = b.gets
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ File.unlink(left, right)
64
+ target
65
+ end
66
+ end
67
+ File.foreach(@runs.first) { |line| yield line } if @runs.first
68
+ end
69
+
70
+ def flush
71
+ path = next_path
72
+ File.open(path, "wb") { |file| @buffer.sort.each { |location| file.puts(location) } }
73
+ @runs << path
74
+ @buffer.clear
75
+ end
76
+
77
+ def next_path
78
+ @sequence += 1
79
+ File.join(@directory, "urls-#{@sequence}")
80
+ end
81
+ end
82
+ end
83
+ end