Skip to content

Checking the Cluster

This recipe asks a pgEdge Control Plane server for its version, the state of its cluster, and the state of each host in the cluster. The checks suit a deployment script that must wait for the Control Plane before it creates a database, or a monitoring probe that watches the hosts. Each step shows the code in every supported language; selecting a language tab switches every tab on the site to that language.

These examples are for the Control Plane v0.11.0.

The program prints the following output when the cluster has one healthy host:

Version: v0.11.0
Cluster: available
Hosts (1):
- cookbook-host-1: healthy

Preparing the Ingredients

The examples assume a running Control Plane server whose cluster has been initialised. The scripts/start_control_plane.sh script in the cookbook repository starts one in Docker, as host cookbook-host-1, initialises its cluster, and prints the environment variables that the Control Plane recipes use.

The program calls three endpoints, and each takes a GET request. The following table describes them:

Endpoint Description
/v1/version Returns the version of the Control Plane server.
/v1/cluster Returns the ID and state of the cluster.
/v1/hosts Lists the hosts in the cluster, with their states.

The server answers /v1/cluster only once the cluster has been initialised, and then reports the state available, so the health of the cluster shows in the state of each host instead. A host is healthy, degraded, unreachable or unknown. An error reply carries a JSON body with a name and a message that explain the problem.

The following tabs show the dependency declaration from each example project:

# httpx, an HTTP client with a requests-like API.
httpx>=0.28,<1
{
  "name": "cluster-status",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/main.js"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.9.0"
  }
}
<dependencies>
  <!-- Jackson, for reading and writing JSON; the HTTP client is part of
       the JDK. -->
  <dependency>
    <groupId>tools.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>3.2.4</version>
  </dependency>
</dependencies>
<!-- HttpClient and System.Text.Json are part of .NET, so the project
     needs no packages. -->
// The program uses only the standard library, so there is nothing to add.
[dependencies]
# The blocking client, with TLS from rustls and request and response bodies
# as JSON; the default features add HTTP/2 and other things this example
# does not use.
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
serde_json = "1"
source "https://rubygems.org"

# The program uses only Net::HTTP and json from the standard library, so
# there is no gem to add.

Importing the Libraries

Each program begins by importing its HTTP client and JSON library, along with any standard library modules that it uses, and the curl script checks that curl and jq are installed. The following tabs show the imports for each language:

import os
import sys

import httpx
// Node.js provides fetch and AbortSignal as globals, so this program
// imports nothing.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
using System.Text.Json;
using System.Text.Json.Nodes;
import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)
use std::env;
use std::error::Error;
use std::process::ExitCode;
use std::time::Duration;

use reqwest::blocking::Client;
use serde_json::Value;
require "json"
require "net/http"
require "uri"
# Stop at the first failed command or unset variable, and treat a failure
# anywhere in a pipeline as a failure of the whole pipeline.
set -euo pipefail

for tool in curl jq; do
    command -v "$tool" >/dev/null || { echo "Control Plane error: $tool is required" >&2; exit 1; }
done

Configuring the Program

The examples read the address of the Control Plane from the CONTROL_PLANE_URL environment variable, and treat an empty value as unset. Each request may take up to 30 seconds. The following table describes the environment variable:

Variable Default Description
CONTROL_PLANE_URL http://localhost:3000 The base URL of the Control Plane.

The following tabs show how each program reads its settings, and how it fetches a path from the server:

# The or operator treats an empty variable as unset.
BASE_URL = (os.environ.get("CONTROL_PLANE_URL") or "http://localhost:3000").rstrip("/")

TIMEOUT_SECONDS = 30.0


class ControlPlaneError(Exception):
    """Raised when the Control Plane rejects a request or replies oddly."""


def get(client: httpx.Client, path: str) -> dict:
    """Fetches a path from the server, and returns the decoded reply."""
    response = client.get(path)
    if response.is_error:
        raise ControlPlaneError(f"HTTP {response.status_code}, {response.text[:200]}")
    try:
        body = response.json()
        if isinstance(body, dict):
            return body
    except ValueError:
        pass
    raise ControlPlaneError(f"Unexpected response: {response.text[:200]}")
// The || operator, unlike ??, also treats an empty variable as unset.
const baseUrl = (process.env.CONTROL_PLANE_URL || "http://localhost:3000").replace(/\/+$/, "");

const timeoutMs = 30_000;

// Fetches a path from the server, and returns the decoded reply.
async function get<T>(path: string): Promise<T> {
  const response = await fetch(`${baseUrl}${path}`, {
    signal: AbortSignal.timeout(timeoutMs),
  });

  // fetch only rejects when it cannot reach the server; an error status
  // is a normal response, so check it explicitly.
  if (!response.ok) {
    const text = await response.text();
    throw new Error(`HTTP ${response.status}, ${text.slice(0, 200) || response.statusText}`);
  }
  return (await response.json()) as T;
}
/** Returns the value of an environment variable, or a fallback. */
static String envOr(String name, String fallback) {
    String value = System.getenv(name);
    return (value != null && !value.isEmpty()) ? value : fallback;
}

static final String BASE_URL =
        envOr("CONTROL_PLANE_URL", "http://localhost:3000")
                .replaceAll("/+$", "");

static final Duration TIMEOUT = Duration.ofSeconds(30);

/** Thrown when the Control Plane rejects a request or replies oddly. */
static class ControlPlaneException extends Exception {
    ControlPlaneException(String message) {
        super(message);
    }
}

// A mapper is safe to share, so the program creates only one.
static final ObjectMapper MAPPER = JsonMapper.builder().build();

/** Returns at most the first 200 characters of a reply. */
static String shorten(String text) {
    return text.length() > 200 ? text.substring(0, 200) : text;
}

/**
 * Sends a request and returns the response with its whole body. The
 * request timeout stops applying once the headers arrive, so a server
 * that stalls part of the way through the body would hang send(); this
 * applies the same timeout to the whole exchange instead.
 */
static HttpResponse<String> send(HttpClient client, HttpRequest request)
        throws IOException, InterruptedException {
    CompletableFuture<HttpResponse<String>> pending =
            client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
    try {
        return pending.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        pending.cancel(true);
        throw new HttpTimeoutException("request timed out");
    } catch (ExecutionException e) {
        if (e.getCause() instanceof IOException cause) {
            throw cause;
        }
        throw new IOException(e.getCause());
    }
}

/** Fetches a path from the server, and returns the decoded reply. */
static JsonNode get(HttpClient client, String path)
        throws IOException, InterruptedException, ControlPlaneException {
    HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + path))
            .timeout(TIMEOUT)
            .GET()
            .build();
    HttpResponse<String> response = send(client, request);
    if (response.statusCode() >= 400) {
        throw new ControlPlaneException("HTTP " + response.statusCode()
                + ", " + shorten(response.body()));
    }
    try {
        JsonNode body = MAPPER.readTree(response.body());
        if (body.isObject()) {
            return body;
        }
    } catch (JacksonException e) {
        // The body is not JSON; report it below.
    }
    throw new ControlPlaneException(
            "Unexpected response: " + shorten(response.body()));
}
// Returns the value of an environment variable, or a fallback if the
// variable is unset or empty.
static string EnvOr(string name, string fallback) =>
    Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
        ? value
        : fallback;

static string BaseUrl() =>
    EnvOr("CONTROL_PLANE_URL", "http://localhost:3000").TrimEnd('/');

const int TimeoutSeconds = 30;

