slurm_hostlist 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/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +152 -0
- data/exe/slurm-hostlist +37 -0
- data/lib/slurm_hostlist/host_range.rb +61 -0
- data/lib/slurm_hostlist/hostlist.rb +517 -0
- data/lib/slurm_hostlist/natural_compare.rb +112 -0
- data/lib/slurm_hostlist/version.rb +5 -0
- data/lib/slurm_hostlist.rb +59 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 995f5429991f0a2fa26b5f7810da33de0ff8f6df6ed6f041a7e340f119248fd0
|
|
4
|
+
data.tar.gz: 716770221f07852d043f8d3dae881fd0514fd9d202ef3f0387e0efa6e850a818
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f6f7f62383ed60a557b5562f774addc72393b6cb518d1ee38e8bf53f4b7110e411180b8b5a75fed62c680a2377c3b935ba2995b7974db286bb796a438c600c99
|
|
7
|
+
data.tar.gz: 86d9552ef5c05c3de523efd5406845d477f582b2df3c5fc763553434009a24cf53b747249cd53aeba198e17539eb3139c7920b0ca81f778425e47292829cabac
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ohio Supercomputer Center
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# slurm_hostlist
|
|
2
|
+
|
|
3
|
+
Expand and compress [Slurm](https://slurm.schedmd.com/) hostlist expressions
|
|
4
|
+
in Ruby, getting the same answers Slurm does.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require 'slurm_hostlist'
|
|
8
|
+
|
|
9
|
+
SlurmHostlist.expand('n[01-03],login1')
|
|
10
|
+
# => ["n01", "n02", "n03", "login1"]
|
|
11
|
+
|
|
12
|
+
SlurmHostlist.compress(%w[n01 n02 n03 n07 login1])
|
|
13
|
+
# => "n[01-03,07],login1"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
No dependencies. It's a port of Slurm's own `src/common/hostlist.c`,
|
|
17
|
+
differentially tested against it, so edge cases that break a quick regex
|
|
18
|
+
work the way Slurm handles them: suffixes after brackets (`node[1-4]-ib`),
|
|
19
|
+
several bracket groups (`rack[1-2]-n[01-16]`), and mixed zero-padding.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
gem install slurm_hostlist
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
or in a Gemfile:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
gem 'slurm_hostlist'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Ruby 2.7 or newer.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
| Call | Same as |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `SlurmHostlist.expand(expression)` → `Array<String>` | `scontrol show hostnames` |
|
|
40
|
+
| `SlurmHostlist.compress(hosts)` → `String` | `scontrol show hostlist` |
|
|
41
|
+
| `SlurmHostlist.compress(hosts, sort: true)` → `String` | `scontrol show hostlistsorted` |
|
|
42
|
+
| `SlurmHostlist.count(expression)` → `Integer` | number of hosts, duplicates included |
|
|
43
|
+
| `SlurmHostlist.valid?(expression)` → `true`/`false` | whether Slurm accepts it |
|
|
44
|
+
|
|
45
|
+
`compress` takes an array of host names or an expression string. Input
|
|
46
|
+
Slurm would reject (`n[3-1]`, `n[1`, `n[]`) raises
|
|
47
|
+
`SlurmHostlist::InvalidHostlist`, a subclass of `ArgumentError`.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
SlurmHostlist.expand('rack[1-2]-n[1-2]')
|
|
51
|
+
# => ["rack1-n1", "rack1-n2", "rack2-n1", "rack2-n2"]
|
|
52
|
+
|
|
53
|
+
SlurmHostlist.compress(%w[n3 n1 n2]) # => "n[3,1-2]" (order kept)
|
|
54
|
+
SlurmHostlist.compress(%w[n3 n1 n2], sort: true) # => "n[1-3]"
|
|
55
|
+
|
|
56
|
+
SlurmHostlist.expand(ENV.fetch('SLURM_JOB_NODELIST'))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A command is included too:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
$ slurm-hostlist expand 'n[01-03]'
|
|
63
|
+
n01
|
|
64
|
+
n02
|
|
65
|
+
n03
|
|
66
|
+
$ slurm-hostlist compress n01 n02 n03
|
|
67
|
+
n[01-03]
|
|
68
|
+
$ squeue -h -o %N | slurm-hostlist compress --sort
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Behaviour worth knowing
|
|
72
|
+
|
|
73
|
+
These all match Slurm:
|
|
74
|
+
|
|
75
|
+
- **Order and duplicates are kept.** `expand('n[1-3],n2')` gives
|
|
76
|
+
`n1 n2 n3 n2`. Without `sort: true`, `compress` only joins a host onto the
|
|
77
|
+
range just before it, so `%w[n3 n1 n2]` gives `n[3,1-2]`.
|
|
78
|
+
- **Zero-padding comes from the low number.** `n[01-3]` gives
|
|
79
|
+
`n01 n02 n03`; `n[1-02]` gives `n1 n2`.
|
|
80
|
+
- **Padding that can't be shared stays apart.** `%w[n01 n1 n2]` compresses to
|
|
81
|
+
`n[01,1-2]`, but `%w[n9 n10]` to `n[9-10]`.
|
|
82
|
+
- **Hosts from a bracket with a suffix aren't re-bracketed.** Compressing
|
|
83
|
+
`node[1-2]-ib` gives `node1-ib,node2-ib`.
|
|
84
|
+
- **With three or more bracket groups, Slurm's order isn't left-to-right:**
|
|
85
|
+
in `a[1-2]b[3-4]c[5-6]` the *middle* group changes slowest
|
|
86
|
+
(`a1b3c5 a1b3c6 a2b3c5 a2b3c6 a1b4c5 ...`).
|
|
87
|
+
- **Numbers are read with C's `strtoul`**, quirks included: `n[+3]` and
|
|
88
|
+
`n[ 3]` give `n03` (width comes from the text as written), and `n[5-]`
|
|
89
|
+
gives `n5`.
|
|
90
|
+
|
|
91
|
+
## How it's checked against Slurm
|
|
92
|
+
|
|
93
|
+
- **Differential testing.** `script/differential` generates random
|
|
94
|
+
expressions, realistic ones and deliberately odd ones, and compares this
|
|
95
|
+
gem with Slurm on all three operations. Before the first release it ran
|
|
96
|
+
300,000 cases against Slurm's `hostlist.c` (Slurm `master`, built the way
|
|
97
|
+
production Slurm is, with asserts off) with no differences.
|
|
98
|
+
`script/oracle/build.sh` rebuilds that oracle from Slurm's source (it needs
|
|
99
|
+
only git and gcc), and
|
|
100
|
+
`script/differential --oracle script/oracle/build/hostlist_oracle` runs the
|
|
101
|
+
comparison.
|
|
102
|
+
- **Recorded cases in CI.** `test/fixtures/slurm_cases.jsonl` holds about
|
|
103
|
+
3,000 cases and Slurm's answers, recorded with `script/record_fixtures`, and
|
|
104
|
+
the test suite replays them, so CI checks against Slurm without Slurm
|
|
105
|
+
installed.
|
|
106
|
+
- **Check against your own Slurm.** On any machine with `scontrol`:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
script/differential --scontrol --cases 1000
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Known differences and limits
|
|
113
|
+
|
|
114
|
+
- **Host numbers of 18446744073709551615 (2⁶⁴ − 1) or more are rejected.**
|
|
115
|
+
Current Slurm rejects them too, but released versions before SchedMD
|
|
116
|
+
commit `0266d18d3e` (September 2026) accept them: the number silently
|
|
117
|
+
becomes 18446744073709551615 (a different host), and a range ending there
|
|
118
|
+
can run the parser out of memory. Against an older `scontrol`,
|
|
119
|
+
`script/differential` counts these separately. Real clusters don't number
|
|
120
|
+
hosts anywhere near this.
|
|
121
|
+
- **Sorted output in cases where Slurm itself misbehaves.** For some inputs,
|
|
122
|
+
Slurm's `hostlistsorted` code reads memory it has already freed, so its
|
|
123
|
+
output depends on how Slurm was compiled. For
|
|
124
|
+
`rack[17-21,18446744073709551614]`, one build of Slurm's code returned
|
|
125
|
+
`rack[18446744073709551614]` (five hosts dropped) and another returned
|
|
126
|
+
something else. There's nothing reliable to match there. It takes unusual input, such as host
|
|
127
|
+
numbers 2³¹ or more apart, or mixed padding widths that overlap, and
|
|
128
|
+
`Hostlist#diverged_from_slurm?` reports when it happens. `expand` and
|
|
129
|
+
unsorted `compress` never go down that path.
|
|
130
|
+
- **`{...}` hostlist functions** are expanded by slurmctld, not by the
|
|
131
|
+
hostlist code, so they're treated as literal text, as Slurm does when no
|
|
132
|
+
controller is reachable.
|
|
133
|
+
- **Multi-dimensional (3D/4D) node naming**, from Slurm builds configured
|
|
134
|
+
for more than one dimension, isn't supported. This covers ordinary
|
|
135
|
+
one-dimensional clusters, which is what current Slurm installs use.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
bundle install
|
|
141
|
+
bundle exec rake test
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
To re-record the fixture from a local Slurm:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
script/record_fixtures --scontrol
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT. Copyright Ohio Supercomputer Center.
|
data/exe/slurm-hostlist
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# slurm-hostlist expand 'n[01-03],login1' one host per line
|
|
5
|
+
# slurm-hostlist compress n01 n02 n03 n[01-03]
|
|
6
|
+
# slurm-hostlist compress --sort < hosts.txt hosts from stdin, sorted
|
|
7
|
+
# slurm-hostlist count 'n[1-100]' 100
|
|
8
|
+
|
|
9
|
+
require 'slurm_hostlist'
|
|
10
|
+
|
|
11
|
+
USAGE = <<~TEXT
|
|
12
|
+
usage: slurm-hostlist expand EXPRESSION
|
|
13
|
+
slurm-hostlist compress [--sort] [HOST ...] (reads stdin if no hosts given)
|
|
14
|
+
slurm-hostlist count EXPRESSION
|
|
15
|
+
TEXT
|
|
16
|
+
|
|
17
|
+
command = ARGV.shift
|
|
18
|
+
begin
|
|
19
|
+
case command
|
|
20
|
+
when 'expand'
|
|
21
|
+
abort USAGE if ARGV.length != 1
|
|
22
|
+
puts SlurmHostlist.expand(ARGV.first)
|
|
23
|
+
when 'compress'
|
|
24
|
+
sort = ARGV.delete('--sort') ? true : false
|
|
25
|
+
hosts = ARGV.empty? ? $stdin.read.split : ARGV
|
|
26
|
+
puts SlurmHostlist.compress(hosts, sort: sort)
|
|
27
|
+
when 'count'
|
|
28
|
+
abort USAGE if ARGV.length != 1
|
|
29
|
+
puts SlurmHostlist.count(ARGV.first)
|
|
30
|
+
when '-v', '--version'
|
|
31
|
+
puts SlurmHostlist::VERSION
|
|
32
|
+
else
|
|
33
|
+
abort USAGE
|
|
34
|
+
end
|
|
35
|
+
rescue SlurmHostlist::InvalidHostlist => e
|
|
36
|
+
abort "slurm-hostlist: #{e.message}"
|
|
37
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlurmHostlist
|
|
4
|
+
# One run of hosts sharing a prefix and a zero-padding width, e.g. the
|
|
5
|
+
# "n[01-04]" in "n[01-04],login1". A host with no trailing number (or one
|
|
6
|
+
# expanded from a bracket followed by a suffix, like "node1-ib") is stored
|
|
7
|
+
# as a "single" host whose prefix is the whole name.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors Slurm's hostrange_t. The fields are mutable on purpose: Slurm
|
|
10
|
+
# adjusts widths in place when it decides two ranges can merge, and the
|
|
11
|
+
# output depends on that.
|
|
12
|
+
class HostRange
|
|
13
|
+
attr_accessor :prefix, :lo, :hi, :width, :single
|
|
14
|
+
|
|
15
|
+
def self.single(name)
|
|
16
|
+
new(name, 0, 0, 0, true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(prefix, lo, hi, width, single = false)
|
|
20
|
+
@prefix = prefix
|
|
21
|
+
@lo = lo
|
|
22
|
+
@hi = hi
|
|
23
|
+
@width = width
|
|
24
|
+
@single = single
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def count
|
|
28
|
+
return 1 if single
|
|
29
|
+
|
|
30
|
+
hi - lo + 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The host names in this range, in order.
|
|
34
|
+
def hosts
|
|
35
|
+
return [prefix] if single
|
|
36
|
+
|
|
37
|
+
(lo..hi).map { |n| prefix + SlurmHostlist.pad(n, width) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The numeric part as written inside brackets: "01" or "01-04".
|
|
41
|
+
# Slurm's hostrange_numstr().
|
|
42
|
+
def numstr
|
|
43
|
+
return '' if single
|
|
44
|
+
|
|
45
|
+
text = SlurmHostlist.pad(lo, width)
|
|
46
|
+
text += "-#{SlurmHostlist.pad(hi, width)}" if lo < hi
|
|
47
|
+
text
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def copy
|
|
51
|
+
return HostRange.single(prefix) if single
|
|
52
|
+
|
|
53
|
+
HostRange.new(prefix, lo, hi, width)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Slurm's "%0*lu": the number, left-padded with zeros to width.
|
|
58
|
+
def self.pad(number, width)
|
|
59
|
+
number.to_s.rjust(width, '0')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlurmHostlist
|
|
4
|
+
# A parsed hostlist: an ordered list of HostRange objects.
|
|
5
|
+
#
|
|
6
|
+
# This is a port of the parts of Slurm's src/common/hostlist.c that back
|
|
7
|
+
# `scontrol show hostnames`, `show hostlist` and `show hostlistsorted`.
|
|
8
|
+
# Method comments name the C function each one follows, so behaviour can be
|
|
9
|
+
# checked against Slurm line by line.
|
|
10
|
+
class Hostlist
|
|
11
|
+
ULONG_MAX = (2**64) - 1
|
|
12
|
+
MAX_RANGE = 64 * 1024 # hosts in one bracket range (MAX_SLURM_NODES)
|
|
13
|
+
MAX_PREFIX_CNT = 64 * 1024 # hosts from brackets earlier in a name
|
|
14
|
+
MAX_RANGES = 256 * 1024 # comma-separated ranges in one bracket
|
|
15
|
+
# Binary, like the text they're compared against (parsing works on bytes).
|
|
16
|
+
SEPARATORS = "\t, \n".b.freeze
|
|
17
|
+
SPACES = " \t\n\v\f\r".b.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :ranges
|
|
20
|
+
|
|
21
|
+
def initialize(ranges = [])
|
|
22
|
+
@ranges = ranges
|
|
23
|
+
@diverged_from_slurm = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True if sort! went down a path where Slurm reads freed memory, so
|
|
27
|
+
# Slurm's own output can't be reproduced (see #coalesce).
|
|
28
|
+
def diverged_from_slurm?
|
|
29
|
+
@diverged_from_slurm
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse a hostlist expression. Raises InvalidHostlist when Slurm would
|
|
33
|
+
# reject it. Slurm's _hostlist_create_bracketed().
|
|
34
|
+
def self.parse(expression)
|
|
35
|
+
text = expression.to_s.b
|
|
36
|
+
# C strings end at the first NUL.
|
|
37
|
+
nul = text.index("\0")
|
|
38
|
+
text = text[0...nul] if nul
|
|
39
|
+
|
|
40
|
+
list = new
|
|
41
|
+
tokens(text).each do |token|
|
|
42
|
+
ok = list.push_token(token)
|
|
43
|
+
raise InvalidHostlist, "invalid hostlist: #{expression}" unless ok
|
|
44
|
+
end
|
|
45
|
+
list
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Split on separators, keeping bracketed parts together:
|
|
49
|
+
# "a[1,2],b" is two tokens, "a[1,2]" and "b". Slurm's _next_tok().
|
|
50
|
+
def self.tokens(text)
|
|
51
|
+
tokens = []
|
|
52
|
+
length = text.length
|
|
53
|
+
pos = 0
|
|
54
|
+
|
|
55
|
+
loop do
|
|
56
|
+
pos += 1 while pos < length && separator?(text[pos])
|
|
57
|
+
break if pos >= length
|
|
58
|
+
|
|
59
|
+
start = pos
|
|
60
|
+
parse = pos
|
|
61
|
+
pos = scan_token_end(text, pos, parse)
|
|
62
|
+
tokens << text[start...pos]
|
|
63
|
+
pos += 1 while pos < length && separator?(text[pos])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
tokens
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Find where the token starting at pos ends. A separator inside a
|
|
70
|
+
# [...] pair doesn't end the token.
|
|
71
|
+
def self.scan_token_end(text, pos, parse)
|
|
72
|
+
length = text.length
|
|
73
|
+
loop do
|
|
74
|
+
pos += 1 while pos < length && !separator?(text[pos])
|
|
75
|
+
|
|
76
|
+
extended = false
|
|
77
|
+
loop do
|
|
78
|
+
open = text.index('[', parse)
|
|
79
|
+
return pos if open.nil? || open >= pos
|
|
80
|
+
|
|
81
|
+
close = text.index(']', parse)
|
|
82
|
+
return pos if close.nil? || close < open
|
|
83
|
+
|
|
84
|
+
if close < pos
|
|
85
|
+
parse = close + 1
|
|
86
|
+
else
|
|
87
|
+
pos = close
|
|
88
|
+
extended = true
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
return pos unless extended
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.separator?(char)
|
|
97
|
+
SEPARATORS.include?(char)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# C's strtoul(), base 10. Returns [value, index after the number], with
|
|
101
|
+
# index 0 when nothing was converted. Skips leading whitespace, takes an
|
|
102
|
+
# optional sign, and clamps overflow to ULONG_MAX, as glibc does.
|
|
103
|
+
def self.strtoul(text)
|
|
104
|
+
i = 0
|
|
105
|
+
i += 1 while i < text.length && SPACES.include?(text[i])
|
|
106
|
+
|
|
107
|
+
negative = false
|
|
108
|
+
if text[i] == '+' || text[i] == '-'
|
|
109
|
+
negative = (text[i] == '-')
|
|
110
|
+
i += 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
digits_start = i
|
|
114
|
+
i += 1 while i < text.length && text[i] >= '0' && text[i] <= '9'
|
|
115
|
+
return [0, 0] if i == digits_start
|
|
116
|
+
|
|
117
|
+
magnitude = text[digits_start...i].to_i
|
|
118
|
+
value = if magnitude > ULONG_MAX
|
|
119
|
+
ULONG_MAX
|
|
120
|
+
elsif negative
|
|
121
|
+
(ULONG_MAX + 1 - magnitude) % (ULONG_MAX + 1)
|
|
122
|
+
else
|
|
123
|
+
magnitude
|
|
124
|
+
end
|
|
125
|
+
[value, i]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One entry inside brackets: "7", "01-16". Returns [lo, hi, width] or nil.
|
|
129
|
+
# The width is the length of the low number as written.
|
|
130
|
+
# Slurm's _parse_single_range().
|
|
131
|
+
def self.parse_single_range(text)
|
|
132
|
+
return nil if text.include?('x') # "boxes" are for multi-dimensional systems
|
|
133
|
+
|
|
134
|
+
dash = text.index('-')
|
|
135
|
+
if dash
|
|
136
|
+
lo_text = text[0...dash]
|
|
137
|
+
hi_text = text[(dash + 1)..]
|
|
138
|
+
return nil if hi_text.start_with?('-') # no negative numbers
|
|
139
|
+
else
|
|
140
|
+
lo_text = text
|
|
141
|
+
hi_text = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
width = lo_text.length
|
|
145
|
+
lo, lo_end = strtoul(lo_text)
|
|
146
|
+
return nil if lo_end.zero?
|
|
147
|
+
|
|
148
|
+
if hi_text && !hi_text.empty?
|
|
149
|
+
hi, hi_end = strtoul(hi_text)
|
|
150
|
+
return nil if hi_end.zero? || hi_end != hi_text.length
|
|
151
|
+
else
|
|
152
|
+
hi = lo
|
|
153
|
+
return nil if lo_end != lo_text.length
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return nil if hi == ULONG_MAX
|
|
157
|
+
return nil if lo > hi
|
|
158
|
+
return nil if hi - lo + 1 > MAX_RANGE
|
|
159
|
+
|
|
160
|
+
[lo, hi, width]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# "1-3,7,10-12" -> [[1, 3, 1], [7, 7, 1], [10, 12, 2]], or nil if any
|
|
164
|
+
# entry is invalid. Slurm's _parse_range_list().
|
|
165
|
+
def self.parse_range_list(text)
|
|
166
|
+
entries = text.empty? ? [''] : text.split(',', -1)
|
|
167
|
+
raise InvalidHostlist, 'too many ranges' if entries.length > MAX_RANGES
|
|
168
|
+
|
|
169
|
+
ranges = []
|
|
170
|
+
entries.each do |entry|
|
|
171
|
+
range = parse_single_range(entry)
|
|
172
|
+
return nil if range.nil?
|
|
173
|
+
|
|
174
|
+
ranges << range
|
|
175
|
+
end
|
|
176
|
+
ranges
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Add one token: a plain host name or a bracketed expression.
|
|
180
|
+
# Returns false if Slurm would reject it.
|
|
181
|
+
def push_token(token)
|
|
182
|
+
open = token.rindex('[')
|
|
183
|
+
return push_host(token) if open.nil?
|
|
184
|
+
|
|
185
|
+
prefix = token[0...open]
|
|
186
|
+
rest = token[(open + 1)..]
|
|
187
|
+
close = rest.index(']')
|
|
188
|
+
return false if close.nil?
|
|
189
|
+
|
|
190
|
+
after = rest[(close + 1)..]
|
|
191
|
+
suffix = after.empty? || after.start_with?(',') ? nil : after
|
|
192
|
+
|
|
193
|
+
ranges = self.class.parse_range_list(rest[0...close])
|
|
194
|
+
return false if ranges.nil?
|
|
195
|
+
|
|
196
|
+
push_range_list(prefix, suffix, ranges) == 0
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Expand any brackets left in the prefix ("rack[1-2]-n" in
|
|
200
|
+
# "rack[1-2]-n[1-4]"), then push the ranges for each resulting prefix.
|
|
201
|
+
# Returns 0 or -1. Slurm's _push_range_list().
|
|
202
|
+
def push_range_list(prefix, suffix, ranges)
|
|
203
|
+
open = prefix.rindex('[')
|
|
204
|
+
close = prefix.rindex(']')
|
|
205
|
+
unless open && close && close > open
|
|
206
|
+
ranges.each { |lo, hi, width| push_hr(prefix, suffix, lo, hi, width) }
|
|
207
|
+
return 0
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
head = prefix[0...open]
|
|
211
|
+
tail = prefix[(close + 1)..]
|
|
212
|
+
recurse = head.include?('[')
|
|
213
|
+
|
|
214
|
+
prefix_ranges = self.class.parse_range_list(prefix[(open + 1)...close])
|
|
215
|
+
return -1 if prefix_ranges.nil?
|
|
216
|
+
|
|
217
|
+
prefix_count = 0
|
|
218
|
+
prefix_ranges.each do |lo, hi, width|
|
|
219
|
+
prefix_count += hi - lo + 1
|
|
220
|
+
return -1 if prefix_count > MAX_PREFIX_CNT
|
|
221
|
+
|
|
222
|
+
(lo..hi).each do |n|
|
|
223
|
+
new_prefix = head + SlurmHostlist.pad(n, width) + tail
|
|
224
|
+
if recurse
|
|
225
|
+
# Slurm keeps MAX(rc, rc1) starting from 0, so a failure in a
|
|
226
|
+
# deeper bracket is ignored rather than failing the whole list.
|
|
227
|
+
push_range_list(new_prefix, suffix, ranges)
|
|
228
|
+
else
|
|
229
|
+
ranges.each { |r_lo, r_hi, r_width| push_hr(new_prefix, suffix, r_lo, r_hi, r_width) }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
0
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Push a range. With a suffix ("node[1-2]-ib"), each host is pushed as a
|
|
237
|
+
# whole name, so it is never re-bracketed. Slurm's hostlist_push_hr().
|
|
238
|
+
def push_hr(prefix, suffix, lo, hi, width)
|
|
239
|
+
if suffix
|
|
240
|
+
(lo..hi).each do |n|
|
|
241
|
+
push_range(HostRange.single(prefix + SlurmHostlist.pad(n, width) + suffix))
|
|
242
|
+
end
|
|
243
|
+
else
|
|
244
|
+
push_range(HostRange.new(prefix, lo, hi, width))
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Push a plain host name, splitting off any trailing number.
|
|
249
|
+
# Slurm's hostlist_push_host_dims() and _parse_hostname_len().
|
|
250
|
+
def push_host(name)
|
|
251
|
+
idx = name.length - 1
|
|
252
|
+
idx -= 1 while idx >= 0 && name[idx] >= '0' && name[idx] <= '9'
|
|
253
|
+
prefix_length = idx + 1
|
|
254
|
+
|
|
255
|
+
if prefix_length == name.length
|
|
256
|
+
push_range(HostRange.single(name))
|
|
257
|
+
return true
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
number = name[prefix_length..].to_i
|
|
261
|
+
return false if number >= ULONG_MAX
|
|
262
|
+
|
|
263
|
+
width = name.length - prefix_length
|
|
264
|
+
push_range(HostRange.new(name[0...prefix_length], number, number, width))
|
|
265
|
+
true
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Append a range, merging it into the last one when it continues it.
|
|
269
|
+
# Only the last range is considered, so input order matters.
|
|
270
|
+
# Slurm's hostlist_push_range().
|
|
271
|
+
def push_range(range)
|
|
272
|
+
last = @ranges.last
|
|
273
|
+
if last &&
|
|
274
|
+
last.hi == range.lo - 1 &&
|
|
275
|
+
self.class.prefix_cmp(last, range).zero? &&
|
|
276
|
+
self.class.width_combine(last, range)
|
|
277
|
+
last.hi = range.hi
|
|
278
|
+
else
|
|
279
|
+
@ranges << range.copy
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def hosts
|
|
284
|
+
@ranges.flat_map(&:hosts)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The compressed form, e.g. "n[1-3,5],login1".
|
|
288
|
+
# Slurm's hostlist_ranged_string() and _get_bracketed_list().
|
|
289
|
+
def to_s
|
|
290
|
+
parts = []
|
|
291
|
+
i = 0
|
|
292
|
+
while i < @ranges.length
|
|
293
|
+
part, i = bracketed_list(i)
|
|
294
|
+
parts << part
|
|
295
|
+
end
|
|
296
|
+
parts.join(',')
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def bracketed_list(start)
|
|
300
|
+
first = @ranges[start]
|
|
301
|
+
following = @ranges[start + 1]
|
|
302
|
+
needs_brackets = first.count > 1 || self.class.within_range(first, following)
|
|
303
|
+
|
|
304
|
+
text = first.prefix.dup
|
|
305
|
+
text << '[' if needs_brackets
|
|
306
|
+
|
|
307
|
+
i = start
|
|
308
|
+
loop do
|
|
309
|
+
text << ',' if i > start
|
|
310
|
+
text << @ranges[i].numstr
|
|
311
|
+
i += 1
|
|
312
|
+
break unless i < @ranges.length && self.class.within_range(@ranges[i], @ranges[i - 1])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
text << ']' if needs_brackets
|
|
316
|
+
[text, i]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# --- sorting (scontrol show hostlistsorted) --------------------------------
|
|
320
|
+
|
|
321
|
+
# Sort, then merge overlapping and adjacent ranges. Duplicates are kept.
|
|
322
|
+
# Slurm's hostlist_sort().
|
|
323
|
+
def sort!
|
|
324
|
+
return self if @ranges.length <= 1
|
|
325
|
+
|
|
326
|
+
@ranges = self.class.merge_sort(@ranges)
|
|
327
|
+
coalesce
|
|
328
|
+
collapse
|
|
329
|
+
self
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# glibc's qsort() is a top-down merge sort (msort.c). The comparison can
|
|
333
|
+
# change widths as a side effect, so the order comparisons happen in
|
|
334
|
+
# matters; this follows msort's exactly.
|
|
335
|
+
def self.merge_sort(items)
|
|
336
|
+
return items.dup if items.length <= 1
|
|
337
|
+
|
|
338
|
+
left_size = items.length / 2
|
|
339
|
+
left = merge_sort(items[0...left_size])
|
|
340
|
+
right = merge_sort(items[left_size..])
|
|
341
|
+
|
|
342
|
+
merged = []
|
|
343
|
+
li = 0
|
|
344
|
+
ri = 0
|
|
345
|
+
while li < left.length && ri < right.length
|
|
346
|
+
if range_cmp(left[li], right[ri]) <= 0
|
|
347
|
+
merged << left[li]
|
|
348
|
+
li += 1
|
|
349
|
+
else
|
|
350
|
+
merged << right[ri]
|
|
351
|
+
ri += 1
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
merged.concat(left[li..]) if li < left.length
|
|
355
|
+
merged.concat(right[ri..]) if ri < right.length
|
|
356
|
+
merged
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Order by prefix (natural order), then by first number, or by width when
|
|
360
|
+
# widths can't be combined. Slurm's hostrange_cmp(); the result is a C
|
|
361
|
+
# int, so a large difference is truncated to 32 bits as it is there.
|
|
362
|
+
def self.range_cmp(first, second)
|
|
363
|
+
result = prefix_cmp(first, second)
|
|
364
|
+
return result unless result.zero?
|
|
365
|
+
|
|
366
|
+
if width_combine(first, second)
|
|
367
|
+
c_int(first.lo - second.lo)
|
|
368
|
+
else
|
|
369
|
+
first.width - second.width
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def self.c_int(value)
|
|
374
|
+
low = value & 0xFFFFFFFF
|
|
375
|
+
low >= 2**31 ? low - 2**32 : low
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Split overlapping neighbours so no host appears in two ranges, keeping
|
|
379
|
+
# the duplicates as their own one-host ranges. Slurm's hostlist_coalesce().
|
|
380
|
+
def coalesce
|
|
381
|
+
i = @ranges.length - 1
|
|
382
|
+
while i > 0
|
|
383
|
+
overlap = self.class.intersect(@ranges[i - 1], @ranges[i])
|
|
384
|
+
if overlap
|
|
385
|
+
previous = @ranges[i - 1]
|
|
386
|
+
following = @ranges[i]
|
|
387
|
+
insert_at = i
|
|
388
|
+
|
|
389
|
+
following.hi = previous.hi if overlap.hi < previous.hi
|
|
390
|
+
previous.hi = overlap.lo
|
|
391
|
+
following.lo = overlap.hi
|
|
392
|
+
|
|
393
|
+
# Slurm removes index i here (the following range, not the empty
|
|
394
|
+
# one) and then keeps reading the range it just freed, so what
|
|
395
|
+
# Slurm outputs from this point on is undefined; it can drop hosts.
|
|
396
|
+
# Flag it so callers and the differential test can tell.
|
|
397
|
+
if previous.hi < previous.lo
|
|
398
|
+
@diverged_from_slurm = true
|
|
399
|
+
delete_range(i)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
while overlap.lo <= overlap.hi
|
|
403
|
+
one = HostRange.new(overlap.prefix, overlap.lo, overlap.lo, overlap.width)
|
|
404
|
+
# Slurm's j++ advances even when the insert is skipped.
|
|
405
|
+
if overlap.lo > previous.hi
|
|
406
|
+
insert_range(one, insert_at)
|
|
407
|
+
insert_at += 1
|
|
408
|
+
end
|
|
409
|
+
if overlap.lo < following.lo
|
|
410
|
+
insert_range(one, insert_at)
|
|
411
|
+
insert_at += 1
|
|
412
|
+
end
|
|
413
|
+
overlap.lo += 1
|
|
414
|
+
end
|
|
415
|
+
i = @ranges.length
|
|
416
|
+
end
|
|
417
|
+
i -= 1
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Join neighbours that continue each other. Slurm's hostlist_collapse().
|
|
422
|
+
def collapse
|
|
423
|
+
i = @ranges.length - 1
|
|
424
|
+
while i > 0
|
|
425
|
+
previous = @ranges[i - 1]
|
|
426
|
+
following = @ranges[i]
|
|
427
|
+
if previous.hi == following.lo - 1 &&
|
|
428
|
+
self.class.prefix_cmp(previous, following).zero? &&
|
|
429
|
+
self.class.width_combine(previous, following)
|
|
430
|
+
previous.hi = following.hi
|
|
431
|
+
delete_range(i)
|
|
432
|
+
end
|
|
433
|
+
i -= 1
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The hosts two ranges share, as a new range, or nil.
|
|
438
|
+
# Slurm's hostrange_intersect().
|
|
439
|
+
def self.intersect(first, second)
|
|
440
|
+
return nil if first.single || second.single
|
|
441
|
+
return nil unless first.hi > second.lo
|
|
442
|
+
return nil unless prefix_cmp(first, second).zero?
|
|
443
|
+
return nil unless width_combine(first, second)
|
|
444
|
+
|
|
445
|
+
overlap = first.copy
|
|
446
|
+
overlap.lo = second.lo
|
|
447
|
+
overlap.hi = [second.hi, first.hi].min
|
|
448
|
+
overlap
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Slurm's hostlist_insert_range(): a copy goes in; nothing happens if the
|
|
452
|
+
# position is past the end.
|
|
453
|
+
def insert_range(range, position)
|
|
454
|
+
return false if position > @ranges.length
|
|
455
|
+
|
|
456
|
+
@ranges.insert(position, range.copy)
|
|
457
|
+
true
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def delete_range(position)
|
|
461
|
+
@ranges.delete_at(position)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# --- comparisons ---------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
# 0 when both ranges share a prefix and both are (or both aren't) single
|
|
467
|
+
# hosts. Slurm's hostrange_prefix_cmp().
|
|
468
|
+
def self.prefix_cmp(first, second)
|
|
469
|
+
return 1 if first.nil?
|
|
470
|
+
return -1 if second.nil?
|
|
471
|
+
|
|
472
|
+
result = if first.prefix == second.prefix
|
|
473
|
+
0
|
|
474
|
+
else
|
|
475
|
+
NaturalCompare.compare(first.prefix, second.prefix)
|
|
476
|
+
end
|
|
477
|
+
return result unless result.zero?
|
|
478
|
+
|
|
479
|
+
(second.single ? 1 : 0) - (first.single ? 1 : 0)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# True when two ranges belong inside the same brackets.
|
|
483
|
+
# Slurm's hostrange_within_range().
|
|
484
|
+
def self.within_range(first, second)
|
|
485
|
+
return false unless prefix_cmp(first, second).zero?
|
|
486
|
+
|
|
487
|
+
!first.single && !second.single
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Can two ranges share one zero-padding width without changing any host
|
|
491
|
+
# name? If so, one width is adjusted in place to match the other.
|
|
492
|
+
# Slurm's hostrange_width_combine() and _width_equiv().
|
|
493
|
+
def self.width_combine(first, second)
|
|
494
|
+
return true if first.width == second.width
|
|
495
|
+
|
|
496
|
+
first_pad = zero_padding(first.lo, first.width)
|
|
497
|
+
first_pad_other = zero_padding(first.lo, second.width)
|
|
498
|
+
second_pad = zero_padding(second.lo, second.width)
|
|
499
|
+
second_pad_other = zero_padding(second.lo, first.width)
|
|
500
|
+
|
|
501
|
+
return false if first_pad != first_pad_other && second_pad != second_pad_other
|
|
502
|
+
|
|
503
|
+
if first_pad != first_pad_other
|
|
504
|
+
second.width = first.width
|
|
505
|
+
else
|
|
506
|
+
first.width = second.width
|
|
507
|
+
end
|
|
508
|
+
true
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Zeros needed to pad number to width. Slurm's _zero_padded().
|
|
512
|
+
def self.zero_padding(number, width)
|
|
513
|
+
digits = number.to_s.length
|
|
514
|
+
width > digits ? width - digits : 0
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlurmHostlist
|
|
4
|
+
# Slurm's natural-order string comparison (strnatcmp, by Martin Pool), ported
|
|
5
|
+
# so prefix ordering and "same prefix" checks agree with Slurm exactly.
|
|
6
|
+
# Works on bytes, as the C original does. Returns -1, 0 or 1.
|
|
7
|
+
module NaturalCompare
|
|
8
|
+
ZERO = 48
|
|
9
|
+
NINE = 57
|
|
10
|
+
# C isspace() in the "C" locale: space, \t, \n, \v, \f, \r
|
|
11
|
+
SPACES = [32, 9, 10, 11, 12, 13].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def compare(a, b)
|
|
16
|
+
a = a.bytes
|
|
17
|
+
b = b.bytes
|
|
18
|
+
ai = 0
|
|
19
|
+
bi = 0
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
ca = a[ai]
|
|
23
|
+
cb = b[bi]
|
|
24
|
+
|
|
25
|
+
# Skip leading whitespace.
|
|
26
|
+
while space?(ca)
|
|
27
|
+
ai += 1
|
|
28
|
+
ca = a[ai]
|
|
29
|
+
end
|
|
30
|
+
while space?(cb)
|
|
31
|
+
bi += 1
|
|
32
|
+
cb = b[bi]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Compare a run of digits.
|
|
36
|
+
if digit?(ca) && digit?(cb)
|
|
37
|
+
fractional = (ca == ZERO || cb == ZERO)
|
|
38
|
+
result = if fractional
|
|
39
|
+
compare_left(a, ai, b, bi)
|
|
40
|
+
else
|
|
41
|
+
compare_right(a, ai, b, bi)
|
|
42
|
+
end
|
|
43
|
+
return result if result != 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return 0 if ca.nil? && cb.nil?
|
|
47
|
+
|
|
48
|
+
return -1 if signed(ca) < signed(cb)
|
|
49
|
+
return 1 if signed(ca) > signed(cb)
|
|
50
|
+
|
|
51
|
+
ai += 1
|
|
52
|
+
bi += 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Right-aligned numbers: the longest run of digits wins; at equal length,
|
|
57
|
+
# the first differing digit decides.
|
|
58
|
+
def compare_right(a, ai, b, bi)
|
|
59
|
+
bias = 0
|
|
60
|
+
loop do
|
|
61
|
+
ca = a[ai]
|
|
62
|
+
cb = b[bi]
|
|
63
|
+
|
|
64
|
+
return bias if !digit?(ca) && !digit?(cb)
|
|
65
|
+
return -1 unless digit?(ca)
|
|
66
|
+
return 1 unless digit?(cb)
|
|
67
|
+
|
|
68
|
+
if ca < cb
|
|
69
|
+
bias = -1 if bias.zero?
|
|
70
|
+
elsif ca > cb
|
|
71
|
+
bias = 1 if bias.zero?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
ai += 1
|
|
75
|
+
bi += 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Left-aligned numbers (one starts with 0): the first different digit wins.
|
|
80
|
+
def compare_left(a, ai, b, bi)
|
|
81
|
+
loop do
|
|
82
|
+
ca = a[ai]
|
|
83
|
+
cb = b[bi]
|
|
84
|
+
|
|
85
|
+
return 0 if !digit?(ca) && !digit?(cb)
|
|
86
|
+
return -1 unless digit?(ca)
|
|
87
|
+
return 1 unless digit?(cb)
|
|
88
|
+
return -1 if ca < cb
|
|
89
|
+
return 1 if ca > cb
|
|
90
|
+
|
|
91
|
+
ai += 1
|
|
92
|
+
bi += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# nil is the end of the string (C's NUL). C's char is signed on the
|
|
97
|
+
# platforms Slurm runs on, so bytes above 127 compare as negative.
|
|
98
|
+
def signed(byte)
|
|
99
|
+
return 0 if byte.nil?
|
|
100
|
+
|
|
101
|
+
byte > 127 ? byte - 256 : byte
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def digit?(byte)
|
|
105
|
+
!byte.nil? && byte >= ZERO && byte <= NINE
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def space?(byte)
|
|
109
|
+
!byte.nil? && SPACES.include?(byte)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'slurm_hostlist/version'
|
|
4
|
+
require_relative 'slurm_hostlist/natural_compare'
|
|
5
|
+
require_relative 'slurm_hostlist/host_range'
|
|
6
|
+
require_relative 'slurm_hostlist/hostlist'
|
|
7
|
+
|
|
8
|
+
# Expand and compress Slurm hostlist expressions, matching Slurm's own
|
|
9
|
+
# behaviour (`scontrol show hostnames`, `show hostlist`, `show hostlistsorted`).
|
|
10
|
+
#
|
|
11
|
+
# SlurmHostlist.expand('n[01-03],login1') # => ["n01", "n02", "n03", "login1"]
|
|
12
|
+
# SlurmHostlist.compress(%w[n1 n2 n3 n7]) # => "n[1-3,7]"
|
|
13
|
+
module SlurmHostlist
|
|
14
|
+
# Raised for input Slurm itself would reject, e.g. "n[3-1]" or "n[1-".
|
|
15
|
+
class InvalidHostlist < ArgumentError; end
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Expand an expression into host names, in Slurm's order and keeping
|
|
20
|
+
# duplicates. Like `scontrol show hostnames`.
|
|
21
|
+
#
|
|
22
|
+
# expand('rack[1-2]-n[1-2]') # => ["rack1-n1", "rack1-n2", "rack2-n1", "rack2-n2"]
|
|
23
|
+
# expand('') # => []
|
|
24
|
+
def expand(expression)
|
|
25
|
+
encoding = encoding_of(expression)
|
|
26
|
+
Hostlist.parse(expression).hosts.map { |host| host.force_encoding(encoding) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compress host names (an Array, or an expression String) into Slurm's
|
|
30
|
+
# compact form. Like `scontrol show hostlist`: input order is kept and
|
|
31
|
+
# duplicates aren't removed. With sort: true, like
|
|
32
|
+
# `scontrol show hostlistsorted`.
|
|
33
|
+
#
|
|
34
|
+
# compress(%w[n3 n1 n2]) # => "n[3,1-2]"
|
|
35
|
+
# compress(%w[n3 n1 n2], sort: true) # => "n[1-3]"
|
|
36
|
+
def compress(hosts, sort: false)
|
|
37
|
+
expression = hosts.is_a?(Array) ? hosts.join(',') : hosts.to_s
|
|
38
|
+
list = Hostlist.parse(expression)
|
|
39
|
+
list.sort! if sort
|
|
40
|
+
list.to_s.force_encoding(encoding_of(expression))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Number of hosts an expression expands to, duplicates included.
|
|
44
|
+
def count(expression)
|
|
45
|
+
Hostlist.parse(expression).ranges.sum(&:count)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# True if Slurm would accept the expression.
|
|
49
|
+
def valid?(expression)
|
|
50
|
+
Hostlist.parse(expression)
|
|
51
|
+
true
|
|
52
|
+
rescue InvalidHostlist
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def encoding_of(value)
|
|
57
|
+
value.is_a?(String) ? value.encoding : Encoding::UTF_8
|
|
58
|
+
end
|
|
59
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: slurm_hostlist
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Travis Ravert
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Pure Ruby, dependency-free expansion and compression of Slurm hostlist
|
|
15
|
+
expressions like "n[01-16],login[1-2]". A port of Slurm's hostlist.c,
|
|
16
|
+
differentially tested against Slurm to give the same results as
|
|
17
|
+
`scontrol show hostnames`, `show hostlist` and `show hostlistsorted`.
|
|
18
|
+
email:
|
|
19
|
+
- travert@osc.edu
|
|
20
|
+
executables:
|
|
21
|
+
- slurm-hostlist
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
- LICENSE
|
|
27
|
+
- README.md
|
|
28
|
+
- exe/slurm-hostlist
|
|
29
|
+
- lib/slurm_hostlist.rb
|
|
30
|
+
- lib/slurm_hostlist/host_range.rb
|
|
31
|
+
- lib/slurm_hostlist/hostlist.rb
|
|
32
|
+
- lib/slurm_hostlist/natural_compare.rb
|
|
33
|
+
- lib/slurm_hostlist/version.rb
|
|
34
|
+
homepage: https://github.com/Oglopf/slurm_hostlist
|
|
35
|
+
licenses:
|
|
36
|
+
- MIT
|
|
37
|
+
metadata:
|
|
38
|
+
homepage_uri: https://github.com/Oglopf/slurm_hostlist
|
|
39
|
+
changelog_uri: https://github.com/Oglopf/slurm_hostlist/blob/main/CHANGELOG.md
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
post_install_message:
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '2.7'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 3.5.22
|
|
57
|
+
signing_key:
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Expand and compress Slurm hostlists, matching Slurm's own output
|
|
60
|
+
test_files: []
|