Add public runner URL configuration and enhance log streaming support

- Introduce options for specifying public runner base URLs (`SOLSTICE_RUNNER_BASE_URL`) and orchestrator contact addresses (`ORCH_CONTACT_ADDR`).
- Update `.env.sample` and `compose.yml` with new configuration fields for external log streaming and runner binary serving.
- Refactor runner URL handling and generation logic for improved flexibility.
- Enhance `cloud-init` templates with updated runner URL environment variables (`RUNNER_SINGLE` and `RUNNER_URLS`).
- Add unit tests for runner URL generation to verify various input cases.

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2025-11-11 20:24:20 +01:00
parent 1e48b1de66
commit 930efe547f
No known key found for this signature in database
4 changed files with 84 additions and 19 deletions

View file

@ -49,6 +49,16 @@ struct Opts {
#[arg(long, env = "GRPC_ADDR", default_value = "0.0.0.0:50051")] #[arg(long, env = "GRPC_ADDR", default_value = "0.0.0.0:50051")]
grpc_addr: String, grpc_addr: String,
/// Public contact address for runners to stream logs to (host:port). Overrides detection.
#[arg(long = "orch-contact-addr", env = "ORCH_CONTACT_ADDR")]
orch_contact_addr: Option<String>,
/// Public base URL where runner binaries are served (preferred). Example: https://runner.svc.domain
/// The orchestrator will append /runners/{filename} to construct full URLs.
#[arg(long = "runner-base-url", env = "SOLSTICE_RUNNER_BASE_URL")]
runner_base_url: Option<String>,
/// Postgres connection string (if empty, persistence is disabled) /// Postgres connection string (if empty, persistence is disabled)
#[arg( #[arg(
long, long,
@ -152,9 +162,9 @@ async fn main() -> Result<()> {
}); });
// Orchestrator contact address for runner to dial back (auto-detect if not provided) // Orchestrator contact address for runner to dial back (auto-detect if not provided)
let orch_contact = match std::env::var("ORCH_CONTACT_ADDR") { let orch_contact = match &opts.orch_contact_addr {
Ok(v) => v, Some(v) if !v.trim().is_empty() => v.clone(),
Err(_) => detect_contact_addr(&opts), _ => detect_contact_addr(&opts),
}; };
info!(contact = %orch_contact, "orchestrator contact address determined"); info!(contact = %orch_contact, "orchestrator contact address determined");
@ -167,12 +177,14 @@ async fn main() -> Result<()> {
.rsplit(':') .rsplit(':')
.next() .next()
.unwrap_or("8081"); .unwrap_or("8081");
let base = format!("http://{}:{}/runners", http_host, http_port); let base = format!("http://{}:{}", http_host, http_port);
let linux_url = format!("{}/{}", base, "solstice-runner-linux"); let (single_url, multi_urls) = build_runner_urls(&base);
let illumos_url = format!("{}/{}", base, "solstice-runner-illumos"); // Log concrete OS URLs for local serving
let single_url = format!("{}/{}", base, "solstice-runner"); let mut parts = multi_urls.split_whitespace();
let linux_url = parts.next().unwrap_or("");
let illumos_url = parts.next().unwrap_or("");
info!(linux = %linux_url, illumos = %illumos_url, "serving runner binaries via orchestrator HTTP"); info!(linux = %linux_url, illumos = %illumos_url, "serving runner binaries via orchestrator HTTP");
(single_url, format!("{} {}", linux_url, illumos_url)) (single_url, multi_urls)
} else { } else {
(String::new(), String::new()) (String::new(), String::new())
}; };
@ -195,9 +207,20 @@ async fn main() -> Result<()> {
} }
}); });
// Determine runner URLs to inject into cloud-init // Determine runner URLs to inject into cloud-init (prefer base URL)
let runner_url_env = std::env::var("SOLSTICE_RUNNER_URL").unwrap_or_else(|_| runner_url_default.clone()); let (runner_url_env, runner_urls_env) = if let Some(base) = opts
let runner_urls_env = std::env::var("SOLSTICE_RUNNER_URLS").unwrap_or_else(|_| runner_urls_default.clone()); .runner_base_url
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let (u1, u2) = build_runner_urls(base);
info!(base = %base, url = %u1, urls = %u2, "using public runner base URL");
(u1, u2)
} else {
// Fall back to URLs served by this orchestrator's HTTP (when RUNNER_DIR configured)
(runner_url_default.clone(), runner_urls_default.clone())
};
// Consumer: enqueue and ack-on-accept // Consumer: enqueue and ack-on-accept
let cfg_clone = cfg.clone(); let cfg_clone = cfg.clone();
@ -435,6 +458,20 @@ fn extract_attr_value<'a>(tag: &'a str, key: &'a str) -> Option<&'a str> {
None None
} }
fn build_runner_urls(base: &str) -> (String, String) {
// Normalize base and ensure it ends with /runners
let trimmed = base.trim_end_matches('/');
let base = if trimmed.ends_with("/runners") {
trimmed.to_string()
} else {
format!("{}/runners", trimmed)
};
let single = format!("{}/{}", base, "solstice-runner");
let linux = format!("{}/{}", base, "solstice-runner-linux");
let illumos = format!("{}/{}", base, "solstice-runner-illumos");
(single, format!("{} {}", linux, illumos))
}
fn make_cloud_init_userdata( fn make_cloud_init_userdata(
repo_url: &str, repo_url: &str,
commit_sha: &str, commit_sha: &str,
@ -461,8 +498,8 @@ write_files:
echo "Solstice: bootstrapping workflow runner for {sha}" | tee /dev/console echo "Solstice: bootstrapping workflow runner for {sha}" | tee /dev/console
RUNNER="/usr/local/bin/solstice-runner" RUNNER="/usr/local/bin/solstice-runner"
# Runner URL(s) provided by orchestrator (local dev) if set # Runner URL(s) provided by orchestrator (local dev) if set
export SOLSTICE_RUNNER_URL='{runner_url}' RUNNER_SINGLE='{runner_url}'
export SOLSTICE_RUNNER_URLS='{runner_urls}' RUNNER_URLS='{runner_urls}'
if [ ! -x "$RUNNER" ]; then if [ ! -x "$RUNNER" ]; then
mkdir -p /usr/local/bin mkdir -p /usr/local/bin
# Helper to download from a URL to $RUNNER # Helper to download from a URL to $RUNNER
@ -481,12 +518,12 @@ write_files:
}} }}
OS=$(uname -s 2>/dev/null || echo unknown) OS=$(uname -s 2>/dev/null || echo unknown)
# Prefer single URL if provided # Prefer single URL if provided
if [ -n "$SOLSTICE_RUNNER_URL" ]; then if [ -n "$RUNNER_SINGLE" ]; then
fetch_runner "$SOLSTICE_RUNNER_URL" || true fetch_runner "$RUNNER_SINGLE" || true
fi fi
# If still missing, iterate URLs with a basic OS-based preference # If still missing, iterate URLs with a basic OS-based preference
if [ ! -x "$RUNNER" ] && [ -n "$SOLSTICE_RUNNER_URLS" ]; then if [ ! -x "$RUNNER" ] && [ -n "$RUNNER_URLS" ]; then
for U in $SOLSTICE_RUNNER_URLS; do for U in $RUNNER_URLS; do
case "$OS" in case "$OS" in
Linux) Linux)
echo "$U" | grep -qi linux || continue ;; echo "$U" | grep -qi linux || continue ;;
@ -498,8 +535,8 @@ write_files:
done done
fi fi
# As a final fallback, try all URLs regardless of OS tag # As a final fallback, try all URLs regardless of OS tag
if [ ! -x "$RUNNER" ] && [ -n "$SOLSTICE_RUNNER_URLS" ]; then if [ ! -x "$RUNNER" ] && [ -n "$RUNNER_URLS" ]; then
for U in $SOLSTICE_RUNNER_URLS; do for U in $RUNNER_URLS; do
fetch_runner "$U" && break || true fetch_runner "$U" && break || true
done done
fi fi
@ -549,6 +586,19 @@ mod tests {
assert!(m.get("other").is_none()); assert!(m.get("other").is_none());
} }
#[test]
fn test_build_runner_urls_variants() {
let (s, m) = super::build_runner_urls("https://runner.svc.example");
assert_eq!(s, "https://runner.svc.example/runners/solstice-runner");
assert_eq!(m, "https://runner.svc.example/runners/solstice-runner-linux https://runner.svc.example/runners/solstice-runner-illumos");
let (s2, m2) = super::build_runner_urls("https://runner.svc.example/");
assert_eq!(s2, s);
assert_eq!(m2, m);
let (s3, m3) = super::build_runner_urls("https://runner.svc.example/runners");
assert_eq!(s3, s);
assert_eq!(m3, m);
}
#[test] #[test]
fn test_make_cloud_init_userdata_includes_fields() { fn test_make_cloud_init_userdata_includes_fields() {
let req_id = uuid::Uuid::new_v4(); let req_id = uuid::Uuid::new_v4();

View file

@ -418,6 +418,7 @@ mod tests {
.send(SchedItem { .send(SchedItem {
spec: make_spec("b"), spec: make_spec("b"),
ctx: make_ctx(), ctx: make_ctx(),
original: None,
}) })
.await; .await;
} }

View file

@ -57,6 +57,16 @@ ORCH_WORK_DIR=/var/lib/solstice-ci
# Default points to the workspace target/runners where mise tasks may place built artifacts. # Default points to the workspace target/runners where mise tasks may place built artifacts.
RUNNER_DIR_HOST=../../target/runners RUNNER_DIR_HOST=../../target/runners
# When orchestrator runs behind NAT or in containers, set the public contact address
# that VMs can reach for gRPC log streaming (host:port). This overrides autodetection.
# Example: grpc.${ENV}.${DOMAIN}:443 (when terminated by Traefik) or a public IP:port
ORCH_CONTACT_ADDR=
# Preferred: Provide a public base URL for runner binaries; the orchestrator will construct
# full URLs like ${SOLSTICE_RUNNER_BASE_URL}/runners/solstice-runner(-linux|-illumos)
# Example: https://runner.svc.${DOMAIN}
SOLSTICE_RUNNER_BASE_URL=
# Forge Integration secrets (set per deployment) # Forge Integration secrets (set per deployment)
# Shared secret used to validate Forgejo/Gitea webhooks (X-Gitea-Signature HMAC-SHA256) # Shared secret used to validate Forgejo/Gitea webhooks (X-Gitea-Signature HMAC-SHA256)
WEBHOOK_SECRET= WEBHOOK_SECRET=

View file

@ -195,6 +195,10 @@ services:
# Libvirt configuration for Linux/KVM # Libvirt configuration for Linux/KVM
LIBVIRT_URI: ${LIBVIRT_URI:-qemu:///system} LIBVIRT_URI: ${LIBVIRT_URI:-qemu:///system}
LIBVIRT_NETWORK: ${LIBVIRT_NETWORK:-default} LIBVIRT_NETWORK: ${LIBVIRT_NETWORK:-default}
# Public contact address for runners to stream logs to (host:port); overrides autodetection
ORCH_CONTACT_ADDR: ${ORCH_CONTACT_ADDR}
# Preferred: public base URL for runner binaries
SOLSTICE_RUNNER_BASE_URL: ${SOLSTICE_RUNNER_BASE_URL}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy