moose-inventory 1.0.9 → 2.1
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 +4 -4
- data/.github/workflows/ci.yml +15 -1
- data/.github/workflows/release.yml +60 -0
- data/.gitignore +2 -1
- data/.gitleaks.toml +9 -0
- data/.rubocop.yml +49 -0
- data/BACKLOG.md +752 -24
- data/Gemfile +2 -0
- data/Gemfile.lock +36 -1
- data/README.md +340 -44
- data/Rakefile +2 -0
- data/bin/moose-inventory +2 -1
- data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
- data/docs/compatibility/cli-output-compatibility.md +76 -0
- data/docs/governance/approval-register.md +37 -0
- data/docs/maintenance/database-backup-restore-guidance.md +162 -0
- data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
- data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
- data/docs/product/product-brief.md +161 -0
- data/docs/product/requirements-baseline.md +477 -0
- data/docs/qa/qa-documentation-and-release-gates.md +283 -0
- data/docs/release/package-provenance-hardening.md +126 -0
- data/docs/release/publishing.md +54 -50
- data/docs/release/release-environment-protection.md +70 -0
- data/docs/release/release-readiness.md +37 -4
- data/docs/security/accepted-risk-register.md +84 -0
- data/docs/security/security-privacy-process.md +287 -0
- data/docs/security-audit-2026-05-26-rerun.md +75 -0
- data/docs/security-audit-2026-05-26.md +63 -0
- data/docs/ux/cli-workflow-notes.md +287 -0
- data/examples/ansible/ansible.cfg +3 -0
- data/examples/ansible/inventory/moose_inventory.yml +5 -0
- data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
- data/examples/ci/README.md +16 -0
- data/examples/ci/github-actions/inventory-review.yml +38 -0
- data/examples/ci/inventory/example-snapshot.yml +19 -0
- data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
- data/lib/moose_inventory/cli/application.rb +133 -5
- data/lib/moose_inventory/cli/association_rendering.rb +74 -0
- data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
- data/lib/moose_inventory/cli/audit.rb +62 -0
- data/lib/moose_inventory/cli/audit_recording.rb +40 -0
- data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
- data/lib/moose_inventory/cli/console.rb +135 -0
- data/lib/moose_inventory/cli/db.rb +64 -0
- data/lib/moose_inventory/cli/factory.rb +28 -0
- data/lib/moose_inventory/cli/formatter.rb +8 -12
- data/lib/moose_inventory/cli/group.rb +7 -1
- data/lib/moose_inventory/cli/group_add.rb +91 -73
- data/lib/moose_inventory/cli/group_addchild.rb +41 -66
- data/lib/moose_inventory/cli/group_addhost.rb +33 -71
- data/lib/moose_inventory/cli/group_addvar.rb +27 -47
- data/lib/moose_inventory/cli/group_get.rb +8 -42
- data/lib/moose_inventory/cli/group_list.rb +7 -40
- data/lib/moose_inventory/cli/group_listvars.rb +9 -55
- data/lib/moose_inventory/cli/group_rm.rb +105 -73
- data/lib/moose_inventory/cli/group_rmchild.rb +47 -57
- data/lib/moose_inventory/cli/group_rmhost.rb +34 -61
- data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
- data/lib/moose_inventory/cli/group_tags.rb +33 -0
- data/lib/moose_inventory/cli/helpers.rb +143 -0
- data/lib/moose_inventory/cli/host.rb +8 -2
- data/lib/moose_inventory/cli/host_add.rb +91 -66
- data/lib/moose_inventory/cli/host_addgroup.rb +39 -66
- data/lib/moose_inventory/cli/host_addvar.rb +28 -52
- data/lib/moose_inventory/cli/host_get.rb +9 -37
- data/lib/moose_inventory/cli/host_list.rb +24 -21
- data/lib/moose_inventory/cli/host_listvars.rb +9 -62
- data/lib/moose_inventory/cli/host_rm.rb +60 -42
- data/lib/moose_inventory/cli/host_rmgroup.rb +39 -55
- data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
- data/lib/moose_inventory/cli/host_tags.rb +33 -0
- data/lib/moose_inventory/cli/listvars_support.rb +55 -0
- data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
- data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
- data/lib/moose_inventory/cli/tag_support.rb +97 -0
- data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
- data/lib/moose_inventory/config/config.rb +185 -108
- data/lib/moose_inventory/db/db.rb +188 -193
- data/lib/moose_inventory/db/exceptions.rb +6 -3
- data/lib/moose_inventory/db/models.rb +16 -0
- data/lib/moose_inventory/db/schema_migrations.rb +248 -0
- data/lib/moose_inventory/inventory_context.rb +116 -0
- data/lib/moose_inventory/operations/add_associations.rb +131 -0
- data/lib/moose_inventory/operations/add_groups.rb +123 -0
- data/lib/moose_inventory/operations/add_hosts.rb +123 -0
- data/lib/moose_inventory/operations/add_variables.rb +77 -0
- data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
- data/lib/moose_inventory/operations/group_child_relations.rb +125 -0
- data/lib/moose_inventory/operations/group_cleanup.rb +70 -0
- data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
- data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
- data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
- data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
- data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
- data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
- data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
- data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
- data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
- data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
- data/lib/moose_inventory/operations/query_inventory.rb +47 -0
- data/lib/moose_inventory/operations/remove_associations.rb +113 -0
- data/lib/moose_inventory/operations/remove_groups.rb +79 -0
- data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
- data/lib/moose_inventory/operations/remove_variables.rb +67 -0
- data/lib/moose_inventory/runtime_options.rb +31 -0
- data/lib/moose_inventory/version.rb +3 -1
- data/lib/moose_inventory.rb +10 -7
- data/moose-inventory.gemspec +22 -35
- data/scripts/check.sh +3 -0
- data/scripts/ci/check_generated_artifacts.sh +41 -0
- data/scripts/ci/check_permissions.sh +5 -0
- data/scripts/ci/check_rubocop.sh +33 -0
- data/scripts/ci/check_secrets.sh +26 -0
- data/scripts/ci/check_security.sh +18 -0
- data/scripts/ci/install_security_tools.sh +47 -0
- data/scripts/files.rb +5 -4
- data/scripts/install_dependencies.sh +2 -0
- data/spec/examples/ci_examples_spec.rb +37 -0
- data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
- data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
- data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
- data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
- data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
- data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
- data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
- data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
- data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
- data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
- data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
- data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
- data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
- data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
- data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
- data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
- data/spec/lib/moose_inventory/cli/group_rm_spec.rb +165 -85
- data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +100 -30
- data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
- data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
- data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
- data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
- data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
- data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
- data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
- data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
- data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
- data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
- data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
- data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
- data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
- data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
- data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
- data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
- data/spec/lib/moose_inventory/db/db_spec.rb +551 -29
- data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
- data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
- data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
- data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
- data/spec/lib/moose_inventory/operations/add_associations_spec.rb +111 -0
- data/spec/lib/moose_inventory/operations/add_groups_spec.rb +80 -0
- data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +82 -0
- data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
- data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +122 -0
- data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
- data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
- data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
- data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
- data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
- data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +113 -0
- data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +78 -0
- data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
- data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
- data/spec/shared/shared_config_setup.rb +4 -3
- data/spec/spec_helper.rb +50 -40
- data/spec/support/cli_harness.rb +33 -0
- metadata +163 -35
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Generated/local artifacts are useful during development but must not become
|
|
5
|
+
# source inputs, package contents, or review noise. Keep this list aligned with
|
|
6
|
+
# .gitignore and scanner excludes.
|
|
7
|
+
generated_paths=(
|
|
8
|
+
".openclaw-security-audit"
|
|
9
|
+
"coverage"
|
|
10
|
+
"pkg"
|
|
11
|
+
"spec/reports"
|
|
12
|
+
"tmp"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
tracked=()
|
|
16
|
+
for path in "${generated_paths[@]}"; do
|
|
17
|
+
while IFS= read -r file; do
|
|
18
|
+
tracked+=("$file")
|
|
19
|
+
done < <(git ls-files "$path" "$path/**")
|
|
20
|
+
done
|
|
21
|
+
|
|
22
|
+
if (( ${#tracked[@]} > 0 )); then
|
|
23
|
+
echo "Generated artifact paths are tracked and must be removed from source commits:" >&2
|
|
24
|
+
printf ' %s\n' "${tracked[@]}" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
ignored_failures=()
|
|
29
|
+
for path in "${generated_paths[@]}"; do
|
|
30
|
+
if ! git check-ignore -q "$path/placeholder"; then
|
|
31
|
+
ignored_failures+=("$path/")
|
|
32
|
+
fi
|
|
33
|
+
done
|
|
34
|
+
|
|
35
|
+
if (( ${#ignored_failures[@]} > 0 )); then
|
|
36
|
+
echo "Generated artifact paths are not ignored:" >&2
|
|
37
|
+
printf ' %s\n' "${ignored_failures[@]}" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
echo "Generated artifact guard passed."
|
|
@@ -3,9 +3,14 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
allowed_executables=(
|
|
5
5
|
"bin/moose-inventory"
|
|
6
|
+
"examples/ci/scripts/validate-inventory-snapshot.sh"
|
|
6
7
|
"scripts/check.sh"
|
|
7
8
|
"scripts/ci/check_permissions.sh"
|
|
9
|
+
"scripts/ci/check_rubocop.sh"
|
|
10
|
+
"scripts/ci/check_secrets.sh"
|
|
11
|
+
"scripts/ci/check_generated_artifacts.sh"
|
|
8
12
|
"scripts/ci/check_security.sh"
|
|
13
|
+
"scripts/ci/install_security_tools.sh"
|
|
9
14
|
"scripts/ci/package_sanity.sh"
|
|
10
15
|
"scripts/files.rb"
|
|
11
16
|
"scripts/install_dependencies.sh"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
repo_root="$(git rev-parse --show-toplevel)"
|
|
5
|
+
cd "$repo_root"
|
|
6
|
+
|
|
7
|
+
rubocop_files=(
|
|
8
|
+
Gemfile
|
|
9
|
+
Rakefile
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
while IFS= read -r -d '' file; do
|
|
13
|
+
rubocop_files+=("$file")
|
|
14
|
+
done < <(find bin -maxdepth 1 -type f -print0 | sort -z)
|
|
15
|
+
|
|
16
|
+
while IFS= read -r -d '' file; do
|
|
17
|
+
rubocop_files+=("$file")
|
|
18
|
+
done < <(
|
|
19
|
+
find \
|
|
20
|
+
lib \
|
|
21
|
+
scripts \
|
|
22
|
+
spec \
|
|
23
|
+
-path 'spec/reports' -prune -o \
|
|
24
|
+
-path 'spec/reports/*' -prune -o \
|
|
25
|
+
-type f \( -name '*.rb' -o -name '*.gemspec' \) \
|
|
26
|
+
-print0 | sort -z
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
while IFS= read -r -d '' file; do
|
|
30
|
+
rubocop_files+=("$file")
|
|
31
|
+
done < <(find . -maxdepth 1 -type f -name '*.gemspec' -print0 | sort -z)
|
|
32
|
+
|
|
33
|
+
bundle exec rubocop "${rubocop_files[@]}"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
BIN_DIR="${MOOSE_INVENTORY_SECURITY_TOOLS_BIN:-$PWD/tmp/security-tools/bin}"
|
|
5
|
+
if command -v gitleaks >/dev/null 2>&1; then
|
|
6
|
+
GITLEAKS=(gitleaks)
|
|
7
|
+
elif [ -x "$BIN_DIR/gitleaks" ]; then
|
|
8
|
+
GITLEAKS=("$BIN_DIR/gitleaks")
|
|
9
|
+
else
|
|
10
|
+
if [ "${MOOSE_INVENTORY_REQUIRE_SECURITY_TOOLS:-0}" = "1" ]; then
|
|
11
|
+
echo "gitleaks is required but was not found. Run scripts/ci/install_security_tools.sh first." >&2
|
|
12
|
+
exit 2
|
|
13
|
+
fi
|
|
14
|
+
echo "gitleaks not found; skipping dedicated secret scan."
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
"${GITLEAKS[@]}" detect \
|
|
19
|
+
--no-git \
|
|
20
|
+
--source . \
|
|
21
|
+
--config .gitleaks.toml \
|
|
22
|
+
--redact \
|
|
23
|
+
--no-banner \
|
|
24
|
+
--log-level warn
|
|
25
|
+
|
|
26
|
+
echo "Gitleaks secret scan passed."
|
|
@@ -48,3 +48,21 @@ if findings:
|
|
|
48
48
|
print(f'- {name} {version}: {vuln_id} {summary}', file=sys.stderr)
|
|
49
49
|
sys.exit(1)
|
|
50
50
|
PY
|
|
51
|
+
|
|
52
|
+
bundle exec bundle-audit check --update
|
|
53
|
+
|
|
54
|
+
BIN_DIR="${MOOSE_INVENTORY_SECURITY_TOOLS_BIN:-$PWD/tmp/security-tools/bin}"
|
|
55
|
+
if command -v osv-scanner >/dev/null 2>&1; then
|
|
56
|
+
OSV_SCANNER=(osv-scanner)
|
|
57
|
+
elif [ -x "$BIN_DIR/osv-scanner" ]; then
|
|
58
|
+
OSV_SCANNER=("$BIN_DIR/osv-scanner")
|
|
59
|
+
else
|
|
60
|
+
if [ "${MOOSE_INVENTORY_REQUIRE_SECURITY_TOOLS:-0}" = "1" ]; then
|
|
61
|
+
echo "osv-scanner is required but was not found. Run scripts/ci/install_security_tools.sh first." >&2
|
|
62
|
+
exit 2
|
|
63
|
+
fi
|
|
64
|
+
echo "osv-scanner not found; skipping osv-scanner lockfile scan."
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
"${OSV_SCANNER[@]}" scan source --lockfile Gemfile.lock .
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Installs optional security audit CLIs used by CI. They are kept out of the
|
|
5
|
+
# gem runtime/development bundle because they are Go command-line tools, not
|
|
6
|
+
# Ruby dependencies.
|
|
7
|
+
|
|
8
|
+
BIN_DIR="${MOOSE_INVENTORY_SECURITY_TOOLS_BIN:-$PWD/tmp/security-tools/bin}"
|
|
9
|
+
GITLEAKS_VERSION="${GITLEAKS_VERSION:-v8.30.0}"
|
|
10
|
+
OSV_SCANNER_VERSION="${OSV_SCANNER_VERSION:-v2.2.3}"
|
|
11
|
+
|
|
12
|
+
mkdir -p "$BIN_DIR"
|
|
13
|
+
|
|
14
|
+
if ! command -v go >/dev/null 2>&1; then
|
|
15
|
+
echo "Go is required to install gitleaks/osv-scanner. Install Go or use a prebuilt package." >&2
|
|
16
|
+
exit 2
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
install_go_tool() {
|
|
20
|
+
local name="$1"
|
|
21
|
+
local module="$2"
|
|
22
|
+
local version="$3"
|
|
23
|
+
|
|
24
|
+
if command -v "$name" >/dev/null 2>&1; then
|
|
25
|
+
echo "$name already available at $(command -v "$name")"
|
|
26
|
+
return
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if [ -x "$BIN_DIR/$name" ]; then
|
|
30
|
+
echo "$name already installed at $BIN_DIR/$name"
|
|
31
|
+
return
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
echo "Installing $name $version into $BIN_DIR"
|
|
35
|
+
GOBIN="$BIN_DIR" go install "$module@$version"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
install_go_tool gitleaks github.com/zricethezav/gitleaks/v8 "$GITLEAKS_VERSION"
|
|
39
|
+
install_go_tool osv-scanner github.com/google/osv-scanner/v2/cmd/osv-scanner "$OSV_SCANNER_VERSION"
|
|
40
|
+
|
|
41
|
+
if [ -n "${GITHUB_PATH:-}" ]; then
|
|
42
|
+
echo "$BIN_DIR" >> "$GITHUB_PATH"
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
export PATH="$BIN_DIR:$PATH"
|
|
46
|
+
gitleaks version || true
|
|
47
|
+
osv-scanner --version
|
data/scripts/files.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'yaml'
|
|
4
5
|
|
|
@@ -8,8 +9,8 @@ test_files = files.grep(%r{^(test|spec|features)/})
|
|
|
8
9
|
require_paths = ['lib']
|
|
9
10
|
|
|
10
11
|
out = {}
|
|
11
|
-
out[
|
|
12
|
-
out[
|
|
13
|
-
out[
|
|
12
|
+
out[:Executables] = executables
|
|
13
|
+
out[:Test_Files] = test_files
|
|
14
|
+
out[:Require_Paths] = require_paths
|
|
14
15
|
|
|
15
|
-
puts out.to_yaml
|
|
16
|
+
puts out.to_yaml
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'yaml'
|
|
7
|
+
|
|
8
|
+
RSpec.describe 'CI/CD examples' do
|
|
9
|
+
it 'ships parseable inventory and GitHub Actions examples' do
|
|
10
|
+
expect(YAML.safe_load_file('examples/ci/inventory/example-snapshot.yml')).to include('version' => 1)
|
|
11
|
+
workflow = YAML.safe_load_file('examples/ci/github-actions/inventory-review.yml')
|
|
12
|
+
|
|
13
|
+
expect(workflow).to include('name' => 'Inventory review example')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'keeps the snapshot validation script syntax-valid' do
|
|
17
|
+
expect(system('bash', '-n', 'examples/ci/scripts/validate-inventory-snapshot.sh')).to eq(true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'validates a snapshot and writes review artifacts without production credentials' do
|
|
21
|
+
Dir.mktmpdir do |dir|
|
|
22
|
+
command = 'bundle exec ruby -Ilib bin/moose-inventory'
|
|
23
|
+
result = system(
|
|
24
|
+
{ 'MOOSE_INVENTORY_CMD' => command },
|
|
25
|
+
'examples/ci/scripts/validate-inventory-snapshot.sh',
|
|
26
|
+
'examples/ci/inventory/example-snapshot.yml',
|
|
27
|
+
dir
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(result).to eq(true)
|
|
31
|
+
expect(File).to exist(File.join(dir, 'doctor.txt'))
|
|
32
|
+
expect(YAML.safe_load_file(File.join(dir, 'inventory.yml'))).to include('hosts')
|
|
33
|
+
expect(JSON.parse(File.read(File.join(dir, 'hosts.json')))).to include('web01')
|
|
34
|
+
expect(JSON.parse(File.read(File.join(dir, 'ansible-inventory.json')))).to include('web')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
RSpec.describe 'Ansible inventory plugin examples' do
|
|
7
|
+
it 'ships a plugin, inventory source, and ansible.cfg example' do
|
|
8
|
+
expect(File).to exist('examples/ansible/inventory_plugins/moose_inventory.py')
|
|
9
|
+
expect(File).to exist('examples/ansible/inventory/moose_inventory.yml')
|
|
10
|
+
expect(File).to exist('examples/ansible/ansible.cfg')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'uses the moose_inventory plugin in the example inventory source' do
|
|
14
|
+
config = YAML.safe_load_file('examples/ansible/inventory/moose_inventory.yml')
|
|
15
|
+
|
|
16
|
+
expect(config).to include(
|
|
17
|
+
'plugin' => 'moose_inventory',
|
|
18
|
+
'executable' => 'moose-inventory',
|
|
19
|
+
'config' => './example.conf',
|
|
20
|
+
'env' => 'dev'
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'keeps the plugin source syntax-valid' do
|
|
25
|
+
result = system('python3', '-m', 'py_compile', 'examples/ansible/inventory_plugins/moose_inventory.py')
|
|
26
|
+
|
|
27
|
+
expect(result).to eq(true)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Moose::Inventory::Cli::Application do
|
|
7
|
+
before(:all) do
|
|
8
|
+
setup_cli_harness(command_class: described_class)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
before(:each) do
|
|
12
|
+
reset_cli_harness
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'prints a human success report when no issues are found' do
|
|
16
|
+
web = @db.models[:group].create(name: 'web')
|
|
17
|
+
host = @db.models[:host].create(name: 'web01')
|
|
18
|
+
host.add_group(web)
|
|
19
|
+
|
|
20
|
+
actual = runner { @app.start(%w[doctor]) }
|
|
21
|
+
|
|
22
|
+
expected(actual, aborted: false, STDOUT: "Inventory doctor found no issues.\n", STDERR: '')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'prints a human report and exits nonzero when issues are found' do
|
|
26
|
+
@db.models[:group].create(name: 'ungrouped')
|
|
27
|
+
host = @db.models[:host].create(name: 'lonely')
|
|
28
|
+
host.add_group(@db.models[:group].find(name: 'ungrouped'))
|
|
29
|
+
|
|
30
|
+
actual = runner { @app.start(%w[doctor]) }
|
|
31
|
+
|
|
32
|
+
expect(actual[:aborted]).to eq(true)
|
|
33
|
+
expect(actual[:STDOUT]).to include('Inventory doctor found')
|
|
34
|
+
expect(actual[:STDOUT]).to include('host_only_in_ungrouped')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'prints a machine-readable report when requested' do
|
|
38
|
+
@db.models[:group].create(name: 'ungrouped')
|
|
39
|
+
host = @db.models[:host].create(name: 'lonely')
|
|
40
|
+
host.add_group(@db.models[:group].find(name: 'ungrouped'))
|
|
41
|
+
|
|
42
|
+
actual = runner { @app.start(%w[doctor --format json]) }
|
|
43
|
+
|
|
44
|
+
expect(actual[:aborted]).to eq(true)
|
|
45
|
+
report = JSON.parse(actual[:STDOUT])
|
|
46
|
+
expect(report['ok']).to eq(false)
|
|
47
|
+
expect(report['issues'].map { |issue| issue['id'] }).to include('host_only_in_ungrouped')
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Moose::Inventory::Cli::Application do
|
|
8
|
+
before(:all) do
|
|
9
|
+
setup_cli_harness(command_class: described_class)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
before(:each) do
|
|
13
|
+
reset_cli_harness
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'exports the current inventory snapshot as yaml' do
|
|
17
|
+
runner { @app.start(%w[host add web01]) }
|
|
18
|
+
runner { @app.start(%w[group add web]) }
|
|
19
|
+
runner { @app.start(%w[host addgroup web01 web]) }
|
|
20
|
+
|
|
21
|
+
actual = runner { @app.start(%w[export]) }
|
|
22
|
+
|
|
23
|
+
expect(actual[:unexpected]).to eq(false)
|
|
24
|
+
expect(actual[:aborted]).to eq(false)
|
|
25
|
+
expect(actual[:STDERR]).to eq('')
|
|
26
|
+
snapshot = YAML.safe_load(actual[:STDOUT])
|
|
27
|
+
expect(snapshot['version']).to eq(1)
|
|
28
|
+
expect(snapshot.dig('hosts', 'web01', 'groups')).to include('web')
|
|
29
|
+
expect(snapshot['groups']).to have_key('web')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'imports a validated inventory snapshot file' do
|
|
33
|
+
Dir.mktmpdir do |dir|
|
|
34
|
+
path = File.join(dir, 'inventory.yml')
|
|
35
|
+
File.write(
|
|
36
|
+
path,
|
|
37
|
+
{
|
|
38
|
+
'version' => 1,
|
|
39
|
+
'hosts' => { 'web01' => { 'groups' => ['web'], 'tags' => [], 'vars' => { 'env' => 'prod' } } },
|
|
40
|
+
'groups' => { 'web' => { 'children' => [], 'tags' => [], 'vars' => { 'role' => 'frontend' } } }
|
|
41
|
+
}.to_yaml
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
actual = runner { @app.start(['import', path]) }
|
|
45
|
+
|
|
46
|
+
expect(actual[:unexpected]).to eq(false)
|
|
47
|
+
expect(actual[:aborted]).to eq(false)
|
|
48
|
+
expect(actual[:STDERR]).to eq('')
|
|
49
|
+
expect(actual[:STDOUT]).to include("Imported inventory snapshot from #{path}.")
|
|
50
|
+
host = @db.models[:host].find(name: 'web01')
|
|
51
|
+
expect(host.groups_dataset[name: 'web']).not_to be_nil
|
|
52
|
+
expect(host.hostvars_dataset[name: 'env'][:value]).to eq('prod')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'previews an inventory snapshot import without writing' do
|
|
57
|
+
runner { @app.start(%w[group add web]) }
|
|
58
|
+
|
|
59
|
+
Dir.mktmpdir do |dir|
|
|
60
|
+
path = File.join(dir, 'inventory.yml')
|
|
61
|
+
File.write(
|
|
62
|
+
path,
|
|
63
|
+
{
|
|
64
|
+
'version' => 1,
|
|
65
|
+
'hosts' => { 'web01' => { 'groups' => ['web'], 'tags' => [], 'vars' => { 'env' => 'prod' } } },
|
|
66
|
+
'groups' => { 'web' => { 'children' => [], 'tags' => [], 'vars' => {} } }
|
|
67
|
+
}.to_yaml
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
actual = runner { @app.start(['import', path, '--preview', '--preview-format', 'json']) }
|
|
71
|
+
|
|
72
|
+
expect(actual[:unexpected]).to eq(false)
|
|
73
|
+
expect(actual[:aborted]).to eq(false)
|
|
74
|
+
expect(actual[:STDERR]).to eq('')
|
|
75
|
+
preview = JSON.parse(actual[:STDOUT])
|
|
76
|
+
expect(preview['schema_version']).to eq('snapshot-import-preview-v1')
|
|
77
|
+
expect(preview['changes_applied']).to eq(false)
|
|
78
|
+
expect(preview.dig('summary', 'hosts_created')).to eq(1)
|
|
79
|
+
expect(preview.dig('creates', 'hosts')).to eq(['web01'])
|
|
80
|
+
expect(@db.models[:host].find(name: 'web01')).to be_nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'aborts on invalid snapshot input without writing' do
|
|
85
|
+
Dir.mktmpdir do |dir|
|
|
86
|
+
path = File.join(dir, 'inventory.yml')
|
|
87
|
+
File.write(
|
|
88
|
+
path,
|
|
89
|
+
{ 'version' => 1, 'hosts' => { 'web01' => { 'groups' => ['missing'], 'vars' => {} } }, 'groups' => {} }.to_yaml
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
actual = runner { @app.start(['import', path]) }
|
|
93
|
+
|
|
94
|
+
expect(actual[:aborted]).to eq(true)
|
|
95
|
+
expect(actual[:STDERR]).to include("Invalid inventory snapshot: host 'web01' references unknown group 'missing'.")
|
|
96
|
+
expect(@db.models[:host].count).to eq(0)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Moose::Inventory::Cli::Application do
|
|
6
|
+
describe 'version' do
|
|
7
|
+
it 'prints the current Moose Inventory version' do
|
|
8
|
+
output = capture(:STDOUT) do
|
|
9
|
+
described_class.start(%w[version])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
expect(output).to eq("Version #{Moose::Inventory::VERSION}\n")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe 'subcommands' do
|
|
17
|
+
it 'registers the group subcommand' do
|
|
18
|
+
expect(described_class.subcommands).to include('group')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'registers the host subcommand' do
|
|
22
|
+
expect(described_class.subcommands).to include('host')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
|
|
6
|
+
# rubocop:disable Metrics/BlockLength
|
|
7
|
+
RSpec.describe Moose::Inventory::Cli::Audit do
|
|
8
|
+
before(:all) do
|
|
9
|
+
setup_cli_harness(command_class: Moose::Inventory::Cli::Audit)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
before(:each) do
|
|
13
|
+
reset_cli_harness
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'starts with an empty audit log' do
|
|
17
|
+
actual = runner { @app.start(%w[audit list]) }
|
|
18
|
+
|
|
19
|
+
expected(actual, aborted: false, STDOUT: "No audit events recorded.\n", STDERR: '')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'records successful mutating host commands' do
|
|
23
|
+
add = runner { @app.start(%w[host add app01]) }
|
|
24
|
+
expect(add[:unexpected]).to eq(false)
|
|
25
|
+
|
|
26
|
+
event = @db.models[:audit_event].last
|
|
27
|
+
expect(event.command).to eq('host add')
|
|
28
|
+
expect(event.action).to eq('add')
|
|
29
|
+
expect(event.entity_type).to eq('host')
|
|
30
|
+
expect(event.entity_name).to eq('app01')
|
|
31
|
+
expect(JSON.parse(event.details)).to include('events')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'does not record dry-run commands' do
|
|
35
|
+
actual = runner { @app.start(%w[host add --dry-run app01]) }
|
|
36
|
+
expect(actual[:unexpected]).to eq(false)
|
|
37
|
+
|
|
38
|
+
expect(@db.models[:audit_event].count).to eq(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'lists audit events as machine-readable JSON' do
|
|
42
|
+
runner { @app.start(%w[group add web]) }
|
|
43
|
+
|
|
44
|
+
actual = runner { @app.start(%w[audit list --format json]) }
|
|
45
|
+
parsed = JSON.parse(actual[:STDOUT])
|
|
46
|
+
|
|
47
|
+
expect(actual[:unexpected]).to eq(false)
|
|
48
|
+
expect(parsed.first).to include(
|
|
49
|
+
'command' => 'group add',
|
|
50
|
+
'action' => 'add',
|
|
51
|
+
'entity_type' => 'group',
|
|
52
|
+
'entity_name' => 'web'
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -1,25 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'spec_helper'
|
|
2
4
|
|
|
3
|
-
RSpec.describe Moose::Inventory::Cli
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
RSpec.describe Moose::Inventory::Cli do
|
|
6
|
+
describe '.start' do
|
|
7
|
+
it 'wires config, db, and application explicitly' do
|
|
8
|
+
args = ['--format', 'yaml']
|
|
9
|
+
config = instance_double('Config')
|
|
10
|
+
db = instance_double('DB')
|
|
11
|
+
application = class_double('Application')
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
expect(result).to eq(true)
|
|
13
|
-
end
|
|
13
|
+
expect(config).to receive(:init).with(args).ordered
|
|
14
|
+
expect(db).to receive(:init).ordered
|
|
15
|
+
expect(config).to receive(:application_args).ordered.and_return(['version'])
|
|
16
|
+
expect(application).to receive(:start).with(['version']).ordered
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# actual = runner { @app.version }
|
|
18
|
-
#
|
|
19
|
-
# desired = {}
|
|
20
|
-
# desired[:STDERR] = "Version #{Moose::Inventory::VERSION}"
|
|
21
|
-
#
|
|
22
|
-
# expected(actual, desired)
|
|
23
|
-
# end
|
|
18
|
+
described_class.start(args, config: config, db: db, application: application)
|
|
19
|
+
end
|
|
24
20
|
end
|
|
25
21
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
# rubocop:disable Metrics/BlockLength
|
|
7
|
+
RSpec.describe Moose::Inventory::Cli::Console do
|
|
8
|
+
before(:all) do
|
|
9
|
+
setup_cli_harness(command_class: Moose::Inventory::Cli::Application)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
before(:each) do
|
|
13
|
+
reset_cli_harness
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_console(input)
|
|
17
|
+
original_stdin = $stdin
|
|
18
|
+
$stdin = StringIO.new(input)
|
|
19
|
+
runner { @app.start(%w[console]) }
|
|
20
|
+
ensure
|
|
21
|
+
$stdin = original_stdin
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'prints help and exits' do
|
|
25
|
+
actual = run_console("help\nquit\n")
|
|
26
|
+
|
|
27
|
+
expect(actual[:unexpected]).to eq(false)
|
|
28
|
+
expect(actual[:STDOUT]).to include('Moose Inventory console (read-only). Type help or quit.')
|
|
29
|
+
expect(actual[:STDOUT]).to include('- hosts')
|
|
30
|
+
expect(actual[:STDOUT]).to include('- group NAME')
|
|
31
|
+
expect(actual[:STDOUT]).to include('Goodbye.')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'browses hosts, groups, tags, and entity detail without mutating' do
|
|
35
|
+
runner { @app.start(%w[group add web]) }
|
|
36
|
+
runner { @app.start(%w[host add web01 --groups web]) }
|
|
37
|
+
runner { @app.start(%w[host addtag web01 prod]) }
|
|
38
|
+
before_audit_count = @db.models[:audit_event].count
|
|
39
|
+
|
|
40
|
+
actual = run_console("hosts\ngroups\nhost web01\ntags host web01\nquit\n")
|
|
41
|
+
|
|
42
|
+
expect(actual[:unexpected]).to eq(false)
|
|
43
|
+
expect(actual[:STDOUT]).to include('Hosts: web01')
|
|
44
|
+
expect(actual[:STDOUT]).to include('Groups: web')
|
|
45
|
+
expect(actual[:STDOUT]).to include('Host: web01')
|
|
46
|
+
expect(actual[:STDOUT]).to include('Tags: prod')
|
|
47
|
+
expect(@db.models[:audit_event].count).to eq(before_audit_count)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'reports unknown and missing entities safely' do
|
|
51
|
+
actual = run_console("nonsense\nhost missing\nquit\n")
|
|
52
|
+
|
|
53
|
+
expect(actual[:unexpected]).to eq(false)
|
|
54
|
+
expect(actual[:STDOUT]).to include('Unknown command: nonsense')
|
|
55
|
+
expect(actual[:STDOUT]).to include("Host 'missing' not found.")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'supports quoted read-only entity names' do
|
|
59
|
+
group = @db.models[:group].create(name: 'prod group')
|
|
60
|
+
host = @db.models[:host].create(name: 'web 01')
|
|
61
|
+
tag = @db.models[:tag].create(name: 'critical host')
|
|
62
|
+
host.add_group(group)
|
|
63
|
+
host.add_tag(tag)
|
|
64
|
+
before_audit_count = @db.models[:audit_event].count
|
|
65
|
+
|
|
66
|
+
actual = run_console("host \"web 01\"\ntags host \"web 01\"\ngroup 'prod group'\nquit\n")
|
|
67
|
+
|
|
68
|
+
expect(actual[:unexpected]).to eq(false)
|
|
69
|
+
expect(actual[:STDOUT]).to include('Host: web 01')
|
|
70
|
+
expect(actual[:STDOUT]).to include('Groups: prod group')
|
|
71
|
+
expect(actual[:STDOUT]).to include('critical host')
|
|
72
|
+
expect(actual[:STDOUT]).to include('Group: prod group')
|
|
73
|
+
expect(actual[:STDOUT]).to include('Hosts: web 01')
|
|
74
|
+
expect(@db.models[:audit_event].count).to eq(before_audit_count)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'reports command-specific usage for extra or invalid arguments' do
|
|
78
|
+
actual = run_console("host one two\ntags host\naudit nope\naudit 0\nhelp extra\nquit\n")
|
|
79
|
+
|
|
80
|
+
expect(actual[:unexpected]).to eq(false)
|
|
81
|
+
expect(actual[:STDOUT]).to include('Usage: host NAME')
|
|
82
|
+
expect(actual[:STDOUT]).to include('Usage: tags host|group NAME')
|
|
83
|
+
expect(actual[:STDOUT].scan('Usage: audit [LIMIT]').length).to eq(2)
|
|
84
|
+
expect(actual[:STDOUT]).to include('Usage: help')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'reports unclosed quoted input without leaving read-only mode' do
|
|
88
|
+
before_audit_count = @db.models[:audit_event].count
|
|
89
|
+
|
|
90
|
+
actual = run_console("host \"unterminated\nquit\n")
|
|
91
|
+
|
|
92
|
+
expect(actual[:unexpected]).to eq(false)
|
|
93
|
+
expect(actual[:STDOUT]).to include('Invalid command syntax:')
|
|
94
|
+
expect(actual[:STDOUT]).to include('Goodbye.')
|
|
95
|
+
expect(@db.models[:audit_event].count).to eq(before_audit_count)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
# rubocop:enable Metrics/BlockLength
|