feat: initial Go/JS/Rust Origin check-in client SDKs
CI / rust (push) Successful in 3m6s
CI / go (push) Successful in 33s
CI / js (push) Successful in 34s

Standalone clients that resolve a machine unique ID (OS machine ID first,
falling back to a generated UUID persisted to disk), detect hostname/IP/
container/orchestrator, and register+re-ping origin.warky.dev's public
service-checkin endpoint on a daily interval. Ported from icy2's internal
origincheckin package but extended to the full check-in contract and made
dependency-light for external reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 15:25:30 +02:00
co-authored by Claude Sonnet 5
parent 282f76a021
commit 2590e404f5
40 changed files with 4105 additions and 181 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "origin-client"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval."
repository = "https://git.warky.dev/wdevs/origin_client"
[dependencies]
dirs = "5"
gethostname = "0.5"
if-addrs = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "macros", "sync"] }
tokio-util = "0.7"
uuid = { version = "1", features = ["v4"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
[dev-dependencies]
mockito = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
+61
View File
@@ -0,0 +1,61 @@
# origin-client (Rust)
```
cargo add origin-client
```
Async, built on `tokio` + `reqwest` (rustls, no OpenSSL dependency).
## Usage
```rust
use origin_client::{Client, Config};
use tokio_util::sync::CancellationToken;
let client = Client::new(Config {
service_key: "...".into(),
type_: "myservice".into(),
version: "1.2.3".into(),
name: "myservice-nova".into(),
..Default::default()
})?;
let cancel = CancellationToken::new();
tokio::spawn({
let cancel = cancel.clone();
async move { client.run(cancel).await }
}); // immediate check-in, then every 24h until cancel.cancel() is called
```
Call `client.checkin_once().await` directly instead of `run` to send a
single check-in and observe the result/error.
## Config
| Field | Required | Default |
|---|---|---|
| `service_key` | yes | — |
| `type_`, `version`, `name` | yes | — |
| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) |
| `app_type` | no | `"rust"` |
| `hostname` | no | OS hostname |
| `container_id` | no | best-effort Docker/cgroup detection |
| `orchestrator` | no | best-effort Kubernetes/Docker detection |
| `install_id` | no | OS machine ID, else a generated UUID persisted to `install_id_path` |
| `interval_hours` | no | `DEFAULT_INTERVAL_HOURS` (24) |
| `logger` | no | discards warnings (`NoopLogger`) |
`site`, `environment`, `database_type`, `database_name`, `port`,
`base_url`, `internal_url`, `description` are optional metadata with no
default. `Config` implements `Default`, so use struct-update syntax
(`..Default::default()`) as shown above.
`hostname()`, `outbound_ip()`, `container_id()`, `orchestrator()`, and
`resolve_machine_id()` are exported standalone for callers that want the
detection logic without the HTTP client.
## Platform notes
macOS/Windows machine-ID detection is compiled but not exercised by CI
(this repo's CI runs on Linux). `cargo build --target ...` for those
targets is a reasonable spot-check before release.
+284
View File
@@ -0,0 +1,284 @@
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::host_info::{container_id, hostname, orchestrator};
use crate::logger::{Logger, NoopLogger};
use crate::machine_id::resolve_machine_id;
use crate::payload::{CheckinResponse, Payload};
/// Origin's public service-checkin endpoint.
pub const DEFAULT_URL: &str = "https://origin.warky.dev/api/public/service-checkin";
/// How often `Client::run` repeats when `Config::interval_hours` is unset.
pub const DEFAULT_INTERVAL_HOURS: u32 = 24;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Config configures a Client. `service_key`, `type_`, `version`, and
/// `name` identify the caller to Origin and are required; everything else
/// is optional metadata, or overrides for values Client would otherwise
/// auto-detect.
#[derive(Clone)]
pub struct Config {
/// Check-in endpoint. Defaults to [`DEFAULT_URL`] when `None`.
pub url: Option<String>,
/// Sent as the `X-Origin-Service-Key` header. Required.
pub service_key: String,
/// Origin service type, which must already be registered in Origin
/// (e.g. `"icy2"`). Required.
pub type_: String,
/// Caller's own version string. Required.
pub version: String,
/// Identifies this deployment/instance to Origin, e.g. `"icy2-nova"`. Required.
pub name: String,
pub site: Option<String>,
pub environment: Option<String>,
pub database_type: Option<String>,
pub database_name: Option<String>,
/// Defaults to `"rust"` when `None`.
pub app_type: Option<String>,
/// Defaults to the OS hostname when `None`.
pub hostname: Option<String>,
pub port: Option<u16>,
pub base_url: Option<String>,
pub internal_url: Option<String>,
/// Defaults to best-effort Docker/cgroup detection when `None`.
pub container_id: Option<String>,
/// Defaults to best-effort Kubernetes/Docker detection when `None`.
pub orchestrator: Option<String>,
pub description: Option<String>,
/// Overrides machine-ID resolution entirely when set.
pub install_id: Option<String>,
/// Where a generated fallback install ID is persisted when no real OS
/// machine ID is available.
pub install_id_path: Option<PathBuf>,
/// How often `run` repeats. Defaults to [`DEFAULT_INTERVAL_HOURS`] when `None`.
pub interval_hours: Option<u32>,
/// Receives non-fatal warnings. Defaults to discarding them.
pub logger: Arc<dyn Logger>,
}
impl Default for Config {
fn default() -> Self {
Self {
url: None,
service_key: String::new(),
type_: String::new(),
version: String::new(),
name: String::new(),
site: None,
environment: None,
database_type: None,
database_name: None,
app_type: None,
hostname: None,
port: None,
base_url: None,
internal_url: None,
container_id: None,
orchestrator: None,
description: None,
install_id: None,
install_id_path: None,
interval_hours: None,
logger: Arc::new(NoopLogger),
}
}
}
/// Error returned by [`Client::new`] and [`Client::checkin_once`].
#[derive(Debug)]
pub enum Error {
MissingRequiredConfig,
BuildClient(reqwest::Error),
Request(reqwest::Error),
Status(reqwest::StatusCode),
Decode(reqwest::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::MissingRequiredConfig => {
write!(
f,
"origin-client: service_key, type, version, and name are required"
)
}
Error::BuildClient(e) => write!(f, "origin-client: build http client: {e}"),
Error::Request(e) => write!(f, "origin-client: request failed: {e}"),
Error::Status(code) => write!(f, "origin-client: non-2xx response: {code}"),
Error::Decode(e) => write!(f, "origin-client: decode response: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::BuildClient(e) | Error::Request(e) | Error::Decode(e) => Some(e),
Error::MissingRequiredConfig | Error::Status(_) => None,
}
}
}
/// Client checks a service in with Origin.
pub struct Client {
cfg: Config,
http: reqwest::Client,
install_id: String,
}
impl Client {
/// Validates `cfg`, resolves the machine/install ID, and returns a
/// ready Client. Resolution happens once, at construction, so every
/// check-in made by this Client reports the same `unique_install_id`.
pub fn new(cfg: Config) -> Result<Self, Error> {
if cfg.service_key.is_empty()
|| cfg.type_.is_empty()
|| cfg.version.is_empty()
|| cfg.name.is_empty()
{
return Err(Error::MissingRequiredConfig);
}
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(Error::BuildClient)?;
let install_id = resolve_machine_id(
cfg.install_id.as_deref(),
cfg.install_id_path.as_deref(),
cfg.logger.as_ref(),
);
Ok(Self {
cfg,
http,
install_id,
})
}
fn url(&self) -> &str {
self.cfg.url.as_deref().unwrap_or(DEFAULT_URL)
}
fn app_type(&self) -> String {
self.cfg
.app_type
.clone()
.unwrap_or_else(|| "rust".to_string())
}
fn interval_hours(&self) -> u32 {
self.cfg
.interval_hours
.filter(|&h| h > 0)
.unwrap_or(DEFAULT_INTERVAL_HOURS)
}
fn build_payload(&self) -> Payload {
let hostname_value = self.cfg.hostname.clone().unwrap_or_else(hostname);
let container_id_value = self.cfg.container_id.clone().unwrap_or_else(container_id);
let orchestrator_value = self.cfg.orchestrator.clone().unwrap_or_else(orchestrator);
Payload {
type_: self.cfg.type_.clone(),
version: self.cfg.version.clone(),
name: self.cfg.name.clone(),
unique_install_id: self.install_id.clone(),
site: self.cfg.site.clone(),
environment: self.cfg.environment.clone(),
database_type: self.cfg.database_type.clone(),
database_name: self.cfg.database_name.clone(),
app_type: Some(self.app_type()),
hostname: non_empty(hostname_value),
port: self.cfg.port,
base_url: self.cfg.base_url.clone(),
internal_url: self.cfg.internal_url.clone(),
container_id: non_empty(container_id_value),
orchestrator: non_empty(orchestrator_value),
description: self.cfg.description.clone(),
}
}
/// Sends a single check-in request and returns Origin's response.
/// Failures are logged via `Config::logger` and also returned, so
/// callers that only want the fire-and-forget behavior of `run` can
/// ignore the returned error.
pub async fn checkin_once(&self) -> Result<CheckinResponse, Error> {
let payload = self.build_payload();
let response = match self
.http
.post(self.url())
.header("X-Origin-Service-Key", &self.cfg.service_key)
.json(&payload)
.send()
.await
{
Ok(r) => r,
Err(err) => {
self.cfg
.logger
.warn("origin checkin: request failed", Some(&err));
return Err(Error::Request(err));
}
};
if !response.status().is_success() {
let status = response.status();
self.cfg
.logger
.warn("origin checkin: non-2xx response", None);
return Err(Error::Status(status));
}
match response.json::<CheckinResponse>().await {
Ok(body) => Ok(body),
Err(err) => {
self.cfg
.logger
.warn("origin checkin: decode response failed", Some(&err));
Err(Error::Decode(err))
}
}
}
/// Performs an immediate check-in, then repeats every
/// `Config::interval_hours` until `cancel` is cancelled. Intended to be
/// spawned: `tokio::spawn(async move { client.run(cancel).await })`.
/// Check-in errors are logged via `Config::logger` and otherwise
/// ignored; call [`Client::checkin_once`] directly to observe them.
pub async fn run(&self, cancel: CancellationToken) {
let _ = self.checkin_once().await;
let interval = Duration::from_secs(u64::from(self.interval_hours()) * 3600);
loop {
tokio::select! {
() = tokio::time::sleep(interval) => {
let _ = self.checkin_once().await;
}
() = cancel.cancelled() => {
return;
}
}
}
}
}
fn non_empty(s: String) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s)
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::env;
use std::fs;
use std::net::IpAddr;
use std::path::Path;
/// Returns the OS hostname, or "" if it cannot be determined.
pub fn hostname() -> String {
gethostname::gethostname().to_string_lossy().into_owned()
}
/// Returns the first non-loopback IPv4 address bound to this host. It is a
/// LAN-facing address, not the host's public internet address, and is not
/// part of Origin's wire contract - it exists so callers can populate
/// `base_url`/`internal_url`/`description` themselves.
pub fn outbound_ip() -> String {
if_addrs::get_if_addrs()
.unwrap_or_default()
.into_iter()
.filter(|iface| !iface.is_loopback())
.find_map(|iface| match iface.ip() {
IpAddr::V4(v4) => Some(v4.to_string()),
IpAddr::V6(_) => None,
})
.unwrap_or_default()
}
/// Best-effort detects the Docker/Kubernetes container ID this process
/// runs in. Returns "" outside a container or when detection fails; an
/// empty container ID is treated as "not containerized" rather than an
/// error.
pub fn container_id() -> String {
if let Ok(data) = fs::read_to_string("/proc/self/cgroup") {
let id = parse_cgroup_container_id(&data);
if !id.is_empty() {
return id;
}
}
if Path::new("/.dockerenv").exists() {
let h = hostname();
if is_container_id_like(&h) {
return h;
}
}
String::new()
}
/// Best-effort detects the orchestration environment: "kubernetes" under
/// Kubernetes, "docker" in a plain Docker container, "" otherwise.
pub fn orchestrator() -> String {
if env::var("KUBERNETES_SERVICE_HOST").is_ok_and(|v| !v.is_empty()) {
return "kubernetes".to_string();
}
if Path::new("/.dockerenv").exists() {
return "docker".to_string();
}
String::new()
}
/// Extracts a container ID from /proc/self/cgroup content, handling both
/// cgroup v1 (".../docker/<id>") and cgroup v2 systemd-scope
/// (".../docker-<id>.scope") layouts.
pub fn parse_cgroup_container_id(data: &str) -> String {
for raw_line in data.lines() {
let line = raw_line.trim();
let idx = match line.rfind('/') {
Some(i) => i,
None => continue,
};
let mut segment = &line[idx + 1..];
segment = segment.strip_suffix(".scope").unwrap_or(segment);
segment = segment.strip_prefix("docker-").unwrap_or(segment);
if is_container_id_like(segment) {
return segment.to_string();
}
}
String::new()
}
pub fn is_container_id_like(s: &str) -> bool {
if s.len() < 12 || s.len() > 64 {
return false;
}
s.chars()
.all(|c| c.is_ascii_digit() || (c.is_ascii_lowercase() && c.is_ascii_hexdigit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_cgroup_v1_docker_path() {
let data =
"12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n";
assert_eq!(
parse_cgroup_container_id(data),
"9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"
);
}
#[test]
fn parses_cgroup_v2_systemd_scope() {
let data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n";
assert_eq!(
parse_cgroup_container_id(data),
"9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"
);
}
#[test]
fn returns_empty_on_non_container_host() {
assert_eq!(
parse_cgroup_container_id("0::/user.slice/user-1000.slice/session-2.scope\n"),
""
);
}
#[test]
fn returns_empty_for_empty_input() {
assert_eq!(parse_cgroup_container_id(""), "");
}
#[test]
fn is_container_id_like_accepts_valid_hex_id() {
assert!(is_container_id_like("9f8b7c6d5e4f"));
}
#[test]
fn is_container_id_like_rejects_short_strings() {
assert!(!is_container_id_like("short"));
}
#[test]
fn is_container_id_like_rejects_non_hex_strings() {
assert!(!is_container_id_like("session-2"));
}
#[test]
fn is_container_id_like_rejects_empty_strings() {
assert!(!is_container_id_like(""));
}
#[test]
fn hostname_is_not_empty() {
assert!(!hostname().is_empty());
}
#[test]
fn orchestrator_detects_kubernetes() {
std::env::set_var("KUBERNETES_SERVICE_HOST", "10.0.0.1");
assert_eq!(orchestrator(), "kubernetes");
std::env::remove_var("KUBERNETES_SERVICE_HOST");
}
}
+18
View File
@@ -0,0 +1,18 @@
//! origin-client registers a running service with Origin
//! (origin.warky.dev)'s public check-in endpoint and re-pings it on an
//! interval, so Origin can track which instances of a service are alive,
//! their version, and where they run. A check-in failure is logged and
//! otherwise ignored: it must never affect the host application's own
//! availability.
mod client;
mod host_info;
mod logger;
mod machine_id;
mod payload;
pub use client::{Client, Config, Error, DEFAULT_INTERVAL_HOURS, DEFAULT_URL};
pub use host_info::{container_id, hostname, orchestrator, outbound_ip};
pub use logger::{FnLogger, Logger, NoopLogger};
pub use machine_id::{default_install_id_path, detect_os_machine_id, resolve_machine_id};
pub use payload::{CheckinResponse, Payload};
+30
View File
@@ -0,0 +1,30 @@
use std::error::Error as StdError;
/// Logger receives non-fatal warnings from the client (request failures,
/// machine-id/persistence fallbacks). Implement it to route warnings into
/// your own logging framework.
pub trait Logger: Send + Sync {
fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>);
}
/// NoopLogger discards every warning. The default when no logger is configured.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopLogger;
impl Logger for NoopLogger {
fn warn(&self, _msg: &str, _err: Option<&(dyn StdError + Send + Sync)>) {}
}
/// FnLogger adapts a closure to Logger.
pub struct FnLogger<F>(pub F)
where
F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync;
impl<F> Logger for FnLogger<F>
where
F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync,
{
fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>) {
(self.0)(msg, err)
}
}
+201
View File
@@ -0,0 +1,201 @@
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::logger::Logger;
/// Reads the real OS machine ID, or `None` if unavailable/unsupported.
#[cfg(target_os = "linux")]
pub fn detect_os_machine_id() -> Option<String> {
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(data) = fs::read_to_string(path) {
let id = data.trim();
if !id.is_empty() {
return Some(id.to_string());
}
}
}
None
}
/// Reads the hardware IOPlatformUUID via `ioreg`, which is stable across
/// reboots and unique per physical/virtual Mac.
#[cfg(target_os = "macos")]
pub fn detect_os_machine_id() -> Option<String> {
use std::process::Command;
let output = Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let marker = "\"IOPlatformUUID\" = \"";
let start = text.find(marker)? + marker.len();
let rest = &text[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
/// Reads `MachineGuid` from the registry, which is generated at Windows
/// install time and stable across reboots.
#[cfg(target_os = "windows")]
pub fn detect_os_machine_id() -> Option<String> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm.open_subkey("SOFTWARE\\Microsoft\\Cryptography").ok()?;
key.get_value("MachineGuid").ok()
}
/// No known OS-specific implementation on this platform; `resolve_machine_id`
/// falls back to a generated, persisted UUID.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub fn detect_os_machine_id() -> Option<String> {
None
}
/// Returns the fallback-UUID persistence path used when no explicit path
/// is given: the OS user-config directory, or the OS temp directory if
/// that cannot be determined.
pub fn default_install_id_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(dirs::config_dir)
.unwrap_or_else(std::env::temp_dir);
base.join("origin-client").join("install-id")
}
/// Returns the unique install ID to report to Origin, in priority order:
///
/// 1. `explicit`, if non-empty - a caller-supplied override.
/// 2. the real OS machine ID (`/etc/machine-id` on Linux, `IOPlatformUUID`
/// on macOS, `MachineGuid` on Windows), if readable.
/// 3. the ID already persisted at `path`, if present.
/// 4. a newly generated UUID, persisted to `path` for future runs.
///
/// OS-ID and persistence failures are logged as warnings, not returned as
/// errors: this always returns a usable ID.
pub fn resolve_machine_id(
explicit: Option<&str>,
path: Option<&Path>,
logger: &dyn Logger,
) -> String {
resolve_machine_id_with(detect_os_machine_id, explicit, path, logger)
}
pub(crate) fn resolve_machine_id_with(
detect: impl Fn() -> Option<String>,
explicit: Option<&str>,
path: Option<&Path>,
logger: &dyn Logger,
) -> String {
if let Some(id) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
return id.to_string();
}
match detect() {
Some(id) if !id.trim().is_empty() => return id.trim().to_string(),
_ => logger.warn(
"machine id: OS machine id unavailable, falling back to a generated id",
None,
),
}
let resolved_path: PathBuf = path
.map(Path::to_path_buf)
.unwrap_or_else(default_install_id_path);
if let Ok(data) = fs::read_to_string(&resolved_path) {
let existing = data.trim();
if !existing.is_empty() {
return existing.to_string();
}
}
let id = Uuid::new_v4().to_string();
if let Err(err) = persist_install_id(&resolved_path, &id) {
logger.warn(
"machine id: failed to persist generated id, using an ephemeral id for this run",
Some(&err),
);
}
id
}
fn persist_install_id(path: &Path, id: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
fs::create_dir_all(dir)?;
}
}
fs::write(path, format!("{id}\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logger::FnLogger;
use std::sync::atomic::{AtomicBool, Ordering};
fn temp_install_id_path() -> PathBuf {
let dir = std::env::temp_dir().join(format!("origin-client-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("install-id")
}
#[test]
fn explicit_override_wins() {
let got = resolve_machine_id_with(
|| Some("os-machine-id".to_string()),
Some("explicit-id"),
Some(&temp_install_id_path()),
&crate::logger::NoopLogger,
);
assert_eq!(got, "explicit-id");
}
#[test]
fn uses_os_machine_id_when_available() {
let got = resolve_machine_id_with(
|| Some("os-machine-id".to_string()),
None,
Some(&temp_install_id_path()),
&crate::logger::NoopLogger,
);
assert_eq!(got, "os-machine-id");
}
#[test]
fn falls_back_and_persists_when_os_id_unavailable() {
let path = temp_install_id_path();
let first = resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger);
assert!(!first.is_empty());
let second =
resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger);
assert_eq!(
first, second,
"second call should read the persisted id rather than generating a new one"
);
}
#[test]
fn logs_warning_when_os_id_unavailable() {
let warned = AtomicBool::new(false);
let logger = FnLogger(
|_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| {
warned.store(true, Ordering::SeqCst);
},
);
resolve_machine_id_with(|| None, None, Some(&temp_install_id_path()), &logger);
assert!(warned.load(Ordering::SeqCst));
}
}
+50
View File
@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
/// Payload is the JSON body posted to Origin's public service-checkin
/// endpoint. Field names and requiredness mirror
/// `POST /api/public/service-checkin` as documented in
/// `origin/doc/service-checkin.md`: `type`, `version`, `name`, and
/// `unique_install_id` are required; everything else is optional and, when
/// omitted, does not overwrite existing instance data on Origin's side.
#[derive(Debug, Clone, Default, Serialize)]
pub struct Payload {
#[serde(rename = "type")]
pub type_: String,
pub version: String,
pub name: String,
pub unique_install_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub internal_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub orchestrator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// CheckinResponse is returned by Origin after a successful check-in.
#[derive(Debug, Clone, Deserialize)]
pub struct CheckinResponse {
pub success: bool,
pub service_instance_id: i64,
pub unique_install_id: String,
pub status: String,
pub pinged_at: String,
}
+124
View File
@@ -0,0 +1,124 @@
use std::sync::Arc;
use std::time::Duration;
use origin_client::{Client, Config, FnLogger};
use tokio_util::sync::CancellationToken;
fn test_config(url: String) -> Config {
Config {
url: Some(url),
service_key: "test-key".to_string(),
type_: "icy2".to_string(),
version: "v1.0.0".to_string(),
name: "icy2-test".to_string(),
site: Some("Test Site".to_string()),
environment: Some("test".to_string()),
install_id: Some("test-install-id".to_string()),
..Default::default()
}
}
#[tokio::test]
async fn new_requires_fields() {
assert!(Client::new(Config::default()).is_err());
}
#[tokio::test]
async fn checkin_once_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/")
.match_header("x-origin-service-key", "test-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"success":true,"service_instance_id":42,"unique_install_id":"test-install-id","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#,
)
.create_async()
.await;
let client = Client::new(test_config(server.url())).unwrap();
let resp = client.checkin_once().await.unwrap();
mock.assert_async().await;
assert!(resp.success);
assert_eq!(resp.service_instance_id, 42);
assert_eq!(resp.unique_install_id, "test-install-id");
}
#[tokio::test]
async fn checkin_once_server_error_returns_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/")
.with_status(500)
.create_async()
.await;
let client = Client::new(test_config(server.url())).unwrap();
assert!(client.checkin_once().await.is_err());
}
#[tokio::test]
async fn checkin_once_unreachable_does_not_panic() {
let client = Client::new(test_config("http://127.0.0.1:1".to_string())).unwrap();
let result = client.checkin_once().await;
assert!(result.is_err());
}
#[tokio::test]
async fn checkin_once_logs_on_failure() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/")
.with_status(500)
.create_async()
.await;
let warned = Arc::new(std::sync::atomic::AtomicBool::new(false));
let warned_clone = warned.clone();
let mut cfg = test_config(server.url());
cfg.logger = Arc::new(FnLogger(
move |_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| {
warned_clone.store(true, std::sync::atomic::Ordering::SeqCst);
},
));
let client = Client::new(cfg).unwrap();
let _ = client.checkin_once().await;
assert!(warned.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn run_checks_in_immediately_then_stops_on_cancel() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"success":true,"service_instance_id":1,"unique_install_id":"x","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#,
)
.expect(1)
.create_async()
.await;
let client = Client::new(test_config(server.url())).unwrap();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
client.run(cancel_clone).await;
});
// The immediate check-in happens synchronously at the start of run();
// give the spawned task a moment to execute it, then cancel before the
// (24h) interval could ever fire again.
tokio::time::sleep(Duration::from_millis(50)).await;
cancel.cancel();
handle.await.unwrap();
mock.assert_async().await;
}