From 2a3ee72b7faa1d1404915bedd1fbd6bb4dd746ba Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 17 Nov 2025 21:38:28 +0000 Subject: [PATCH] Improve sensor proxy installer compatibility --- scripts/install-sensor-proxy.sh | 312 ++++++++++++++++++++++++++++---- 1 file changed, 281 insertions(+), 31 deletions(-) diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index a83688ca4..a1cceda74 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -8,6 +8,10 @@ set -euo pipefail CONFIG_FILE="/etc/pulse-sensor-proxy/config.yaml" ALLOWED_NODES_FILE="/etc/pulse-sensor-proxy/allowed_nodes.yaml" +MIN_ALLOWED_NODES_FILE_VERSION="v4.31.1" + +ALLOWLIST_MODE="file" +INSTALLED_PROXY_VERSION="" # Colors for output RED='\033[0;31m' @@ -33,6 +37,74 @@ print_success() { echo -e "${GREEN}✓${NC} $1" } +normalize_semver() { + local ver="${1#v}" + ver="${ver%%+*}" + ver="${ver%%-*}" + printf '%s' "$ver" +} + +semver_to_tuple() { + local ver + ver="$(normalize_semver "$1")" + IFS='.' read -r major minor patch <<< "$ver" + [[ -n "$major" ]] || major=0 + [[ -n "$minor" ]] || minor=0 + [[ -n "$patch" ]] || patch=0 + printf '%s %s %s' "$major" "$minor" "$patch" +} + +version_at_least() { + local current target + read -r c_major c_minor c_patch <<< "$(semver_to_tuple "$1")" + read -r t_major t_minor t_patch <<< "$(semver_to_tuple "$2")" + + if (( c_major > t_major )); then + return 0 + elif (( c_major < t_major )); then + return 1 + fi + + if (( c_minor > t_minor )); then + return 0 + elif (( c_minor < t_minor )); then + return 1 + fi + + if (( c_patch >= t_patch )); then + return 0 + fi + return 1 +} + +detect_proxy_version() { + local binary="$1" + if [[ -x "$binary" ]]; then + "$binary" version 2>/dev/null | awk '/pulse-sensor-proxy/{print $2; exit}' + fi +} + +determine_allowlist_mode() { + INSTALLED_PROXY_VERSION="$(detect_proxy_version "$BINARY_PATH")" + + if [[ -z "$INSTALLED_PROXY_VERSION" ]]; then + print_warn "Unable to detect installed pulse-sensor-proxy version; assuming allowed_nodes_file is supported" + ALLOWLIST_MODE="file" + return + fi + + if version_at_least "$INSTALLED_PROXY_VERSION" "$MIN_ALLOWED_NODES_FILE_VERSION"; then + if [[ "$QUIET" != true ]]; then + print_info "Detected pulse-sensor-proxy ${INSTALLED_PROXY_VERSION} (allowed_nodes_file supported)" + fi + ALLOWLIST_MODE="file" + return + fi + + ALLOWLIST_MODE="inline" + print_warn "pulse-sensor-proxy ${INSTALLED_PROXY_VERSION} does not support allowed_nodes_file; using inline allow list updates" +} + configure_local_authorized_key() { local auth_line=$1 local auth_keys_file="/root/.ssh/authorized_keys" @@ -83,62 +155,228 @@ EOF } ensure_allowed_nodes_file_reference() { - if [[ ! -f "$CONFIG_FILE" ]]; then + if [[ "$ALLOWLIST_MODE" != "file" ]]; then + if [[ -f "$CONFIG_FILE" ]]; then + sed -i '/^[[:space:]]*allowed_nodes_file:/d' "$CONFIG_FILE" 2>/dev/null || true + fi return fi - if ! grep -q "allowed_nodes_file" "$CONFIG_FILE" 2>/dev/null; then - echo 'allowed_nodes_file: "/etc/pulse-sensor-proxy/allowed_nodes.yaml"' >> "$CONFIG_FILE" - fi + + normalize_allowed_nodes_section } remove_allowed_nodes_block() { - if ! command -v python3 >/dev/null 2>&1; then - sed -i '/^allowed_nodes:/,/^[^[:space:]]/d' "$CONFIG_FILE" 2>/dev/null || true + if [[ "$ALLOWLIST_MODE" != "file" ]]; then return fi - python3 - "$CONFIG_FILE" <<'PY' + + normalize_allowed_nodes_section +} + +normalize_allowed_nodes_section() { + if [[ ! -f "$CONFIG_FILE" ]]; then + return + fi + + if command -v python3 >/dev/null 2>&1; then + python3 - "$CONFIG_FILE" <<'PY' import sys from pathlib import Path path = Path(sys.argv[1]) if not path.exists(): sys.exit(0) -lines = path.read_text().splitlines() -out = [] -pending = [] -skip = False -def flush_pending(): - if pending: - out.extend(pending) - pending.clear() +lines = path.read_text().splitlines() +to_skip = set() +saved_comment = None + +def capture_comment_block(idx: int): + global saved_comment + blanks = [] + comments = [] + j = idx - 1 + while j >= 0 and lines[j].strip() == "": + blanks.append((j, lines[j])) + j -= 1 + while j >= 0 and lines[j].lstrip().startswith("#"): + comments.append((j, lines[j])) + j -= 1 + if not comments: + return [] + blanks.reverse() + comments.reverse() + block = blanks + comments + for index, _ in block: + to_skip.add(index) + return [text for _, text in block] i = 0 while i < len(lines): line = lines[i] stripped = line.lstrip() - if skip: - if stripped == '' or stripped.startswith('#') or stripped.startswith('-') or line.startswith((' ', '\t')): - i += 1 - continue - skip = False - if stripped.startswith('allowed_nodes:'): - pending.clear() - skip = True + if stripped.startswith("allowed_nodes_file:"): + comment_block = capture_comment_block(i) + if comment_block: + saved_comment = comment_block + to_skip.add(i) i += 1 continue - if stripped == '' or stripped.startswith('#'): - pending.append(line) + + if stripped.startswith("allowed_nodes:"): + comment_block = capture_comment_block(i) + if comment_block: + saved_comment = comment_block + to_skip.add(i) i += 1 + while i < len(lines): + next_line = lines[i] + next_stripped = next_line.lstrip() + if ( + next_stripped == "" + or next_stripped.startswith("#") + or next_stripped.startswith("-") + or next_line.startswith((" ", "\t")) + ): + to_skip.add(i) + i += 1 + continue + break continue - flush_pending() - out.append(line) + i += 1 -flush_pending() -path.write_text('\n'.join(out) + ('\n' if out else '')) + +result = [text for idx, text in enumerate(lines) if idx not in to_skip] + +default_comment = [ + "# Cluster nodes (auto-discovered during installation)", + "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable", +] + +if saved_comment is None: + saved_comment = [""] + default_comment +else: + while saved_comment and saved_comment[-1].strip() == "": + saved_comment.pop() + if saved_comment and saved_comment[0].strip() != "": + saved_comment.insert(0, "") + +if result and result[-1].strip() != "": + result.append("") + +result.extend(saved_comment) +result.append('allowed_nodes_file: "/etc/pulse-sensor-proxy/allowed_nodes.yaml"') + +path.write_text("\n".join(result).rstrip() + "\n") +PY + return + fi + + # Fallback when python3 is unavailable + sed -i '/^[[:space:]]*allowed_nodes:/,/^[^[:space:]]/d' "$CONFIG_FILE" 2>/dev/null || true + sed -i '/^[[:space:]]*allowed_nodes_file:/d' "$CONFIG_FILE" 2>/dev/null || true + if ! grep -q "allowed_nodes_file" "$CONFIG_FILE" 2>/dev/null; then + { + echo "" + echo "# Cluster nodes (auto-discovered during installation)" + echo "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable" + echo 'allowed_nodes_file: "/etc/pulse-sensor-proxy/allowed_nodes.yaml"' + } >>"$CONFIG_FILE" + fi +} + +write_inline_allowed_nodes() { + local comment_line="$1" + shift || true + local nodes=("$@") + + if [[ "$ALLOWLIST_MODE" != "inline" ]]; then + return + fi + + if ! command -v python3 >/dev/null 2>&1; then + print_warn "python3 is required to manage inline allowed_nodes; skipping update" + return + fi + + python3 - "$CONFIG_FILE" "$comment_line" "${nodes[@]}" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +comment = (sys.argv[2] or "").strip() +new_nodes = [n.strip() for n in sys.argv[3:] if n.strip()] + +lines = [] +if path.exists(): + lines = path.read_text().splitlines() + +skip = set() +existing = [] +i = 0 +while i < len(lines): + line = lines[i] + stripped = line.lstrip() + if stripped.startswith("allowed_nodes_file:"): + skip.add(i) + i += 1 + continue + if stripped.startswith("allowed_nodes:"): + skip.add(i) + i += 1 + while i < len(lines): + current = lines[i] + current_stripped = current.lstrip() + if current_stripped.startswith("-"): + value = current_stripped[1:].strip() + if value: + existing.append(value) + skip.add(i) + i += 1 + continue + if ( + current_stripped == "" or + current_stripped.startswith("#") or + current.startswith((" ", "\t")) + ): + skip.add(i) + i += 1 + continue + break + continue + i += 1 + +result = [line for idx, line in enumerate(lines) if idx not in skip] + +seen = set() +merged = [] +for entry in existing + new_nodes: + normalized = entry.strip() + if not normalized: + continue + key = normalized.lower() + if key in seen: + continue + seen.add(key) + merged.append(normalized) + +if merged: + if result and result[-1].strip() != "": + result.append("") + if comment: + result.append(f"# {comment}") + else: + result.append("# Cluster nodes (auto-discovered during installation)") + result.append("allowed_nodes:") + for entry in merged: + result.append(f" - {entry}") + +content = "\n".join(result).rstrip() +if content: + content += "\n" +path.parent.mkdir(parents=True, exist_ok=True) +path.write_text(content) PY - # Fallback cleanup in case formatting prevented python script from matching - sed -i '/^allowed_nodes:/,/^[^[:space:]]/d' "$CONFIG_FILE" 2>/dev/null || true } update_allowed_nodes() { @@ -146,6 +384,11 @@ update_allowed_nodes() { shift local nodes=("$@") + if [[ "$ALLOWLIST_MODE" == "inline" ]]; then + write_inline_allowed_nodes "$comment_line" "${nodes[@]}" + return + fi + ensure_allowed_nodes_file_reference remove_allowed_nodes_block @@ -1073,6 +1316,11 @@ register_with_pulse() { print_warn "Add the node in Pulse (Settings → Nodes) and re-run the sensor proxy installer to enable control-plane sync." >&2 return 0 fi + if [[ "$http_code" == "400" && "$body" == *'"missing_proxy_url"'* && "${mode:-socket}" != "http" ]]; then + print_warn "Pulse refused proxy registration because the node '$hostname' hasn't been added yet." >&2 + print_warn "Control-plane sync will be deferred until the node exists in Pulse; temperature proxy will run with a local allow list." >&2 + return 0 + fi if [[ $attempt -eq $max_attempts ]]; then print_error "Failed to register with Pulse API after $max_attempts attempts" >&2 @@ -1135,6 +1383,8 @@ pulse_control_plane: EOF } +determine_allowlist_mode + # Create base config file if it doesn't exist if [[ ! -f /etc/pulse-sensor-proxy/config.yaml ]]; then print_info "Creating base configuration file..."