// Returns at most the first 200 characters of a reply.
static string Shorten(string text) =>
    text.Length > 200 ? text[..200] : text;

// Fetches a path from the server, and returns the decoded reply.
static async Task<JsonObject> GetAsync(HttpClient client, string path)
{
    using var response = await client.GetAsync(BaseUrl() + path);
    var body = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode)
    {
        throw new ControlPlaneException(
            $"HTTP {(int)response.StatusCode}, {Shorten(body)}");
    }
    try
    {
        if (JsonNode.Parse(body) is JsonObject reply)
        {
            return reply;
        }
    }
    catch (JsonException)
    {
        // The body is not JSON; report it below.
    }
    throw new ControlPlaneException($"Unexpected response: {Shorten(body)}");
}
// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

var baseURL = strings.TrimRight(envOr("CONTROL_PLANE_URL", "http://localhost:3000"), "/")

const timeout = 30 * time.Second

// get fetches a path from the server, and decodes the reply into out.
func get(client *http.Client, path string, out any) error {
    response, err := client.Get(baseURL + path)
    if err != nil {
        return err
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        return err
    }

    // Go treats an error status as a normal response, so check it here.
    if response.StatusCode != http.StatusOK {
        return fmt.Errorf("HTTP %d, %.200s", response.StatusCode, strings.TrimSpace(string(body)))
    }
    if err := json.Unmarshal(body, out); err != nil {
        return fmt.Errorf("unexpected response: %.200s", body)
    }
    return nil
}
/// Returns the value of an environment variable, or a fallback if the
/// variable is unset or empty.
fn env_or(name: &str, fallback: &str) -> String {
    env::var(name)
        .ok()
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| fallback.to_string())
}

fn base_url() -> String {
    env_or("CONTROL_PLANE_URL", "http://localhost:3000")
        .trim_end_matches('/')
        .to_string()
}

const TIMEOUT: Duration = Duration::from_secs(30);

/// The errors that the functions below return; a string converts into one
/// with the ? operator or into().
type BoxError = Box<dyn Error>;

/// Returns at most the first 200 characters of a reply.
fn shorten(text: &str) -> String {
    text.chars().take(200).collect()
}

/// Fetches a path from the server, and returns the decoded reply.
fn get(client: &Client, path: &str) -> Result<Value, BoxError> {
    let response = client.get(format!("{}{path}", base_url())).send()?;
    let status = response.status();
    let body = response.text()?;
    if !status.is_success() {
        return Err(format!("HTTP {}, {}", status.as_u16(), shorten(&body)).into());
    }
    match serde_json::from_str::<Value>(&body) {
        Ok(reply) if reply.is_object() => Ok(reply),
        _ => Err(format!("Unexpected response: {}", shorten(&body)).into()),
    }
}
# Returns the value of an environment variable, or a fallback if the
# variable is unset or empty.
def env_or(name, fallback)
  value = ENV[name]
  value.nil? || value.empty? ? fallback : value
end

BASE_URL = env_or("CONTROL_PLANE_URL", "http://localhost:3000").sub(%r{/+\z}, "")

TIMEOUT_SECONDS = 30

# Raised when the Control Plane rejects a request or replies oddly.
class ControlPlaneError < StandardError; end

# Fetches a path from the server, and returns the decoded reply.
def get(http, path)
  response = http.request(Net::HTTP::Get.new(URI("#{BASE_URL}#{path}")))
  unless response.is_a?(Net::HTTPSuccess)
    raise ControlPlaneError, "HTTP #{response.code}, #{response.body.to_s[0, 200]}"
  end

  begin
    body = JSON.parse(response.body.to_s)
    return body if body.is_a?(Hash)
  rescue JSON::ParserError
    # The body is not JSON; report it below.
  end
  raise ControlPlaneError, "Unexpected response: #{response.body.to_s[0, 200]}"
end
# The :- operator treats an empty variable as unset.
CONTROL_PLANE_URL="${CONTROL_PLANE_URL:-http://localhost:3000}"
CONTROL_PLANE_URL="${CONTROL_PLANE_URL%/}"

TIMEOUT_SECONDS=30

# Fetches a path from the server and prints the JSON reply. With
# --fail-with-body, curl exits non-zero on an HTTP error status but still
# prints the body, which carries the server's explanation.
get() {
    curl --silent --show-error --fail-with-body \
        --max-time "$TIMEOUT_SECONDS" "$CONTROL_PLANE_URL$1"
}

Reading the Version

Each program asks for the version first, because the reply shows that the server is running and which release of the API it offers. The following tabs show the version request:

def show_version(client: httpx.Client) -> None:
    """Prints the version of the Control Plane server."""
    version = get(client, "/v1/version").get("version")
    if not isinstance(version, str):
        raise ControlPlaneError("Unexpected response: missing version")
    print(f"Version: {version}")
// Prints the version of the Control Plane server.
async function showVersion(): Promise<void> {
  const body = await get<{ version?: unknown }>("/v1/version");
  if (typeof body.version !== "string") {
    throw new Error("Unexpected response: missing version");
  }
  console.log(`Version: ${body.version}`);
}
/** Prints the version of the Control Plane server. */
static void showVersion(HttpClient client)
        throws IOException, InterruptedException, ControlPlaneException {
    JsonNode version = get(client, "/v1/version").path("version");
    if (!version.isString()) {
        throw new ControlPlaneException("Unexpected response: missing version");
    }
    System.out.println("Version: " + version.stringValue());
}
// Prints the version of the Control Plane server.
static async Task ShowVersionAsync(HttpClient client)
{
    var reply = await GetAsync(client, "/v1/version");
    if (reply["version"] is not JsonValue node ||
        !node.TryGetValue(out string? version))
    {
        throw new ControlPlaneException("Unexpected response: missing version");
    }
    Console.WriteLine($"Version: {version}");
}
// showVersion prints the version of the Control Plane server.
func showVersion(client *http.Client) error {
    var body struct {
        Version *string `json:"version"`
    }
    if err := get(client, "/v1/version", &body); err != nil {
        return err
    }
    if body.Version == nil {
        return errors.New("unexpected response: missing version")
    }
    fmt.Printf("Version: %s\n", *body.Version)
    return nil
}
/// Prints the version of the Control Plane server.
fn show_version(client: &Client) -> Result<(), BoxError> {
    let reply = get(client, "/v1/version")?;
    let Some(version) = reply["version"].as_str() else {
        return Err("Unexpected response: missing version".into());
    };
    println!("Version: {version}");
    Ok(())
}
# Prints the version of the Control Plane server.
def show_version(http)
  version = get(http, "/v1/version")["version"]
  raise ControlPlaneError, "Unexpected response: missing version" unless version.is_a?(String)

  puts "Version: #{version}"
end
# Prints the version of the Control Plane server.
show_version() {
    local response
    response="$(get /v1/version)" || {
        echo "Control Plane error: $response" >&2
        return 1
    }
    jq -er '"Version: \(.version | strings)"' <<<"$response" || {
        echo "Unexpected response: $response" >&2
        return 1
    }
}

Reading the Cluster State

Each program then reads the state of the cluster from the status object of the reply. The following tabs show the cluster request:

def show_cluster(client: httpx.Client) -> None:
    """Prints the state of the cluster. A server answers this request only
    once its cluster has been initialised, and it then reports
    "available"; the health of the cluster shows in the state of each
    host."""
    state = (get(client, "/v1/cluster").get("status") or {}).get("state")
    if not isinstance(state, str):
        raise ControlPlaneError("Unexpected response: missing state")
    print(f"Cluster: {state}")
