Private
Public Access
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
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:
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user