Add Galaxy repository API and clients
This commit is contained in:
@@ -5,13 +5,14 @@ use std::time::Duration;
|
||||
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use futures_util::StreamExt;
|
||||
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, OpenSessionRequest,
|
||||
PingCommand, StreamEventsRequest,
|
||||
};
|
||||
use mxgateway_client::{
|
||||
ApiKey, ClientOptions, Error, GatewayClient, MxValue, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION,
|
||||
WORKER_PROTOCOL_VERSION,
|
||||
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, CLIENT_VERSION,
|
||||
GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
@@ -178,6 +179,51 @@ enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
#[command(subcommand)]
|
||||
Galaxy(GalaxyCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum GalaxyCommand {
|
||||
TestConnection {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
LastDeployTime {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
DiscoverHierarchy {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Subscribe to the WatchDeployEvents server stream.
|
||||
///
|
||||
/// Prints one line per received event (or one JSON object with `--json`).
|
||||
/// Runs until the stream ends, the server fails the call, or the
|
||||
/// process is interrupted (Ctrl+C).
|
||||
#[command(alias = "watch-deploy-events")]
|
||||
Watch {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
/// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
|
||||
/// supplied, the server suppresses the bootstrap event if its
|
||||
/// cached deploy time matches this value.
|
||||
#[arg(long)]
|
||||
last_seen_deploy_time: Option<String>,
|
||||
/// Optional cap on the number of events to print before exiting.
|
||||
/// 0 (the default) means run until cancelled or the stream ends.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
max_events: usize,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
@@ -465,6 +511,7 @@ async fn run(cli: Cli) -> Result<(), Error> {
|
||||
.await?;
|
||||
print_ok("write2", json);
|
||||
}
|
||||
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
|
||||
Command::Smoke {
|
||||
connection,
|
||||
item,
|
||||
@@ -514,6 +561,133 @@ async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
|
||||
GatewayClient::connect(connection.options()).await
|
||||
}
|
||||
|
||||
async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
|
||||
GalaxyClient::connect(connection.options()).await
|
||||
}
|
||||
|
||||
async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
|
||||
match command {
|
||||
GalaxyCommand::TestConnection { connection, json } => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let ok = client.test_connection().await?;
|
||||
if json {
|
||||
println!("{}", json!({ "ok": ok }));
|
||||
} else if ok {
|
||||
println!("ok");
|
||||
} else {
|
||||
println!("not ok");
|
||||
}
|
||||
}
|
||||
GalaxyCommand::LastDeployTime { connection, json } => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let timestamp = client.get_last_deploy_time().await?;
|
||||
match (json, timestamp) {
|
||||
(true, Some(ts)) => {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"present": true,
|
||||
"seconds": ts.seconds,
|
||||
"nanos": ts.nanos,
|
||||
})
|
||||
);
|
||||
}
|
||||
(true, None) => {
|
||||
println!("{}", json!({ "present": false }));
|
||||
}
|
||||
(false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
|
||||
(false, None) => println!("(absent)"),
|
||||
}
|
||||
}
|
||||
GalaxyCommand::Watch {
|
||||
connection,
|
||||
last_seen_deploy_time,
|
||||
max_events,
|
||||
json,
|
||||
} => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let last_seen = last_seen_deploy_time
|
||||
.as_deref()
|
||||
.map(parse_rfc3339_timestamp)
|
||||
.transpose()?;
|
||||
let mut stream = client.watch_deploy_events(last_seen).await?;
|
||||
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|err| Error::InvalidArgument {
|
||||
name: "ctrl_c".to_owned(),
|
||||
detail: err.to_string(),
|
||||
})?;
|
||||
// Drop the stream below by breaking; tonic tears the
|
||||
// gRPC call down cooperatively.
|
||||
break;
|
||||
}
|
||||
next = stream.next() => {
|
||||
let Some(event) = next else { break; };
|
||||
let event = event?;
|
||||
count += 1;
|
||||
print_deploy_event(&event, json);
|
||||
if max_events != 0 && count >= max_events {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GalaxyCommand::DiscoverHierarchy { connection, json } => {
|
||||
let mut client = connect_galaxy(connection).await?;
|
||||
let objects = client.discover_hierarchy().await?;
|
||||
if json {
|
||||
let payload: Vec<_> = objects
|
||||
.iter()
|
||||
.map(|object| {
|
||||
json!({
|
||||
"gobjectId": object.gobject_id,
|
||||
"tagName": object.tag_name,
|
||||
"containedName": object.contained_name,
|
||||
"browseName": object.browse_name,
|
||||
"parentGobjectId": object.parent_gobject_id,
|
||||
"isArea": object.is_area,
|
||||
"categoryId": object.category_id,
|
||||
"hostedByGobjectId": object.hosted_by_gobject_id,
|
||||
"templateChain": object.template_chain,
|
||||
"attributes": object.attributes.iter().map(|attribute| json!({
|
||||
"attributeName": attribute.attribute_name,
|
||||
"fullTagReference": attribute.full_tag_reference,
|
||||
"mxDataType": attribute.mx_data_type,
|
||||
"dataTypeName": attribute.data_type_name,
|
||||
"isArray": attribute.is_array,
|
||||
"arrayDimension": attribute.array_dimension,
|
||||
"arrayDimensionPresent": attribute.array_dimension_present,
|
||||
"mxAttributeCategory": attribute.mx_attribute_category,
|
||||
"securityClassification": attribute.security_classification,
|
||||
"isHistorized": attribute.is_historized,
|
||||
"isAlarm": attribute.is_alarm,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", json!({ "objects": payload }));
|
||||
} else {
|
||||
println!("{}", objects.len());
|
||||
for object in &objects {
|
||||
println!(
|
||||
"{} {} {} ({} attribute(s))",
|
||||
object.gobject_id,
|
||||
object.tag_name,
|
||||
object.browse_name,
|
||||
object.attributes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn session_for(
|
||||
connection: ConnectionArgs,
|
||||
session_id: String,
|
||||
@@ -616,6 +790,208 @@ fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error>
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn print_deploy_event(event: &DeployEvent, use_json: bool) {
|
||||
if use_json {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"sequence": event.sequence,
|
||||
"observedAt": event.observed_at.as_ref().map(|ts| json!({
|
||||
"seconds": ts.seconds,
|
||||
"nanos": ts.nanos,
|
||||
})),
|
||||
"timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
|
||||
"seconds": ts.seconds,
|
||||
"nanos": ts.nanos,
|
||||
})),
|
||||
"timeOfLastDeployPresent": event.time_of_last_deploy_present,
|
||||
"objectCount": event.object_count,
|
||||
"attributeCount": event.attribute_count,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
let observed = event
|
||||
.observed_at
|
||||
.as_ref()
|
||||
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
|
||||
.unwrap_or_else(|| "(absent)".to_owned());
|
||||
let last_deploy = if event.time_of_last_deploy_present {
|
||||
event
|
||||
.time_of_last_deploy
|
||||
.as_ref()
|
||||
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
|
||||
.unwrap_or_else(|| "(absent)".to_owned())
|
||||
} else {
|
||||
"(absent)".to_owned()
|
||||
};
|
||||
println!(
|
||||
"seq={} observed={} lastDeploy={} objects={} attributes={}",
|
||||
event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a small but practically-complete subset of RFC3339:
|
||||
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
|
||||
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
|
||||
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
|
||||
fn invalid(detail: impl Into<String>) -> Error {
|
||||
Error::InvalidArgument {
|
||||
name: "last-seen-deploy-time".to_owned(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = input.as_bytes();
|
||||
if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
|
||||
return Err(invalid(format!(
|
||||
"expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
|
||||
std::str::from_utf8(&bytes[start..start + len])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<u32>().ok())
|
||||
.ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
|
||||
};
|
||||
|
||||
let year = read_u32(0, 4)? as i32;
|
||||
if bytes[4] != b'-' {
|
||||
return Err(invalid("expected '-' after year"));
|
||||
}
|
||||
let month = read_u32(5, 2)?;
|
||||
if bytes[7] != b'-' {
|
||||
return Err(invalid("expected '-' after month"));
|
||||
}
|
||||
let day = read_u32(8, 2)?;
|
||||
let hour = read_u32(11, 2)?;
|
||||
if bytes[13] != b':' {
|
||||
return Err(invalid("expected ':' after hour"));
|
||||
}
|
||||
let minute = read_u32(14, 2)?;
|
||||
if bytes[16] != b':' {
|
||||
return Err(invalid("expected ':' after minute"));
|
||||
}
|
||||
let second = read_u32(17, 2)?;
|
||||
|
||||
let mut cursor = 19usize;
|
||||
let mut nanos: u32 = 0;
|
||||
if cursor < bytes.len() && bytes[cursor] == b'.' {
|
||||
cursor += 1;
|
||||
let frac_start = cursor;
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
|
||||
cursor += 1;
|
||||
}
|
||||
let frac_len = cursor - frac_start;
|
||||
if frac_len == 0 {
|
||||
return Err(invalid("expected fractional digits after '.'"));
|
||||
}
|
||||
let take = frac_len.min(9);
|
||||
let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<u32>().ok())
|
||||
.ok_or_else(|| invalid("invalid fractional digits"))?;
|
||||
nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
|
||||
}
|
||||
|
||||
let mut offset_seconds: i64 = 0;
|
||||
if cursor >= bytes.len() {
|
||||
return Err(invalid("missing timezone designator (Z or +HH:MM)"));
|
||||
}
|
||||
match bytes[cursor] {
|
||||
b'Z' | b'z' => cursor += 1,
|
||||
sign @ (b'+' | b'-') => {
|
||||
cursor += 1;
|
||||
if cursor + 5 > bytes.len() {
|
||||
return Err(invalid("offset must be ±HH:MM"));
|
||||
}
|
||||
let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<i64>().ok())
|
||||
.ok_or_else(|| invalid("invalid offset hour"))?;
|
||||
if bytes[cursor + 2] != b':' {
|
||||
return Err(invalid("offset must contain ':' between HH and MM"));
|
||||
}
|
||||
let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
|
||||
.ok()
|
||||
.and_then(|slice| slice.parse::<i64>().ok())
|
||||
.ok_or_else(|| invalid("invalid offset minute"))?;
|
||||
cursor += 5;
|
||||
let signed = if sign == b'-' { -1 } else { 1 };
|
||||
offset_seconds = signed * (oh * 3600 + om * 60);
|
||||
}
|
||||
other => {
|
||||
return Err(invalid(format!(
|
||||
"unexpected timezone designator byte {other:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if cursor != bytes.len() {
|
||||
return Err(invalid("trailing characters after timezone"));
|
||||
}
|
||||
|
||||
let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
|
||||
let seconds = unix - offset_seconds;
|
||||
|
||||
Ok(prost_types::Timestamp {
|
||||
seconds,
|
||||
nanos: nanos as i32,
|
||||
})
|
||||
}
|
||||
|
||||
fn ymdhms_to_unix(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
second: u32,
|
||||
) -> Result<i64, Error> {
|
||||
if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "last-seen-deploy-time".to_owned(),
|
||||
detail: "calendar component out of range".to_owned(),
|
||||
});
|
||||
}
|
||||
fn is_leap(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let mut max = DAYS[(month - 1) as usize];
|
||||
if month == 2 && is_leap(year) {
|
||||
max = 29;
|
||||
}
|
||||
if day > max {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "last-seen-deploy-time".to_owned(),
|
||||
detail: format!("day {day} out of range for month {month}/{year}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Days from 1970-01-01 to year-month-day.
|
||||
let mut total_days: i64 = 0;
|
||||
if year >= 1970 {
|
||||
for y in 1970..year {
|
||||
total_days += if is_leap(y) { 366 } else { 365 };
|
||||
}
|
||||
} else {
|
||||
for y in year..1970 {
|
||||
total_days -= if is_leap(y) { 366 } else { 365 };
|
||||
}
|
||||
}
|
||||
for m in 1..month {
|
||||
let mut dim = DAYS[(m - 1) as usize];
|
||||
if m == 2 && is_leap(year) {
|
||||
dim = 29;
|
||||
}
|
||||
total_days += dim as i64;
|
||||
}
|
||||
total_days += (day - 1) as i64;
|
||||
|
||||
Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
|
||||
}
|
||||
|
||||
fn parse_cli_value<T>(value: &str) -> Result<T, Error>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
@@ -665,4 +1041,38 @@ mod tests {
|
||||
assert_eq!(value["gatewayProtocolVersion"], 1);
|
||||
assert_eq!(value["workerProtocolVersion"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"galaxy",
|
||||
"watch",
|
||||
"--last-seen-deploy-time",
|
||||
"2026-04-28T15:30:00Z",
|
||||
"--max-events",
|
||||
"5",
|
||||
"--json",
|
||||
]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_galaxy_watch_deploy_events_alias() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
|
||||
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
|
||||
let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
|
||||
let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
|
||||
let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();
|
||||
|
||||
assert_eq!(utc.seconds, plus.seconds);
|
||||
assert_eq!(utc.nanos, 0);
|
||||
assert_eq!(frac.seconds, utc.seconds);
|
||||
assert_eq!(frac.nanos, 250_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user