// Prints the state of the cluster. A server answers this request only
// once its cluster has been initialised, and it then reports "available";
// the health of the cluster shows in the state of each host.
async function showCluster(): Promise<void> {
  const body = await get<{ status?: { state?: unknown } }>("/v1/cluster");
  if (typeof body.status?.state !== "string") {
    throw new Error("Unexpected response: missing state");
  }
  console.log(`Cluster: ${body.status.state}`);
}
/**
 * Prints the state of the cluster. A server answers this request only
 * once its cluster has been initialised, and it then reports
 * "available"; the health of the cluster shows in the state of each
 * host.
 */
static void showCluster(HttpClient client)
        throws IOException, InterruptedException, ControlPlaneException {
    JsonNode state = get(client, "/v1/cluster").path("status").path("state");
    if (!state.isString()) {
        throw new ControlPlaneException("Unexpected response: missing state");
    }
    System.out.println("Cluster: " + state.stringValue());
}
// Prints the state of the cluster. A server answers this request only once
// its cluster has been initialised, and it then reports "available"; the
// health of the cluster shows in the state of each host.
static async Task ShowClusterAsync(HttpClient client)
{
    var reply = await GetAsync(client, "/v1/cluster");
    if (reply["status"]?["state"] is not JsonValue node ||
        !node.TryGetValue(out string? state))
    {
        throw new ControlPlaneException("Unexpected response: missing state");
    }
    Console.WriteLine($"Cluster: {state}");
}
// showCluster prints the state of the cluster. A server answers this
// request only once its cluster has been initialised, and it then reports
// "available"; the health of the cluster shows in the state of each host.
func showCluster(client *http.Client) error {
    var body struct {
        Status struct {
            State *string `json:"state"`
        } `json:"status"`
    }
    if err := get(client, "/v1/cluster", &body); err != nil {
        return err
    }
    if body.Status.State == nil {
        return errors.New("unexpected response: missing state")
    }
    fmt.Printf("Cluster: %s\n", *body.Status.State)
    return nil
}
/// Prints the state of the cluster. A server answers this request only
/// once its cluster has been initialised, and it then reports
/// "available"; the health of the cluster shows in the state of each host.
fn show_cluster(client: &Client) -> Result<(), BoxError> {
    let reply = get(client, "/v1/cluster")?;
    let Some(state) = reply["status"]["state"].as_str() else {
        return Err("Unexpected response: missing state".into());
    };
    println!("Cluster: {state}");
    Ok(())
}
# Prints the state of the cluster. A server answers this request only once
# its cluster has been initialised, and it then reports "available"; the
# health of the cluster shows in the state of each host.
def show_cluster(http)
  state = (get(http, "/v1/cluster")["status"] || {})["state"]
  raise ControlPlaneError, "Unexpected response: missing state" unless state.is_a?(String)

  puts "Cluster: #{state}"
end
# Prints the state of the cluster. A server answers this request only once
# its cluster has been initialised, and it then reports "available"; the
# health of the cluster shows in the state of each host.
show_cluster() {
    local response
    response="$(get /v1/cluster)" || {
        echo "Control Plane error: $response" >&2
        return 1
    }
    jq -er '"Cluster: \(.status.state | strings)"' <<<"$response" || {
        echo "Unexpected response: $response" >&2
        return 1
    }
}

Listing the Hosts

Each program lists every host with its state, and notes whether any host is not healthy. The following tabs show how each program lists the hosts:

def show_hosts(client: httpx.Client) -> bool:
    """Lists the hosts in the cluster with their states, and returns
    whether every host is "healthy". The other states are "degraded",
    "unreachable" and "unknown"."""
    hosts = get(client, "/v1/hosts").get("hosts")
    if not isinstance(hosts, list):
        raise ControlPlaneError("Unexpected response: missing hosts")

    print(f"Hosts ({len(hosts)}):")
    healthy = True
    for host in hosts:
        state = (host.get("status") or {}).get("state", "unknown")
        print(f"- {host.get('id')}: {state}")
        healthy = healthy and state == "healthy"
    return healthy
interface Host {
  id?: string;
  status?: { state?: string };
}

// Lists the hosts in the cluster with their states, and returns whether
// every host is "healthy". The other states are "degraded", "unreachable"
// and "unknown".
async function showHosts(): Promise<boolean> {
  const body = await get<{ hosts?: Host[] }>("/v1/hosts");
  if (!Array.isArray(body.hosts)) {
    throw new Error("Unexpected response: missing hosts");
  }

  console.log(`Hosts (${body.hosts.length}):`);
  let healthy = true;
  for (const host of body.hosts) {
    const state = host.status?.state ?? "unknown";
    console.log(`- ${host.id}: ${state}`);
    healthy &&= state === "healthy";
  }
  return healthy;
}
/**
 * Lists the hosts in the cluster with their states, and returns whether
 * every host is "healthy". The other states are "degraded",
 * "unreachable" and "unknown".
 */
static boolean showHosts(HttpClient client)
        throws IOException, InterruptedException, ControlPlaneException {
    JsonNode hosts = get(client, "/v1/hosts").path("hosts");
    if (!hosts.isArray()) {
        throw new ControlPlaneException("Unexpected response: missing hosts");
    }

    System.out.println("Hosts (" + hosts.size() + "):");
    boolean healthy = true;
    for (JsonNode host : hosts) {
        String state = host.path("status").path("state").asString("unknown");
        System.out.println("- " + host.path("id").asString() + ": " + state);
        healthy = healthy && state.equals("healthy");
    }
    return healthy;
}
// Lists the hosts in the cluster with their states, and returns whether
// every host is "healthy". The other states are "degraded", "unreachable"
// and "unknown".
static async Task<bool> ShowHostsAsync(HttpClient client)
{
    var reply = await GetAsync(client, "/v1/hosts");
    if (reply["hosts"] is not JsonArray hosts)
    {
        throw new ControlPlaneException("Unexpected response: missing hosts");
    }

    Console.WriteLine($"Hosts ({hosts.Count}):");
    var healthy = true;
    foreach (var host in hosts)
    {
        var state = host?["status"]?["state"]?.ToString() ?? "unknown";
        Console.WriteLine($"- {host?["id"]}: {state}");
        healthy = healthy && state == "healthy";
    }
    return healthy;
}
// showHosts lists the hosts in the cluster with their states, and returns
// whether every host is "healthy". The other states are "degraded",
// "unreachable" and "unknown".
func showHosts(client *http.Client) (bool, error) {
    var body struct {
        Hosts *[]struct {
            ID     string `json:"id"`
            Status struct {
                State string `json:"state"`
            } `json:"status"`
        } `json:"hosts"`
    }
    if err := get(client, "/v1/hosts", &body); err != nil {
        return false, err
    }
    if body.Hosts == nil {
        return false, errors.New("unexpected response: missing hosts")
    }

    fmt.Printf("Hosts (%d):\n", len(*body.Hosts))
    healthy := true
    for _, host := range *body.Hosts {
        state := host.Status.State
        if state == "" {
            state = "unknown"
        }
        fmt.Printf("- %s: %s\n", host.ID, state)
        healthy = healthy && state == "healthy"
    }
    return healthy, nil
}
/// Lists the hosts in the cluster with their states, and returns whether
/// every host is "healthy". The other states are "degraded",
/// "unreachable" and "unknown".
fn show_hosts(client: &Client) -> Result<bool, BoxError> {
    let reply = get(client, "/v1/hosts")?;
    let Some(hosts) = reply["hosts"].as_array() else {
        return Err("Unexpected response: missing hosts".into());
    };

    println!("Hosts ({}):", hosts.len());
    let mut healthy = true;
    for host in hosts {
        let state = host["status"]["state"].as_str().unwrap_or("unknown");
        println!("- {}: {state}", host["id"].as_str().unwrap_or(""));
        healthy = healthy && state == "healthy";
    }
    Ok(healthy)
}
# Lists the hosts in the cluster with their states, and returns whether
# every host is "healthy". The other states are "degraded", "unreachable"
# and "unknown".
def show_hosts(http)
  hosts = get(http, "/v1/hosts")["hosts"]
  raise ControlPlaneError, "Unexpected response: missing hosts" unless hosts.is_a?(Array)

  puts "Hosts (#{hosts.length}):"
  hosts.map do |host|
    state = (host["status"] || {}).fetch("state", "unknown")
    puts "- #{host["id"]}: #{state}"
    state == "healthy"
  end.all?
