diff --git a/lib/towerops/agent/validator.ex b/lib/towerops/agent/validator.ex index 80c602c0..2c7fba91 100644 --- a/lib/towerops/agent/validator.ex +++ b/lib/towerops/agent/validator.ex @@ -191,15 +191,28 @@ defmodule Towerops.Agent.Validator do {:error, {:decode_error, "Invalid protobuf message format"}} end - # Validate semantic version format (empty string allowed for backward compat) + # Validate version format - accepts either semantic version or RFC 3339 timestamp + # Empty string allowed for backward compat defp validate_version(""), do: :ok defp validate_version(version) when is_binary(version) do - if String.match?(version, ~r/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/) and - String.length(version) <= @max_short_string_length do - :ok - else - {:error, {:invalid_version, "Version must be semver format (e.g., 1.2.3)"}} + cond do + # Semantic version format (backward compatibility with old agents) + # Pattern: X.Y.Z or X.Y.Z-suffix + String.match?(version, ~r/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/) -> + if String.length(version) <= @max_short_string_length, + do: :ok, + else: {:error, {:invalid_version, "Version too long"}} + + # RFC 3339 timestamp format (new agents with compile timestamp) + # Pattern: YYYY-MM-DDTHH:MM:SSZ + String.match?(version, ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) -> + if String.length(version) <= @max_short_string_length, + do: :ok, + else: {:error, {:invalid_version, "Version too long"}} + + true -> + {:error, {:invalid_version, "Version must be semver format (e.g., 1.2.3) or RFC 3339 timestamp"}} end end diff --git a/test/towerops/agent/validator_test.exs b/test/towerops/agent/validator_test.exs index f5229ff4..e45f9a89 100644 --- a/test/towerops/agent/validator_test.exs +++ b/test/towerops/agent/validator_test.exs @@ -535,6 +535,55 @@ defmodule Towerops.Agent.ValidatorTest do assert {:ok, _} = Validator.validate_heartbeat(binary) end + test "accepts RFC 3339 timestamp version format" do + heartbeat = %AgentHeartbeat{ + version: "2025-02-09T15:30:45Z", + hostname: "agent01", + uptime_seconds: 100, + ip_address: "" + } + + binary = AgentHeartbeat.encode(heartbeat) + assert {:ok, validated} = Validator.validate_heartbeat(binary) + assert validated.version == "2025-02-09T15:30:45Z" + end + + test "accepts RFC 3339 timestamp with different date/time" do + heartbeat = %AgentHeartbeat{ + version: "2026-12-31T23:59:59Z", + hostname: "agent02", + uptime_seconds: 200, + ip_address: "" + } + + binary = AgentHeartbeat.encode(heartbeat) + assert {:ok, _} = Validator.validate_heartbeat(binary) + end + + test "rejects RFC 3339 timestamp without Z suffix" do + heartbeat = %AgentHeartbeat{ + version: "2025-02-09T15:30:45", + hostname: "agent01", + uptime_seconds: 100, + ip_address: "" + } + + binary = AgentHeartbeat.encode(heartbeat) + assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary) + end + + test "rejects RFC 3339 timestamp with offset instead of Z" do + heartbeat = %AgentHeartbeat{ + version: "2025-02-09T15:30:45+00:00", + hostname: "agent01", + uptime_seconds: 100, + ip_address: "" + } + + binary = AgentHeartbeat.encode(heartbeat) + assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary) + end + test "rejects version with spaces" do heartbeat = %AgentHeartbeat{ version: "1.2 .3",