end
# Lists the hosts in the cluster with their states, and fails when a
# request or reply fails or when a host is anything but "healthy". The
# other states are "degraded", "unreachable" and "unknown".
show_hosts() {
    local response
    response="$(get /v1/hosts)" || {
        echo "Control Plane error: $response" >&2
        return 1
    }
    jq -er '
        if (.hosts | type) != "array" then error("missing hosts") else
            "Hosts (\(.hosts | length)):",
            (.hosts[] | "- \(.id): \(.status.state // "unknown")")
        end' <<<"$response" || {
        echo "Unexpected response: $response" >&2
        return 1
    }
    jq -e 'all(.hosts[]; .status.state == "healthy")' <<<"$response" >/dev/null || {
        echo "A host is not healthy" >&2
        return 1
    }
}

Handling Errors

The main part of each program runs the three checks, reports any failure on stderr, and exits with a non-zero status when the server is unreachable or a host is not healthy, so that a script can act on the result. The following tabs show the main part of each program:

def main() -> int:
    try:
        with httpx.Client(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as client:
            show_version(client)
            show_cluster(client)
            healthy = show_hosts(client)
    # httpx.HTTPError is the base class for failures to reach the server,
    # such as a refused connection or a timeout.
    except (httpx.HTTPError, ControlPlaneError) as e:
        print(f"Control Plane error: {e}", file=sys.stderr)
        return 1

    # Report an unhealthy host with the exit status.
    if not healthy:
        print("A host is not healthy", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
// Returns a readable description of an error. fetch reports a connection
// failure as a TypeError with the cause (often an AggregateError, whose
// own message is empty) attached.
function describe(err: unknown): string {
  if (err instanceof AggregateError) {
    return err.errors.map(describe).join("; ");
  }
  if (err instanceof Error) {
    return err.cause ? `${err.message}: ${describe(err.cause)}` : err.message;
  }
  return String(err);
}

async function main(): Promise<number> {
  try {
    await showVersion();
    await showCluster();
    const healthy = await showHosts();

    // Report an unhealthy host with the exit status.
    if (!healthy) {
      console.error("A host is not healthy");
      return 1;
    }
    return 0;
  } catch (err) {
    console.error(`Control Plane error: ${describe(err)}`);
    return 1;
  }
}

process.exitCode = await main();
public static void main(String[] args) {
    HttpClient client = HttpClient.newHttpClient();
    boolean healthy;
    try {
        showVersion(client);
        showCluster(client);
        healthy = showHosts(client);
    // IOException covers failures to reach the server, such as a
    // refused connection or a timeout. The message of a refused
    // connection can be empty, so print the exception itself, which
    // includes its type.
    } catch (IOException | InterruptedException e) {
        System.err.println("Control Plane error: " + e);
        System.exit(1);
        return;
    } catch (ControlPlaneException e) {
        System.err.println("Control Plane error: " + e.getMessage());
        System.exit(1);
        return;
    }

    // Report an unhealthy host with the exit status.
    if (!healthy) {
        System.err.println("A host is not healthy");
        System.exit(1);
    }
}
bool healthy;
try
{
    // using disposes of the client, and closes its connections, when the
    // block exits, whether or not an error occurred.
    using var client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
    };
    await ShowVersionAsync(client);
    await ShowClusterAsync(client);
    healthy = await ShowHostsAsync(client);
}
// HttpRequestException covers failures to reach the server, such as a
// refused connection, and TaskCanceledException covers a timeout.
catch (Exception e) when (
    e is HttpRequestException or TaskCanceledException or ControlPlaneException)
{
    Console.Error.WriteLine($"Control Plane error: {e.Message}");
    return 1;
}

// Report an unhealthy host with the exit status.
if (!healthy)
{
    Console.Error.WriteLine("A host is not healthy");
    return 1;
}
return 0;

// C# requires type declarations to follow the top-level statements, so
// the exception that the program throws for an error reply is declared
// here, at the end.
class ControlPlaneException(string message) : Exception(message);
// run performs the checks and returns any error to the caller.
func run() error {
    client := &http.Client{Timeout: timeout}

    if err := showVersion(client); err != nil {
        return err
    }
    if err := showCluster(client); err != nil {
        return err
    }
    healthy, err := showHosts(client)
    if err != nil {
        return err
    }

    // Report an unhealthy host with the exit status.
    if !healthy {
        return errors.New("a host is not healthy")
    }
    return nil
}

// main reports any error returned by run() and exits with a non-zero
// status.
func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Control Plane error: %v\n", err)
        os.Exit(1)
    }
}
/// Runs the three checks, and returns whether every host is healthy. The
/// ? operator returns any error to the caller.
fn run() -> Result<bool, BoxError> {
    let client = Client::builder().timeout(TIMEOUT).build()?;
    show_version(&client)?;
    show_cluster(&client)?;
    show_hosts(&client)
}

/// Reports any error returned by run() and exits with a failure status.
/// The top-level error is often generic ("error sending request"), so the
/// chain of underlying causes is printed too.
fn main() -> ExitCode {
    match run() {
        Ok(true) => ExitCode::SUCCESS,
        // Report an unhealthy host with the exit status.
        Ok(false) => {
            eprintln!("A host is not healthy");
            ExitCode::FAILURE
        }
        Err(e) => {
            eprintln!("Control Plane error: {e}");
            let mut cause = e.source();
            while let Some(c) = cause {
                eprintln!("  caused by: {c}");
                cause = c.source();
            }
            ExitCode::FAILURE
        }
    }
}
begin
  uri = URI(BASE_URL)
  # start opens a connection, and closes it when the block exits, whether
  # or not an error occurred.
  healthy = Net::HTTP.start(
    uri.host, uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
  ) do |http|
    show_version(http)
    show_cluster(http)
    show_hosts(http)
  end
# SystemCallError covers a refused connection, Timeout::Error covers the
# connection and read timeouts, and SocketError covers an unknown host.
rescue ControlPlaneError, SystemCallError, Timeout::Error, SocketError, IOError => e
  warn "Control Plane error: #{e.message}"
  exit 1
end

# Report an unhealthy host with the exit status.
unless healthy
  warn "A host is not healthy"
  exit 1
end
main() {
    # Any failure, including an unhealthy host, gives a non-zero exit
    # status.
    show_version || exit 1
    show_cluster || exit 1
    show_hosts || exit 1
}

main

Running the Example

The complete project for each language is available in the cookbook repository. Set the environment variables for your Control Plane, and then run the command for your language from the corresponding project directory. The following tabs show the commands that build and run each example:

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python main.py
npm install
npm run build
npm start
mvn compile exec:java
dotnet run
go run .
cargo run
bundle install
bundle exec ruby main.rb
bash main.sh

Viewing the Complete Program

The following tabs show the complete program for each language:

"""
Ellie's Postgres Cookbook
Checking the version of the pgEdge Control Plane, its cluster, and the
state of each host, using httpx.

These examples are for the Control Plane v0.11.0.

Copyright (c) 2026, pgEdge, Inc.
This code is released under the PostgreSQL License.
"""

import os
import sys

import httpx


# The or operator treats an empty variable as unset.
BASE_URL = (os.environ.get("CONTROL_PLANE_URL") or "http://localhost:3000").rstrip("/")

TIMEOUT_SECONDS = 30.0


class ControlPlaneError(Exception):
    """Raised when the Control Plane rejects a request or replies oddly."""


def get(client: httpx.Client, path: str) -> dict:
    """Fetches a path from the server, and returns the decoded reply."""
    response = client.get(path)
    if response.is_error:
        raise ControlPlaneError(f"HTTP {response.status_code}, {response.text[:200]}")
    try:
        body = response.json()
        if isinstance(body, dict):
            return body
    except ValueError:
        pass
    raise ControlPlaneError(f"Unexpected response: {response.text[:200]}")


def show_version(client: httpx.Client) -> None:
    """Prints the version of the Control Plane server."""
    version = get(client, "/v1/version").get("version")
    if not isinstance(version, str):
        raise ControlPlaneError("Unexpected response: missing version")
    print(f"Version: {version}")


def show_cluster(client: httpx.Client) -> None:
    """Prints the state of the cluster. A server answers this request only
    once its cluster has been initialised, and it then reports
    "available"; the health of the cluster shows in the state of each
    host."""
    state = (get(client, "/v1/cluster").get("status") or {}).get("state")
    if not isinstance(state, str):
        raise ControlPlaneError("Unexpected response: missing state")
    print(f"Cluster: {state}")


def show_hosts(client: httpx.Client) -> bool:
    """Lists the hosts in the cluster with their states, and returns
    whether every host is "healthy". The other states are "degraded",
    "unreachable" and "unknown"."""
    hosts = get(client, "/v1/hosts").get("hosts")
    if not isinstance(hosts, list):
        raise ControlPlaneError("Unexpected response: missing hosts")

    print(f"Hosts ({len(hosts)}):")
    healthy = True
    for host in hosts:
        state = (host.get("status") or {}).get("state", "unknown")
        print(f"- {host.get('id')}: {state}")
        healthy = healthy and state == "healthy"
    return healthy


def main() -> int:
    try:
        with httpx.Client(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as client:
            show_version(client)
            show_cluster(client)
            healthy = show_hosts(client)
    # httpx.HTTPError is the base class for failures to reach the server,
    # such as a refused connection or a timeout.
    except (httpx.HTTPError, ControlPlaneError) as e:
        print(f"Control Plane error: {e}", file=sys.stderr)
        return 1

    # Report an unhealthy host with the exit status.
    if not healthy:
        print("A host is not healthy", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
/*
 * Ellie's Postgres Cookbook
 * Checking the version of the pgEdge Control Plane, its cluster, and the
 * state of each host, using the built-in fetch.
 *
 * These examples are for the Control Plane v0.11.0.
 *
 * Copyright (c) 2026, pgEdge, Inc.
 * This code is released under the PostgreSQL License.
 */

// Node.js provides fetch and AbortSignal as globals, so this program
// imports nothing.

// The || operator, unlike ??, also treats an empty variable as unset.
const baseUrl = (process.env.CONTROL_PLANE_URL || "http://localhost:3000").replace(/\/+$/, "");

const timeoutMs = 30_000;

// Fetches a path from the server, and returns the decoded reply.
async function get<T>(path: string): Promise<T> {
  const response = await fetch(`${baseUrl}${path}`, {
    signal: AbortSignal.timeout(timeoutMs),
  });

  // fetch only rejects when it cannot reach the server; an error status
  // is a normal response, so check it explicitly.
  if (!response.ok) {
    const text = await response.text();
    throw new Error(`HTTP ${response.status}, ${text.slice(0, 200) || response.statusText}`);
  }
  return (await response.json()) as T;
}

// Prints the version of the Control Plane server.
async function showVersion(): Promise<void> {
  const body = await get<{ version?: unknown }>("/v1/version");
  if (typeof body.version !== "string") {
    throw new Error("Unexpected response: missing version");
  }
  console.log(`Version: ${body.version}`);
}

// Prints the state of the cluster. A server answers this request only
// once its cluster has been initialised, and it then reports "available";
// the health of the cluster shows in the state of each host.
async function showCluster(): Promise<void> {
  const body = await get<{ status?: { state?: unknown } }>("/v1/cluster");
  if (typeof body.status?.state !== "string") {
    throw new Error("Unexpected response: missing state");
  }
  console.log(`Cluster: ${body.status.state}`);
}

interface Host {
  id?: string;
  status?: { state?: string };
}

// Lists the hosts in the cluster with their states, and returns whether
// every host is "healthy". The other states are "degraded", "unreachable"
// and "unknown".
async function showHosts(): Promise<boolean> {
  const body = await get<{ hosts?: Host[] }>("/v1/hosts");
  if (!Array.isArray(body.hosts)) {
    throw new Error("Unexpected response: missing hosts");
  }

  console.log(`Hosts (${body.hosts.length}):`);
  let healthy = true;
  for (const host of body.hosts) {
    const state = host.status?.state ?? "unknown";
    console.log(`- ${host.id}: ${state}`);
    healthy &&= state === "healthy";
  }
  return healthy;
}

// Returns a readable description of an error. fetch reports a connection
// failure as a TypeError with the cause (often an AggregateError, whose
// own message is empty) attached.
function describe(err: unknown): string {
  if (err instanceof AggregateError) {
    return err.errors.map(describe).join("; ");
  }
  if (err instanceof Error) {
    return err.cause ? `${err.message}: ${describe(err.cause)}` : err.message;
  }
  return String(err);
}

async function main(): Promise<number> {
  try {
    await showVersion();
    await showCluster();
    const healthy = await showHosts();

    // Report an unhealthy host with the exit status.
    if (!healthy) {
      console.error("A host is not healthy");
      return 1;
    }
    return 0;
  } catch (err) {
    console.error(`Control Plane error: ${describe(err)}`);
    return 1;
  }
}

process.exitCode = await main();
/*
 * Ellie's Postgres Cookbook
 * Checking the version of the pgEdge Control Plane, its cluster, and the
 * state of each host, using java.net.http and Jackson.
 *
 * These examples are for the Control Plane v0.11.0.
 *
 * Copyright (c) 2026, pgEdge, Inc.
 * This code is released under the PostgreSQL License.
 */

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;

public class Main {

    /** Returns the value of an environment variable, or a fallback. */
    static String envOr(String name, String fallback) {
        String value = System.getenv(name);
        return (value != null && !value.isEmpty()) ? value : fallback;
    }

    static final String BASE_URL =
            envOr("CONTROL_PLANE_URL", "http://localhost:3000")
                    .replaceAll("/+$", "");

    static final Duration TIMEOUT = Duration.ofSeconds(30);

    /** Thrown when the Control Plane rejects a request or replies oddly. */
    static class ControlPlaneException extends Exception {
        ControlPlaneException(String message) {
            super(message);
        }
    }

    // A mapper is safe to share, so the program creates only one.
    static final ObjectMapper MAPPER = JsonMapper.builder().build();

    /** Returns at most the first 200 characters of a reply. */
    static String shorten(String text) {
        return text.length() > 200 ? text.substring(0, 200) : text;
    }

    /**
     * Sends a request and returns the response with its whole body. The
     * request timeout stops applying once the headers arrive, so a server
     * that stalls part of the way through the body would hang send(); this
     * applies the same timeout to the whole exchange instead.
     */
    static HttpResponse<String> send(HttpClient client, HttpRequest request)
            throws IOException, InterruptedException {
        CompletableFuture<HttpResponse<String>> pending =
                client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
        try {
            return pending.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            pending.cancel(true);
            throw new HttpTimeoutException("request timed out");
        } catch (ExecutionException e) {
            if (e.getCause() instanceof IOException cause) {
                throw cause;
            }
            throw new IOException(e.getCause());
        }
    }

    /** Fetches a path from the server, and returns the decoded reply. */
    static JsonNode get(HttpClient client, String path)
            throws IOException, InterruptedException, ControlPlaneException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + path))
                .timeout(TIMEOUT)
                .GET()
                .build();
        HttpResponse<String> response = send(client, request);
        if (response.statusCode() >= 400) {
            throw new ControlPlaneException("HTTP " + response.statusCode()
                    + ", " + shorten(response.body()));
        }
        try {
            JsonNode body = MAPPER.readTree(response.body());
            if (body.isObject()) {
                return body;
            }
        } catch (JacksonException e) {
            // The body is not JSON; report it below.
        }
        throw new ControlPlaneException(
                "Unexpected response: " + shorten(response.body()));
    }

    /** Prints the version of the Control Plane server. */
    static void showVersion(HttpClient client)
            throws IOException, InterruptedException, ControlPlaneException {
        JsonNode version = get(client, "/v1/version").path("version");
        if (!version.isString()) {
            throw new ControlPlaneException("Unexpected response: missing version");
        }
        System.out.println("Version: " + version.stringValue());
    }

    /**
     * Prints the state of the cluster. A server answers this request only
     * once its cluster has been initialised, and it then reports
     * "available"; the health of the cluster shows in the state of each
     * host.
     */
    static void showCluster(HttpClient client)
            throws IOException, InterruptedException, ControlPlaneException {
        JsonNode state = get(client, "/v1/cluster").path("status").path("state");
        if (!state.isString()) {
            throw new ControlPlaneException("Unexpected response: missing state");
        }
        System.out.println("Cluster: " + state.stringValue());
    }

    /**
     * Lists the hosts in the cluster with their states, and returns whether
     * every host is "healthy". The other states are "degraded",
     * "unreachable" and "unknown".
     */
    static boolean showHosts(HttpClient client)
            throws IOException, InterruptedException, ControlPlaneException {
        JsonNode hosts = get(client, "/v1/hosts").path("hosts");
        if (!hosts.isArray()) {
            throw new ControlPlaneException("Unexpected response: missing hosts");
        }

        System.out.println("Hosts (" + hosts.size() + "):");
        boolean healthy = true;
        for (JsonNode host : hosts) {
            String state = host.path("status").path("state").asString("unknown");
            System.out.println("- " + host.path("id").asString() + ": " + state);
            healthy = healthy && state.equals("healthy");
        }
        return healthy;
    }

    public static void main(String[] args) {
        HttpClient client = HttpClient.newHttpClient();
        boolean healthy;
        try {
            showVersion(client);
            showCluster(client);
            healthy = showHosts(client);
        // IOException covers failures to reach the server, such as a
        // refused connection or a timeout. The message of a refused
        // connection can be empty, so print the exception itself, which
        // includes its type.
        } catch (IOException | InterruptedException e) {
            System.err.println("Control Plane error: " + e);
            System.exit(1);
            return;
        } catch (ControlPlaneException e) {
            System.err.println("Control Plane error: " + e.getMessage());
            System.exit(1);
            return;
        }

        // Report an unhealthy host with the exit status.
        if (!healthy) {
            System.err.println("A host is not healthy");
            System.exit(1);
        }
    }
}
/*
 * Ellie's Postgres Cookbook
 * Checking the version of the pgEdge Control Plane, its cluster, and the
 * state of each host, using HttpClient and System.Text.Json.
 *
 * These examples are for the Control Plane v0.11.0.
 *
 * Copyright (c) 2026, pgEdge, Inc.
 * This code is released under the PostgreSQL License.
 */

using System.Text.Json;
using System.Text.Json.Nodes;

// Returns the value of an environment variable, or a fallback if the
// variable is unset or empty.
static string EnvOr(string name, string fallback) =>
    Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
        ? value
        : fallback;

static string BaseUrl() =>
    EnvOr("CONTROL_PLANE_URL", "http://localhost:3000").TrimEnd('/');

const int TimeoutSeconds = 30;

// Returns at most the first 200 characters of a reply.
static string Shorten(string text) =>
    text.Length > 200 ? text[..200] : text;

// Fetches a path from the server, and returns the decoded reply.
static async Task<JsonObject> GetAsync(HttpClient client, string path)
{
    using var response = await client.GetAsync(BaseUrl() + path);
    var body = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode)
    {
        throw new ControlPlaneException(
            $"HTTP {(int)response.StatusCode}, {Shorten(body)}");
    }
    try
    {
        if (JsonNode.Parse(body) is JsonObject reply)
        {
            return reply;
        }
    }
    catch (JsonException)
    {
        // The body is not JSON; report it below.
    }
    throw new ControlPlaneException($"Unexpected response: {Shorten(body)}");
}

// Prints the version of the Control Plane server.
static async Task ShowVersionAsync(HttpClient client)
{
    var reply = await GetAsync(client, "/v1/version");
    if (reply["version"] is not JsonValue node ||
        !node.TryGetValue(out string? version))
    {
        throw new ControlPlaneException("Unexpected response: missing version");
    }
    Console.WriteLine($"Version: {version}");
}

// Prints the state of the cluster. A server answers this request only once
// its cluster has been initialised, and it then reports "available"; the
// health of the cluster shows in the state of each host.
static async Task ShowClusterAsync(HttpClient client)
{
    var reply = await GetAsync(client, "/v1/cluster");
    if (reply["status"]?["state"] is not JsonValue node ||
        !node.TryGetValue(out string? state))
    {
        throw new ControlPlaneException("Unexpected response: missing state");
    }
    Console.WriteLine($"Cluster: {state}");
}

// Lists the hosts in the cluster with their states, and returns whether
// every host is "healthy". The other states are "degraded", "unreachable"
// and "unknown".
static async Task<bool> ShowHostsAsync(HttpClient client)
{
    var reply = await GetAsync(client, "/v1/hosts");
    if (reply["hosts"] is not JsonArray hosts)
    {
        throw new ControlPlaneException("Unexpected response: missing hosts");
    }

    Console.WriteLine($"Hosts ({hosts.Count}):");
    var healthy = true;
    foreach (var host in hosts)
    {
        var state = host?["status"]?["state"]?.ToString() ?? "unknown";
        Console.WriteLine($"- {host?["id"]}: {state}");
        healthy = healthy && state == "healthy";
    }
    return healthy;
}

bool healthy;
try
{
    // using disposes of the client, and closes its connections, when the
    // block exits, whether or not an error occurred.
    using var client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
    };
    await ShowVersionAsync(client);
    await ShowClusterAsync(client);
    healthy = await ShowHostsAsync(client);
}
// HttpRequestException covers failures to reach the server, such as a
// refused connection, and TaskCanceledException covers a timeout.
catch (Exception e) when (
    e is HttpRequestException or TaskCanceledException or ControlPlaneException)
{
    Console.Error.WriteLine($"Control Plane error: {e.Message}");
    return 1;
}

// Report an unhealthy host with the exit status.
if (!healthy)
{
    Console.Error.WriteLine("A host is not healthy");
    return 1;
}
return 0;

// C# requires type declarations to follow the top-level statements, so
// the exception that the program throws for an error reply is declared
// here, at the end.
class ControlPlaneException(string message) : Exception(message);
// Ellie's Postgres Cookbook
// Checking the version of the pgEdge Control Plane, its cluster, and the
// state of each host, using net/http.
//
// These examples are for the Control Plane v0.11.0.
//
// Copyright (c) 2026, pgEdge, Inc.
// This code is released under the PostgreSQL License.

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
)


// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

var baseURL = strings.TrimRight(envOr("CONTROL_PLANE_URL", "http://localhost:3000"), "/")

const timeout = 30 * time.Second

// get fetches a path from the server, and decodes the reply into out.
func get(client *http.Client, path string, out any) error {
    response, err := client.Get(baseURL + path)
    if err != nil {
        return err
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        return err
    }

    // Go treats an error status as a normal response, so check it here.
    if response.StatusCode != http.StatusOK {
        return fmt.Errorf("HTTP %d, %.200s", response.StatusCode, strings.TrimSpace(string(body)))
    }
    if err := json.Unmarshal(body, out); err != nil {
        return fmt.Errorf("unexpected response: %.200s", body)
    }
    return nil
}


// showVersion prints the version of the Control Plane server.
func showVersion(client *http.Client) error {
    var body struct {
        Version *string `json:"version"`
    }
    if err := get(client, "/v1/version", &body); err != nil {
        return err
    }
    if body.Version == nil {
        return errors.New("unexpected response: missing version")
    }
    fmt.Printf("Version: %s\n", *body.Version)
    return nil
}


// showCluster prints the state of the cluster. A server answers this
// request only once its cluster has been initialised, and it then reports
// "available"; the health of the cluster shows in the state of each host.
func showCluster(client *http.Client) error {
    var body struct {
        Status struct {
            State *string `json:"state"`
        } `json:"status"`
    }
    if err := get(client, "/v1/cluster", &body); err != nil {
        return err
    }
    if body.Status.State == nil {
        return errors.New("unexpected response: missing state")
    }
    fmt.Printf("Cluster: %s\n", *body.Status.State)
    return nil
}


// showHosts lists the hosts in the cluster with their states, and returns
// whether every host is "healthy". The other states are "degraded",
// "unreachable" and "unknown".
func showHosts(client *http.Client) (bool, error) {
    var body struct {
        Hosts *[]struct {
            ID     string `json:"id"`
            Status struct {
                State string `json:"state"`
            } `json:"status"`
        } `json:"hosts"`
    }
    if err := get(client, "/v1/hosts", &body); err != nil {
        return false, err
    }
    if body.Hosts == nil {
        return false, errors.New("unexpected response: missing hosts")
    }

    fmt.Printf("Hosts (%d):\n", len(*body.Hosts))
    healthy := true
    for _, host := range *body.Hosts {
        state := host.Status.State
        if state == "" {
            state = "unknown"
        }
        fmt.Printf("- %s: %s\n", host.ID, state)
        healthy = healthy && state == "healthy"
    }
    return healthy, nil
}


// run performs the checks and returns any error to the caller.
func run() error {
    client := &http.Client{Timeout: timeout}

    if err := showVersion(client); err != nil {
        return err
    }
    if err := showCluster(client); err != nil {
        return err
    }
    healthy, err := showHosts(client)
    if err != nil {
        return err
    }

    // Report an unhealthy host with the exit status.
    if !healthy {
        return errors.New("a host is not healthy")
    }
    return nil
}

// main reports any error returned by run() and exits with a non-zero
// status.
func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Control Plane error: %v\n", err)
        os.Exit(1)
    }
}
// Ellie's Postgres Cookbook
// Checking the version of the pgEdge Control Plane, its cluster, and the
// state of each host, using reqwest and serde_json.
//
// These examples are for the Control Plane v0.11.0.
//
// Copyright (c) 2026, pgEdge, Inc.
// This code is released under the PostgreSQL License.

use std::env;
use std::error::Error;
use std::process::ExitCode;
use std::time::Duration;

use reqwest::blocking::Client;
use serde_json::Value;

/// Returns the value of an environment variable, or a fallback if the
/// variable is unset or empty.
fn env_or(name: &str, fallback: &str) -> String {
    env::var(name)
        .ok()
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| fallback.to_string())
}

fn base_url() -> String {
    env_or("CONTROL_PLANE_URL", "http://localhost:3000")
        .trim_end_matches('/')
        .to_string()
}

const TIMEOUT: Duration = Duration::from_secs(30);

/// The errors that the functions below return; a string converts into one
/// with the ? operator or into().
type BoxError = Box<dyn Error>;

/// Returns at most the first 200 characters of a reply.
fn shorten(text: &str) -> String {
    text.chars().take(200).collect()
}

/// Fetches a path from the server, and returns the decoded reply.
fn get(client: &Client, path: &str) -> Result<Value, BoxError> {
    let response = client.get(format!("{}{path}", base_url())).send()?;
    let status = response.status();
    let body = response.text()?;
    if !status.is_success() {
        return Err(format!("HTTP {}, {}", status.as_u16(), shorten(&body)).into());
    }
    match serde_json::from_str::<Value>(&body) {
        Ok(reply) if reply.is_object() => Ok(reply),
        _ => Err(format!("Unexpected response: {}", shorten(&body)).into()),
    }
}

/// Prints the version of the Control Plane server.
fn show_version(client: &Client) -> Result<(), BoxError> {
    let reply = get(client, "/v1/version")?;
    let Some(version) = reply["version"].as_str() else {
        return Err("Unexpected response: missing version".into());
    };
    println!("Version: {version}");
    Ok(())
}

/// Prints the state of the cluster. A server answers this request only
/// once its cluster has been initialised, and it then reports
/// "available"; the health of the cluster shows in the state of each host.
fn show_cluster(client: &Client) -> Result<(), BoxError> {
    let reply = get(client, "/v1/cluster")?;
    let Some(state) = reply["status"]["state"].as_str() else {
        return Err("Unexpected response: missing state".into());
    };
    println!("Cluster: {state}");
    Ok(())
}

/// Lists the hosts in the cluster with their states, and returns whether
/// every host is "healthy". The other states are "degraded",
/// "unreachable" and "unknown".
fn show_hosts(client: &Client) -> Result<bool, BoxError> {
    let reply = get(client, "/v1/hosts")?;
    let Some(hosts) = reply["hosts"].as_array() else {
        return Err("Unexpected response: missing hosts".into());
    };

    println!("Hosts ({}):", hosts.len());
    let mut healthy = true;
    for host in hosts {
        let state = host["status"]["state"].as_str().unwrap_or("unknown");
        println!("- {}: {state}", host["id"].as_str().unwrap_or(""));
        healthy = healthy && state == "healthy";
    }
    Ok(healthy)
}

/// Runs the three checks, and returns whether every host is healthy. The
/// ? operator returns any error to the caller.
fn run() -> Result<bool, BoxError> {
    let client = Client::builder().timeout(TIMEOUT).build()?;
    show_version(&client)?;
    show_cluster(&client)?;
    show_hosts(&client)
}

/// Reports any error returned by run() and exits with a failure status.
/// The top-level error is often generic ("error sending request"), so the
/// chain of underlying causes is printed too.
fn main() -> ExitCode {
    match run() {
        Ok(true) => ExitCode::SUCCESS,
        // Report an unhealthy host with the exit status.
        Ok(false) => {
            eprintln!("A host is not healthy");
            ExitCode::FAILURE
        }
        Err(e) => {
            eprintln!("Control Plane error: {e}");
            let mut cause = e.source();
            while let Some(c) = cause {
                eprintln!("  caused by: {c}");
                cause = c.source();
            }
            ExitCode::FAILURE
        }
    }
}
# Ellie's Postgres Cookbook
# Checking the version of the pgEdge Control Plane, its cluster, and the
# state of each host, using Net::HTTP.
#
# These examples are for the Control Plane v0.11.0.
#
# Copyright (c) 2026, pgEdge, Inc.
# This code is released under the PostgreSQL License.

require "json"
require "net/http"
require "uri"

# Returns the value of an environment variable, or a fallback if the
# variable is unset or empty.
def env_or(name, fallback)
  value = ENV[name]
  value.nil? || value.empty? ? fallback : value
end

BASE_URL = env_or("CONTROL_PLANE_URL", "http://localhost:3000").sub(%r{/+\z}, "")

TIMEOUT_SECONDS = 30

# Raised when the Control Plane rejects a request or replies oddly.
class ControlPlaneError < StandardError; end

# Fetches a path from the server, and returns the decoded reply.
def get(http, path)
  response = http.request(Net::HTTP::Get.new(URI("#{BASE_URL}#{path}")))
  unless response.is_a?(Net::HTTPSuccess)
    raise ControlPlaneError, "HTTP #{response.code}, #{response.body.to_s[0, 200]}"
  end

  begin
    body = JSON.parse(response.body.to_s)
    return body if body.is_a?(Hash)
  rescue JSON::ParserError
    # The body is not JSON; report it below.
  end
  raise ControlPlaneError, "Unexpected response: #{response.body.to_s[0, 200]}"
end

# Prints the version of the Control Plane server.
def show_version(http)
  version = get(http, "/v1/version")["version"]
  raise ControlPlaneError, "Unexpected response: missing version" unless version.is_a?(String)

  puts "Version: #{version}"
end

# Prints the state of the cluster. A server answers this request only once
# its cluster has been initialised, and it then reports "available"; the
# health of the cluster shows in the state of each host.
def show_cluster(http)
  state = (get(http, "/v1/cluster")["status"] || {})["state"]
  raise ControlPlaneError, "Unexpected response: missing state" unless state.is_a?(String)

  puts "Cluster: #{state}"
end

# Lists the hosts in the cluster with their states, and returns whether
# every host is "healthy". The other states are "degraded", "unreachable"
# and "unknown".
def show_hosts(http)
  hosts = get(http, "/v1/hosts")["hosts"]
  raise ControlPlaneError, "Unexpected response: missing hosts" unless hosts.is_a?(Array)

  puts "Hosts (#{hosts.length}):"
  hosts.map do |host|
    state = (host["status"] || {}).fetch("state", "unknown")
    puts "- #{host["id"]}: #{state}"
    state == "healthy"
  end.all?
end

begin
  uri = URI(BASE_URL)
  # start opens a connection, and closes it when the block exits, whether
  # or not an error occurred.
  healthy = Net::HTTP.start(
    uri.host, uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
  ) do |http|
    show_version(http)
    show_cluster(http)
    show_hosts(http)
  end
# SystemCallError covers a refused connection, Timeout::Error covers the
# connection and read timeouts, and SocketError covers an unknown host.
rescue ControlPlaneError, SystemCallError, Timeout::Error, SocketError, IOError => e
  warn "Control Plane error: #{e.message}"
  exit 1
end

# Report an unhealthy host with the exit status.
unless healthy
  warn "A host is not healthy"
  exit 1
end
#!/usr/bin/env bash
#
# Ellie's Postgres Cookbook
# Checking the version of the pgEdge Control Plane, its cluster, and the
# state of each host, using curl and jq.
#
# These examples are for the Control Plane v0.11.0.
#
# Copyright (c) 2026, pgEdge, Inc.
# This code is released under the PostgreSQL License.

# Stop at the first failed command or unset variable, and treat a failure
# anywhere in a pipeline as a failure of the whole pipeline.
set -euo pipefail

for tool in curl jq; do
    command -v "$tool" >/dev/null || { echo "Control Plane error: $tool is required" >&2; exit 1; }
done

# The :- operator treats an empty variable as unset.
CONTROL_PLANE_URL="${CONTROL_PLANE_URL:-http://localhost:3000}"
CONTROL_PLANE_URL="${CONTROL_PLANE_URL%/}"

TIMEOUT_SECONDS=30

# Fetches a path from the server and prints the JSON reply. With
# --fail-with-body, curl exits non-zero on an HTTP error status but still
# prints the body, which carries the server's explanation.
get() {
    curl --silent --show-error --fail-with-body \
        --max-time "$TIMEOUT_SECONDS" "$CONTROL_PLANE_URL$1"
}

# Prints the version of the Control Plane server.
show_version() {
    local response
    response="$(get /v1/version)" || {
        echo "Control Plane error: $response" >&2
        return 1
    }
    jq -er '"Version: \(.version | strings)"' <<<"$response" || {
        echo "Unexpected response: $response" >&2
        return 1
    }
}

# Prints the state of the cluster. A server answers this request only once
# its cluster has been initialised, and it then reports "available"; the
# health of the cluster shows in the state of each host.
show_cluster() {
    local response
    response="$(get /v1/cluster)" || {
        echo "Control Plane error: $response" >&2
        return 1
    }
    jq -er '"Cluster: \(.status.state | strings)"' <<<"$response" || {
        echo "Unexpected response: $response" >&2
        return 1
    }
}

# Lists the hosts in the cluster with their states, and fails when a
# request or reply fails or when a host is anything but "healthy". The
# other states are "degraded", "unreachable" and "unknown".
show_hosts() {
    local response
    response="$(get /v1/hosts)" || {
        echo "Control Plane error: $response" >&2
        return 1
    }
    jq -er '
        if (.hosts | type) != "array" then error("missing hosts") else
            "Hosts (\(.hosts | length)):",
            (.hosts[] | "- \(.id): \(.status.state // "unknown")")
        end' <<<"$response" || {
        echo "Unexpected response: $response" >&2
        return 1
    }
    jq -e 'all(.hosts[]; .status.state == "healthy")' <<<"$response" >/dev/null || {
        echo "A host is not healthy" >&2
        return 1
    }
}

main() {
    # Any failure, including an unhealthy host, gives a non-zero exit
    # status.
    show_version || exit 1
    show_cluster || exit 1
    show_hosts || exit 1
}

main

Next Steps

The following resources provide more information about the topics in this recipe: