diff --git a/cmd/client_agent_get.go b/cmd/client_agent_get.go
index dd3c0be9..b80ae80d 100644
--- a/cmd/client_agent_get.go
+++ b/cmd/client_agent_get.go
@@ -127,6 +127,10 @@ func displayAgentGetDetail(
cli.PrintKV("Package Mgr", data.PackageMgr)
}
+ if data.PrimaryInterface != "" {
+ cli.PrintKV("Primary Iface", data.PrimaryInterface)
+ }
+
if len(data.Interfaces) > 0 {
for _, iface := range data.Interfaces {
parts := []string{}
@@ -145,6 +149,20 @@ func displayAgentGetDetail(
var sections []cli.Section
+ if len(data.Routes) > 0 {
+ routeRows := make([][]string, 0, len(data.Routes))
+ for _, r := range data.Routes {
+ routeRows = append(routeRows, []string{
+ r.Destination, r.Gateway, r.Interface, r.Mask, fmt.Sprintf("%d", r.Metric),
+ })
+ }
+ sections = append(sections, cli.Section{
+ Title: "Routes",
+ Headers: []string{"DESTINATION", "GATEWAY", "INTERFACE", "MASK", "METRIC"},
+ Rows: routeRows,
+ })
+ }
+
if len(data.Conditions) > 0 {
condRows := make([][]string, 0, len(data.Conditions))
for _, c := range data.Conditions {
@@ -166,20 +184,21 @@ func displayAgentGetDetail(
})
}
- if len(data.Timeline) > 0 {
- timelineRows := make([][]string, 0, len(data.Timeline))
- for _, te := range data.Timeline {
- timelineRows = append(
- timelineRows,
- []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error},
- )
- }
- sections = append(sections, cli.Section{
- Title: "Timeline",
- Headers: []string{"TIMESTAMP", "EVENT", "HOSTNAME", "MESSAGE", "ERROR"},
- Rows: timelineRows,
- })
+ timelineRows := make([][]string, 0, len(data.Timeline))
+ for _, te := range data.Timeline {
+ timelineRows = append(
+ timelineRows,
+ []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error},
+ )
+ }
+ if len(timelineRows) == 0 {
+ timelineRows = [][]string{{"No events"}}
}
+ sections = append(sections, cli.Section{
+ Title: "Timeline",
+ Headers: []string{"TIMESTAMP", "EVENT", "HOSTNAME", "MESSAGE", "ERROR"},
+ Rows: timelineRows,
+ })
for _, sec := range sections {
cli.PrintCompactTable([]cli.Section{sec})
diff --git a/docs/docs/gen/api/get-agent-details.api.mdx b/docs/docs/gen/api/get-agent-details.api.mdx
index 2ef825ed..7aa73a3a 100644
--- a/docs/docs/gen/api/get-agent-details.api.mdx
+++ b/docs/docs/gen/api/get-agent-details.api.mdx
@@ -5,7 +5,7 @@ description: "Get detailed information about a specific agent by hostname."
sidebar_label: "Get agent details"
hide_title: true
hide_table_of_contents: true
-api: eJztWdFu47YS/RWCT72A4pUSO83qLd0mvbltd4PdBH1YBAYtjiw2EqklR+66hv79YkjZlmwncZH2ocA+xbHIMzNnhuPD0YpLcJlVNSqjecp/AmQSUKgSJFM6N7YS9IiJmWmQCeZqyFSuMibmoJHNlqwwDrWoYMQjbmqwfv2N5CmfA17Sqh89oOMRRzF3PP3M/dfTX4UWc6jo4+XtzdQjTjcQjj9E3EHWWIVLnn5e8R9AWLCXDRaE4ZenFoTkD+1DxGthRQUI1vnF5BJP+do7HnFFAdYCCx5xC18aZUHyFG0DEXdZAZXg6YrjsqZ9Dq3Sc95GOwTdFbCJmJmcYQEdFWiYBbQKFjDi5JAFVxvtwBHsaRzTnyGYp6Hj2xF/mdEIGmmlqOtSZZ6JN787Wr7a99LMfocMecRrS7yhCsY2Qe/Fc3Q4IwrdocDGHUIB3VSUhI8g5JJH/L3B8PHhkIWssZYiDXj7dkoxg9IdiktIqQhIlLeDCF/I0s+wPFmIsgEWoFlmdK7mjQXJjN6xbmGuHIIFORV4KNhwCnjKpUA4QeWraWjwtwJ6sKwUDpmF3IIr6CChYwUIizMQG2Yt/o0Ga2sycI51uN6GcVM6wYdoHWJ9CEdOz5lbOoSqf/BHe8UlFfk5a8LmYwqsX1y/KN18ZX0MsgBfRVWXBHM/azQ25P4CrDvaRLf4WCun8Sge87btN4LPw8i2DjxEHBX6fR8+3ejcfOwONnnZ1D49RznZsRu2hMI3Qk7FAqyYw8t56mHQRtZtdCw3liURm0RMaMmSCauUbhDcfvKSSvUZ1U01A7tn6JceugcnTn1NJx30gM54dHbaRnzySuyB2z3w04s24slr0ZMn4Se7deBJ6uJZW+4VAdm4DCb6lVBBZezy5Sz+6texxpGLz540NCjKHqDSCPMDQd/ROhbsM6XZbLkb5cXZxcV5TDzmFuAIyGsL8Cziafz2+2RCaW8csfYi4r0D+SziOHk7PovHu8kIJHSOd9Z6yQhs9vMgbFYohAwbe8S5fHd7z/o7hn1CVPJ8TKCPYDWU06N70odPLGxZd6Yh7mSUTEbxydvkZA4arMrIRlY308w0Go9g870vf2p3pZmrTJTs3e39Dp+U7S/yCGevm7Jcsi+NKFWuQDJpKqE0W4u6rdt/wOwkTkbdF6PMVP63DOxCZTCt5vZlWzdaYdfEhtjhO0mAtcgexfxIwNuwmFVeTNqd/NVIgMSizUU20A7CWkHSRSFUB7XH8DA+pae2xgCL2FurF+PnVyZvT0fJ+cUoGSXrHefP78jhIk7TxPcZkT2/No7TJElPT9Ozs3Q8TicT2pWLSpXLI/Jzy4SUlrRE2DIkVDb+MK7ln9KAXlkDnhMUPX3YPcGeud6RfQ/4h7GPN+ukbA+v9zPDv6AEg3ofhnD1FUFLkMxjsdyaim13k1RaKAnWbSTu4bzuKNwfrVA6PHpnrDQ69KFDgp5kumxKL6gI3hvKjA4uvKIGw9OnfQ3N8JayR80v4v9V84J+rsh/5R43Tx4OifuZMSUIHSSxcIfanNfqDqdohXY+mulT8ueAht1r7bRl48gT0P3CMRLerWncF/1XpPgFgmTaSGBbwj3/BFYqDa9gX1XgUFT1seFGHBagDyj8NnrmiualhHNDRbh9BtaaA21xj9uNs2svekTedVxc+Qd7RHZVTKXLtulghXJo7HK0Z6t3x+5y2bPlwUg187aljeM42b8I32vRYGGs+hMkO2GXtzfsEZZsY+Rvuxk/wd4eA6z3//pS4fcyLAQyk/krrRw2x+swNukNArob7yhwHKYgLxvf9qpuTzd92Thx0KxsgEzr0F0ZFYBpsOs98hjRd7cJkjYMjEziuG23Sb2iVb2+HRJ7tp/Ya2NnSkrQ7ITdaNfkucqUv7CCrZRzvh9+y+6/Ibvjp+ZX2iDLTaO/HdN/QyInhwaRfuGaDtIt4h+aTH5L7D+UWK8asDDd3J2Ip0l3yt/4VL5ZrX+kWx5ubGFO3huxf6IUhiz1B+0brwtEEhM+014u+kU86j5crwXQ/3678wphM3vsCwG2nfnTr3xvypbyZBSP/HWoNg4robe3Lv9aYlCSu9yttgX62ncYXbgIX/FNXQrllWZj/SAmcNq9e+ARTzfS5yFIOnq4Ws2Eg3tbti19/aUBGgsR1QthlZgRG5/9HJU+S57monS7t5h+QN997HTQf9iRrx+eiGKtezWpXj8f5ynnNN1Y9l+VtHQ9KEBIsN7T8Pgyy6DG3sa9PkAvPTZF+NPVHd3ahjW0UzMe/aBTq1VYcWceQbftxkek/8nBtv0/q4g1/g==
+api: eJztWV9v2zgS/yoEn24BxZGSOJvqLZsmvdzttkGaYB+KwKDFsc2NRKrkKK3P8Hc/DCnLkq0kWmT3YYEiD7HN4W/+kzPDFZfgMqtKVEbzlH8AZBJQqBwkU3pmbCFoiYmpqZAJ5krI1ExlTMxBI5su2cI41KKAEY+4KcF6+mvJUz4HPCeq9x7Q8YijmDuefuH+58lvQos5FPTx/OZ64hEnDYTjDxF3kFVW4ZKnX1b8FxAW7HmFC8Lw5KkFIfnD+iHipbCiAATrPDGJxFO+kY5HXJGCpcAFj7iFr5WyIHmKtoKIu2wBheDpiuOypH0OrdJzvo52DHS3gEZjZmYMF1CbAg2zgFbBE4w4CWTBlUY7cAR7FMf0rwvmzVDb25H9MqMRNBKlKMtcZd4Sh384Il/tS2mmf0CGPOKlJbuhCswapff0GazOiFR3KLByfSigq4KccAtCLnnEPxoMHx/6OGSVtaRpwNvnk4sp5K5PLyGlIiCR33Q0fMVL/4XlwZPIK2ABmmVGz9S8siCZ0TvcLcyVQ7AgJwL7lA1ZwFMuBcIBKh9NXYa/L6AFy3LhkFmYWXALSiR0bAHC4hREY1mLfyHD0poMnGM1rudh3IQyuM+sXaxPIeX0nLmlQyjaiT/aCy6pSM5pFTYPCbB2cP2qdPWdtTGIA3wXRZkTzP200liR+E9g3WAWNfFQLkfxKD7h63X7IPjS1WwrwEPEUaHf9+nztZ6Z2zqxScqq9O4ZJGRt3bAlBL4RciKewIo5vO6nFgZtZPVGx2bGsiRi44gJLVkyZoXSFYLbd15SqLZFdVVMwe4x+rWF7sHJpj6mkxq6Y854dHy0jvj4jdgdsVvgR2friCdvRU+ehR/vxoE3Uq3PhnMrCIjHeWDRjoQCCmOXr3vxN0/HKkcivphpaFDkLUClEeY9St8RHQv8mdJsutzV8uz47Ow0JjvOLMAAyCsL8CLiUfzu52RMbq8cWe1VxHsH8kXEk+TdyXF8suuMYIRa8JpbyxnBmm0/CJstFEKGlR2Qlxc396y9o3tOiEKenhDoI1gN+WTwmfTpMwtbNidTF3c8Ssaj+OBdcjAHDVZlxCMrq0lmKo0DrPnRhz8dd7mZq0zk7OLmfsee5O2vcoCwV1WeL9nXSuRqpkAyaQqhNNsUdVuxv8H0IE5G9Q+jzBT+LgP7pDKYFHP7Oq9rrbA+xLrY4TdJgKXIHsV8IOBNIGaFLybtjv9KJECyop2JrFM7CGsFlS4KoeitPbrJ+Fw9tWUGuIg9t/Lp5GXK5N3RKDk9GyWjZLPj9OUdMziL0zTx54zIXqaN4zRJ0qOj9Pg4PTlJx2PaNROFypcD/HPDhJSWaomwpWtQWflk3JR/SgP6yhrwlKBo9WE3g73lWin7EfCbsY/XG6dsk5d8b1Uh7HLSuOx1kT+2yotmG6ODorkBJMxElSOzZvfuarzml/5UfOzU8kwHvTwTKqZQTHNgoNEue6oocKi0GFZEvd8SN1xqL3WViUf+j/SZC4RvYoDHPwTCfsBWpHYSaYBXajm3Htk/Tza2L4R7HI5I1HSFXFy/v2XaoNiv7w4DLLVj2YDT9JZ8zwJ5ByiJY8qd3Letr8kXUDzxjlfi+Hi/2GwFwNZbbRO3UsZDty+5fsvsR56iS9anf4Z/osEKTXGXyeV3BC0pqQiLzawp2HY3dSBPSoJ1TefYf1zuNI7vrVA6LF0YK40O13tfn0zdr6xy36cQvGeUGR1EeMPRHlaflzXUGDeUHVRTRPzfar6gKpDkV+6xWXno65mnxuQgdOg0hevLd98CO5ygFdp5bSbPdRU9reFexURbGkGegW6fx0bCxcaM+8F1SY20QJBMGwlsa3BvfwLLlYY3WF8V4FAU5VB1Iw5PoHsa53X0wuTDnwfOdRut7RpYa3qqjT3bNsJupGgZ8q62xaVf2DNkHcUUumzrDrZQDg1dEbu8WqOr2pctXh6MmlG+XtPGkzjZny/da1Hhwlj1P5DsgJ3fXLNHWLKGyV82cHrGevt3ZOv75sr2exkuBDKT+UmR7B6fV2Ea2Zqv1YOkUbBxGC6+znx7VtV76qFmI0QvW1kBsd5cuxQApsL67JFDeqm7Rkna0GEyjuP1euvUS6JqlUPBscf7jr0ydqqkBM0O2LV21WymMuXnQGAL5Zw/D39495/g3ZPnxsLaIJuZSv9I03+CI8d9831PuDEH1S3ibxr4/3Ds3+TY0EUsTP2cRYanB6SUH3pXHq42l/Sah0FIeH5qvVx9JhcGL7XfrxqpF4hUTHhP+3LRE/Go/nC1KYD+8/udrxCakX67EGDbpzS65VvD65Qnm46wNA4LobfDDP/a1wnJXduttgH61qfBWl2E73hY5kL5SrOyfr4ZbFo/6fGIp03p8xBKOlpcrabCwb3N12v6+WsFNG0lUz8Jq6jj8e9+Ujn6LHk6E7nb7WLaCv3rtq6DfmIDX/We0WJT92qqev2zE085p6Hhsv0Cuab2YAFCgvWShuXzLIMSWxv3zgF6S2yC8MPlHXVt3RjaiRmP3ivUahUo7swj6PW6kRHpOwm4Xv8fAFNaBg==
sidebar_class_name: "get api-method"
info_path: gen/api/agent-management-api
custom_edit_url: null
@@ -547,6 +547,119 @@ Get detailed information about a specific agent by hostname.
+
+
+
+
+
+
+ routes
+
+ object[]
+
+
+
+
+
+
+ Network routing table entries.
+
+
+
+
+ Array [
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]
+
+
+
+
+
@@ -792,7 +905,7 @@ Get detailed information about a specific agent by hostname.
value={"Example (from schema)"}
>
diff --git a/docs/docs/gen/api/get-node-network-dns-by-interface.api.mdx b/docs/docs/gen/api/get-node-network-dns-by-interface.api.mdx
index 714d86f4..af43460c 100644
--- a/docs/docs/gen/api/get-node-network-dns-by-interface.api.mdx
+++ b/docs/docs/gen/api/get-node-network-dns-by-interface.api.mdx
@@ -5,7 +5,7 @@ description: "Retrieve the list of currently configured DNS servers for a specif
sidebar_label: "List DNS servers"
hide_title: true
hide_table_of_contents: true
-api: eJztV99v2zYQ/leIe2oB+Uc6J9sE7CFt2sJDFwRNij1khkGLZ4uJRKrkyYtn6H8fjpJsOdbarliGDehTJIdHfvy+7053W1DoE6cL0tZADO+RnMY1CkpRZNqTsEuRlM6hoWwjEmuWelU6VOLi8lp4dGt0XiytE1L4AhO91IkwSL9bdy+0IXRLmeDwNwMRkFx5iG/h0iqc/yKNXGGOhubnV9O5Mn5uC3SScXiYRbB7myqI4S0Sh13WO19cXr/cTNvdIQKPSek0bSC+3cJLlA7deUkpn9aAiR1KBbNqFkEhncyR0Pmw3MgcIYbUegqPEWhmopCUQgQOP5baoYKYXInRI7pupFshCblCQ6LdIRIOAzNKOFuSNiuxllmJ4tlcmk0k5jLLnkfCOpHJBWbCY4YJWSee3eMmDkuf14w9DKws9CCxCldoBvhATg5qGrewlplWkhh7CzLKtfnpJAr/mVPABlUEPkkxlxxDm4LXe3LarCCCXJt3aFbM1ElVRTsydspdfp6Rvw1SZkUqTZl/Clp1xHSKgsGxH9mbRx4TZIVr3cvmbL0aTMQWHYpfSja0qY1MUhuRIbERhDRKmDJfoPNMPdvEoS+s8Rju8WI85j+HmN41+dFJhSFEwHujIV4viyLTSUAwuvMctD2+sl3cYUIQQeHY9KTrI+/sYq5Vn2pL63JJEENZagV9TN3ZhZheiNKjYl4KZxP0XlCqvWAd0BMjxQeZFxnvfXo6xh8m4/EAX/y4GExO1GQgvz85G0wmZ2enp5PJeDwesygOfZmR76CSzskNW4Qw95+/1S7Neu51fI92dat6nWiUShJNhoUr1VIPg6FqHXoA9mvXX9GGPRf6C5jTq/WEc3l6tT4TUinHRDdw9zsOoQrgpEvSubK51ObLMdZhogn7BDY+A52z7vOwX/MykaP3coVCd+ldSp2hqhHvk/12L90sAtIUfHNxef0qMPi+yRaoHoe1lumLemUzrn3amm58FcGkL9+mJpSTTs4HbxTOrrVixP9Y7n0hieei896KHmJrj9okfDrVYa69Cfwe1CtPkko/rMseSZ19gfHOldL8KDPRxAi5sCXtQfQeq8pQKtv6STpHW1I4mkt451ymeYWuNzHrS3LAwSGn4zGr1+ocTHak7Mmxsh+MLCm1Tv+BSgzE+dVU3ONG7Gz0Tdj/g7DfHQv7xrqFVgqNGIip8eVyqRPNRaZAl2vvQ7f3Td3/vrqnfQW5/og0dHCf+zSd0DdZn0jWKoIcKbU8YfGsENU9fgwjYxWOtu0Xvxo1AEfK+NH2YDqooNN03c72s9g1q1sL2J3IdhdKiQpoJgB+X4RFEDUPb9om9+dfb0Iros3ShvDmOuehWdnPkfzVgAgYSM3MyXA8DE1rYT3lMliumW9CZ9Ux62NWt3vrPu1IXFNB+ECjIpPaMNzSZYygluIWWAqIIO4MqM1ujDqUz/hwXptFoVfj4O12IT1+cFlV8c8fS3SbWqa1dFoumMnbLSjt+VlBvJSZfzzmdtl49r75Jj8XTzz89nLT9suGu+WwGmKACO5x053heZT9ujv9q2PmV13yUOtqVkWQolTogpL1mvMkwYI60UfllyfcXe6/fX0DEcjD/HyUj2H3XmTbbb3ixt6jqaodUOJ3BlhVfwI3m2XU
+api: eJztV99v2zYQ/leIe2oB+Uc6J9sE7CFt2sJDFwRNij1khkGLZ4uJRKrkyYtn6H8fjpJsOdbarliGDehTJIdHfvy+7053W1DoE6cL0tZADO+RnMY1CkpRZNqTsEuRlM6hoWwjEmuWelU6VOLi8lp4dGt0XiytE1L4AhO91IkwSL9bdy+0IXRLmeDwNwMRkFx5iG/h0iqc/yKNXGGOhubnV9O5Mn5uC3SScXiYRbB7myqI4S0Sh13WO19cXr/cTNvdIQKPSek0bSC+3cJLlA7deUkpn9aAiR1KBbNqFkEhncyR0Pmw3MgcIYbUegqPEWhmopCUQgQOP5baoYKYXInRI7pupFshCblCQ6LdIRIOAzNKOFuSNiuxllmJ4tlcmk0k5jLLnkfCOpHJBWbCY4YJWSee3eMmDkuf14w9DKws9CCxCldoBvhATg5qGrewlplWkhh7CzLKtfnpJAr/mVPABlUEPkkxlxxDm4LXe3LarCCCXJt3aFbM1ElVRTsydspdfp6Rvw1SZkUqTZnPrZsvZfJJiNUR4ykKBsm+ZI8eeU2QFa51MZu09WwwE1t1KH4p2dimNjRJbUSGxIYQ0ihhynyBzrMEbBeHvrDGY7jPi/GY/xxietfkSSclhhAB742GeL0sikwnAcHoznPQ9vjKdnGHCUEEhWPzk66PvLOLuVZ96i2tyyVBDGWpFfQxdWcXYnohSo+KeSmcTdB7Qan2gvVAT4wUH2ReZLz36ekYf5iMxwN88eNiMDlRk4H8/uRsMJmcnZ2eTibj8XjMojj0ZUa+g0o6JzdsFcLcf/5Wu3TrudfxPdrVrep1wlEqSTSZFq5USz0Mhqp16AHYr11/ZRv2XOgvYE6v1hPO6enV+kxIpRwT3cDd7ziEKoCTLknnyuZSmy/HWIeJJuwT2PgMdM66z8N+zctEjt7LFQrdpXcpdYaqRrxP+tu9dLMISFPwzcXl9avA4PsmW6B6HNZapi/qlc24BmpruvFVBJO+fJuaUFY6OR+8UTi71ooR/2O594UknovOeyt6iK09apPwCVWHufYm8HtQrzxJKv2wLnskdfYFxjtXSvOjzEQTI+TClrQH0XusKkOpbOsn6RxtSeFoLuWdc5nmFbrexKwvyQEHh5yOx6xeq3Mw2ZGyJ8fKfjCypNQ6/QcqMRDnV1Nxjxuxs9E3Yf8Pwn53LOwb6xZaKTRiIKbGl8ulTjQXmQJdrr0PXd83df/76p72FeT6I9LQwf3u03RC32R9IlmrCHKk1PKkxTNDVPf6MYyMVTjatl/8atQAHCnjR9uDKaGCTtN1O9vPZNesbi1gdzLbXSglKqCZAPh9ERZB1Dy8aZvcn3+9Ca2INksbwpvrnIdmZT9P8lcDImAgNTMnw/EwNK2F9ZTLYLlmzgmdVcesj1nd7q37tKNxTQXhA42KTGrDcEuXMYJailtgKSCCuDOoNrsx6lA+48O5bRaFXo2Dt9uF9PjBZVXFP38s0W1qmdbSablgJm+3oLTnZwXxUmb+8bjbZePZ++ab/Fw88RDcy03bLxvulsNqiAEiuMdNd5bnkfbr7vSvjplfdclDratZFUGKUqELStZrzpMEC+pEH5VfnnB3uf/29Q1EIA/z81E+ht17kW239Yobe4+mqnZAid8ZYFX9CeTFaRE=
sidebar_class_name: "get api-method"
info_path: gen/api/agent-management-api
custom_edit_url: null
@@ -74,7 +74,7 @@ Retrieve the list of currently configured DNS servers for a specific network int
diff --git a/docs/docs/gen/api/list-active-agents.api.mdx b/docs/docs/gen/api/list-active-agents.api.mdx
index 956db4a4..be986f26 100644
--- a/docs/docs/gen/api/list-active-agents.api.mdx
+++ b/docs/docs/gen/api/list-active-agents.api.mdx
@@ -5,7 +5,7 @@ description: "Discover all active agents in the fleet."
sidebar_label: "List active agents"
hide_title: true
hide_table_of_contents: true
-api: eJztWE1v2zgQ/SsEz4orOXY28S2bJrvZ7YfRJuihCAxaHFlsJFIhR268hv/7YijZlmQncZEusIeeLIucN8P3RsMhl1yCi60qUBnNR/ytcrGZg2Uiy5iIUc2BiRlodExphimwJAPAHg84ipnjo6/8nIYn74UWM8jp8Xx8PfE2E1OAFYTs+F3AHcSlVbjgo69L/jsIC/a8xJQw/PSRBSH53eou4BZcYbQDx0dL3g9D+mkH+k45ZCZpx0hhxUYjaCQLURSZin0Ab745MltyF6eQC3rCRQF8xM30G8TIA15YChdV5bQCbMwT1ooFD7hCyN3L9qlxqEUOjZkOrdIzHnRWcpMCW8+mFRHJ3nuPrwLuUGDp9qGALnPi7hMISYF9MFg93u3zEJfWgkZW4e36ycQUsr3rElIqAhLZuLXCdjyrrtO/YXE0F1kJrIJmsdGJmpUWJDO6493CTDkEC3IicN9iE2NzGuFSIByhymGHxy8pNGBZJhwyC4kFl4JkCh1LQVicgtgwa/EnOiysicE5VuN6H8ZNlE7MPlrbWB+rL0XPmFs4hJyRGUWgjO7tJJdUFOe0rIwPSbBmcr1TunxkTQzyAI8iLzKCuZ2WGksKfw7WHeyinnyol37YCwd85cV/KJUFScncWtk2gLuAo0Jv9/HztU7Mp7pAUJRl4eU5KMia3cqkSnwj5ETMwYoZvKxTA4MMWW3oWGIsiwI2DJjQkkVDlitdIrhd8aJcNRnVZT4Fu+PoXQPdgxOnPqejGrpFZ9g77q8CPnwldivsBnj/dBXw6LXo0ZPww24eeJLq9aw9N5KAfJxXLpqZkENu7OJlFd/7eax0FOKzXxoaFFkDUGmE2Z5F39A8VvmnnXK66K7y9Pj09CQkHhMLcADklQV4FrEfnv0WDUn20hFrLyLeOpDPIg6is8FxOOiKUZFQB157a4hRsdnUQdg4VQgxlvaA7/JifMuaFu06IXJ5MiDQe7AassnBNenjZ1aZrCtTG3fYi4a98OgsOpqBBqti8hEX5SQ2pcYD2Pzg05/KXWZmKhYZuxjfdvgktR/kAcFelVm2YA+lyFSiQDJpcqG0L9vtsL/D9CiMevWLXmxyv5eBnasYJvnMvuzrWiusi1gbu3onCbAQ8b2YHQg4riaz3PeAtqNfgQRILNpExPCKnuqpfmrrDDANvbdiPnh+ZnTW70Unp72oF60tTp63SOA0HI0iX2dE/PzcMBxF0ajfHx0fjwaD0XBIVonIVbY4QJ8xE1Ja6iUqkzahsvQf47r9UxqIKfo5ISgavet+wZ65xif7AfC7sffXa1G2H6+PM8Yf6ATRltBdwuUjgpYgmcdiiTU521pTqzRXEqzbtLj7de10uG+tULoaujBWGl3VobZrfxxh1ObLMvMNFcF7R7HRVQivyMFq9OlYq2I4JvWo+AX8TzVLabui+JW734zc7Wvup8ZkIHTVEgu3r8z5Xt3hBK3Qzq9m8lT7s6eH3SntZLIJ5AnoZuIYCRdrGneb/kvq+AWCZNpIYFvCPf8ElikNr2Bf5eBQ5MWhyw04zEHv6fBXwTNHNN9KONfuCLdjYK3ZUxZ3uN0Eu46iQeRNzcWlH9ghss5iSl22lYOlyqGxi96Or81aNlo2fHkw6pq92Y+1NHqzy3VO2t0I6gPzGr/ZrCmHPgTXKDNkPgij3YP9rRYlpsaqf0CyI3Y+vmb3sGAbVz/thP+EijtKsMb/9eHG2zJMBTIT+6O1bBfpK6EykAwNs4BWwRzqk3ev0hqFyvae6jvOtzWztmFiakrcBrHXrSyBXOuqyjNKRFNiXQPlIc3nzWaRZNByMgxDn0a1upc0a0fY411hr4ydKilBsyN2rV2ZJCpW/uAMNlfO+br8S93/v7rDffdxfiId9/31Ie27P/1K7pek/5GkfrvD1Eg+4jPfTRaC7mX5G68hr84XYOm6t3GP+5l0q6Rp3uZuQk0Raevz8vrmxk/iQf1wtd6u//py43eTzU1Zc9ti24tl2gsad0IjHvXCnm/eC+MwF3p7Rqiuh1s7Vpex5TYtf+jWu1obwiO+KTKhfBNUWr+hVqzVWyHtgLQr04vlcioc3NpstaLXDyXQLQVxORdWiSkt9ys1hCkICdZfkN/DgjiIYyhIAX+V6o9TnQ+Irss36v1xeUN9eluHDu8efd1+6UUDe7msZtyYe9CrFQ/qIJD+89XdarX6F9otUpM=
+api: eJztWd9v2zYQ/lcIPjuulNhZ6rcsTbpsXRu0CfowBAYtnm02EqmSJ7ee4f99OFKWJVlJVKQD9jDkIbbF++543/F+UBsuwSVW5aiM5hP+RrnErMAykaZMJKhWwMQCNDqmNMMlsHkKgEM+4CgWjk/+4uf0ePqn0GIBGX08v7meepmpycEKQnb8fsAdJIVVuOaTvzb8VxAW7HmBS8LwyycWhOT32/sBt+Byox04Ptnw4yiif01D3ymHzMybNpJZidEIGklC5HmqEm/Aqy+OxDbcJUvIBH3CdQ58ws3sCyTIBzy3ZC6qoDQA1tYJa8WaD7hCyNzz8kvjUIsMaisdWqUXfNDaye0S2G417Yic7LUP+XbAHQosXBcK6CIj330EIcmw9wbDx/suDUlhLWhkAe9QTypmkHbuS0ipCEikN40dNu3ZtpX+AeujlUgLYAGaJUbP1aKwIJnRLe0WFsohWJBTgV2bnRub0RMuBcIRqgwO/Ph5CTVYlgqHzMLcgluCZAodW4KwOANRedbiT1SYW5OAc6zE9TqMmyo9N11ubWJ9CCdFL5hbO4SMkRhZoIweHgSXVGTnrAjCfQKsHlzvlC6+szoGaYDvIstTgrmbFRoLMn8F1vVWUS7uq+U4GkYjvvXkfy2UBUnB3NjZ3oD7AUeFXu7Dp2s9Nx/LBEFWFrmnp5eRpXeDSAh8I+RUrMCKBTzPUw2DBFkp6NjcWBYP2HjAhJYsHrNM6QLBHZIXZ6ruUV1kM7AHit7V0D04+dTHdFxCN9wZDU+OtwM+fiF2w+wa+PHZdsDjl6LHj8KP23HgnVTuZ6e5FgSk4zyoqEdCBpmx6+dZ/NOvY4UjE588aWhQpDVApREWHZu+pXUs6KdKOVu3d3l2cnZ2GpEf5xagB+SVBXgS8Th6/Us8JtoLR157FvHOgXwScRS/Hp1EozYZwQml4aW2GhnBm3UehE2WCiHBwvY4lxc3d6wu0cwTIpOnIwJ9AKshnfbOSR8+sSCyy0xN3PEwHg+jo9fx0QI0WJWQjiQvpokpNPbw5nsf/pTuUrNQiUjZxc1dy5/E9lfZw9irIk3X7GshUjVXIJk0mVDap+2m2d9gdhTFw/KHYWIyX8vArlQC02xhn9d1rRWWSayJHX6TBJiL5EEsegLehMUs8z2gbfGXIwGSF+1cJPCCnuqxfmqvDHAZeW35avT0yvj18TA+PRvGw3gncfq0xBzOoskk9nlGJE+vjaJJHE+OjycnJ5PRaDIek9RcZCpd9+DnhgkpLfUSQaTpUFn4w7hr/5QG8hT9OyUoenrfPsHec7Uj+x7wm7EP1ztS9oeXuLcqE3Y9rSh73uT3tfaiEmOUKKoKIGEuihSZNe3aVbHmH/1QfDStOGc67MsroWYKxSwFBhrtuqOLAodKi35N1Jv94kpLyVJzM9HQ/9F+FgLhm+jB+NuwsBuwFqmNg9SDldLOPSOH+WTn+0y4h/6ItJpKyMX1m49MGxSH/d2rAAtIqfX5bPqRuGdheQMojiI6O6mfNp+zL6D4xS1WoujksNmsBcCerbqLa0fGQ9eLXLdnDiNPUZH1xz/BHxiw0BbQVnL5HUFLOlSExebWZGwvTRPISkmwrpocu9Nla3B8Y4XS4dGFsdLoUN5bZ8vPODQ9yyL1cwrBe0WJ0cGEF6T28PRxW0OPcUOng3qKAf9NLZbUBZL9yj1UT+67ZuaZMSkIHSZN4brOux+BHU7RCu38bqaPTRUdo+FBx0QilSGPQNfzsZFwsXPjYXBd0iAtECTTRgLbO9z7n8BSpeEF3lcZOBRZ3ne7Aw4r0B2D83bwxM2HzwfONQet/TOw1nR0Gwe+rYzdWVFz5G3pi0v/4MCRZRRT6LI9HWypHBoqEW1d1V4qLmu6PBgNo17sxyYFXTWPrQustgXlPdQOvz4DKYfeBFer3iQ+iuLD+7I7LQpcGqv+BsmO2PnNNXuANatU/bSLs0dYPKzVte+71sHLMlwKZCbxN1aymcavhEpBMjTMUqWAFZQXWsPANQqV9igT5/ucWcowMTMF7o3oVCsLINW78k+BaAosc6DsM9PdVpskgYaScRT5MCrZvaRVB8SeHBJ7ZexMSQmaHbFr7Yr5XCXK30eBzZRzPi//z+5/n91x1zW3X0i3aP5WnuruT7/p/p/Sf4nS0P4ujeQTvvBDWi7odQd/5TnkYWwHS29Raq9HPhFvgZr6S5LK1CUilT5Pr29u/CI+KD9c7cr1759vfTWpLqDrZYvt39dQLahdtU54vJtfcuMwE3o/eoe3Lo2K1fbYZh+WP/QyKewN4Tu+ylOhfBNUWF9Qg9fKUkgVkKoy/bDZzISDO5tut/Tz1wLo8o98uRJWUQNO36glASHB+vdOD7AmHyQJ5MSAf0PhbylaB4jeQlXsvb28pT69yUPL7x59137pdQ17swkrbs0D6O2WD0ojkL7z7f12u/0HTox2mw==
sidebar_class_name: "get api-method"
info_path: gen/api/agent-management-api
custom_edit_url: null
@@ -556,6 +556,119 @@ Discover all active agents in the fleet.
+
+
+
+
+
+
+ routes
+
+ object[]
+
+
+
+
+
+
+ Network routing table entries.
+
+
+
+
+ Array [
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]
+
+
+
+
+
@@ -819,7 +932,7 @@ Discover all active agents in the fleet.
value={"Example (from schema)"}
>
diff --git a/docs/docs/gen/api/post-node-network-ping.api.mdx b/docs/docs/gen/api/post-node-network-ping.api.mdx
index 37f8623c..3215cc1d 100644
--- a/docs/docs/gen/api/post-node-network-ping.api.mdx
+++ b/docs/docs/gen/api/post-node-network-ping.api.mdx
@@ -5,7 +5,7 @@ description: "Send a ping to a remote server to verify network connectivity."
sidebar_label: "Ping a remote server"
hide_title: true
hide_table_of_contents: true
-api: eJztWN1v2zYQ/1cIPrWA7MiJkqYC9uB+DR62zmhS7CEzDFo820wkUiWPjj1D//twlOQotrcGbQd0QOEXSb7P332Qd1suwWVWlaiM5im/Ai2ZYKXSC4aGCWahMAjMgV2BpU8rsGq+YRrw3tg7lhmtIUO1Urjp/6l5xFEsHE9v+HsjYfqb0GIBBWicDsejacM1NSVYQSodn0R89zaSPOVj45B439e0Y6UXPOIOMm8Vbnh6s+WvQFiwQ49LUtQITe+tQuCTahLxUlhRAIJ1gV6LAnjKl8ZheIy4ImdLgUsecQufvLIgeYrWQ7SHyLWwC0AmFqCRtRIiZiFAIpk1Hgmtlcg9sGdToTcRm4o8fx4xY1kuZpAzBzlkaCx7dgebNJA+r9Fa94woVS8zEhage7BGK3o1hFu+ErmSAsn21sioUPqnQRT+mWKwjVcRd9kSCkE8uCmJ3qGtgSuU/hX0gqAaVIQNSQKHr4zcEP2et8turCkN+kcQyoxG0EjsoixzlYXondw6krE9NMbMbiFDHvHSUqxRQXBPSGnBuWNWH1o1GrOGnpk5w0M72ZUvS2PRsZnBJRuNVwkTWtLDRY01rEVR5qTnsh9+X4K/KnlVVV1MbnaOTKrmL1ca7WonT+P4EOYrn2Xg3Nzndam1HAT2NwL31symSh7Ddm5sIZCn3Hslj2J9a2Zs9IZ5BzLAaw1Zy3CpHGvSp/8Iz/PzGC6TOO7B6ctZLxnIpCdeDC56SXJxcX6eJHEcx7xGxufYjbiwVmyoIBEK93mvdiX8pJxpqduMqYsYlwIZrCHzSP4tocnzivpGdgfopq4JQKNCaYQF2AMd730xA0vSG0ZGjI+gSTpSLWSgViC/SHLL/A/Sp7l5VEk6COhGWxo/y+FA0xhsBhrFAh60MRL2SFFchU4ytYifh/43pVXhC2qNWvbQqpKhKoApzX424bH/xtcdn9Xm7dfnIOlfnL0YxGeFo7CI1eJpmocrsOTJV2i+7F8kL5KXl7XmQqyf6LNYf6XPp0n/LH55msS1ZrDW2M/rfUtkrADnyG/VzfO5UDnly3632tXQJOKoMCinU/ZD04QO+1tbtnsMr01Ox5oyustaRTw51vNGOrTTtoGwUmxyI+Q3bHlPhGzIOu9tawi8dWswWeat3as0/i6gSe3QAloFK2AOBXoXGocEFCp/wlE2lFLRo8hZw8PEzHh8MOKoWumBVLe3Lson4zGopqPrCR3leuckMTxSch7HFLU2tCGlDiI6OIzoRy08Lo1Vf4FkPTYcj9gdbNguc34E9v8Q2LPDwL4zdqakBM16bKSdn89VpqillGAL5Vy4uP+I7vcf3fNjjbg+MkqwdA6FQa+5ArHdIPajKX//0aXbCeDS0MxcGheQp4E25SfaSDjZtud8ddJYeFK2szQNTjQaTx4G6yuKZx2y7ni9c2GJWPJm0KT3WSDiUfPwrr1m/vLHdbhxUJ58eBg137Z+dca+3RhW0UA+N0Fb4+8wXGEe1gd0uvCIk901dIN+3A8zBfleiJCTzZxPd5P91cU+9NuH/P4mS48aJIQ1npS5UJos8zYnRXVUbjhFhUc87awhGoEUOgrNJAqXMyLebmfCwUebVxV9/uTBbuqArYRVgm7ytNmQytGz5Olc5G5/edF18tmH5lh+zv7jlcZRLNpxT9OwF6h5ynnE72DT3cxUkyriSxASbPCv/vt17UXvmoQ8sB+0pSpqOYZZBiX+K+2kUz/j36+uKZWbnUgRqpdbcU/LD3Ffm2rKemOVbutvW54LvfBiQbS1TEp88bhu9uokeHUUjO22prg2d6CraocN0jsBU1V/Awnr0LE=
+api: eJztWG1v2zYQ/isEP7WA7MiJ8lIDA+a+DR7WzmhS7ENmGLR4tplIpHo8OfEM/ffhKMlxbG8N2g7ogMJfJPlen+d45HEtNfgUTUHGWdmXl2C1UKIwdi7ICSUQckcgPOASkD8tAc1sJSzQncNbkTprISWzNLTq/mllJEnNvexfy/dOw+SdsmoOOViaDEbDSaM1cQWgYpdejiO5eRtq2Zcj54l139eyI2PnMpIe0hINrWT/ei1fgkLAQUkLdtQY7d+hIZDjahzJQqHKgQB9kLcqB9mXC+cpPEbScLKFooWMJMKn0iBo2ScsIdpB5ErhHEioOVgSrYVIIARItEBXEqO1VFkJ4tlE2VUkJirLnkfCocjUFDLhIYOUHIpnt7DqB9HnNVr3HacK00mdhjnYDtwTqk4N4VouVWa0Io69DTLKjf2pF4V/JhRik1UkfbqAXLEOrQqW94Q1cLmxv4GdM1S9irFhS+DppdMrlt/JdrHNNZdB9wBCqbMEllhdFUVm0sDe0Y1nG+v9YNz0BlKSkSyQuSYDIT2lNYL3h6Lej2o4Eo28cDNB+3GKy7IoHJIXU0cLMRwtE6Gs5oezrhhk3gmVplCQFz/PVEpdgTADBJuCF7RQJBQC8+oy5jUQ3vFGQ00U3Ku8yDjIi274fQl5ppg4nLB3WVXVNrDXGzTGVfOXL5z1NVLHcbzP1WWZpuD9rMzq9dpqMGPfiKEbN50YfYigmcNckezLsjT6IGE3biqGr0XpQQeO0HG0ghbGi6YGu49wPT2N4SKJ4w4cv5h2kp5OOuq8d9ZJkrOz09MkieM4ljUyZUbbZaMQ1YpXNUHuP5/Vpg88qfBa6bbs6k4QCgbuIS2J81tAs1gqbj7pLZCf+IaAxoWxBHPAPR/vy3wKyNYbRcGKj6BJtqwipGCWoL/Icqv8D9YnmXu0HG0wsM22duU0gz1PI8AULKk5PHgTbOyRo7gK7WiCRJ+H/p2xJi9z7q9WdwhNIcjkIIwVv7jw2H1d1tuGqMPbXae9pHt2ct6LT3LPtKjl/GmeB0tAzuQrPF90z5Lz5MVF7TlX90/MWd1/Zc7HSfckfnGcxLVnQHT4eb9vWEzk4D3nbbbrfKZMxvWy2602a2gcSTIUnPNW/aFpQvv9rV22OwqvXMZ7o3F2W7WKZHKo5w1taKttAxGFWmVO6W/Y8p4I2UBsvbetIejWrcGlaYm4s9Lk24Amt0MEQgNLEJ4UlT40Dg2kTPaE/XCgteFHlYlGR6ipK+khiINudQnsuj26cT25koJr3sKe0FGuNkmywiMnp3HMrLXUhpLaY7S3z+hHq0paODR/gRYdMRgNxS2sxKZyfhD7fyD2ZJ/Ytw6nRmuwoiOG1pezmUkNt5QCMDfeh9P/D3a/f3ZPDzXiessoAHkfCtNicwQSm2nuR1P+/tnl0wnQwvHgXTgfkOepuC+PrNNwtG73+eqoifCoaAdynr54vh4/TOeXzGdN2faMvklhQVTIZlrl92kQklHz8LY9Zv76x1U4cXCdfHiYV9+0eW3NjptxrOKpfuaCtybfQTjCPNxB8O4iI8lx19D1unE3zBSce65CTTaXBXw22b3/2IV+/VDf3+TmpAaJ4J6OikwZy5GVmLGjmpVryazISPa37jIag0wdUzOOwuGMhdfrqfLwEbOq4s+fSsBVTdhSoVF8kufrEW08P2vZn6nM796AbCf57EOzLT8X//G9yEEs2nHP8rAXpGVfykjewmr7eqcaV5FcgNKAIb/671d1Fp0rNvKgvteWqqjVGITrgn+VHW+tn9Hvl1dcys3FSh5Wr0R1xzco6q4O1RX1tVd/XX9by0zZeanmLFvb5MJXj9fNzjoJWR0EY72uJa7cLdiq2mBD/M7AVNXfaADp6g==
sidebar_class_name: "post api-method"
info_path: gen/api/agent-management-api
custom_edit_url: null
@@ -122,7 +122,7 @@ Send a ping to a remote server to verify network connectivity.
required={true}
schemaName={"string"}
qualifierMessage={undefined}
- schema={{"type":"string","description":"The IP address of the server to ping. Supports both IPv4 and IPv6.\n","example":"8.8.8.8","x-oapi-codegen-extra-tags":{"validate":"required,ip"}}}
+ schema={{"type":"string","description":"The IP address of the server to ping. Supports both IPv4 and IPv6. Also accepts @fact. references that are resolved agent-side.\n","example":"8.8.8.8","x-oapi-codegen-extra-tags":{"validate":"required,ip_or_fact"}}}
>
diff --git a/docs/docs/gen/api/put-node-network-dns.api.mdx b/docs/docs/gen/api/put-node-network-dns.api.mdx
index 77de150d..9cd728d5 100644
--- a/docs/docs/gen/api/put-node-network-dns.api.mdx
+++ b/docs/docs/gen/api/put-node-network-dns.api.mdx
@@ -5,7 +5,7 @@ description: "Update the system's DNS server configuration."
sidebar_label: "Update DNS servers"
hide_title: true
hide_table_of_contents: true
-api: eJztWG1v2zYQ/ivEfVkLyInTOdlmoB/SpgU8rEGQF/SDZxi0eLaYSKRKHu14hv77cJRsyy8d2qEDOqCfIkdH8u55njvdcQUKfep0Sdoa6MNDqSShoAyFX3rC4icvrq7vhEc3RydSa6Z6Fpxk8xNIgOTMQ38I11bh+IM0coYFGhpf3gzGyvixLbE29jBKYPNroKAPN4F42TXSwrqnq+s7SMBjGpymJfSHK3iD0qG7DJTxEaa26y+cJoRRNUqglE4WSOh8tDeyQOhDZj3FxwQ0x1RKyiABh5+CdqigTy5gshf4vXQzJCFnaEisd0iEwxi5Es4G0mYm5jIPKF6MpVkmYizz/GUirBO5nGAuPOaYknXixRMu+9H05cmfBhJ47lhZ6k5qFc7QdPCZnOzU4K1gLnPNsEN/42RSaPP6LIlvxhR9gyoBn2ZYSF5Dy5LtPTltZpBAoc0faGYM1VnF2PBO6OmNVUu2348+tYbQEL+SZZnrNPJy+ugZjdXhQXbyiClBAqVjFkljdL3WhW8ZSufk8l9EPF5oymyg13coXZpd2UJq4xNbaMKipGWi9BwTXdbIMBi7BF7jQuTak7DTlmK9ILtRLbJieTt/DMHd7QY38x4TO7iZXwiplEPveWvOjO32J1AxK9Hjsapd/rZYxCD2Udjo8wuwqJ0TjXNfCgeHpQ2hm8oUx3ViHUL21aqWeZlJE4pDl+8zFHzMGuMm28XGCfaclbqM8O8UIkH2RHwIHK/Jl/yOpDYiR+LSIKRRwoRigs5zMnJo22wY7oc5SoA05ez21fXd23hOXRVv64yCqqq38KU1vk6DV91X/Gc3pLYKQ11XfUhT9H4a8nwpZJpiSaiYhm+Ujo92MtbqGFVT6wpJ0IcQtDpQO6P/aCdicCWCR8VYl86yq4Iy7UVTTNhTfJZFGeE5P+/ir71ut4Ovfpt0emeq15G/nF10er2Li/PzXq/b7XahRirkdCwv9sX3mag2Bf1Qpgl4khSOJjSy0PpDsE8cv9Q5KhhVCaSZNDNsozSxNkdpDmD5mCFl6DZJv6u6hfRCphQkk1lYpaeayawSQOesO55UbeW1PlRNFLviW8uO4RsQFlDt77CG9ti6tzbnr5G25raRaqPcXrd7KNaBifkqtCkDfUNFfgaKfaQvRev3ugbEtYIyScKmaXCuzpWtAt9HUlmtDslpnKOogTypCwxJnX9Brb9USvOjzEWzRsiJDbR14uixKsSitK5UpAu0jB1LzKq2WrnEzNAdTbs6SF6wc8h5t8tcrVl9x1YHPJ4d8vhgZKDMOv0XKtERlzcD8YRLsRHND2L/D8T+fEjse+smWik0oiMGxofpVKeam9USXaG9jz32D3a/f3bPj5XfaFh3CTxltFqHH7X4+ye1SqBAyixPtWWIwPPM2YdTYxWertYf+uq0cfBUxWTdTE/D0Xb0vWM2a8LaA/AmgIyohGYUjM1LNIKkeXi/bvR+/3gfOw5Wye12GHy3jqo1vA3XbIwOh5n2u/2JoNWGaTO10ckGpMs4SG9vBPhLBAnweTXeZyfdk9geltZTIaOQm12bS4hWEuyztdqmxNdfWdQwEj7TaZlLbdiJ4HLetaZtCEwbJNBvdWgNc+xJfZnB79h2tZpIjw8uryr+96eAblkzOpdOywnDMVyB0p6fFfSnMvf79w/tgF7cNh/rl+I/vpU4CsW6RzfcoUdr6AMk8ITL9uVKxa10hlKhi/HVr9/WUXTueZPt8oOqVSXrFZdxCvpH21ErvW4e7lnqza1GEXMbnFzwBY9c1J7asr5x4msP/t8KcmlmQc7Ytt6SE0Pu5tVeHsWgjmKxWtUW9/YJTVVtoCH+zbhU1d+uFbzr
+api: eJztWG1v2zYQ/ivEfVkLyI7TOdlmoMDSpgU8bEGQF/RDZhi0eLaYSKRKnux4hv77cKRsyy8d2qEDOqCfIkfH493zPHc6cgUKfep0SdoaGMB9qSShoAyFX3rC4gcvLq9uhUc3RydSa6Z6VjnJ5l1IgOTMw+ABrqzC8R/SyBkWaGh8cT0cK+PHtsRo7GGUwObXUMEAriviZVdIC+ueLq9uIQGPaeU0LWHwsII3KB26i4oy3sJEu8HCaUIY1aMESulkgYTOB3sjC4QBZNZTeExAc06lpAwScPix0g4VDMhVmOwlfifdDEnIGRoSaw+JcBgyV8LZirSZibnMKxQvxtIsEzGWef4yEdaJXE4wFx5zTMk68eIJl4Ng+rL7p4EEnjtWlrqTWoUzNB18Jic7EbwVzGWuGXYYbIJMCm1enybhzZhCbFAn4NMMC8lraFmyvSenzQwSKLT5Hc2MoTqtGRv2hJ7eWLVk+/3sU2sIDfErWZa5TgMvJ4+e0VgdbmQnj5gSJFA6ZpE0htCjLnzLUDonl/8i4/FCU2Yren2L0qXZpS2kNj6xhSYsSlomSs8x0WVEhsHYJfAKFyLXnoSdthTrBdmNapEVy+78MQR33Q2v530mdng9PxdSKYfes2uujK37LtTMSoh4rGLIXxeLkMQ+Cht9fgYWMTjRBPe5cHBa2hC6qUxxHAvrELIvVrXMy0yaqhhbN57KlA5Dv8tQ8HZrrJuqF5tgOANW7DLQsNOQBNmuuEhTLMmL9VbodBo8eqbzV961KxxO0aFJ0XN5crLb+njYT3yUAGnKOZHLq9u3YcfYJ29ijUFdRxe+tMbHwnjVe8V/dpNr67KKndZXaYreT6s8XwoZYkfFxHylAn20k7FWx8ibWldIggFUlVYH+mceHu1EDC9F5VEx6qWzHKqgTHvRtBeOFJ9lUQZ4zs56+HO/1+vgq18mnf6p6nfkT6fnnX7//PzsrN/v9Xo9iEhVOR2rlH05fiKrTYs/FG4CniRVR0scTVUwwfaJ85c6RwWjOoE0k2aGbZQm1uYozQEsHzKkDN2mDezqbyG9kClVksksrNJTzWTWCaBz1h0vs7byWp+uJotd8a1lx/ANCQuo9z2soT227q3N+fukrblppNoot9/rHYp1aEIFC23Kir6iIj8BxT7SF6L1e90NwlpBmSRh07RyLtbKVoHvA6msVofkNM5RRCC7sdWQ1PlndP8LpTQ/ylw0a4Sc2Iq2QRzdVlWhPa17FukCLWPHErOqrVZuMTN0R8suJskLdjY56/WYqzWr79jqgMfTQx7vjawos07/hUp0xMX1UDzhUmxE853Y/wOxPx4S+966iVYKjeiIofHVdKpTzeNria7Q3oep+zu73z67Z8fabzCMUwKfO1qjw/de/O2TWidQIGWWz7llFYDnU+gAToxVeLJaf+jrkybAExWKdXOeehhtD8O3zGYkrH0k3iSQEZXQHA7D8BKMIGke3q8Hvd8+3IWJg1Vysz0evltn1TrOPazZGB0eb9rv9s8IrTFMm6kNQTYgXYSj9faOgL9EkADvF/E+7fa6YTwsradCBiE3XptriVYR7LO12pbEl19iRBgJn+mkzKU2HETlcvYaaXsApg0SGLQmtIY5jiReb/A7tl2tJtLjvcvrmv/9sUK3jIzOpdNywnA8rEBpz88KBlOZ+/0biXZCL26aj/VL8R/fUxyFYj2jG57QgzUMABJ4wmX7uqXmUTpDqdCF/OLrtzGLzh072S4/6Fp1sl4RT3D/aDtqldf1/R1LvbnnKEJtg5MLvvKRixipLeMdFF+E8P9WkEszq+SMbaNLLgy5W1d7dRSSOorFahUt7uwTmrreQEP8m3Gp678BcwjDfg==
sidebar_class_name: "put api-method"
info_path: gen/api/agent-management-api
custom_edit_url: null
@@ -131,7 +131,7 @@ Update the system's DNS server configuration.
required={true}
schemaName={"string"}
qualifierMessage={undefined}
- schema={{"type":"string","x-oapi-codegen-extra-tags":{"validate":"required,alphanum"},"description":"The name of the network interface to apply DNS configuration to. Must only contain letters and numbers.\n"}}
+ schema={{"type":"string","x-oapi-codegen-extra-tags":{"validate":"required,alphanum_or_fact"},"description":"The name of the network interface to apply DNS configuration to. Accepts alphanumeric names or @fact. references.\n"}}
>
diff --git a/docs/docs/sidebar/architecture/job-architecture.md b/docs/docs/sidebar/architecture/job-architecture.md
index b4049bf7..056a8ebe 100644
--- a/docs/docs/sidebar/architecture/job-architecture.md
+++ b/docs/docs/sidebar/architecture/job-architecture.md
@@ -464,8 +464,8 @@ default:
Agents collect **system facts** independently from the job system. Facts are
typed system properties — architecture, kernel version, FQDN, CPU count, network
-interfaces, service manager, and package manager — gathered via providers on a
-60-second interval.
+interfaces, routes, primary interface, service manager, and package manager —
+gathered via providers on a 60-second interval.
Facts are stored in a dedicated `agent-facts` KV bucket with a 5-minute TTL,
separate from the `agent-registry` heartbeat bucket. This keeps the heartbeat
@@ -477,6 +477,11 @@ When the API serves an `AgentInfo` response (via `GET /node/{hostname}` or
and lightweight metrics, and facts for detailed system properties — into a
single unified response.
+Facts also power **fact references** (`@fact.*`) in job parameters. When an
+agent processes a job, it replaces `@fact.interface.primary`, `@fact.hostname`,
+and other tokens with live values from its cached facts. See
+[System Facts](../features/system-facts.md) for the full reference.
+
## Operation Examples
### System Operations
diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-facts-gathering.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-facts-gathering.md
deleted file mode 100644
index aed88aa4..00000000
--- a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-facts-gathering.md
+++ /dev/null
@@ -1,76 +0,0 @@
----
-title: System facts/inventory gathering
-status: backlog
-created: 2026-02-15
-updated: 2026-02-15
----
-
-## Objective
-
-Add a comprehensive system facts endpoint. Ansible's `setup` module (fact
-gathering) is run automatically on every play — it collects hardware, OS,
-network, and storage facts into a single structured document. This is invaluable
-for inventory and fleet management.
-
-## API Endpoints
-
-```
-GET /facts - Get all system facts
-GET /facts/{category} - Get facts by category (hardware, os,
- network, storage)
-```
-
-## Response Structure
-
-```json
-{
- "hostname": "server-01",
- "fqdn": "server-01.example.com",
- "os": {
- "distribution": "Ubuntu",
- "version": "24.04",
- "kernel": "6.8.0-45-generic",
- "arch": "x86_64"
- },
- "hardware": {
- "cpu_count": 4,
- "cpu_model": "Intel Xeon E-2236",
- "memory_total_mb": 32768,
- "swap_total_mb": 4096
- },
- "network": {
- "interfaces": [...],
- "default_gateway": "192.168.1.1",
- "dns_servers": [...]
- },
- "storage": {
- "disks": [...],
- "mounts": [...]
- },
- "virtualization": {
- "type": "kvm",
- "role": "guest"
- },
- "python_version": "3.12.3",
- "date_time": {...}
-}
-```
-
-## Operations
-
-- `facts.all.get` (query)
-- `facts.category.get` (query)
-
-## Provider
-
-- `internal/provider/node/facts/`
-- Aggregates data from existing providers (host, disk, mem, load) plus
- additional hardware detection (CPU model, virtualization type)
-- Use `lscpu`, `dmidecode`, `systemd-detect-virt`
-
-## Notes
-
-- This is essentially what Ansible gathers on every connection
-- Cache results (facts don't change frequently) with TTL
-- No auth for basic facts; detailed hardware may need `facts:read`
-- Useful for fleet management when querying multiple hosts via `_all`
diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-24-sdk-response-types.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-24-sdk-response-types.md
deleted file mode 100644
index a24a33b1..00000000
--- a/docs/docs/sidebar/development/tasks/backlog/2026-02-24-sdk-response-types.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-title: Investigate wrapping SDK gen response types
-status: backlog
-created: 2026-02-24
-updated: 2026-02-24
----
-
-## Objective
-
-Investigate whether the SDK should wrap the generated `gen.*Response` types in
-SDK-owned types rather than exposing them directly.
-
-Currently, consumers of the SDK (including the osapi CLI) import
-`github.com/osapi-io/osapi-sdk/pkg/osapi/gen` to access response types. This
-couples consumers to the oapi-codegen output format.
-
-## Considerations
-
-- Wrapping types adds a translation layer but provides stability across codegen
- changes.
-- Direct gen types are simpler and avoid duplication, but any codegen change
- (field renames, type changes) ripples to all consumers.
-- The CLI currently accesses `resp.JSON200`, `resp.StatusCode()`, `resp.Body`,
- etc. directly on gen response types.
-- The `internal/cli/ui.go` and `internal/audit/export/` packages also depend on
- gen types (`gen.JobDetailResponse`, `gen.AuditEntry`, `gen.StatusResponse`,
- `gen.QueueStatsResponse`).
-
-## Notes
-
-This task was created as part of the internal/client to osapi-sdk migration. The
-current approach (returning gen types directly) was chosen for simplicity.
-Revisit once the SDK API stabilizes.
diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-25-feat-declarative-playbook-engine.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-25-feat-declarative-playbook-engine.md
deleted file mode 100644
index a2499a86..00000000
--- a/docs/docs/sidebar/development/tasks/backlog/2026-02-25-feat-declarative-playbook-engine.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-title: Declarative playbook engine (osapi-apply)
-status: backlog
-created: 2026-02-25
-updated: 2026-02-25
----
-
-## Objective
-
-Build a declarative automation layer on top of the `osapi-sdk` that parses YAML
-task files and executes steps via the SDK — similar to Ansible playbooks but
-simpler. This is the planned `osapi-apply` orchestration tool.
-
-## Motivation
-
-The SDK provides composable primitives (`client.Command.Exec()`,
-`client.Network.DNS.Get()`, etc.) for programmatic Go usage. A declarative
-engine would let operators define desired system state in YAML and apply it
-without writing Go code:
-
-```yaml
-tasks:
- - name: Set DNS servers
- network.dns.update:
- interface: eth0
- servers: [1.1.1.1, 8.8.8.8]
- target: _all
-
- - name: Verify connectivity
- command.exec:
- command: ping
- args: [-c, '1', '1.1.1.1']
- target: _all
-```
-
-The `changed` field added to mutation responses is the foundation for reporting
-convergence status (e.g., "3 of 5 operations changed, 2 already converged").
-
-## Design Considerations
-
-- **Playbook format** — YAML task files with step names, module references, and
- parameters
-- **Execution model** — sequential steps with optional conditionals and error
- handling
-- **Change reporting** — aggregate `changed` status across steps to report
- convergence
-- **Targeting** — inherit SDK target routing (`_any`, `_all`, hostname, label
- selectors)
-- **Dry-run mode** — preview what would change without applying
-
-## Notes
-
-- Spun out from the completed Client SDK task which delivered the programmatic
- SDK layer
-- The `changed` field (done) is a prerequisite for meaningful convergence
- reporting
diff --git a/docs/docs/sidebar/features/command-execution.md b/docs/docs/sidebar/features/command-execution.md
index 513e6958..6491a75a 100644
--- a/docs/docs/sidebar/features/command-execution.md
+++ b/docs/docs/sidebar/features/command-execution.md
@@ -44,6 +44,20 @@ for usage and examples, or the
[API Reference](/gen/api/node-management-api-command-operations) for the REST
endpoints.
+## Fact References
+
+Command arguments support `@fact.*` references that resolve to live system
+values on the executing agent. This is especially useful with broadcast
+targeting, where each agent substitutes its own values:
+
+```bash
+$ osapi client node command exec \
+ --command ip --args "addr,show,dev,@fact.interface.primary" \
+ --target _all
+```
+
+See [System Facts](system-facts.md) for the full list of available references.
+
## Use Cases
- **Ad-hoc debugging** -- quickly check a process table, inspect a log file, or
@@ -100,6 +114,7 @@ roles or tokens explicitly when needed.
- [CLI Reference](../usage/cli/client/node/command/command.mdx) -- command
execution commands (exec, shell)
+- [System Facts](system-facts.md) -- available `@fact.*` references
- [API Reference](/gen/api/node-management-api-command-operations) -- REST API
documentation
- [Job System](job-system.md) -- how async job processing works
diff --git a/docs/docs/sidebar/features/network-management.md b/docs/docs/sidebar/features/network-management.md
index a681b5c0..81496a78 100644
--- a/docs/docs/sidebar/features/network-management.md
+++ b/docs/docs/sidebar/features/network-management.md
@@ -19,7 +19,9 @@ unprivileged while agents execute the actual changes.
**DNS** -- queries read the current nameserver configuration for a network
interface. Updates modify the nameservers and search domains, applying changes
-through the host's network manager.
+through the host's network manager. The `--interface-name` parameter supports
+[fact references](system-facts.md) — use `@fact.interface.primary` to
+automatically target the default route interface.
**Ping** -- sends ICMP echo requests to a target host and reports the results.
@@ -49,6 +51,7 @@ The `read` role includes only `network:read`.
- [CLI Reference](../usage/cli/client/node/network/network.mdx) -- network
commands
+- [System Facts](system-facts.md) -- available `@fact.*` references
- [API Reference](/gen/api/node-management-api-network-operations) -- REST API
documentation
- [Job System](job-system.md) -- how async job processing works
diff --git a/docs/docs/sidebar/features/node-management.md b/docs/docs/sidebar/features/node-management.md
index a9eb931d..3583ee7c 100644
--- a/docs/docs/sidebar/features/node-management.md
+++ b/docs/docs/sidebar/features/node-management.md
@@ -29,14 +29,14 @@ OSAPI separates agent fleet discovery from node system queries:
## What It Manages
-| Resource | Description |
-| ------------ | ------------------------------------------------------- |
-| Hostname | System hostname |
-| Status | Uptime, OS name and version, kernel, platform info |
-| Disk | Per-mount usage (total, used, free, percent) |
-| Memory | RAM and swap usage (total, used, free, percent) |
-| Load | 1-, 5-, and 15-minute load averages |
-| System Facts | Architecture, kernel, FQDN, CPUs, NICs, service/pkg mgr |
+| Resource | Description |
+| ------------------------------- | --------------------------------------------------------------- |
+| Hostname | System hostname |
+| Status | Uptime, OS name and version, kernel, platform info |
+| Disk | Per-mount usage (total, used, free, percent) |
+| Memory | RAM and swap usage (total, used, free, percent) |
+| Load | 1-, 5-, and 15-minute load averages |
+| [System Facts](system-facts.md) | Architecture, kernel, FQDN, CPUs, NICs, routes, service/pkg mgr |
## How It Works
@@ -73,6 +73,7 @@ and `read` roles all include both permissions.
- [Agent CLI Reference](../usage/cli/client/agent/agent.mdx) -- agent fleet
commands
- [Node CLI Reference](../usage/cli/client/node/node.mdx) -- node job commands
+- [System Facts](system-facts.md) -- fact collection and `@fact.*` references
- [API Reference](/gen/api/node-management-api-node-operations) -- REST API
documentation
- [Job System](job-system.md) -- how async job processing works
diff --git a/docs/docs/sidebar/features/system-facts.md b/docs/docs/sidebar/features/system-facts.md
new file mode 100644
index 00000000..31bad709
--- /dev/null
+++ b/docs/docs/sidebar/features/system-facts.md
@@ -0,0 +1,181 @@
+---
+sidebar_position: 5
+---
+
+# System Facts
+
+Agents automatically collect **system facts** — typed properties about the host
+they run on — and publish them to a dedicated NATS KV bucket. Facts power two
+features: the `agent get` display and **fact references** (`@fact.*`) that let
+you inject live system values into job parameters.
+
+## What Gets Collected
+
+Facts are gathered from providers every 60 seconds (configurable via
+`agent.facts.interval`) and stored in the `agent-facts` KV bucket with a
+5-minute TTL.
+
+| Fact | Description | Example Value |
+| ----------------- | ----------------------------------------------- | -------------------- |
+| Architecture | CPU architecture | `amd64`, `arm64` |
+| Kernel Version | OS kernel version string | `6.8.0-51-generic` |
+| FQDN | Fully qualified domain name | `web-01.example.com` |
+| CPU Count | Number of logical CPUs | `8` |
+| Service Manager | Init system | `systemd`, `launchd` |
+| Package Manager | System package manager | `apt`, `brew` |
+| Interfaces | Network interfaces with IPv4, IPv6, MAC, family | See below |
+| Primary Interface | Interface name of the default route | `eth0`, `en0` |
+| Routes | IP routing table entries | See below |
+
+### Network Interfaces
+
+Each interface entry includes:
+
+- **Name** — interface name (e.g., `eth0`, `en0`)
+- **IPv4** — IPv4 address (if assigned)
+- **IPv6** — IPv6 address (if assigned)
+- **MAC** — hardware address
+- **Family** — `inet`, `inet6`, or `dual`
+
+Only non-loopback, up interfaces are included.
+
+### Routes
+
+Each route entry includes:
+
+- **Destination** — target network or `default` / `0.0.0.0`
+- **Gateway** — next-hop address
+- **Interface** — outgoing interface name
+- **Mask** — CIDR mask (Linux only, e.g., `/24`)
+- **Metric** — route metric (Linux only)
+- **Flags** — route flags
+
+## Fact References (`@fact.*`)
+
+Fact references let you use live system values in job parameters. When an agent
+processes a job, it replaces any `@fact.*` token in the request data with the
+corresponding value from its cached facts. This happens transparently — the CLI
+and API send the literal `@fact.*` string, and the agent resolves it before
+executing the operation.
+
+### Available References
+
+| Reference | Resolves To | Example Value |
+| ------------------------- | ------------------------ | -------------------- |
+| `@fact.hostname` | Agent's hostname | `web-01` |
+| `@fact.arch` | CPU architecture | `amd64` |
+| `@fact.kernel` | Kernel version | `6.8.0-51-generic` |
+| `@fact.fqdn` | Fully qualified hostname | `web-01.example.com` |
+| `@fact.interface.primary` | Default route interface | `eth0` |
+| `@fact.custom.` | Custom fact value | _(user-defined)_ |
+
+### Usage Examples
+
+Query DNS configuration on the primary network interface:
+
+```bash
+osapi client node network dns get \
+ --interface-name @fact.interface.primary
+```
+
+Echo the hostname on the remote host:
+
+```bash
+osapi client node command exec \
+ --command echo --args "@fact.hostname"
+```
+
+Use multiple references in a single command:
+
+```bash
+osapi client node command exec \
+ --command echo \
+ --args "@fact.interface.primary on @fact.hostname"
+```
+
+Use fact references with broadcast targeting:
+
+```bash
+osapi client node command exec \
+ --command ip --args "addr,show,dev,@fact.interface.primary" \
+ --target _all
+```
+
+### How It Works
+
+1. The CLI sends the literal `@fact.*` string in the job request data
+2. The API server publishes the job to NATS as-is
+3. The agent receives the job and checks the request data for `@fact.*` tokens
+4. Each token is resolved against the agent's locally cached facts
+5. The resolved data is passed to the provider for execution
+
+Because resolution happens agent-side, fact references work correctly with
+broadcast (`_all`) and label-based routing — each agent substitutes its own
+values. For example, `@fact.interface.primary` resolves to `eth0` on one host
+and `en0` on another.
+
+If a referenced fact is not available (e.g., the agent hasn't collected facts
+yet, or the fact key doesn't exist), the job fails with an error describing
+which reference could not be resolved.
+
+### Supported Contexts
+
+Fact references work in any string value within job request data:
+
+- **Command arguments** — `--args "@fact.hostname"`
+- **DNS interface name** — `--interface-name @fact.interface.primary`
+- **Nested values** — references inside maps and arrays are resolved recursively
+
+Non-string values (numbers, booleans) are not modified.
+
+## Viewing Facts
+
+Use `agent get` to see the full facts for a specific agent:
+
+```bash
+osapi client agent get --hostname web-01
+```
+
+The output includes architecture, kernel, FQDN, CPUs, service/package manager,
+network interfaces, and routes. Use `--json` for the complete structured data.
+
+## Configuration
+
+| Key | Description | Default |
+| ---------------------- | ------------------------------------ | ------------- |
+| `agent.facts.interval` | How often facts are collected | `60s` |
+| `nats.facts.bucket` | KV bucket name for facts storage | `agent-facts` |
+| `nats.facts.ttl` | TTL for facts entries | `5m` |
+| `nats.facts.storage` | Storage backend (`file` or `memory`) | `file` |
+
+See [Configuration](../usage/configuration.md) for the full reference.
+
+## Platform Support
+
+Facts are collected using platform-specific providers. All facts are available
+on both Linux and macOS:
+
+| Fact | Linux Provider | macOS Provider |
+| ----------------- | ------------------------- | ------------------------- |
+| Architecture | `gopsutil` | `gopsutil` |
+| Kernel Version | `gopsutil` | `gopsutil` |
+| FQDN | `gopsutil` | `gopsutil` |
+| CPU Count | `gopsutil` | `gopsutil` |
+| Service Manager | `gopsutil` | `gopsutil` |
+| Package Manager | `gopsutil` | `gopsutil` |
+| Interfaces | `net.Interfaces` (stdlib) | `net.Interfaces` (stdlib) |
+| Primary Interface | `/proc/net/route` parsing | `netstat -rn` parsing |
+| Routes | `/proc/net/route` parsing | `netstat -rn` parsing |
+
+Provider errors are non-fatal — if a provider fails, the agent still publishes
+whatever facts it could gather. This means `@fact.interface.primary` may be
+unavailable if route collection fails, but `@fact.hostname` and `@fact.arch`
+will still work.
+
+## Related
+
+- [Agent CLI Reference](../usage/cli/client/agent/agent.mdx) -- view agent facts
+- [Command Execution](command-execution.md) -- use `@fact.*` in commands
+- [Network Management](network-management.md) -- use `@fact.*` in DNS queries
+- [Node Management](node-management.md) -- agent vs. node overview
+- [Configuration](../usage/configuration.md) -- facts interval and KV settings
diff --git a/docs/docs/sidebar/usage/cli/client/node/command/exec.md b/docs/docs/sidebar/usage/cli/client/node/command/exec.md
index 4674ec78..39d89f3a 100644
--- a/docs/docs/sidebar/usage/cli/client/node/command/exec.md
+++ b/docs/docs/sidebar/usage/cli/client/node/command/exec.md
@@ -45,6 +45,18 @@ Target by label to execute on a group of servers:
$ osapi client node command exec --command whoami --target group:web
```
+Use `@fact.*` references to inject live system values. Each agent resolves its
+own facts, so this works correctly with broadcast targeting:
+
+```bash
+$ osapi client node command exec \
+ --command ip --args "addr,show,dev,@fact.interface.primary" \
+ --target _all
+```
+
+See [System Facts](../../../../../features/system-facts.md) for all available
+`@fact.*` references.
+
## JSON Output
Use `--json` to get the full untruncated API response:
diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts
index a1927b92..68fcf3d9 100644
--- a/docs/docusaurus.config.ts
+++ b/docs/docusaurus.config.ts
@@ -90,6 +90,11 @@ const config: Config = {
label: 'Network Management',
docId: 'sidebar/features/network-management'
},
+ {
+ type: 'doc',
+ label: 'System Facts',
+ docId: 'sidebar/features/system-facts'
+ },
{
type: 'doc',
label: 'Agent Lifecycle',
diff --git a/docs/plans/2026-03-05-agent-facts-routes-factref.md b/docs/plans/2026-03-05-agent-facts-routes-factref.md
new file mode 100644
index 00000000..06c40a87
--- /dev/null
+++ b/docs/plans/2026-03-05-agent-facts-routes-factref.md
@@ -0,0 +1,239 @@
+# Agent Facts, Routes, Fact References, and Timeline Fix
+
+## Context
+
+Agents collect system facts (OS, memory, load, interfaces, etc.) but lack two
+useful capabilities: (1) knowing the primary network interface and full routing
+table, and (2) allowing CLI/API parameters to reference agent facts dynamically.
+For example, a user should be able to run:
+
+```
+osapi client network dns get --interface-name @fact.interface.primary --target _all
+```
+
+...and have each agent resolve `@fact.interface.primary` to its own primary
+interface before executing the operation.
+
+Additionally, the `agent get` CLI output is missing timeline events
+(cordon/uncordon history) — the data path exists but timeline should always be
+displayed.
+
+This is a multi-phase effort. All phases stay on a single branch before pushing
+upstream.
+
+## Repo
+
+All changes in `osapi` at `/Users/john/git/osapi-io/osapi/`.
+
+---
+
+## Phase 1: Fix Timeline Display and Configs
+
+### Step 1.1: Sync local/nerd configs with osapi.yaml
+
+`configs/osapi.local.yaml` and `configs/osapi.nerd.yaml` are missing sections
+that exist in `osapi.yaml`:
+
+- **`nats.state`** — missing in both. This is why timeline isn't showing:
+ `stateKV` is nil so `GetAgentTimeline()` returns early.
+- **`nats.facts`** — missing in `osapi.nerd.yaml`
+- **`telemetry.metrics`** — missing in both
+- **`agent.facts`** — missing in `osapi.nerd.yaml`
+- **`agent.conditions`** — missing in both
+
+Add these sections to both configs to match `osapi.yaml`.
+
+### Step 1.2: Always show Timeline section in agent get CLI
+
+File: `cmd/client_agent_get.go`
+
+Line 169: change `if len(data.Timeline) > 0` to always display the Timeline
+section. Show empty table or "No events" when empty.
+
+### Step 1.3: Always show Timeline section in job get CLI
+
+File: `internal/cli/ui.go`
+
+Line 601: same fix — change `if len(resp.Timeline) > 0` to always display the
+Timeline section for job details.
+
+---
+
+## Phase 2: Route Collection and Primary Interface
+
+### Step 2.1: Add Route type to job types
+
+File: `internal/job/types.go`
+
+Add a `Route` struct:
+
+```go
+type Route struct {
+ Destination string `json:"destination"`
+ Gateway string `json:"gateway"`
+ Interface string `json:"interface"`
+ Mask string `json:"mask,omitempty"`
+ Metric int `json:"metric,omitempty"`
+ Flags string `json:"flags,omitempty"`
+}
+```
+
+Add fields to `FactsRegistration`:
+
+```go
+PrimaryInterface string `json:"primary_interface,omitempty"`
+Routes []Route `json:"routes,omitempty"`
+```
+
+Add same fields to `AgentInfo`.
+
+### Step 2.2: Add route provider to netinfo
+
+File: `internal/provider/network/netinfo/types.go`
+
+Extend `Provider` interface:
+
+```go
+type Provider interface {
+ GetInterfaces() ([]job.NetworkInterface, error)
+ GetRoutes() ([]job.Route, error)
+ GetPrimaryInterface() (string, error)
+}
+```
+
+### Step 2.3: Linux route implementation
+
+File: `internal/provider/network/netinfo/linux_get_routes.go` (build tag:
+`//go:build linux`)
+
+Parse `/proc/net/route` using Go (no exec). Use injectable `RouteReaderFn`
+(defaults to `os.Open("/proc/net/route")`) for testing. The default route
+(destination `00000000`) determines the primary interface.
+
+Return all routes as `[]job.Route` and identify the primary interface from the
+default route entry.
+
+### Step 2.4: Darwin route stub
+
+File: `internal/provider/network/netinfo/darwin_get_routes.go` (build tag:
+`//go:build darwin`)
+
+Stub that returns empty routes and empty primary interface (or uses a heuristic
+like first interface with a default gateway). Darwin route detection can be
+improved later.
+
+### Step 2.5: Collect routes in agent facts
+
+File: `internal/agent/facts.go`
+
+In `writeFacts()`, call `a.netinfoProvider.GetRoutes()` and
+`a.netinfoProvider.GetPrimaryInterface()`. Add results to `FactsRegistration`.
+
+Cache `FactsRegistration` on the Agent struct as `cachedFacts` for use by fact
+reference resolution (Phase 3).
+
+### Step 2.6: Expose via API and CLI
+
+- `internal/job/client/query.go` `mergeFacts()`: map new fields
+- `internal/api/agent/gen/api.yaml`: add `primary_interface` and `routes` to
+ AgentInfo schema
+- `internal/api/agent/agent_list.go` `buildAgentInfo()`: map fields
+- `cmd/client_agent_get.go`: display primary interface and routes
+- SDK: update `Agent` type and agent spec
+
+### Step 2.7: Tests
+
+- `internal/provider/network/netinfo/linux_get_routes_public_test.go`:
+ table-driven tests for `/proc/net/route` parsing (mock file content via
+ `RouteReaderFn`)
+- Update existing facts test to verify new fields
+
+---
+
+## Phase 3: `@fact.X` Resolution
+
+### Step 3.1: Fact reference resolver
+
+New file: `internal/agent/factref.go`
+
+```go
+func ResolveFacts(
+ params map[string]any,
+ facts *job.FactsRegistration,
+) (map[string]any, error)
+```
+
+Walk all string values in the params map. For each string containing `@fact.X`,
+resolve against the facts struct:
+
+- `@fact.interface.primary` → `facts.PrimaryInterface`
+- `@fact.hostname` → agent hostname
+- `@fact.arch` → `facts.Architecture`
+- `@fact.os` → `facts.OSInfo` distribution
+- `@fact.kernel` → `facts.KernelVersion`
+- Extensible: `@fact.custom.X` → `facts.Facts["X"]`
+
+If a reference cannot be resolved, return an error (fail the job). Multiple
+references in one string are supported:
+`"@fact.interface.primary on @fact.hostname"` → `"eth0 on web-01"`.
+
+### Step 3.2: Inject resolution in handler
+
+File: `internal/agent/handler.go`
+
+In `handleJobMessage()`, after unmarshalling the `jobRequest` (line ~163) and
+before `processJobOperation()` (line ~225), call `ResolveFacts()` on the job
+request parameters using the agent's cached facts. Replace the request params
+with resolved values.
+
+If resolution fails (unresolvable reference), fail the job with an error message
+indicating which fact reference could not be resolved.
+
+### Step 3.3: Tests
+
+File: `internal/agent/factref_public_test.go`
+
+Table-driven tests:
+
+- Simple substitution (`@fact.interface.primary` → `eth0`)
+- Multiple references in one string
+- Nested map values
+- Unknown fact reference → error
+- No `@fact.` references → params unchanged
+- Nil facts → error for any reference
+- Custom facts via `@fact.custom.X`
+
+---
+
+## Phase 4 (Future): File Upload and Templates
+
+Deferred — will be planned separately after Phases 1-3 are complete. Will use
+NATS Object Store for blob storage and Go `text/template` for file content
+rendering with fact data.
+
+---
+
+## Verification
+
+After each phase:
+
+```bash
+go build ./...
+just go::unit
+just go::vet
+```
+
+Integration test after Phase 2:
+
+```bash
+# Start osapi, then:
+go run main.go client agent get --hostname --json | jq .primary_interface
+go run main.go client agent get --hostname --json | jq .routes
+```
+
+Integration test after Phase 3:
+
+```bash
+go run main.go client network dns get \
+ --interface-name @fact.interface.primary --target _all
+```
diff --git a/go.mod b/go.mod
index b53e2db0..b8ce9c40 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,7 @@ require (
github.com/oapi-codegen/runtime v1.2.0
github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86
github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848
- github.com/osapi-io/osapi-sdk v0.0.0-20260306002247-11cb3395b3f9
+ github.com/osapi-io/osapi-sdk v0.0.0-20260306055249-0916698b04ef
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2
github.com/samber/slog-echo v1.21.0
diff --git a/go.sum b/go.sum
index 30b725b2..fd632e25 100644
--- a/go.sum
+++ b/go.sum
@@ -755,8 +755,8 @@ github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86 h1:ML0fdgr0M4
github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86/go.mod h1:TQqODOjF2JuAOFrLtm1ItsMzPPAizKfHo+grOMuPDyE=
github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848 h1:ELW1sTVBn5JIc17mHgd5fhpO3/7btaxJpxykG2Fe0U4=
github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848/go.mod h1:4rzeY9jiJF/+Ej4WNwqK5HQ2sflZrEs60GxQpg3Iya8=
-github.com/osapi-io/osapi-sdk v0.0.0-20260306002247-11cb3395b3f9 h1:v7MKMVLktP3FotS5josRw5DlOKEsIwOQFAj2cd04VwE=
-github.com/osapi-io/osapi-sdk v0.0.0-20260306002247-11cb3395b3f9/go.mod h1:gL9oHgIkG+VMazSIXO4Nvwd3IXEuzRvuXstGiphSycc=
+github.com/osapi-io/osapi-sdk v0.0.0-20260306055249-0916698b04ef h1:F0+X0uOVGuHIaui62KTmyhZRBIeL0PXurEPevZXGmDU=
+github.com/osapi-io/osapi-sdk v0.0.0-20260306055249-0916698b04ef/go.mod h1:gL9oHgIkG+VMazSIXO4Nvwd3IXEuzRvuXstGiphSycc=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
diff --git a/internal/agent/condition.go b/internal/agent/condition.go
index db786c99..d6b43163 100644
--- a/internal/agent/condition.go
+++ b/internal/agent/condition.go
@@ -60,7 +60,7 @@ func transitionTime(
}
func evaluateMemoryPressure(
- stats *mem.Stats,
+ stats *mem.Result,
threshold int,
prev []job.Condition,
) job.Condition {
@@ -85,7 +85,7 @@ func evaluateMemoryPressure(
}
func evaluateHighLoad(
- loadAvg *load.AverageStats,
+ loadAvg *load.Result,
cpuCount int,
multiplier float64,
prev []job.Condition,
@@ -108,7 +108,7 @@ func evaluateHighLoad(
}
func evaluateDiskPressure(
- disks []disk.UsageStats,
+ disks []disk.Result,
threshold int,
prev []job.Condition,
) job.Condition {
diff --git a/internal/agent/condition_test.go b/internal/agent/condition_test.go
index 720c971e..1a27d183 100644
--- a/internal/agent/condition_test.go
+++ b/internal/agent/condition_test.go
@@ -190,14 +190,14 @@ func (s *ConditionTestSuite) TestTransitionTime() {
func (s *ConditionTestSuite) TestEvaluateMemoryPressure() {
tests := []struct {
name string
- stats *mem.Stats
+ stats *mem.Result
threshold int
prev []job.Condition
validateFunc func(job.Condition)
}{
{
name: "when usage above threshold returns true with reason",
- stats: &mem.Stats{
+ stats: &mem.Result{
Total: 8 * 1024 * 1024 * 1024, // 8 GB
Available: 1 * 1024 * 1024 * 1024, // 1 GB available = 87.5% used
},
@@ -213,7 +213,7 @@ func (s *ConditionTestSuite) TestEvaluateMemoryPressure() {
},
{
name: "when usage below threshold returns false",
- stats: &mem.Stats{
+ stats: &mem.Result{
Total: 8 * 1024 * 1024 * 1024, // 8 GB
Available: 6 * 1024 * 1024 * 1024, // 6 GB available = 25% used
},
@@ -238,7 +238,7 @@ func (s *ConditionTestSuite) TestEvaluateMemoryPressure() {
},
{
name: "when total is zero returns false",
- stats: &mem.Stats{
+ stats: &mem.Result{
Total: 0,
Available: 0,
},
@@ -252,7 +252,7 @@ func (s *ConditionTestSuite) TestEvaluateMemoryPressure() {
},
{
name: "when usage exactly at threshold returns false",
- stats: &mem.Stats{
+ stats: &mem.Result{
Total: 100,
Available: 20, // 80% used, threshold is 80 (> not >=)
},
@@ -277,7 +277,7 @@ func (s *ConditionTestSuite) TestEvaluateMemoryPressure() {
func (s *ConditionTestSuite) TestEvaluateHighLoad() {
tests := []struct {
name string
- loadAvg *load.AverageStats
+ loadAvg *load.Result
cpuCount int
multiplier float64
prev []job.Condition
@@ -285,7 +285,7 @@ func (s *ConditionTestSuite) TestEvaluateHighLoad() {
}{
{
name: "when load above threshold returns true with reason",
- loadAvg: &load.AverageStats{
+ loadAvg: &load.Result{
Load1: 8.5,
Load5: 7.0,
Load15: 6.0,
@@ -303,7 +303,7 @@ func (s *ConditionTestSuite) TestEvaluateHighLoad() {
},
{
name: "when load below threshold returns false",
- loadAvg: &load.AverageStats{
+ loadAvg: &load.Result{
Load1: 2.0,
Load5: 1.5,
Load15: 1.0,
@@ -331,7 +331,7 @@ func (s *ConditionTestSuite) TestEvaluateHighLoad() {
},
{
name: "when cpu count is zero returns false",
- loadAvg: &load.AverageStats{
+ loadAvg: &load.Result{
Load1: 8.5,
Load5: 7.0,
Load15: 6.0,
@@ -347,7 +347,7 @@ func (s *ConditionTestSuite) TestEvaluateHighLoad() {
},
{
name: "when load exactly at threshold returns false",
- loadAvg: &load.AverageStats{
+ loadAvg: &load.Result{
Load1: 8.0,
Load5: 5.0,
Load15: 3.0,
@@ -374,14 +374,14 @@ func (s *ConditionTestSuite) TestEvaluateHighLoad() {
func (s *ConditionTestSuite) TestEvaluateDiskPressure() {
tests := []struct {
name string
- disks []disk.UsageStats
+ disks []disk.Result
threshold int
prev []job.Condition
validateFunc func(job.Condition)
}{
{
name: "when one disk above threshold returns true",
- disks: []disk.UsageStats{
+ disks: []disk.Result{
{
Name: "/dev/sda1",
Total: 100 * 1024 * 1024 * 1024, // 100 GB
@@ -401,7 +401,7 @@ func (s *ConditionTestSuite) TestEvaluateDiskPressure() {
},
{
name: "when all disks below threshold returns false",
- disks: []disk.UsageStats{
+ disks: []disk.Result{
{
Name: "/dev/sda1",
Total: 100 * 1024 * 1024 * 1024,
@@ -436,7 +436,7 @@ func (s *ConditionTestSuite) TestEvaluateDiskPressure() {
},
{
name: "when disks is empty returns false",
- disks: []disk.UsageStats{},
+ disks: []disk.Result{},
threshold: 90,
prev: nil,
validateFunc: func(c job.Condition) {
@@ -447,7 +447,7 @@ func (s *ConditionTestSuite) TestEvaluateDiskPressure() {
},
{
name: "when disk total is zero skips it",
- disks: []disk.UsageStats{
+ disks: []disk.Result{
{
Name: "/dev/sda1",
Total: 0,
@@ -465,7 +465,7 @@ func (s *ConditionTestSuite) TestEvaluateDiskPressure() {
},
{
name: "when second disk is above threshold reports it",
- disks: []disk.UsageStats{
+ disks: []disk.Result{
{
Name: "/dev/sda1",
Total: 100 * 1024 * 1024 * 1024,
@@ -510,7 +510,7 @@ func (s *ConditionTestSuite) TestLastTransitionTimeTracking() {
name: "when status flips from false to true transition time updates",
evalFunc: func(prev []job.Condition) job.Condition {
return evaluateMemoryPressure(
- &mem.Stats{
+ &mem.Result{
Total: 100,
Available: 10, // 90% used
},
@@ -535,7 +535,7 @@ func (s *ConditionTestSuite) TestLastTransitionTimeTracking() {
name: "when status stays true transition time is preserved",
evalFunc: func(prev []job.Condition) job.Condition {
return evaluateMemoryPressure(
- &mem.Stats{
+ &mem.Result{
Total: 100,
Available: 10, // 90% used
},
@@ -559,7 +559,7 @@ func (s *ConditionTestSuite) TestLastTransitionTimeTracking() {
name: "when status flips from true to false transition time updates",
evalFunc: func(prev []job.Condition) job.Condition {
return evaluateMemoryPressure(
- &mem.Stats{
+ &mem.Result{
Total: 100,
Available: 80, // 20% used
},
@@ -584,7 +584,7 @@ func (s *ConditionTestSuite) TestLastTransitionTimeTracking() {
name: "when status stays false transition time is preserved",
evalFunc: func(prev []job.Condition) job.Condition {
return evaluateMemoryPressure(
- &mem.Stats{
+ &mem.Result{
Total: 100,
Available: 80, // 20% used
},
diff --git a/internal/agent/consumer.go b/internal/agent/consumer.go
index 03b95168..b5899a04 100644
--- a/internal/agent/consumer.go
+++ b/internal/agent/consumer.go
@@ -233,7 +233,9 @@ func (a *Agent) startConsumers() {
// goroutines to finish. After this returns, the agent is no longer
// receiving new jobs.
func (a *Agent) stopConsumers() {
- a.consumerCancel()
+ if a.consumerCancel != nil {
+ a.consumerCancel()
+ }
a.consumerWg.Wait()
}
diff --git a/internal/agent/factory.go b/internal/agent/factory.go
index f0028150..cb79221b 100644
--- a/internal/agent/factory.go
+++ b/internal/agent/factory.go
@@ -72,9 +72,7 @@ func (f *ProviderFactory) CreateProviders() (
}
if platform == "darwin" {
- f.logger.Warn("running on darwin with development providers",
- slog.String("note", "DNS and ping return mock data"),
- )
+ f.logger.Info("running on darwin")
}
// Create system providers
@@ -125,7 +123,7 @@ func (f *ProviderFactory) CreateProviders() (
case "ubuntu":
dnsProvider = dns.NewUbuntuProvider(f.logger, execManager)
case "darwin":
- dnsProvider = dns.NewDarwinProvider()
+ dnsProvider = dns.NewDarwinProvider(f.logger, execManager)
default:
dnsProvider = dns.NewLinuxProvider()
}
@@ -141,7 +139,13 @@ func (f *ProviderFactory) CreateProviders() (
}
// Create network info provider
- netinfoProvider := netinfo.New()
+ var netinfoProvider netinfo.Provider
+ switch platform {
+ case "darwin":
+ netinfoProvider = netinfo.NewDarwinProvider(execManager)
+ default:
+ netinfoProvider = netinfo.NewLinuxProvider()
+ }
// Create command provider (cross-platform, uses exec.Manager)
commandProvider := command.New(f.logger, execManager)
diff --git a/internal/agent/factref.go b/internal/agent/factref.go
new file mode 100644
index 00000000..87d0e983
--- /dev/null
+++ b/internal/agent/factref.go
@@ -0,0 +1,181 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package agent
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/retr0h/osapi/internal/job"
+)
+
+const factPrefix = "@fact."
+
+// ResolveFacts walks all string values in params and replaces @fact.X
+// references with values from the agent's cached facts. Returns a new
+// map with resolved values, or an error if any reference cannot be resolved.
+func ResolveFacts(
+ params map[string]any,
+ facts *job.FactsRegistration,
+ hostname string,
+) (map[string]any, error) {
+ if params == nil {
+ return nil, nil
+ }
+
+ result := make(map[string]any, len(params))
+ for k, v := range params {
+ resolved, err := resolveValue(v, facts, hostname)
+ if err != nil {
+ return nil, err
+ }
+ result[k] = resolved
+ }
+
+ return result, nil
+}
+
+// resolveValue resolves @fact.X references in a single value.
+func resolveValue(
+ v any,
+ facts *job.FactsRegistration,
+ hostname string,
+) (any, error) {
+ switch val := v.(type) {
+ case string:
+ return resolveString(val, facts, hostname)
+ case map[string]any:
+ return ResolveFacts(val, facts, hostname)
+ case []any:
+ return resolveSlice(val, facts, hostname)
+ default:
+ return v, nil
+ }
+}
+
+// resolveSlice resolves @fact.X references in each element of a slice.
+func resolveSlice(
+ s []any,
+ facts *job.FactsRegistration,
+ hostname string,
+) ([]any, error) {
+ result := make([]any, len(s))
+ for i, v := range s {
+ resolved, err := resolveValue(v, facts, hostname)
+ if err != nil {
+ return nil, err
+ }
+ result[i] = resolved
+ }
+ return result, nil
+}
+
+// resolveString replaces all @fact.X references in a string.
+func resolveString(
+ s string,
+ facts *job.FactsRegistration,
+ hostname string,
+) (string, error) {
+ if !strings.Contains(s, factPrefix) {
+ return s, nil
+ }
+
+ result := s
+ for strings.Contains(result, factPrefix) {
+ start := strings.Index(result, factPrefix)
+ // Find the end of the reference (next space, quote, or end of string)
+ end := len(result)
+ for i := start + len(factPrefix); i < len(result); i++ {
+ c := result[i]
+ if c == ' ' || c == '"' || c == '\'' || c == ',' || c == ';' {
+ end = i
+ break
+ }
+ }
+
+ ref := result[start:end]
+ key := result[start+len(factPrefix) : end]
+
+ replacement, err := lookupFact(key, facts, hostname)
+ if err != nil {
+ return "", fmt.Errorf("unresolvable fact reference %q: %w", ref, err)
+ }
+
+ result = result[:start] + replacement + result[end:]
+ }
+
+ return result, nil
+}
+
+// lookupFact resolves a fact key to its value.
+func lookupFact(
+ key string,
+ facts *job.FactsRegistration,
+ hostname string,
+) (string, error) {
+ if facts == nil {
+ return "", fmt.Errorf("facts not available")
+ }
+
+ switch key {
+ case "interface.primary":
+ if facts.PrimaryInterface == "" {
+ return "", fmt.Errorf("primary interface not set")
+ }
+ return facts.PrimaryInterface, nil
+ case "hostname":
+ if hostname == "" {
+ return "", fmt.Errorf("hostname not set")
+ }
+ return hostname, nil
+ case "arch":
+ if facts.Architecture == "" {
+ return "", fmt.Errorf("architecture not set")
+ }
+ return facts.Architecture, nil
+ case "os":
+ return "", fmt.Errorf("os fact not available in FactsRegistration")
+ case "kernel":
+ if facts.KernelVersion == "" {
+ return "", fmt.Errorf("kernel version not set")
+ }
+ return facts.KernelVersion, nil
+ case "fqdn":
+ if facts.FQDN == "" {
+ return "", fmt.Errorf("fqdn not set")
+ }
+ return facts.FQDN, nil
+ default:
+ // Check @fact.custom.X pattern
+ if strings.HasPrefix(key, "custom.") {
+ customKey := key[len("custom."):]
+ if facts.Facts == nil {
+ return "", fmt.Errorf("custom fact %q not found", customKey)
+ }
+ val, ok := facts.Facts[customKey]
+ if !ok {
+ return "", fmt.Errorf("custom fact %q not found", customKey)
+ }
+ return fmt.Sprintf("%v", val), nil
+ }
+ return "", fmt.Errorf("unknown fact key %q", key)
+ }
+}
diff --git a/internal/agent/factref_public_test.go b/internal/agent/factref_public_test.go
new file mode 100644
index 00000000..3895da99
--- /dev/null
+++ b/internal/agent/factref_public_test.go
@@ -0,0 +1,380 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package agent_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/agent"
+ "github.com/retr0h/osapi/internal/job"
+)
+
+type FactRefPublicTestSuite struct {
+ suite.Suite
+}
+
+func (s *FactRefPublicTestSuite) TestResolveFacts() {
+ tests := []struct {
+ name string
+ params map[string]any
+ facts *job.FactsRegistration
+ hostname string
+ wantErr bool
+ errContains string
+ validateFunc func(result map[string]any)
+ }{
+ {
+ name: "when simple interface.primary substitution",
+ params: map[string]any{
+ "interface_name": "@fact.interface.primary",
+ },
+ facts: &job.FactsRegistration{
+ PrimaryInterface: "eth0",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("eth0", result["interface_name"])
+ },
+ },
+ {
+ name: "when hostname substitution",
+ params: map[string]any{
+ "target": "@fact.hostname",
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("web-01", result["target"])
+ },
+ },
+ {
+ name: "when arch substitution",
+ params: map[string]any{
+ "arch": "@fact.arch",
+ },
+ facts: &job.FactsRegistration{
+ Architecture: "x86_64",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("x86_64", result["arch"])
+ },
+ },
+ {
+ name: "when kernel substitution",
+ params: map[string]any{
+ "kernel": "@fact.kernel",
+ },
+ facts: &job.FactsRegistration{
+ KernelVersion: "6.1.0",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("6.1.0", result["kernel"])
+ },
+ },
+ {
+ name: "when fqdn substitution",
+ params: map[string]any{
+ "fqdn": "@fact.fqdn",
+ },
+ facts: &job.FactsRegistration{
+ FQDN: "web-01.example.com",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("web-01.example.com", result["fqdn"])
+ },
+ },
+ {
+ name: "when os substitution returns error",
+ params: map[string]any{"os": "@fact.os"},
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "os fact not available",
+ },
+ {
+ name: "when custom fact substitution",
+ params: map[string]any{
+ "env": "@fact.custom.environment",
+ },
+ facts: &job.FactsRegistration{
+ Facts: map[string]any{
+ "environment": "production",
+ },
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("production", result["env"])
+ },
+ },
+ {
+ name: "when multiple references in one string",
+ params: map[string]any{
+ "desc": "@fact.interface.primary on @fact.hostname",
+ },
+ facts: &job.FactsRegistration{
+ PrimaryInterface: "eth0",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("eth0 on web-01", result["desc"])
+ },
+ },
+ {
+ name: "when nested map values",
+ params: map[string]any{
+ "config": map[string]any{
+ "iface": "@fact.interface.primary",
+ "host": "@fact.hostname",
+ },
+ },
+ facts: &job.FactsRegistration{
+ PrimaryInterface: "eth0",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ config := result["config"].(map[string]any)
+ s.Equal("eth0", config["iface"])
+ s.Equal("web-01", config["host"])
+ },
+ },
+ {
+ name: "when no fact references params unchanged",
+ params: map[string]any{
+ "address": "192.168.1.1",
+ "count": 4,
+ "interface_name": "eth0",
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal("192.168.1.1", result["address"])
+ s.Equal(4, result["count"])
+ s.Equal("eth0", result["interface_name"])
+ },
+ },
+ {
+ name: "when nil params returns nil",
+ params: nil,
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Nil(result)
+ },
+ },
+ {
+ name: "when nil facts returns error for any reference",
+ params: map[string]any{
+ "iface": "@fact.interface.primary",
+ },
+ facts: nil,
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "facts not available",
+ },
+ {
+ name: "when unknown fact reference returns error",
+ params: map[string]any{
+ "value": "@fact.nonexistent",
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "unknown fact key",
+ },
+ {
+ name: "when custom fact not found returns error",
+ params: map[string]any{
+ "val": "@fact.custom.missing",
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "custom fact \"missing\" not found",
+ },
+ {
+ name: "when custom fact key exists but facts map is nil",
+ params: map[string]any{
+ "val": "@fact.custom.key",
+ },
+ facts: &job.FactsRegistration{Facts: nil},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "custom fact \"key\" not found",
+ },
+ {
+ name: "when custom fact key missing from non-nil facts map",
+ params: map[string]any{
+ "val": "@fact.custom.missing",
+ },
+ facts: &job.FactsRegistration{
+ Facts: map[string]any{"other": "value"},
+ },
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "custom fact \"missing\" not found",
+ },
+ {
+ name: "when primary interface not set returns error",
+ params: map[string]any{
+ "iface": "@fact.interface.primary",
+ },
+ facts: &job.FactsRegistration{PrimaryInterface: ""},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "primary interface not set",
+ },
+ {
+ name: "when hostname not set returns error",
+ params: map[string]any{
+ "host": "@fact.hostname",
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "",
+ wantErr: true,
+ errContains: "hostname not set",
+ },
+ {
+ name: "when arch not set returns error",
+ params: map[string]any{
+ "arch": "@fact.arch",
+ },
+ facts: &job.FactsRegistration{Architecture: ""},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "architecture not set",
+ },
+ {
+ name: "when kernel not set returns error",
+ params: map[string]any{
+ "kernel": "@fact.kernel",
+ },
+ facts: &job.FactsRegistration{KernelVersion: ""},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "kernel version not set",
+ },
+ {
+ name: "when fqdn not set returns error",
+ params: map[string]any{
+ "fqdn": "@fact.fqdn",
+ },
+ facts: &job.FactsRegistration{FQDN: ""},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "fqdn not set",
+ },
+ {
+ name: "when non-string values pass through unchanged",
+ params: map[string]any{
+ "count": 42,
+ "enabled": true,
+ "ratio": 3.14,
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ s.Equal(42, result["count"])
+ s.Equal(true, result["enabled"])
+ s.Equal(3.14, result["ratio"])
+ },
+ },
+ {
+ name: "when slice values are resolved",
+ params: map[string]any{
+ "args": []any{"addr", "show", "dev", "@fact.interface.primary"},
+ },
+ facts: &job.FactsRegistration{
+ PrimaryInterface: "eth0",
+ },
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ args := result["args"].([]any)
+ s.Equal("addr", args[0])
+ s.Equal("show", args[1])
+ s.Equal("dev", args[2])
+ s.Equal("eth0", args[3])
+ },
+ },
+ {
+ name: "when slice error propagates",
+ params: map[string]any{
+ "args": []any{"ok", "@fact.nonexistent"},
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "unknown fact key",
+ },
+ {
+ name: "when nested slice in map is resolved",
+ params: map[string]any{
+ "config": map[string]any{
+ "hosts": []any{"@fact.hostname", "other"},
+ },
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ validateFunc: func(result map[string]any) {
+ config := result["config"].(map[string]any)
+ hosts := config["hosts"].([]any)
+ s.Equal("web-01", hosts[0])
+ s.Equal("other", hosts[1])
+ },
+ },
+ {
+ name: "when nested map error propagates",
+ params: map[string]any{
+ "config": map[string]any{
+ "bad": "@fact.nonexistent",
+ },
+ },
+ facts: &job.FactsRegistration{},
+ hostname: "web-01",
+ wantErr: true,
+ errContains: "unknown fact key",
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ result, err := agent.ResolveFacts(tc.params, tc.facts, tc.hostname)
+
+ if tc.wantErr {
+ s.Error(err)
+ s.Contains(err.Error(), tc.errContains)
+ } else {
+ s.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(result)
+ }
+ }
+ })
+ }
+}
+
+func TestFactRefPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(FactRefPublicTestSuite))
+}
diff --git a/internal/agent/facts.go b/internal/agent/facts.go
index cac82974..2506e39f 100644
--- a/internal/agent/facts.go
+++ b/internal/agent/facts.go
@@ -96,10 +96,41 @@ func (a *Agent) writeFacts(
reg.PackageMgr = mgr
}
- if ifaces, err := a.netinfoProvider.GetInterfaces(); err == nil {
+ if providerIfaces, err := a.netinfoProvider.GetInterfaces(); err == nil {
+ ifaces := make([]job.NetworkInterface, len(providerIfaces))
+ for i, iface := range providerIfaces {
+ ifaces[i] = job.NetworkInterface{
+ Name: iface.Name,
+ IPv4: iface.IPv4,
+ IPv6: iface.IPv6,
+ MAC: iface.MAC,
+ Family: iface.Family,
+ }
+ }
reg.Interfaces = ifaces
}
+ if providerRoutes, err := a.netinfoProvider.GetRoutes(); err == nil {
+ routes := make([]job.Route, len(providerRoutes))
+ for i, r := range providerRoutes {
+ routes[i] = job.Route{
+ Destination: r.Destination,
+ Gateway: r.Gateway,
+ Interface: r.Interface,
+ Mask: r.Mask,
+ Metric: r.Metric,
+ Flags: r.Flags,
+ }
+ }
+ reg.Routes = routes
+ }
+
+ if primary, err := a.netinfoProvider.GetPrimaryInterface(); err == nil {
+ reg.PrimaryInterface = primary
+ }
+
+ a.cachedFacts = ®
+
data, err := marshalJSON(reg)
if err != nil {
a.logger.Warn(
diff --git a/internal/agent/facts_test.go b/internal/agent/facts_test.go
index 864a2507..a4decfa4 100644
--- a/internal/agent/facts_test.go
+++ b/internal/agent/facts_test.go
@@ -166,6 +166,11 @@ func (s *FactsTestSuite) TestWriteFacts() {
s.agent.netinfoProvider = func() *netinfoMocks.MockProvider {
m := netinfoMocks.NewPlainMockProvider(s.mockCtrl)
m.EXPECT().GetInterfaces().Return(nil, errors.New("net fail")).AnyTimes()
+ m.EXPECT().GetRoutes().Return(nil, errors.New("routes fail")).AnyTimes()
+ m.EXPECT().
+ GetPrimaryInterface().
+ Return("", errors.New("primary fail")).
+ AnyTimes()
return m
}()
diff --git a/internal/agent/handler.go b/internal/agent/handler.go
index 81cfe919..a76aa27d 100644
--- a/internal/agent/handler.go
+++ b/internal/agent/handler.go
@@ -187,6 +187,24 @@ func (a *Agent) handleJobMessage(
)
}
+ // Resolve @fact.X references in job request data.
+ // Always attempt resolution so @fact. strings never pass through as literals.
+ // ResolveFacts handles nil cachedFacts with a clear "facts not available" error.
+ var resolveFactsErr error
+ if len(jobRequest.Data) > 0 {
+ var dataMap map[string]any
+ if err := json.Unmarshal(jobRequest.Data, &dataMap); err == nil {
+ resolved, err := ResolveFacts(dataMap, a.cachedFacts, a.hostname)
+ if err != nil {
+ resolveFactsErr = fmt.Errorf("failed to resolve fact references: %w", err)
+ } else if resolved != nil {
+ if resolvedJSON, err := json.Marshal(resolved); err == nil {
+ jobRequest.Data = resolvedJSON
+ }
+ }
+ }
+ }
+
// Process the job
a.logger.InfoContext(
ctx,
@@ -221,8 +239,15 @@ func (a *Agent) handleJobMessage(
Timestamp: time.Now(),
}
- // Process based on category and operation
- result, err := a.processJobOperation(jobRequest)
+ // Process based on category and operation.
+ // Fact resolution errors flow through the same path as processing errors
+ // so the error is written to KV and clients get it instead of timing out.
+ var result json.RawMessage
+ if resolveFactsErr != nil {
+ err = resolveFactsErr
+ } else {
+ result, err = a.processJobOperation(jobRequest)
+ }
if err != nil {
a.logger.ErrorContext(
ctx,
diff --git a/internal/agent/handler_test.go b/internal/agent/handler_test.go
index cde0ed24..ccde0457 100644
--- a/internal/agent/handler_test.go
+++ b/internal/agent/handler_test.go
@@ -33,6 +33,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/retr0h/osapi/internal/config"
+ "github.com/retr0h/osapi/internal/job"
"github.com/retr0h/osapi/internal/job/mocks"
commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks"
"github.com/retr0h/osapi/internal/provider/network/dns"
@@ -80,13 +81,13 @@ func (s *HandlerTestSuite) SetupTest() {
// Use plain DNS mock with appropriate expectations
dnsMock := dnsMocks.NewPlainMockProvider(s.mockCtrl)
- dnsMock.EXPECT().GetResolvConfByInterface(gomock.Any()).Return(&dns.Config{
+ dnsMock.EXPECT().GetResolvConfByInterface(gomock.Any()).Return(&dns.GetResult{
DNSServers: []string{"192.168.1.1", "8.8.8.8"},
SearchDomains: []string{"example.com"},
}, nil).AnyTimes()
dnsMock.EXPECT().
UpdateResolvConfByInterface(gomock.Any(), gomock.Any(), gomock.Any()).
- Return(&dns.Result{Changed: true}, nil).
+ Return(&dns.UpdateResult{Changed: true}, nil).
AnyTimes()
// Use plain ping mock with appropriate expectations
@@ -134,7 +135,7 @@ func (s *HandlerTestSuite) TestWriteStatusEvent() {
errorMsg string
}{
{
- name: "successful status event write",
+ name: "when successful status event write",
jobID: "test-job-123",
event: "started",
data: map[string]interface{}{"agent_version": "1.0.0", "pid": 12345},
@@ -146,7 +147,7 @@ func (s *HandlerTestSuite) TestWriteStatusEvent() {
expectError: false,
},
{
- name: "status event write with nil data",
+ name: "when status event write with nil data",
jobID: "test-job-456",
event: "completed",
data: nil,
@@ -158,7 +159,7 @@ func (s *HandlerTestSuite) TestWriteStatusEvent() {
expectError: false,
},
{
- name: "status event write failure",
+ name: "when status event write failure",
jobID: "test-job-789",
event: "failed",
data: map[string]interface{}{"error": "processing failed"},
@@ -171,7 +172,7 @@ func (s *HandlerTestSuite) TestWriteStatusEvent() {
errorMsg: "KV storage failed",
},
{
- name: "empty job ID",
+ name: "when empty job ID",
jobID: "",
event: "started",
data: map[string]interface{}{},
@@ -211,7 +212,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg string
}{
{
- name: "successful job processing",
+ name: "when successful job processing",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("test-job-123"),
@@ -249,7 +250,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
expectError: false,
},
{
- name: "job processing with failure",
+ name: "when job processing fails",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("test-job-456"),
@@ -288,7 +289,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "job processing failed",
},
{
- name: "invalid subject format",
+ name: "when invalid subject format",
msg: &mockJetStreamMsg{
subject: "invalid",
data: []byte("test-job-789"),
@@ -300,7 +301,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "failed to parse subject",
},
{
- name: "job not found",
+ name: "when job not found",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("nonexistent-job"),
@@ -314,7 +315,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "job not found",
},
{
- name: "invalid job data format",
+ name: "when invalid job data format",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("invalid-job"),
@@ -328,7 +329,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "failed to parse job data",
},
{
- name: "missing job ID",
+ name: "when missing job ID",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("missing-id-job"),
@@ -347,7 +348,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "invalid job format: missing id",
},
{
- name: "missing operation",
+ name: "when missing operation",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("missing-op-job"),
@@ -363,7 +364,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "invalid job format: missing operation",
},
{
- name: "missing operation type",
+ name: "when missing operation type",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("missing-type-job"),
@@ -382,7 +383,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "invalid operation format: missing type field",
},
{
- name: "invalid operation type format",
+ name: "when invalid operation type format",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("invalid-type-job"),
@@ -402,7 +403,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "invalid operation type format",
},
{
- name: "acknowledged write error logged",
+ name: "when acknowledged write error logged",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("ack-err-job"),
@@ -437,7 +438,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
expectError: false,
},
{
- name: "started write error logged",
+ name: "when started write error logged",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("start-err-job"),
@@ -472,7 +473,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
expectError: false,
},
{
- name: "completed write error logged",
+ name: "when completed write error logged",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("comp-err-job"),
@@ -507,7 +508,7 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
expectError: false,
},
{
- name: "failed write error logged",
+ name: "when failed write error logged",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("fail-err-job"),
@@ -543,7 +544,122 @@ func (s *HandlerTestSuite) TestHandleJobMessage() {
errorMsg: "job processing failed",
},
{
- name: "response storage failure",
+ name: "when fact reference resolved in job data",
+ msg: &mockJetStreamMsg{
+ subject: "jobs.query.test-agent",
+ data: []byte("fact-resolve-job"),
+ },
+ setupMocks: func() {
+ s.agent.cachedFacts = &job.FactsRegistration{
+ PrimaryInterface: "eth0",
+ }
+
+ s.mockJobClient.EXPECT().
+ GetJobData(gomock.Any(), "jobs.fact-resolve-job").
+ Return([]byte(`{
+ "id": "fact-resolve-job",
+ "operation": {
+ "type": "node.hostname.get",
+ "data": {"iface": "@fact.interface.primary"}
+ }
+ }`), nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-resolve-job", "acknowledged", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-resolve-job", "started", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-resolve-job", "completed", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteJobResponse(gomock.Any(), "fact-resolve-job", gomock.Any(), gomock.Any(), "completed", "", gomock.Any()).
+ Return(nil)
+ },
+ expectError: false,
+ },
+ {
+ name: "when fact reference with nil cached facts writes error to KV",
+ msg: &mockJetStreamMsg{
+ subject: "jobs.query.test-agent",
+ data: []byte("fact-nil-job"),
+ },
+ setupMocks: func() {
+ s.agent.cachedFacts = nil
+
+ s.mockJobClient.EXPECT().
+ GetJobData(gomock.Any(), "jobs.fact-nil-job").
+ Return([]byte(`{
+ "id": "fact-nil-job",
+ "operation": {
+ "type": "network.dns.get",
+ "data": {"interface": "@fact.interface.primary"}
+ }
+ }`), nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-nil-job", "acknowledged", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-nil-job", "started", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-nil-job", "failed", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteJobResponse(gomock.Any(), "fact-nil-job", gomock.Any(), gomock.Any(), "failed", gomock.Any(), gomock.Any()).
+ Return(nil)
+ },
+ expectError: true,
+ errorMsg: "facts not available",
+ },
+ {
+ name: "when unresolvable fact reference writes error to KV",
+ msg: &mockJetStreamMsg{
+ subject: "jobs.query.test-agent",
+ data: []byte("fact-fail-job"),
+ },
+ setupMocks: func() {
+ s.agent.cachedFacts = &job.FactsRegistration{}
+
+ s.mockJobClient.EXPECT().
+ GetJobData(gomock.Any(), "jobs.fact-fail-job").
+ Return([]byte(`{
+ "id": "fact-fail-job",
+ "operation": {
+ "type": "node.hostname.get",
+ "data": {"iface": "@fact.nonexistent"}
+ }
+ }`), nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-fail-job", "acknowledged", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-fail-job", "started", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteStatusEvent(gomock.Any(), "fact-fail-job", "failed", gomock.Any(), gomock.Any()).
+ Return(nil)
+
+ s.mockJobClient.EXPECT().
+ WriteJobResponse(gomock.Any(), "fact-fail-job", gomock.Any(), gomock.Any(), "failed", gomock.Any(), gomock.Any()).
+ Return(nil)
+ },
+ expectError: true,
+ errorMsg: "failed to resolve fact references",
+ },
+ {
+ name: "when response storage failure",
msg: &mockJetStreamMsg{
subject: "jobs.query.test-agent",
data: []byte("storage-fail-job"),
@@ -609,7 +725,7 @@ func (s *HandlerTestSuite) TestHandleJobMessageModifyJobs() {
expectError bool
}{
{
- name: "modify job type identification",
+ name: "when modify job type identification",
subject: "jobs.modify.test-agent",
jobData: `{
"id": "modify-job-123",
diff --git a/internal/agent/heartbeat.go b/internal/agent/heartbeat.go
index 959d7814..ecbb437a 100644
--- a/internal/agent/heartbeat.go
+++ b/internal/agent/heartbeat.go
@@ -110,19 +110,19 @@ func (a *Agent) writeRegistration(
reg.Uptime = uptime
}
- var loadAvg *load.AverageStats
+ var loadAvg *load.Result
if avg, err := a.loadProvider.GetAverageStats(); err == nil {
loadAvg = avg
reg.LoadAverages = avg
}
- var memStats *mem.Stats
+ var memStats *mem.Result
if stats, err := a.memProvider.GetStats(); err == nil {
memStats = stats
reg.MemoryStats = stats
}
- var diskStats []disk.UsageStats
+ var diskStats []disk.Result
if stats, err := a.diskProvider.GetLocalUsageStats(); err == nil {
diskStats = stats
}
diff --git a/internal/agent/processor_test.go b/internal/agent/processor_test.go
index a26940dd..291209e7 100644
--- a/internal/agent/processor_test.go
+++ b/internal/agent/processor_test.go
@@ -79,13 +79,13 @@ func (s *ProcessorTestSuite) SetupTest() {
// Use plain DNS mock to avoid hardcoded interface expectations
dnsMock := dnsMocks.NewPlainMockProvider(s.mockCtrl)
// Set up expectations for eth0 interface used in tests
- dnsMock.EXPECT().GetResolvConfByInterface("eth0").Return(&dns.Config{
+ dnsMock.EXPECT().GetResolvConfByInterface("eth0").Return(&dns.GetResult{
DNSServers: []string{"192.168.1.1", "8.8.8.8"},
SearchDomains: []string{"example.com"},
}, nil).AnyTimes()
dnsMock.EXPECT().
UpdateResolvConfByInterface(gomock.Any(), gomock.Any(), gomock.Any()).
- Return(&dns.Result{Changed: true}, nil).
+ Return(&dns.UpdateResult{Changed: true}, nil).
AnyTimes()
// Use plain ping mock to avoid hardcoded address expectations
diff --git a/internal/agent/types.go b/internal/agent/types.go
index e3b31e01..00a7cc9b 100644
--- a/internal/agent/types.go
+++ b/internal/agent/types.go
@@ -81,6 +81,9 @@ type Agent struct {
// cpuCount cached from facts for HighLoad evaluation.
cpuCount int
+ // cachedFacts holds the latest collected facts for @fact.X resolution.
+ cachedFacts *job.FactsRegistration
+
// state is the agent's scheduling state (Ready, Draining, Cordoned).
state string
diff --git a/internal/api/agent/agent_drain_public_test.go b/internal/api/agent/agent_drain_public_test.go
index 91c99de5..c804d14d 100644
--- a/internal/api/agent/agent_drain_public_test.go
+++ b/internal/api/agent/agent_drain_public_test.go
@@ -67,14 +67,15 @@ func (s *AgentDrainPublicTestSuite) TearDownTest() {
func (s *AgentDrainPublicTestSuite) TestDrainAgent() {
tests := []struct {
- name string
- hostname string
- mockAgent *jobtypes.AgentInfo
- mockGetErr error
- mockWriteErr error
- skipWrite bool
- mockSetDrain bool
- validateFunc func(resp gen.DrainAgentResponseObject)
+ name string
+ hostname string
+ mockAgent *jobtypes.AgentInfo
+ mockGetErr error
+ mockWriteErr error
+ skipWrite bool
+ mockSetDrain bool
+ mockSetDrainErr error
+ validateFunc func(resp gen.DrainAgentResponseObject)
}{
{
name: "success drains agent",
@@ -126,6 +127,51 @@ func (s *AgentDrainPublicTestSuite) TestDrainAgent() {
s.True(ok)
},
},
+ {
+ name: "when SetDrainFlag fails returns 409",
+ hostname: "server1",
+ mockAgent: &jobtypes.AgentInfo{
+ Hostname: "server1",
+ State: jobtypes.AgentStateReady,
+ },
+ mockSetDrain: true,
+ mockSetDrainErr: fmt.Errorf("kv connection failed"),
+ skipWrite: true,
+ validateFunc: func(resp gen.DrainAgentResponseObject) {
+ r, ok := resp.(gen.DrainAgent409JSONResponse)
+ s.True(ok)
+ s.Contains(*r.Error, "failed to set drain flag")
+ },
+ },
+ {
+ name: "when WriteAgentTimelineEvent returns not found error returns 404",
+ hostname: "server1",
+ mockAgent: &jobtypes.AgentInfo{
+ Hostname: "server1",
+ State: jobtypes.AgentStateReady,
+ },
+ mockSetDrain: true,
+ mockWriteErr: fmt.Errorf("agent not found: server1"),
+ validateFunc: func(resp gen.DrainAgentResponseObject) {
+ _, ok := resp.(gen.DrainAgent404JSONResponse)
+ s.True(ok)
+ },
+ },
+ {
+ name: "when WriteAgentTimelineEvent returns other error returns 409",
+ hostname: "server1",
+ mockAgent: &jobtypes.AgentInfo{
+ Hostname: "server1",
+ State: jobtypes.AgentStateReady,
+ },
+ mockSetDrain: true,
+ mockWriteErr: fmt.Errorf("connection failed"),
+ validateFunc: func(resp gen.DrainAgentResponseObject) {
+ r, ok := resp.(gen.DrainAgent409JSONResponse)
+ s.True(ok)
+ s.Contains(*r.Error, "connection failed")
+ },
+ },
}
for _, tt := range tests {
@@ -137,7 +183,7 @@ func (s *AgentDrainPublicTestSuite) TestDrainAgent() {
if tt.mockSetDrain {
s.mockJobClient.EXPECT().
SetDrainFlag(gomock.Any(), tt.hostname).
- Return(nil)
+ Return(tt.mockSetDrainErr)
}
if !tt.skipWrite {
diff --git a/internal/api/agent/agent_get_public_test.go b/internal/api/agent/agent_get_public_test.go
index 635958b3..41c3e848 100644
--- a/internal/api/agent/agent_get_public_test.go
+++ b/internal/api/agent/agent_get_public_test.go
@@ -85,10 +85,10 @@ func (s *AgentGetPublicTestSuite) TestGetAgentDetails() {
Labels: map[string]string{"group": "web"},
RegisteredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
StartedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC),
- OSInfo: &host.OSInfo{Distribution: "Ubuntu", Version: "24.04"},
+ OSInfo: &host.Result{Distribution: "Ubuntu", Version: "24.04"},
Uptime: 5 * time.Hour,
- LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.2},
- MemoryStats: &mem.Stats{Total: 8388608, Free: 4194304, Cached: 2097152},
+ LoadAverages: &load.Result{Load1: 0.5, Load5: 0.3, Load15: 0.2},
+ MemoryStats: &mem.Result{Total: 8388608, Free: 4194304, Cached: 2097152},
},
validateFunc: func(resp gen.GetAgentDetailsResponseObject) {
r, ok := resp.(gen.GetAgentDetails200JSONResponse)
@@ -158,10 +158,10 @@ func (s *AgentGetPublicTestSuite) TestGetAgentDetailsValidationHTTP() {
Labels: map[string]string{"group": "web"},
RegisteredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
StartedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC),
- OSInfo: &host.OSInfo{Distribution: "Ubuntu", Version: "24.04"},
+ OSInfo: &host.Result{Distribution: "Ubuntu", Version: "24.04"},
Uptime: 5 * time.Hour,
- LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.2},
- MemoryStats: &mem.Stats{Total: 8388608, Free: 4194304, Cached: 2097152},
+ LoadAverages: &load.Result{Load1: 0.5, Load5: 0.3, Load15: 0.2},
+ MemoryStats: &mem.Result{Total: 8388608, Free: 4194304, Cached: 2097152},
}, nil)
return mock
},
diff --git a/internal/api/agent/agent_list.go b/internal/api/agent/agent_list.go
index 02e0b44f..9b72a693 100644
--- a/internal/api/agent/agent_list.go
+++ b/internal/api/agent/agent_list.go
@@ -65,6 +65,19 @@ func buildAgentInfo(
Status: status,
}
+ setIdentity(a, &info)
+ setSystem(a, &info)
+ setNetwork(a, &info)
+ setScheduling(a, &info)
+
+ return info
+}
+
+// setIdentity populates labels, timestamps, and basic identification fields.
+func setIdentity(
+ a *job.AgentInfo,
+ info *gen.AgentInfo,
+) {
if len(a.Labels) > 0 {
labels := a.Labels
info.Labels = &labels
@@ -83,6 +96,17 @@ func buildAgentInfo(
info.Uptime = &uptime
}
+ if len(a.Facts) > 0 {
+ facts := a.Facts
+ info.Facts = &facts
+ }
+}
+
+// setSystem populates OS, CPU, memory, and package/service manager fields.
+func setSystem(
+ a *job.AgentInfo,
+ info *gen.AgentInfo,
+) {
if a.OSInfo != nil {
info.OsInfo = &gen.OSInfoResponse{
Distribution: a.OSInfo.Distribution,
@@ -135,7 +159,13 @@ func buildAgentInfo(
pkgMgr := a.PackageMgr
info.PackageMgr = &pkgMgr
}
+}
+// setNetwork populates interfaces, primary interface, and routes.
+func setNetwork(
+ a *job.AgentInfo,
+ info *gen.AgentInfo,
+) {
if len(a.Interfaces) > 0 {
ifaces := make([]gen.NetworkInterfaceResponse, len(a.Interfaces))
for i, ni := range a.Interfaces {
@@ -162,11 +192,41 @@ func buildAgentInfo(
info.Interfaces = &ifaces
}
- if len(a.Facts) > 0 {
- facts := a.Facts
- info.Facts = &facts
+ if a.PrimaryInterface != "" {
+ pi := a.PrimaryInterface
+ info.PrimaryInterface = &pi
+ }
+
+ if len(a.Routes) > 0 {
+ routes := make([]gen.RouteResponse, len(a.Routes))
+ for i, r := range a.Routes {
+ routes[i] = gen.RouteResponse{
+ Destination: r.Destination,
+ Gateway: r.Gateway,
+ Interface: r.Interface,
+ }
+ if r.Mask != "" {
+ mask := r.Mask
+ routes[i].Mask = &mask
+ }
+ if r.Metric != 0 {
+ metric := r.Metric
+ routes[i].Metric = &metric
+ }
+ if r.Flags != "" {
+ flags := r.Flags
+ routes[i].Flags = &flags
+ }
+ }
+ info.Routes = &routes
}
+}
+// setScheduling populates state, conditions, and timeline.
+func setScheduling(
+ a *job.AgentInfo,
+ info *gen.AgentInfo,
+) {
if a.State != "" {
state := gen.AgentInfoState(a.State)
info.State = &state
@@ -210,8 +270,6 @@ func buildAgentInfo(
}
info.Timeline = &timeline
}
-
- return info
}
func formatDuration(
diff --git a/internal/api/agent/agent_list_public_test.go b/internal/api/agent/agent_list_public_test.go
index 78b641d0..ee554506 100644
--- a/internal/api/agent/agent_list_public_test.go
+++ b/internal/api/agent/agent_list_public_test.go
@@ -85,10 +85,10 @@ func (s *AgentListPublicTestSuite) TestGetAgent() {
Labels: map[string]string{"group": "web"},
RegisteredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
StartedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC),
- OSInfo: &host.OSInfo{Distribution: "Ubuntu", Version: "24.04"},
+ OSInfo: &host.Result{Distribution: "Ubuntu", Version: "24.04"},
Uptime: 5 * time.Hour,
- LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.2},
- MemoryStats: &mem.Stats{Total: 8388608, Free: 4194304, Cached: 2097152},
+ LoadAverages: &load.Result{Load1: 0.5, Load5: 0.3, Load15: 0.2},
+ MemoryStats: &mem.Result{Total: 8388608, Free: 4194304, Cached: 2097152},
},
{Hostname: "server2"},
},
@@ -175,6 +175,124 @@ func (s *AgentListPublicTestSuite) TestGetAgent() {
s.Equal("prod", (*a.Facts)["env"])
},
},
+ {
+ name: "success with routes and primary interface",
+ mockAgents: []jobtypes.AgentInfo{
+ {
+ Hostname: "server1",
+ PrimaryInterface: "eth0",
+ Routes: []jobtypes.Route{
+ {
+ Destination: "0.0.0.0",
+ Gateway: "192.168.1.1",
+ Interface: "eth0",
+ Mask: "/0",
+ Metric: 100,
+ Flags: "0003",
+ },
+ {
+ Destination: "192.168.1.0",
+ Gateway: "0.0.0.0",
+ Interface: "eth0",
+ },
+ },
+ },
+ },
+ validateFunc: func(resp gen.GetAgentResponseObject) {
+ r, ok := resp.(gen.GetAgent200JSONResponse)
+ s.True(ok)
+ s.Equal(1, r.Total)
+
+ a := r.Agents[0]
+ s.Require().NotNil(a.PrimaryInterface)
+ s.Equal("eth0", *a.PrimaryInterface)
+ s.Require().NotNil(a.Routes)
+ s.Len(*a.Routes, 2)
+ route0 := (*a.Routes)[0]
+ s.Equal("0.0.0.0", route0.Destination)
+ s.Equal("192.168.1.1", route0.Gateway)
+ s.Equal("eth0", route0.Interface)
+ s.Require().NotNil(route0.Mask)
+ s.Equal("/0", *route0.Mask)
+ s.Require().NotNil(route0.Metric)
+ s.Equal(100, *route0.Metric)
+ s.Require().NotNil(route0.Flags)
+ s.Equal("0003", *route0.Flags)
+ route1 := (*a.Routes)[1]
+ s.Equal("192.168.1.0", route1.Destination)
+ s.Nil(route1.Mask)
+ s.Nil(route1.Metric)
+ s.Nil(route1.Flags)
+ },
+ },
+ {
+ name: "success with scheduling fields",
+ mockAgents: []jobtypes.AgentInfo{
+ {
+ Hostname: "server1",
+ State: jobtypes.AgentStateDraining,
+ Conditions: []jobtypes.Condition{
+ {
+ Type: "MemoryPressure",
+ Status: true,
+ LastTransitionTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Reason: "memory above threshold",
+ },
+ {
+ Type: "DiskPressure",
+ Status: false,
+ LastTransitionTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Timeline: []jobtypes.TimelineEvent{
+ {
+ Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
+ Event: "drain",
+ Hostname: "server1",
+ Message: "Drain initiated via API",
+ Error: "some error",
+ },
+ {
+ Timestamp: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC),
+ Event: "ready",
+ },
+ },
+ },
+ },
+ validateFunc: func(resp gen.GetAgentResponseObject) {
+ r, ok := resp.(gen.GetAgent200JSONResponse)
+ s.True(ok)
+ s.Equal(1, r.Total)
+
+ a := r.Agents[0]
+ s.Require().NotNil(a.State)
+ s.Equal(gen.AgentInfoState("Draining"), *a.State)
+ s.Require().NotNil(a.Conditions)
+ s.Len(*a.Conditions, 2)
+ c0 := (*a.Conditions)[0]
+ s.Equal(gen.NodeConditionType("MemoryPressure"), c0.Type)
+ s.True(c0.Status)
+ s.Require().NotNil(c0.Reason)
+ s.Equal("memory above threshold", *c0.Reason)
+ c1 := (*a.Conditions)[1]
+ s.Nil(c1.Reason)
+ s.Require().NotNil(a.Timeline)
+ s.Len(*a.Timeline, 2)
+ t0 := (*a.Timeline)[0]
+ s.Equal("drain", t0.Event)
+ s.Require().NotNil(t0.Hostname)
+ s.Equal("server1", *t0.Hostname)
+ s.Require().NotNil(t0.Message)
+ s.Equal("Drain initiated via API", *t0.Message)
+ s.Require().NotNil(t0.Error)
+ s.Equal("some error", *t0.Error)
+ t1 := (*a.Timeline)[1]
+ s.Equal("ready", t1.Event)
+ s.Nil(t1.Hostname)
+ s.Nil(t1.Message)
+ s.Nil(t1.Error)
+ },
+ },
{
name: "success with no agents",
mockAgents: []jobtypes.AgentInfo{},
diff --git a/internal/api/agent/agent_undrain_public_test.go b/internal/api/agent/agent_undrain_public_test.go
index 30b55bbb..21079b13 100644
--- a/internal/api/agent/agent_undrain_public_test.go
+++ b/internal/api/agent/agent_undrain_public_test.go
@@ -67,14 +67,15 @@ func (s *AgentUndrainPublicTestSuite) TearDownTest() {
func (s *AgentUndrainPublicTestSuite) TestUndrainAgent() {
tests := []struct {
- name string
- hostname string
- mockAgent *jobtypes.AgentInfo
- mockGetErr error
- mockWriteErr error
- skipWrite bool
- mockDeleteDrain bool
- validateFunc func(resp gen.UndrainAgentResponseObject)
+ name string
+ hostname string
+ mockAgent *jobtypes.AgentInfo
+ mockGetErr error
+ mockWriteErr error
+ skipWrite bool
+ mockDeleteDrain bool
+ mockDeleteDrainErr error
+ validateFunc func(resp gen.UndrainAgentResponseObject)
}{
{
name: "success undrains draining agent",
@@ -140,6 +141,51 @@ func (s *AgentUndrainPublicTestSuite) TestUndrainAgent() {
s.True(ok)
},
},
+ {
+ name: "when DeleteDrainFlag fails returns 409",
+ hostname: "server1",
+ mockAgent: &jobtypes.AgentInfo{
+ Hostname: "server1",
+ State: jobtypes.AgentStateDraining,
+ },
+ mockDeleteDrain: true,
+ mockDeleteDrainErr: fmt.Errorf("kv connection failed"),
+ skipWrite: true,
+ validateFunc: func(resp gen.UndrainAgentResponseObject) {
+ r, ok := resp.(gen.UndrainAgent409JSONResponse)
+ s.True(ok)
+ s.Contains(*r.Error, "failed to delete drain flag")
+ },
+ },
+ {
+ name: "when WriteAgentTimelineEvent returns not found error returns 404",
+ hostname: "server1",
+ mockAgent: &jobtypes.AgentInfo{
+ Hostname: "server1",
+ State: jobtypes.AgentStateDraining,
+ },
+ mockDeleteDrain: true,
+ mockWriteErr: fmt.Errorf("agent not found: server1"),
+ validateFunc: func(resp gen.UndrainAgentResponseObject) {
+ _, ok := resp.(gen.UndrainAgent404JSONResponse)
+ s.True(ok)
+ },
+ },
+ {
+ name: "when WriteAgentTimelineEvent returns other error returns 409",
+ hostname: "server1",
+ mockAgent: &jobtypes.AgentInfo{
+ Hostname: "server1",
+ State: jobtypes.AgentStateDraining,
+ },
+ mockDeleteDrain: true,
+ mockWriteErr: fmt.Errorf("connection failed"),
+ validateFunc: func(resp gen.UndrainAgentResponseObject) {
+ r, ok := resp.(gen.UndrainAgent409JSONResponse)
+ s.True(ok)
+ s.Contains(*r.Error, "connection failed")
+ },
+ },
}
for _, tt := range tests {
@@ -151,7 +197,7 @@ func (s *AgentUndrainPublicTestSuite) TestUndrainAgent() {
if tt.mockDeleteDrain {
s.mockJobClient.EXPECT().
DeleteDrainFlag(gomock.Any(), tt.hostname).
- Return(nil)
+ Return(tt.mockDeleteDrainErr)
}
if !tt.skipWrite {
diff --git a/internal/api/agent/gen/agent.gen.go b/internal/api/agent/gen/agent.gen.go
index 16ef4bec..7eb96e5e 100644
--- a/internal/api/agent/gen/agent.gen.go
+++ b/internal/api/agent/gen/agent.gen.go
@@ -86,9 +86,15 @@ type AgentInfo struct {
// PackageMgr Package manager.
PackageMgr *string `json:"package_mgr,omitempty"`
+ // PrimaryInterface Name of the interface used for the default route.
+ PrimaryInterface *string `json:"primary_interface,omitempty"`
+
// RegisteredAt When the agent last refreshed its heartbeat.
RegisteredAt *time.Time `json:"registered_at,omitempty"`
+ // Routes Network routing table entries.
+ Routes *[]RouteResponse `json:"routes,omitempty"`
+
// ServiceMgr Init system.
ServiceMgr *string `json:"service_mgr,omitempty"`
@@ -182,6 +188,27 @@ type OSInfoResponse struct {
Version string `json:"version"`
}
+// RouteResponse A network routing table entry.
+type RouteResponse struct {
+ // Destination Destination network address.
+ Destination string `json:"destination"`
+
+ // Flags Route flags.
+ Flags *string `json:"flags,omitempty"`
+
+ // Gateway Gateway address.
+ Gateway string `json:"gateway"`
+
+ // Interface Network interface name.
+ Interface string `json:"interface"`
+
+ // Mask Network mask in CIDR notation.
+ Mask *string `json:"mask,omitempty"`
+
+ // Metric Route metric.
+ Metric *int `json:"metric,omitempty"`
+}
+
// TimelineEvent defines model for TimelineEvent.
type TimelineEvent struct {
Error *string `json:"error,omitempty"`
diff --git a/internal/api/agent/gen/api.yaml b/internal/api/agent/gen/api.yaml
index 26fe3050..6fa564aa 100644
--- a/internal/api/agent/gen/api.yaml
+++ b/internal/api/agent/gen/api.yaml
@@ -304,6 +304,15 @@ components:
type: array
items:
$ref: '#/components/schemas/NetworkInterfaceResponse'
+ primary_interface:
+ type: string
+ description: Name of the interface used for the default route.
+ example: "eth0"
+ routes:
+ type: array
+ items:
+ $ref: '#/components/schemas/RouteResponse'
+ description: Network routing table entries.
facts:
type: object
additionalProperties: true
@@ -428,6 +437,39 @@ components:
- status
- last_transition_time
+ RouteResponse:
+ type: object
+ description: A network routing table entry.
+ properties:
+ destination:
+ type: string
+ description: Destination network address.
+ example: "0.0.0.0"
+ gateway:
+ type: string
+ description: Gateway address.
+ example: "192.168.1.1"
+ interface:
+ type: string
+ description: Network interface name.
+ example: "eth0"
+ mask:
+ type: string
+ description: Network mask in CIDR notation.
+ example: "/0"
+ metric:
+ type: integer
+ description: Route metric.
+ example: 100
+ flags:
+ type: string
+ description: Route flags.
+ example: "0003"
+ required:
+ - destination
+ - gateway
+ - interface
+
TimelineEvent:
type: object
properties:
diff --git a/internal/api/gen/api.yaml b/internal/api/gen/api.yaml
index 104c36da..8c0b59db 100644
--- a/internal/api/gen/api.yaml
+++ b/internal/api/gen/api.yaml
@@ -1140,10 +1140,11 @@ paths:
type: string
description: >
The IP address of the server to ping. Supports both IPv4 and
- IPv6.
+ IPv6. Also accepts @fact. references that are resolved
+ agent-side.
example: 8.8.8.8
x-oapi-codegen-extra-tags:
- validate: required,ip
+ validate: required,ip_or_fact
required:
- address
responses:
@@ -1196,7 +1197,7 @@ paths:
in: path
required: true
x-oapi-codegen-extra-tags:
- validate: required,alphanum
+ validate: required,alphanum_or_fact
schema:
type: string
description: >
@@ -1486,6 +1487,15 @@ components:
type: array
items:
$ref: '#/components/schemas/NetworkInterfaceResponse'
+ primary_interface:
+ type: string
+ description: Name of the interface used for the default route.
+ example: eth0
+ routes:
+ type: array
+ items:
+ $ref: '#/components/schemas/RouteResponse'
+ description: Network routing table entries.
facts:
type: object
additionalProperties: true
@@ -1610,6 +1620,38 @@ components:
- type
- status
- last_transition_time
+ RouteResponse:
+ type: object
+ description: A network routing table entry.
+ properties:
+ destination:
+ type: string
+ description: Destination network address.
+ example: 0.0.0.0
+ gateway:
+ type: string
+ description: Gateway address.
+ example: 192.168.1.1
+ interface:
+ type: string
+ description: Network interface name.
+ example: eth0
+ mask:
+ type: string
+ description: Network mask in CIDR notation.
+ example: /0
+ metric:
+ type: integer
+ description: Route metric.
+ example: 100
+ flags:
+ type: string
+ description: Route flags.
+ example: '0003'
+ required:
+ - destination
+ - gateway
+ - interface
TimelineEvent:
type: object
properties:
@@ -2491,10 +2533,10 @@ components:
interface_name:
type: string
x-oapi-codegen-extra-tags:
- validate: required,alphanum
+ validate: required,alphanum_or_fact
description: >
The name of the network interface to apply DNS configuration to.
- Must only contain letters and numbers.
+ Accepts alphanumeric names or @fact. references.
required:
- interface_name
CommandExecRequest:
diff --git a/internal/api/node/gen/api.yaml b/internal/api/node/gen/api.yaml
index ed3cd522..3383f8a3 100644
--- a/internal/api/node/gen/api.yaml
+++ b/internal/api/node/gen/api.yaml
@@ -381,10 +381,11 @@ paths:
type: string
description: >
The IP address of the server to ping. Supports both
- IPv4 and IPv6.
+ IPv4 and IPv6. Also accepts @fact. references that
+ are resolved agent-side.
example: "8.8.8.8"
x-oapi-codegen-extra-tags:
- validate: required,ip
+ validate: required,ip_or_fact
required:
- address
responses:
@@ -440,7 +441,7 @@ paths:
# generate validate tags in strict-server mode. Validation
# is handled manually in the handler via node.validateInterfaceName().
x-oapi-codegen-extra-tags:
- validate: required,alphanum
+ validate: required,alphanum_or_fact
schema:
type: string
description: >
@@ -1130,10 +1131,10 @@ components:
interface_name:
type: string
x-oapi-codegen-extra-tags:
- validate: required,alphanum
+ validate: required,alphanum_or_fact
description: >
The name of the network interface to apply DNS configuration
- to. Must only contain letters and numbers.
+ to. Accepts alphanumeric names or @fact. references.
required:
- interface_name
diff --git a/internal/api/node/gen/node.gen.go b/internal/api/node/gen/node.gen.go
index 6e73653e..90eae3e6 100644
--- a/internal/api/node/gen/node.gen.go
+++ b/internal/api/node/gen/node.gen.go
@@ -108,8 +108,8 @@ type DNSConfigResponse struct {
// DNSConfigUpdateRequest defines model for DNSConfigUpdateRequest.
type DNSConfigUpdateRequest struct {
- // InterfaceName The name of the network interface to apply DNS configuration to. Must only contain letters and numbers.
- InterfaceName string `json:"interface_name" validate:"required,alphanum"`
+ // InterfaceName The name of the network interface to apply DNS configuration to. Accepts alphanumeric names or @fact. references.
+ InterfaceName string `json:"interface_name" validate:"required,alphanum_or_fact"`
// SearchDomains New list of search domains to configure.
SearchDomains *[]string `json:"search_domains,omitempty" validate:"required_without=Servers,omitempty,dive,hostname,min=1"`
@@ -375,8 +375,8 @@ type Hostname = string
// PostNodeNetworkPingJSONBody defines parameters for PostNodeNetworkPing.
type PostNodeNetworkPingJSONBody struct {
- // Address The IP address of the server to ping. Supports both IPv4 and IPv6.
- Address string `json:"address" validate:"required,ip"`
+ // Address The IP address of the server to ping. Supports both IPv4 and IPv6. Also accepts @fact. references that are resolved agent-side.
+ Address string `json:"address" validate:"required,ip_or_fact"`
}
// PostNodeCommandExecJSONRequestBody defines body for PostNodeCommandExec for application/json ContentType.
diff --git a/internal/api/node/network_dns_get_by_interface_public_test.go b/internal/api/node/network_dns_get_by_interface_public_test.go
index 1f401267..ddb77421 100644
--- a/internal/api/node/network_dns_get_by_interface_public_test.go
+++ b/internal/api/node/network_dns_get_by_interface_public_test.go
@@ -84,7 +84,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
validateFunc func(resp gen.GetNodeNetworkDNSByInterfaceResponseObject)
}{
{
- name: "success",
+ name: "when success",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "_any",
InterfaceName: "eth0",
@@ -94,7 +94,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
QueryNetworkDNS(gomock.Any(), "_any", "eth0").
Return(
"550e8400-e29b-41d4-a716-446655440000",
- &dns.Config{
+ &dns.GetResult{
DNSServers: []string{"8.8.8.8"},
SearchDomains: []string{"example.com"},
},
@@ -114,7 +114,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
},
},
{
- name: "validation error empty hostname",
+ name: "when validation error empty hostname",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "",
InterfaceName: "eth0",
@@ -128,7 +128,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
},
},
{
- name: "validation error empty interface name",
+ name: "when validation error empty interface name",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "_any",
InterfaceName: "",
@@ -142,7 +142,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
},
},
{
- name: "job client error",
+ name: "when job client error",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "_any",
InterfaceName: "eth0",
@@ -158,7 +158,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
},
},
{
- name: "broadcast all success",
+ name: "when broadcast all success",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "_all",
InterfaceName: "eth0",
@@ -168,7 +168,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
QueryNetworkDNSBroadcast(gomock.Any(), "_all", "eth0").
Return(
"550e8400-e29b-41d4-a716-446655440000",
- map[string]*dns.Config{
+ map[string]*dns.GetResult{
"server1": {
DNSServers: []string{"8.8.8.8"},
SearchDomains: []string{"example.com"},
@@ -187,7 +187,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
},
},
{
- name: "broadcast all with errors",
+ name: "when broadcast all with errors",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "_all",
InterfaceName: "eth0",
@@ -197,7 +197,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
QueryNetworkDNSBroadcast(gomock.Any(), "_all", "eth0").
Return(
"550e8400-e29b-41d4-a716-446655440000",
- map[string]*dns.Config{
+ map[string]*dns.GetResult{
"server1": {
DNSServers: []string{"8.8.8.8"},
SearchDomains: []string{"example.com"},
@@ -225,7 +225,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa
},
},
{
- name: "broadcast all error",
+ name: "when broadcast all error",
request: gen.GetNodeNetworkDNSByInterfaceRequestObject{
Hostname: "_all",
InterfaceName: "eth0",
@@ -268,7 +268,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceHT
mock := jobmocks.NewMockJobClient(s.mockCtrl)
mock.EXPECT().
QueryNetworkDNS(gomock.Any(), "server1", "eth0").
- Return("550e8400-e29b-41d4-a716-446655440000", &dns.Config{
+ Return("550e8400-e29b-41d4-a716-446655440000", &dns.GetResult{
DNSServers: []string{"8.8.8.8"},
SearchDomains: []string{"example.com"},
}, "agent1", nil)
@@ -283,6 +283,30 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceHT
`"example.com"`,
},
},
+ {
+ name: "when fact reference interface name passes validation",
+ path: "/node/server1/network/dns/@fact.interface.primary",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ QueryNetworkDNS(gomock.Any(), "server1", "@fact.interface.primary").
+ Return("550e8400-e29b-41d4-a716-446655440000", &dns.GetResult{
+ DNSServers: []string{"8.8.8.8"},
+ }, "agent1", nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"results"`, `"8.8.8.8"`},
+ },
+ {
+ name: "when partial fact reference rejected",
+ path: "/node/server1/network/dns/@fact",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusBadRequest,
+ wantContains: []string{`"error"`, "alphanum_or_fact"},
+ },
{
name: "when non-alphanum interface name",
path: "/node/server1/network/dns/eth-0!",
@@ -290,7 +314,16 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceHT
return jobmocks.NewMockJobClient(s.mockCtrl)
},
wantCode: http.StatusBadRequest,
- wantContains: []string{`"error"`, "alphanum"},
+ wantContains: []string{`"error"`, "alphanum_or_fact"},
+ },
+ {
+ name: "when unknown fact key rejected",
+ path: "/node/server1/network/dns/@fact.primary_interface",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusBadRequest,
+ wantContains: []string{`"error"`, "alphanum_or_fact"},
},
{
name: "when broadcast all",
@@ -299,7 +332,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceHT
mock := jobmocks.NewMockJobClient(s.mockCtrl)
mock.EXPECT().
QueryNetworkDNSBroadcast(gomock.Any(), "_all", "eth0").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*dns.Config{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*dns.GetResult{
"server1": {
DNSServers: []string{"8.8.8.8"},
SearchDomains: []string{"example.com"},
@@ -394,7 +427,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceRB
QueryNetworkDNS(gomock.Any(), "server1", "eth0").
Return(
"550e8400-e29b-41d4-a716-446655440000",
- &dns.Config{
+ &dns.GetResult{
DNSServers: []string{"8.8.8.8"},
SearchDomains: []string{"example.com"},
},
diff --git a/internal/api/node/network_dns_put_by_interface_public_test.go b/internal/api/node/network_dns_put_by_interface_public_test.go
index 7ff6aa0a..d01afeac 100644
--- a/internal/api/node/network_dns_put_by_interface_public_test.go
+++ b/internal/api/node/network_dns_put_by_interface_public_test.go
@@ -85,7 +85,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
validateFunc func(resp gen.PutNodeNetworkDNSResponseObject)
}{
{
- name: "success",
+ name: "when success",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "_any",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -121,7 +121,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
},
},
{
- name: "validation error empty hostname",
+ name: "when validation error empty hostname",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -138,7 +138,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
},
},
{
- name: "body validation error empty interface name",
+ name: "when body validation error empty interface name",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "_any",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -154,7 +154,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
},
},
{
- name: "job client error",
+ name: "when job client error",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "_any",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -179,7 +179,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
},
},
{
- name: "broadcast all success",
+ name: "when broadcast all success",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "_all",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -215,7 +215,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
},
},
{
- name: "broadcast all with errors",
+ name: "when broadcast all with errors",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "_all",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -262,7 +262,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() {
},
},
{
- name: "broadcast all error",
+ name: "when broadcast all error",
request: gen.PutNodeNetworkDNSRequestObject{
Hostname: "_all",
Body: &gen.PutNodeNetworkDNSJSONRequestBody{
@@ -332,6 +332,30 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNSHTTP() {
wantCode: http.StatusBadRequest,
wantContains: []string{`"error"`, "InterfaceName", "required"},
},
+ {
+ name: "when fact reference interface name passes validation",
+ path: "/node/server1/network/dns",
+ body: `{"servers":["1.1.1.1"],"interface_name":"@fact.interface.primary"}`,
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ ModifyNetworkDNS(gomock.Any(), "server1", gomock.Any(), gomock.Any(), "@fact.interface.primary").
+ Return("550e8400-e29b-41d4-a716-446655440000", "agent1", true, nil)
+ return mock
+ },
+ wantCode: http.StatusAccepted,
+ wantContains: []string{`"results"`, `"agent1"`},
+ },
+ {
+ name: "when partial fact reference rejected",
+ path: "/node/server1/network/dns",
+ body: `{"servers":["1.1.1.1"],"interface_name":"@fact"}`,
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusBadRequest,
+ wantContains: []string{`"error"`, "InterfaceName", "alphanum_or_fact"},
+ },
{
name: "when non-alphanum interface name",
path: "/node/server1/network/dns",
@@ -340,7 +364,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNSHTTP() {
return jobmocks.NewMockJobClient(s.mockCtrl)
},
wantCode: http.StatusBadRequest,
- wantContains: []string{`"error"`, "InterfaceName", "alphanum"},
+ wantContains: []string{`"error"`, "InterfaceName", "alphanum_or_fact"},
},
{
name: "when invalid server IP",
@@ -362,6 +386,16 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNSHTTP() {
wantCode: http.StatusBadRequest,
wantContains: []string{`"error"`, "SearchDomains", "hostname"},
},
+ {
+ name: "when unknown fact key interface rejected",
+ path: "/node/server1/network/dns",
+ body: `{"servers":["1.1.1.1"],"interface_name":"@fact.primary_interface"}`,
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusBadRequest,
+ wantContains: []string{`"error"`, "InterfaceName", "alphanum_or_fact"},
+ },
{
name: "when target agent not found",
path: "/node/nonexistent/network/dns",
diff --git a/internal/api/node/network_ping_post_public_test.go b/internal/api/node/network_ping_post_public_test.go
index 5f077353..3075c9ad 100644
--- a/internal/api/node/network_ping_post_public_test.go
+++ b/internal/api/node/network_ping_post_public_test.go
@@ -86,7 +86,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
validateFunc func(resp gen.PostNodeNetworkPingResponseObject)
}{
{
- name: "success",
+ name: "when success",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "_any",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -122,7 +122,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
},
},
{
- name: "validation error empty hostname",
+ name: "when validation error empty hostname",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -138,7 +138,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
},
},
{
- name: "body validation error empty address",
+ name: "when body validation error empty address",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "_any",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -153,7 +153,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
},
},
{
- name: "job client error",
+ name: "when job client error",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "_any",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -171,7 +171,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
},
},
{
- name: "broadcast all success",
+ name: "when broadcast all success",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "_all",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -204,7 +204,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
},
},
{
- name: "broadcast all with errors",
+ name: "when broadcast all with errors",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "_all",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -245,7 +245,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() {
},
},
{
- name: "broadcast all error",
+ name: "when broadcast all error",
request: gen.PostNodeNetworkPingRequestObject{
Hostname: "_all",
Body: &gen.PostNodeNetworkPingJSONRequestBody{
@@ -323,7 +323,48 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPingHTTP() {
return jobmocks.NewMockJobClient(s.mockCtrl)
},
wantCode: http.StatusBadRequest,
- wantContains: []string{`"error"`, "Address", "ip"},
+ wantContains: []string{`"error"`, "Address", "ip_or_fact"},
+ },
+ {
+ name: "when fact reference passes validation",
+ path: "/node/server1/network/ping",
+ body: `{"address":"@fact.custom.gateway"}`,
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ QueryNetworkPing(gomock.Any(), "server1", "@fact.custom.gateway").
+ Return("550e8400-e29b-41d4-a716-446655440000", &ping.Result{
+ PacketsSent: 3,
+ PacketsReceived: 3,
+ PacketLoss: 0,
+ MinRTT: 10 * time.Millisecond,
+ AvgRTT: 15 * time.Millisecond,
+ MaxRTT: 20 * time.Millisecond,
+ }, "agent1", nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"results"`, `"packets_sent":3`},
+ },
+ {
+ name: "when partial fact reference rejected",
+ path: "/node/server1/network/ping",
+ body: `{"address":"@fact"}`,
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusBadRequest,
+ wantContains: []string{`"error"`, "ip_or_fact"},
+ },
+ {
+ name: "when unknown fact key rejected",
+ path: "/node/server1/network/ping",
+ body: `{"address":"@fact.primary_interface"}`,
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusBadRequest,
+ wantContains: []string{`"error"`, "ip_or_fact"},
},
{
name: "when broadcast all",
diff --git a/internal/api/node/node_disk_get_public_test.go b/internal/api/node/node_disk_get_public_test.go
index 00be05cb..e5623c57 100644
--- a/internal/api/node/node_disk_get_public_test.go
+++ b/internal/api/node/node_disk_get_public_test.go
@@ -80,7 +80,7 @@ func (s *NodeDiskGetPublicTestSuite) TestGetNodeDisk() {
s.mockJobClient.EXPECT().
QueryNodeDisk(gomock.Any(), "_any").
Return("550e8400-e29b-41d4-a716-446655440000", &job.NodeDiskResponse{
- Disks: []disk.UsageStats{
+ Disks: []disk.Result{
{Name: "/dev/sda1", Total: 1000, Used: 500, Free: 500},
},
}, "agent1", nil)
@@ -122,12 +122,12 @@ func (s *NodeDiskGetPublicTestSuite) TestGetNodeDisk() {
QueryNodeDiskBroadcast(gomock.Any(), "_all").
Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.NodeDiskResponse{
"server1": {
- Disks: []disk.UsageStats{
+ Disks: []disk.Result{
{Name: "/dev/sda1", Total: 1000, Used: 500, Free: 500},
},
},
"server2": {
- Disks: []disk.UsageStats{
+ Disks: []disk.Result{
{Name: "/dev/sda1", Total: 2000, Used: 1000, Free: 1000},
},
},
@@ -145,7 +145,7 @@ func (s *NodeDiskGetPublicTestSuite) TestGetNodeDisk() {
QueryNodeDiskBroadcast(gomock.Any(), "_all").
Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.NodeDiskResponse{
"server1": {
- Disks: []disk.UsageStats{
+ Disks: []disk.Result{
{Name: "/dev/sda1", Total: 1000, Used: 500, Free: 500},
},
},
diff --git a/internal/api/node/node_load_get.go b/internal/api/node/node_load_get.go
index ad028597..645fd395 100644
--- a/internal/api/node/node_load_get.go
+++ b/internal/api/node/node_load_get.go
@@ -100,10 +100,10 @@ func (s *Node) getNodeLoadBroadcast(
}, nil
}
-// buildLoadResultItem converts load.AverageStats to a LoadResultItem.
+// buildLoadResultItem converts load.Result to a LoadResultItem.
func buildLoadResultItem(
hostname string,
- loadStats *load.AverageStats,
+ loadStats *load.Result,
) *gen.LoadResultItem {
item := &gen.LoadResultItem{
Hostname: hostname,
diff --git a/internal/api/node/node_load_get_public_test.go b/internal/api/node/node_load_get_public_test.go
index 3610cf8d..02bf72ff 100644
--- a/internal/api/node/node_load_get_public_test.go
+++ b/internal/api/node/node_load_get_public_test.go
@@ -78,7 +78,7 @@ func (s *NodeLoadGetPublicTestSuite) TestGetNodeLoad() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeLoad(gomock.Any(), "_any").
- Return("550e8400-e29b-41d4-a716-446655440000", &load.AverageStats{
+ Return("550e8400-e29b-41d4-a716-446655440000", &load.Result{
Load1: 1.5,
Load5: 2.0,
Load15: 1.8,
@@ -119,7 +119,7 @@ func (s *NodeLoadGetPublicTestSuite) TestGetNodeLoad() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeLoadBroadcast(gomock.Any(), "_all").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*load.AverageStats{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*load.Result{
"server1": {Load1: 1.5, Load5: 2.0, Load15: 1.8},
"server2": {Load1: 0.5, Load5: 0.8, Load15: 0.6},
}, map[string]string{}, nil)
@@ -134,7 +134,7 @@ func (s *NodeLoadGetPublicTestSuite) TestGetNodeLoad() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeLoadBroadcast(gomock.Any(), "_all").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*load.AverageStats{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*load.Result{
"server1": {Load1: 1.5, Load5: 2.0, Load15: 1.8},
}, map[string]string{
"server2": "some error",
diff --git a/internal/api/node/node_memory_get.go b/internal/api/node/node_memory_get.go
index 7cec9b12..6f780e6f 100644
--- a/internal/api/node/node_memory_get.go
+++ b/internal/api/node/node_memory_get.go
@@ -100,10 +100,10 @@ func (s *Node) getNodeMemoryBroadcast(
}, nil
}
-// buildMemoryResultItem converts mem.Stats to a MemoryResultItem.
+// buildMemoryResultItem converts mem.Result to a MemoryResultItem.
func buildMemoryResultItem(
hostname string,
- memStats *mem.Stats,
+ memStats *mem.Result,
) *gen.MemoryResultItem {
item := &gen.MemoryResultItem{
Hostname: hostname,
diff --git a/internal/api/node/node_memory_get_public_test.go b/internal/api/node/node_memory_get_public_test.go
index 266b59f9..90ae0321 100644
--- a/internal/api/node/node_memory_get_public_test.go
+++ b/internal/api/node/node_memory_get_public_test.go
@@ -78,7 +78,7 @@ func (s *NodeMemoryGetPublicTestSuite) TestGetNodeMemory() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeMemory(gomock.Any(), "_any").
- Return("550e8400-e29b-41d4-a716-446655440000", &mem.Stats{
+ Return("550e8400-e29b-41d4-a716-446655440000", &mem.Result{
Total: 8192,
Free: 4096,
Cached: 2048,
@@ -119,7 +119,7 @@ func (s *NodeMemoryGetPublicTestSuite) TestGetNodeMemory() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeMemoryBroadcast(gomock.Any(), "_all").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*mem.Stats{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*mem.Result{
"server1": {Total: 8192, Free: 4096, Cached: 2048},
"server2": {Total: 16384, Free: 8192, Cached: 4096},
}, map[string]string{}, nil)
@@ -134,7 +134,7 @@ func (s *NodeMemoryGetPublicTestSuite) TestGetNodeMemory() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeMemoryBroadcast(gomock.Any(), "_all").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*mem.Stats{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*mem.Result{
"server1": {Total: 8192, Free: 4096, Cached: 2048},
}, map[string]string{
"server2": "some error",
diff --git a/internal/api/node/node_os_get.go b/internal/api/node/node_os_get.go
index 331ed5e1..17fad875 100644
--- a/internal/api/node/node_os_get.go
+++ b/internal/api/node/node_os_get.go
@@ -100,10 +100,10 @@ func (s *Node) getNodeOSBroadcast(
}, nil
}
-// buildOSInfoResultItem converts host.OSInfo to an OSInfoResultItem.
+// buildOSInfoResultItem converts host.Result to an OSInfoResultItem.
func buildOSInfoResultItem(
hostname string,
- osInfo *host.OSInfo,
+ osInfo *host.Result,
) *gen.OSInfoResultItem {
item := &gen.OSInfoResultItem{
Hostname: hostname,
diff --git a/internal/api/node/node_os_get_public_test.go b/internal/api/node/node_os_get_public_test.go
index b87308b0..ce2929fc 100644
--- a/internal/api/node/node_os_get_public_test.go
+++ b/internal/api/node/node_os_get_public_test.go
@@ -78,7 +78,7 @@ func (s *NodeOSGetPublicTestSuite) TestGetNodeOS() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeOS(gomock.Any(), "_any").
- Return("550e8400-e29b-41d4-a716-446655440000", &host.OSInfo{
+ Return("550e8400-e29b-41d4-a716-446655440000", &host.Result{
Distribution: "Ubuntu",
Version: "22.04",
}, "agent1", nil)
@@ -118,7 +118,7 @@ func (s *NodeOSGetPublicTestSuite) TestGetNodeOS() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeOSBroadcast(gomock.Any(), "_all").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*host.OSInfo{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*host.Result{
"server1": {Distribution: "Ubuntu", Version: "22.04"},
"server2": {Distribution: "CentOS", Version: "8.3"},
}, map[string]string{}, nil)
@@ -133,7 +133,7 @@ func (s *NodeOSGetPublicTestSuite) TestGetNodeOS() {
setupMock: func() {
s.mockJobClient.EXPECT().
QueryNodeOSBroadcast(gomock.Any(), "_all").
- Return("550e8400-e29b-41d4-a716-446655440000", map[string]*host.OSInfo{
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*host.Result{
"server1": {Distribution: "Ubuntu", Version: "22.04"},
}, map[string]string{
"server2": "some error",
diff --git a/internal/api/node/node_status_get_public_test.go b/internal/api/node/node_status_get_public_test.go
index 49cc2cef..409df420 100644
--- a/internal/api/node/node_status_get_public_test.go
+++ b/internal/api/node/node_status_get_public_test.go
@@ -215,21 +215,21 @@ func (s *NodeStatusGetPublicTestSuite) TestGetNodeStatusHTTP() {
Return("550e8400-e29b-41d4-a716-446655440000", &jobtypes.NodeStatusResponse{
Hostname: "default-hostname",
Uptime: 5 * time.Hour,
- OSInfo: &host.OSInfo{
+ OSInfo: &host.Result{
Distribution: "Ubuntu",
Version: "24.04",
},
- LoadAverages: &load.AverageStats{
+ LoadAverages: &load.Result{
Load1: 1,
Load5: 0.5,
Load15: 0.2,
},
- MemoryStats: &mem.Stats{
+ MemoryStats: &mem.Result{
Total: 8388608,
Free: 4194304,
Cached: 2097152,
},
- DiskUsage: []disk.UsageStats{
+ DiskUsage: []disk.Result{
{
Name: "/dev/disk1",
Total: 500000000000,
@@ -400,21 +400,21 @@ func (s *NodeStatusGetPublicTestSuite) TestGetNodeStatusRBACHTTP() {
&jobtypes.NodeStatusResponse{
Hostname: "default-hostname",
Uptime: 5 * time.Hour,
- OSInfo: &host.OSInfo{
+ OSInfo: &host.Result{
Distribution: "Ubuntu",
Version: "24.04",
},
- LoadAverages: &load.AverageStats{
+ LoadAverages: &load.Result{
Load1: 1,
Load5: 0.5,
Load15: 0.2,
},
- MemoryStats: &mem.Stats{
+ MemoryStats: &mem.Result{
Total: 8388608,
Free: 4194304,
Cached: 2097152,
},
- DiskUsage: []disk.UsageStats{
+ DiskUsage: []disk.Result{
{
Name: "/dev/disk1",
Total: 500000000000,
diff --git a/internal/api/node/validate.go b/internal/api/node/validate.go
index a98545ae..f0d54b88 100644
--- a/internal/api/node/validate.go
+++ b/internal/api/node/validate.go
@@ -38,5 +38,5 @@ func validateHostname(
func validateInterfaceName(
name string,
) (string, bool) {
- return validation.Var(name, "required,alphanum")
+ return validation.Var(name, "required,alphanum_or_fact")
}
diff --git a/internal/cli/nats_public_test.go b/internal/cli/nats_public_test.go
index d92f12f8..89180b59 100644
--- a/internal/cli/nats_public_test.go
+++ b/internal/cli/nats_public_test.go
@@ -285,6 +285,53 @@ func (suite *NATSPublicTestSuite) TestBuildFactsKVConfig() {
}
}
+func (suite *NATSPublicTestSuite) TestBuildStateKVConfig() {
+ tests := []struct {
+ name string
+ namespace string
+ stateCfg config.NATSState
+ validateFn func(jetstream.KeyValueConfig)
+ }{
+ {
+ name: "when namespace is set",
+ namespace: "osapi",
+ stateCfg: config.NATSState{
+ Bucket: "agent-state",
+ Storage: "file",
+ Replicas: 1,
+ },
+ validateFn: func(cfg jetstream.KeyValueConfig) {
+ assert.Equal(suite.T(), "osapi-agent-state", cfg.Bucket)
+ assert.Equal(suite.T(), time.Duration(0), cfg.TTL)
+ assert.Equal(suite.T(), jetstream.FileStorage, cfg.Storage)
+ assert.Equal(suite.T(), 1, cfg.Replicas)
+ },
+ },
+ {
+ name: "when namespace is empty",
+ namespace: "",
+ stateCfg: config.NATSState{
+ Bucket: "agent-state",
+ Storage: "memory",
+ Replicas: 3,
+ },
+ validateFn: func(cfg jetstream.KeyValueConfig) {
+ assert.Equal(suite.T(), "agent-state", cfg.Bucket)
+ assert.Equal(suite.T(), jetstream.MemoryStorage, cfg.Storage)
+ assert.Equal(suite.T(), 3, cfg.Replicas)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got := cli.BuildStateKVConfig(tc.namespace, tc.stateCfg)
+
+ tc.validateFn(got)
+ })
+ }
+}
+
func (suite *NATSPublicTestSuite) TestBuildAuditKVConfig() {
tests := []struct {
name string
diff --git a/internal/cli/ui.go b/internal/cli/ui.go
index 972defc1..930bb833 100644
--- a/internal/cli/ui.go
+++ b/internal/cli/ui.go
@@ -177,6 +177,18 @@ func BoolToSafeString(
// compactMaxColWidth is the maximum column width before truncation.
const compactMaxColWidth = 50
+// printJSONBlock prints a titled JSON block without truncation.
+func printJSONBlock(
+ title string,
+ jsonStr string,
+) {
+ titleStyle := lipgloss.NewStyle().Bold(true).Foreground(Purple)
+ dataStyle := lipgloss.NewStyle().Foreground(Teal)
+
+ fmt.Printf("\n %s:\n", titleStyle.Render(title))
+ fmt.Printf(" %s\n", dataStyle.Render(jsonStr))
+}
+
// PrintCompactTable renders a compact column-aligned table (kubectl-style).
// Headers are uppercase purple, data rows are teal, with 2-space indent.
// Multi-line cell values are flattened to a single line and long values
@@ -544,19 +556,14 @@ func DisplayJobDetail(
}
}
- var sections []Section
-
- // Display the operation request
+ // Display the operation request as an untruncated JSON block
if resp.Operation != nil {
- jobOperationJSON, _ := json.MarshalIndent(resp.Operation, "", " ")
- operationRows := [][]string{{string(jobOperationJSON)}}
- sections = append(sections, Section{
- Title: "Job Request",
- Headers: []string{"DATA"},
- Rows: operationRows,
- })
+ jobOperationJSON, _ := json.MarshalIndent(resp.Operation, " ", " ")
+ printJSONBlock("Job Request", string(jobOperationJSON))
}
+ var sections []Section
+
// Display agent responses (for broadcast jobs)
if len(resp.Responses) > 0 {
responseRows := make([][]string, 0, len(resp.Responses))
@@ -598,34 +605,29 @@ func DisplayJobDetail(
}
// Display timeline
- if len(resp.Timeline) > 0 {
- timelineRows := make([][]string, 0, len(resp.Timeline))
- for _, te := range resp.Timeline {
- timelineRows = append(
- timelineRows,
- []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error},
- )
- }
-
- sections = append(sections, Section{
- Title: "Timeline",
- Headers: []string{"TIMESTAMP", "EVENT", "HOSTNAME", "MESSAGE", "ERROR"},
- Rows: timelineRows,
- })
+ timelineRows := make([][]string, 0, len(resp.Timeline))
+ for _, te := range resp.Timeline {
+ timelineRows = append(
+ timelineRows,
+ []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error},
+ )
}
-
- // Display result if completed
- if resp.Result != nil {
- resultJSON, _ := json.MarshalIndent(resp.Result, "", " ")
- resultRows := [][]string{{string(resultJSON)}}
- sections = append(sections, Section{
- Title: "Job Result",
- Headers: []string{"DATA"},
- Rows: resultRows,
- })
+ if len(timelineRows) == 0 {
+ timelineRows = [][]string{{"No events"}}
}
+ sections = append(sections, Section{
+ Title: "Timeline",
+ Headers: []string{"TIMESTAMP", "EVENT", "HOSTNAME", "MESSAGE", "ERROR"},
+ Rows: timelineRows,
+ })
for _, sec := range sections {
PrintCompactTable([]Section{sec})
}
+
+ // Display result as an untruncated JSON block after tables
+ if resp.Result != nil {
+ resultJSON, _ := json.MarshalIndent(resp.Result, " ", " ")
+ printJSONBlock("Job Result", string(resultJSON))
+ }
}
diff --git a/internal/exec/exec.go b/internal/exec/exec.go
index ac79c1f4..b2b8b7e9 100644
--- a/internal/exec/exec.go
+++ b/internal/exec/exec.go
@@ -22,11 +22,14 @@
package exec
import (
+ "fmt"
"log/slog"
"os/exec"
"strings"
)
+const maxLogOutputLen = 200
+
// New factory to create a new Exec instance.
func New(
logger *slog.Logger,
@@ -49,11 +52,17 @@ func (e *Exec) RunCmdImpl(
cmd.Dir = cwd
}
out, err := cmd.CombinedOutput()
+
+ logOutput := string(out)
+ if len(logOutput) > maxLogOutputLen {
+ logOutput = logOutput[:maxLogOutputLen] + fmt.Sprintf("... (%d bytes total)", len(out))
+ }
+
e.logger.Debug(
"exec",
slog.String("command", strings.Join(cmd.Args, " ")),
slog.String("cwd", cwd),
- slog.String("output", string(out)),
+ slog.String("output", logOutput),
slog.Any("error", err),
)
if err != nil {
diff --git a/internal/job/client/agent.go b/internal/job/client/agent.go
index 9b9aec44..37496f0b 100644
--- a/internal/job/client/agent.go
+++ b/internal/job/client/agent.go
@@ -184,7 +184,7 @@ func (c *Client) WriteAgentTimelineEvent(
now.UnixNano(),
)
- data, err := json.Marshal(job.TimelineEvent{
+ data, err := c.JSONMarshalFn(job.TimelineEvent{
Timestamp: now,
Event: event,
Hostname: hostname,
diff --git a/internal/job/client/agent_timeline_public_test.go b/internal/job/client/agent_timeline_public_test.go
index c3be65a4..a428361c 100644
--- a/internal/job/client/agent_timeline_public_test.go
+++ b/internal/job/client/agent_timeline_public_test.go
@@ -88,6 +88,7 @@ func (s *AgentTimelinePublicTestSuite) TestWriteAgentTimelineEvent() {
event string
message string
useState bool
+ marshalErr bool
setupMocks func(*jobmocks.MockKeyValue)
expectError bool
errorMsg string
@@ -143,6 +144,19 @@ func (s *AgentTimelinePublicTestSuite) TestWriteAgentTimelineEvent() {
expectError: true,
errorMsg: "agent state bucket not configured",
},
+ {
+ name: "when json marshal fails returns error",
+ hostname: "server1",
+ event: "drain",
+ message: "drain requested",
+ useState: true,
+ marshalErr: true,
+ setupMocks: func(_ *jobmocks.MockKeyValue) {
+ // No KV expectations — marshal fails before Put
+ },
+ expectError: true,
+ errorMsg: "marshal timeline event",
+ },
}
for _, tt := range tests {
@@ -158,6 +172,12 @@ func (s *AgentTimelinePublicTestSuite) TestWriteAgentTimelineEvent() {
jobsClient = s.newClientWithoutState()
}
+ if tt.marshalErr {
+ jobsClient.JSONMarshalFn = func(_ any) ([]byte, error) {
+ return nil, errors.New("marshal failed")
+ }
+ }
+
err := jobsClient.WriteAgentTimelineEvent(
s.ctx,
tt.hostname,
diff --git a/internal/job/client/client.go b/internal/job/client/client.go
index 5096913b..8b1dfeef 100644
--- a/internal/job/client/client.go
+++ b/internal/job/client/client.go
@@ -45,6 +45,9 @@ type Client struct {
stateKV jetstream.KeyValue
timeout time.Duration
streamName string
+ // JSONMarshalFn is the function used to marshal JSON. Defaults to json.Marshal.
+ // Exported for testing.
+ JSONMarshalFn func(v any) ([]byte, error)
}
// Options configures the jobs client.
@@ -77,14 +80,15 @@ func New(
}
return &Client{
- logger: logger,
- natsClient: natsClient,
- kv: opts.KVBucket,
- registryKV: opts.RegistryKV,
- factsKV: opts.FactsKV,
- stateKV: opts.StateKV,
- streamName: opts.StreamName,
- timeout: opts.Timeout,
+ logger: logger,
+ natsClient: natsClient,
+ kv: opts.KVBucket,
+ registryKV: opts.RegistryKV,
+ factsKV: opts.FactsKV,
+ stateKV: opts.StateKV,
+ streamName: opts.StreamName,
+ timeout: opts.Timeout,
+ JSONMarshalFn: json.Marshal,
}, nil
}
diff --git a/internal/job/client/query.go b/internal/job/client/query.go
index f3c02ca5..117bcf40 100644
--- a/internal/job/client/query.go
+++ b/internal/job/client/query.go
@@ -104,7 +104,7 @@ func (c *Client) QueryNetworkDNS(
ctx context.Context,
hostname string,
iface string,
-) (string, *dns.Config, string, error) {
+) (string, *dns.GetResult, string, error) {
data, _ := json.Marshal(map[string]interface{}{
"interface": iface,
})
@@ -125,7 +125,7 @@ func (c *Client) QueryNetworkDNS(
return "", nil, "", fmt.Errorf("job failed: %s", resp.Error)
}
- var result dns.Config
+ var result dns.GetResult
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", nil, "", fmt.Errorf("failed to unmarshal DNS response: %w", err)
}
@@ -288,7 +288,7 @@ func (c *Client) QueryNetworkDNSBroadcast(
ctx context.Context,
target string,
iface string,
-) (string, map[string]*dns.Config, map[string]string, error) {
+) (string, map[string]*dns.GetResult, map[string]string, error) {
data, _ := json.Marshal(map[string]interface{}{
"interface": iface,
})
@@ -305,7 +305,7 @@ func (c *Client) QueryNetworkDNSBroadcast(
return "", nil, nil, fmt.Errorf("failed to collect broadcast responses: %w", err)
}
- results := make(map[string]*dns.Config)
+ results := make(map[string]*dns.GetResult)
errs := make(map[string]string)
for hostname, resp := range responses {
if resp.Status == "failed" {
@@ -313,7 +313,7 @@ func (c *Client) QueryNetworkDNSBroadcast(
continue
}
- var result dns.Config
+ var result dns.GetResult
if err := json.Unmarshal(resp.Data, &result); err != nil {
continue
}
@@ -328,7 +328,7 @@ func (c *Client) QueryNetworkDNSBroadcast(
func (c *Client) QueryNetworkDNSAll(
ctx context.Context,
iface string,
-) (string, map[string]*dns.Config, map[string]string, error) {
+) (string, map[string]*dns.GetResult, map[string]string, error) {
return c.QueryNetworkDNSBroadcast(ctx, job.BroadcastHost, iface)
}
@@ -486,6 +486,8 @@ func (c *Client) mergeFacts(
info.ServiceMgr = facts.ServiceMgr
info.PackageMgr = facts.PackageMgr
info.Interfaces = facts.Interfaces
+ info.PrimaryInterface = facts.PrimaryInterface
+ info.Routes = facts.Routes
info.Facts = facts.Facts
}
diff --git a/internal/job/client/query_node.go b/internal/job/client/query_node.go
index f44b91ee..1f7d474f 100644
--- a/internal/job/client/query_node.go
+++ b/internal/job/client/query_node.go
@@ -102,7 +102,7 @@ func (c *Client) QueryNodeDiskBroadcast(
func (c *Client) QueryNodeMemory(
ctx context.Context,
hostname string,
-) (string, *mem.Stats, string, error) {
+) (string, *mem.Result, string, error) {
req := &job.Request{
Type: job.TypeQuery,
Category: "node",
@@ -120,7 +120,7 @@ func (c *Client) QueryNodeMemory(
return "", nil, "", fmt.Errorf("job failed: %s", resp.Error)
}
- var result mem.Stats
+ var result mem.Result
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", nil, "", fmt.Errorf("failed to unmarshal memory response: %w", err)
}
@@ -132,7 +132,7 @@ func (c *Client) QueryNodeMemory(
func (c *Client) QueryNodeMemoryBroadcast(
ctx context.Context,
target string,
-) (string, map[string]*mem.Stats, map[string]string, error) {
+) (string, map[string]*mem.Result, map[string]string, error) {
req := &job.Request{
Type: job.TypeQuery,
Category: "node",
@@ -146,7 +146,7 @@ func (c *Client) QueryNodeMemoryBroadcast(
return "", nil, nil, fmt.Errorf("failed to collect broadcast responses: %w", err)
}
- results := make(map[string]*mem.Stats)
+ results := make(map[string]*mem.Result)
errs := make(map[string]string)
for hostname, resp := range responses {
if resp.Status == "failed" {
@@ -154,7 +154,7 @@ func (c *Client) QueryNodeMemoryBroadcast(
continue
}
- var result mem.Stats
+ var result mem.Result
if err := json.Unmarshal(resp.Data, &result); err != nil {
continue
}
@@ -169,7 +169,7 @@ func (c *Client) QueryNodeMemoryBroadcast(
func (c *Client) QueryNodeLoad(
ctx context.Context,
hostname string,
-) (string, *load.AverageStats, string, error) {
+) (string, *load.Result, string, error) {
req := &job.Request{
Type: job.TypeQuery,
Category: "node",
@@ -187,7 +187,7 @@ func (c *Client) QueryNodeLoad(
return "", nil, "", fmt.Errorf("job failed: %s", resp.Error)
}
- var result load.AverageStats
+ var result load.Result
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", nil, "", fmt.Errorf("failed to unmarshal load response: %w", err)
}
@@ -199,7 +199,7 @@ func (c *Client) QueryNodeLoad(
func (c *Client) QueryNodeLoadBroadcast(
ctx context.Context,
target string,
-) (string, map[string]*load.AverageStats, map[string]string, error) {
+) (string, map[string]*load.Result, map[string]string, error) {
req := &job.Request{
Type: job.TypeQuery,
Category: "node",
@@ -213,7 +213,7 @@ func (c *Client) QueryNodeLoadBroadcast(
return "", nil, nil, fmt.Errorf("failed to collect broadcast responses: %w", err)
}
- results := make(map[string]*load.AverageStats)
+ results := make(map[string]*load.Result)
errs := make(map[string]string)
for hostname, resp := range responses {
if resp.Status == "failed" {
@@ -221,7 +221,7 @@ func (c *Client) QueryNodeLoadBroadcast(
continue
}
- var result load.AverageStats
+ var result load.Result
if err := json.Unmarshal(resp.Data, &result); err != nil {
continue
}
@@ -236,7 +236,7 @@ func (c *Client) QueryNodeLoadBroadcast(
func (c *Client) QueryNodeOS(
ctx context.Context,
hostname string,
-) (string, *host.OSInfo, string, error) {
+) (string, *host.Result, string, error) {
req := &job.Request{
Type: job.TypeQuery,
Category: "node",
@@ -254,7 +254,7 @@ func (c *Client) QueryNodeOS(
return "", nil, "", fmt.Errorf("job failed: %s", resp.Error)
}
- var result host.OSInfo
+ var result host.Result
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", nil, "", fmt.Errorf("failed to unmarshal OS info response: %w", err)
}
@@ -266,7 +266,7 @@ func (c *Client) QueryNodeOS(
func (c *Client) QueryNodeOSBroadcast(
ctx context.Context,
target string,
-) (string, map[string]*host.OSInfo, map[string]string, error) {
+) (string, map[string]*host.Result, map[string]string, error) {
req := &job.Request{
Type: job.TypeQuery,
Category: "node",
@@ -280,7 +280,7 @@ func (c *Client) QueryNodeOSBroadcast(
return "", nil, nil, fmt.Errorf("failed to collect broadcast responses: %w", err)
}
- results := make(map[string]*host.OSInfo)
+ results := make(map[string]*host.Result)
errs := make(map[string]string)
for hostname, resp := range responses {
if resp.Status == "failed" {
@@ -288,7 +288,7 @@ func (c *Client) QueryNodeOSBroadcast(
continue
}
- var result host.OSInfo
+ var result host.Result
if err := json.Unmarshal(resp.Data, &result); err != nil {
continue
}
diff --git a/internal/job/client/query_public_test.go b/internal/job/client/query_public_test.go
index d5394087..2d319ca8 100644
--- a/internal/job/client/query_public_test.go
+++ b/internal/job/client/query_public_test.go
@@ -1393,6 +1393,34 @@ func (s *QueryPublicTestSuite) TestListAgents() {
s.Empty(agents[0].KernelVersion)
},
},
+ {
+ name: "when key without agents prefix is skipped",
+ useRegistryKV: true,
+ setupMockKV: func(kv *jobmocks.MockKeyValue) {
+ kv.EXPECT().
+ Keys(gomock.Any()).
+ Return([]string{"drain.server1", "agents.server1"}, nil)
+
+ entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl)
+ entry.EXPECT().Value().Return(
+ []byte(
+ `{"hostname":"server1","registered_at":"2026-01-01T00:00:00Z"}`,
+ ),
+ )
+ kv.EXPECT().
+ Get(gomock.Any(), "agents.server1").
+ Return(entry, nil)
+ },
+ setupStateKV: func(kv *jobmocks.MockKeyValue) {
+ kv.EXPECT().
+ Get(gomock.Any(), "drain.server1").
+ Return(nil, errors.New("key not found"))
+ },
+ expectedCount: 1,
+ validateFunc: func(agents []job.AgentInfo) {
+ s.Equal("server1", agents[0].Hostname)
+ },
+ },
{
name: "when factsKV returns invalid JSON degrades gracefully",
useRegistryKV: true,
diff --git a/internal/job/client/types.go b/internal/job/client/types.go
index 5723b5dc..6b82b1dc 100644
--- a/internal/job/client/types.go
+++ b/internal/job/client/types.go
@@ -97,27 +97,27 @@ type JobClient interface {
QueryNodeMemory(
ctx context.Context,
hostname string,
- ) (string, *mem.Stats, string, error)
+ ) (string, *mem.Result, string, error)
QueryNodeMemoryBroadcast(
ctx context.Context,
target string,
- ) (string, map[string]*mem.Stats, map[string]string, error)
+ ) (string, map[string]*mem.Result, map[string]string, error)
QueryNodeLoad(
ctx context.Context,
hostname string,
- ) (string, *load.AverageStats, string, error)
+ ) (string, *load.Result, string, error)
QueryNodeLoadBroadcast(
ctx context.Context,
target string,
- ) (string, map[string]*load.AverageStats, map[string]string, error)
+ ) (string, map[string]*load.Result, map[string]string, error)
QueryNodeOS(
ctx context.Context,
hostname string,
- ) (string, *host.OSInfo, string, error)
+ ) (string, *host.Result, string, error)
QueryNodeOSBroadcast(
ctx context.Context,
target string,
- ) (string, map[string]*host.OSInfo, map[string]string, error)
+ ) (string, map[string]*host.Result, map[string]string, error)
QueryNodeUptime(
ctx context.Context,
hostname string,
@@ -130,16 +130,16 @@ type JobClient interface {
ctx context.Context,
hostname string,
iface string,
- ) (string, *dns.Config, string, error)
+ ) (string, *dns.GetResult, string, error)
QueryNetworkDNSAll(
ctx context.Context,
iface string,
- ) (string, map[string]*dns.Config, map[string]string, error)
+ ) (string, map[string]*dns.GetResult, map[string]string, error)
QueryNetworkDNSBroadcast(
ctx context.Context,
target string,
iface string,
- ) (string, map[string]*dns.Config, map[string]string, error)
+ ) (string, map[string]*dns.GetResult, map[string]string, error)
// Modify operations — all return (jobID, result..., error)
ModifyNetworkDNS(
diff --git a/internal/job/mocks/job_client.gen.go b/internal/job/mocks/job_client.gen.go
index 60a0265d..434b67ea 100644
--- a/internal/job/mocks/job_client.gen.go
+++ b/internal/job/mocks/job_client.gen.go
@@ -386,11 +386,11 @@ func (mr *MockJobClientMockRecorder) ModifyNetworkDNSBroadcast(arg0, arg1, arg2,
}
// QueryNetworkDNS mocks base method.
-func (m *MockJobClient) QueryNetworkDNS(arg0 context.Context, arg1, arg2 string) (string, *dns.Config, string, error) {
+func (m *MockJobClient) QueryNetworkDNS(arg0 context.Context, arg1, arg2 string) (string, *dns.GetResult, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNetworkDNS", arg0, arg1, arg2)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(*dns.Config)
+ ret1, _ := ret[1].(*dns.GetResult)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -403,11 +403,11 @@ func (mr *MockJobClientMockRecorder) QueryNetworkDNS(arg0, arg1, arg2 interface{
}
// QueryNetworkDNSAll mocks base method.
-func (m *MockJobClient) QueryNetworkDNSAll(arg0 context.Context, arg1 string) (string, map[string]*dns.Config, map[string]string, error) {
+func (m *MockJobClient) QueryNetworkDNSAll(arg0 context.Context, arg1 string) (string, map[string]*dns.GetResult, map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNetworkDNSAll", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(map[string]*dns.Config)
+ ret1, _ := ret[1].(map[string]*dns.GetResult)
ret2, _ := ret[2].(map[string]string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -420,11 +420,11 @@ func (mr *MockJobClientMockRecorder) QueryNetworkDNSAll(arg0, arg1 interface{})
}
// QueryNetworkDNSBroadcast mocks base method.
-func (m *MockJobClient) QueryNetworkDNSBroadcast(arg0 context.Context, arg1, arg2 string) (string, map[string]*dns.Config, map[string]string, error) {
+func (m *MockJobClient) QueryNetworkDNSBroadcast(arg0 context.Context, arg1, arg2 string) (string, map[string]*dns.GetResult, map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNetworkDNSBroadcast", arg0, arg1, arg2)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(map[string]*dns.Config)
+ ret1, _ := ret[1].(map[string]*dns.GetResult)
ret2, _ := ret[2].(map[string]string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -590,11 +590,11 @@ func (mr *MockJobClientMockRecorder) QueryNodeHostnameBroadcast(arg0, arg1 inter
}
// QueryNodeLoad mocks base method.
-func (m *MockJobClient) QueryNodeLoad(arg0 context.Context, arg1 string) (string, *load.AverageStats, string, error) {
+func (m *MockJobClient) QueryNodeLoad(arg0 context.Context, arg1 string) (string, *load.Result, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNodeLoad", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(*load.AverageStats)
+ ret1, _ := ret[1].(*load.Result)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -607,11 +607,11 @@ func (mr *MockJobClientMockRecorder) QueryNodeLoad(arg0, arg1 interface{}) *gomo
}
// QueryNodeLoadBroadcast mocks base method.
-func (m *MockJobClient) QueryNodeLoadBroadcast(arg0 context.Context, arg1 string) (string, map[string]*load.AverageStats, map[string]string, error) {
+func (m *MockJobClient) QueryNodeLoadBroadcast(arg0 context.Context, arg1 string) (string, map[string]*load.Result, map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNodeLoadBroadcast", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(map[string]*load.AverageStats)
+ ret1, _ := ret[1].(map[string]*load.Result)
ret2, _ := ret[2].(map[string]string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -624,11 +624,11 @@ func (mr *MockJobClientMockRecorder) QueryNodeLoadBroadcast(arg0, arg1 interface
}
// QueryNodeMemory mocks base method.
-func (m *MockJobClient) QueryNodeMemory(arg0 context.Context, arg1 string) (string, *mem.Stats, string, error) {
+func (m *MockJobClient) QueryNodeMemory(arg0 context.Context, arg1 string) (string, *mem.Result, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNodeMemory", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(*mem.Stats)
+ ret1, _ := ret[1].(*mem.Result)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -641,11 +641,11 @@ func (mr *MockJobClientMockRecorder) QueryNodeMemory(arg0, arg1 interface{}) *go
}
// QueryNodeMemoryBroadcast mocks base method.
-func (m *MockJobClient) QueryNodeMemoryBroadcast(arg0 context.Context, arg1 string) (string, map[string]*mem.Stats, map[string]string, error) {
+func (m *MockJobClient) QueryNodeMemoryBroadcast(arg0 context.Context, arg1 string) (string, map[string]*mem.Result, map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNodeMemoryBroadcast", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(map[string]*mem.Stats)
+ ret1, _ := ret[1].(map[string]*mem.Result)
ret2, _ := ret[2].(map[string]string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -658,11 +658,11 @@ func (mr *MockJobClientMockRecorder) QueryNodeMemoryBroadcast(arg0, arg1 interfa
}
// QueryNodeOS mocks base method.
-func (m *MockJobClient) QueryNodeOS(arg0 context.Context, arg1 string) (string, *host.OSInfo, string, error) {
+func (m *MockJobClient) QueryNodeOS(arg0 context.Context, arg1 string) (string, *host.Result, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNodeOS", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(*host.OSInfo)
+ ret1, _ := ret[1].(*host.Result)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
@@ -675,11 +675,11 @@ func (mr *MockJobClientMockRecorder) QueryNodeOS(arg0, arg1 interface{}) *gomock
}
// QueryNodeOSBroadcast mocks base method.
-func (m *MockJobClient) QueryNodeOSBroadcast(arg0 context.Context, arg1 string) (string, map[string]*host.OSInfo, map[string]string, error) {
+func (m *MockJobClient) QueryNodeOSBroadcast(arg0 context.Context, arg1 string) (string, map[string]*host.Result, map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "QueryNodeOSBroadcast", arg0, arg1)
ret0, _ := ret[0].(string)
- ret1, _ := ret[1].(map[string]*host.OSInfo)
+ ret1, _ := ret[1].(map[string]*host.Result)
ret2, _ := ret[2].(map[string]string)
ret3, _ := ret[3].(error)
return ret0, ret1, ret2, ret3
diff --git a/internal/job/types.go b/internal/job/types.go
index 753a9be3..7da99d2c 100644
--- a/internal/job/types.go
+++ b/internal/job/types.go
@@ -258,16 +258,28 @@ type NetworkInterface struct {
Family string `json:"family,omitempty"`
}
+// Route represents a network routing table entry.
+type Route struct {
+ Destination string `json:"destination"`
+ Gateway string `json:"gateway"`
+ Interface string `json:"interface"`
+ Mask string `json:"mask,omitempty"`
+ Metric int `json:"metric,omitempty"`
+ Flags string `json:"flags,omitempty"`
+}
+
// FactsRegistration represents an agent's facts entry in the facts KV bucket.
type FactsRegistration struct {
- Architecture string `json:"architecture,omitempty"`
- KernelVersion string `json:"kernel_version,omitempty"`
- CPUCount int `json:"cpu_count,omitempty"`
- FQDN string `json:"fqdn,omitempty"`
- ServiceMgr string `json:"service_mgr,omitempty"`
- PackageMgr string `json:"package_mgr,omitempty"`
- Interfaces []NetworkInterface `json:"interfaces,omitempty"`
- Facts map[string]any `json:"facts,omitempty"`
+ Architecture string `json:"architecture,omitempty"`
+ KernelVersion string `json:"kernel_version,omitempty"`
+ CPUCount int `json:"cpu_count,omitempty"`
+ FQDN string `json:"fqdn,omitempty"`
+ ServiceMgr string `json:"service_mgr,omitempty"`
+ PackageMgr string `json:"package_mgr,omitempty"`
+ Interfaces []NetworkInterface `json:"interfaces,omitempty"`
+ PrimaryInterface string `json:"primary_interface,omitempty"`
+ Routes []Route `json:"routes,omitempty"`
+ Facts map[string]any `json:"facts,omitempty"`
}
// Condition type constants.
@@ -303,13 +315,13 @@ type AgentRegistration struct {
// StartedAt is the timestamp when the agent process started.
StartedAt time.Time `json:"started_at"`
// OSInfo contains operating system information.
- OSInfo *host.OSInfo `json:"os_info,omitempty"`
+ OSInfo *host.Result `json:"os_info,omitempty"`
// Uptime is the system uptime.
Uptime time.Duration `json:"uptime,omitempty"`
// LoadAverages contains the system load averages.
- LoadAverages *load.AverageStats `json:"load_averages,omitempty"`
+ LoadAverages *load.Result `json:"load_averages,omitempty"`
// MemoryStats contains memory usage information.
- MemoryStats *mem.Stats `json:"memory_stats,omitempty"`
+ MemoryStats *mem.Result `json:"memory_stats,omitempty"`
// AgentVersion is the version of the agent binary.
AgentVersion string `json:"agent_version,omitempty"`
// Conditions contains the evaluated node conditions.
@@ -329,13 +341,13 @@ type AgentInfo struct {
// StartedAt is the timestamp when the agent process started.
StartedAt time.Time `json:"started_at"`
// OSInfo contains operating system information.
- OSInfo *host.OSInfo `json:"os_info,omitempty"`
+ OSInfo *host.Result `json:"os_info,omitempty"`
// Uptime is the system uptime.
Uptime time.Duration `json:"uptime,omitempty"`
// LoadAverages contains the system load averages.
- LoadAverages *load.AverageStats `json:"load_averages,omitempty"`
+ LoadAverages *load.Result `json:"load_averages,omitempty"`
// MemoryStats contains memory usage information.
- MemoryStats *mem.Stats `json:"memory_stats,omitempty"`
+ MemoryStats *mem.Result `json:"memory_stats,omitempty"`
// AgentVersion is the version of the agent binary.
AgentVersion string `json:"agent_version,omitempty"`
// Architecture is the CPU architecture (e.g., x86_64, aarch64).
@@ -352,6 +364,10 @@ type AgentInfo struct {
PackageMgr string `json:"package_mgr,omitempty"`
// Interfaces contains network interface information.
Interfaces []NetworkInterface `json:"interfaces,omitempty"`
+ // PrimaryInterface is the name of the interface used for the default route.
+ PrimaryInterface string `json:"primary_interface,omitempty"`
+ // Routes contains the network routing table.
+ Routes []Route `json:"routes,omitempty"`
// Facts contains arbitrary key-value facts collected by the agent.
Facts map[string]any `json:"facts,omitempty"`
// Conditions contains the evaluated node conditions.
@@ -364,7 +380,7 @@ type AgentInfo struct {
// NodeDiskResponse represents the response for node.disk.get operations.
type NodeDiskResponse struct {
- Disks []disk.UsageStats `json:"disks"`
+ Disks []disk.Result `json:"disks"`
}
// NodeUptimeResponse represents the response for node.uptime.get operations.
@@ -381,11 +397,11 @@ type NodeStatusResponse struct {
// Uptime from the host provider
Uptime time.Duration `json:"uptime"`
// OSInfo from the host provider
- OSInfo *host.OSInfo `json:"os_info"`
+ OSInfo *host.Result `json:"os_info"`
// LoadAverages from the load provider
- LoadAverages *load.AverageStats `json:"load_averages"`
+ LoadAverages *load.Result `json:"load_averages"`
// MemoryStats from the memory provider
- MemoryStats *mem.Stats `json:"memory_stats"`
+ MemoryStats *mem.Result `json:"memory_stats"`
// DiskUsage from the disk provider
- DiskUsage []disk.UsageStats `json:"disk_usage"`
+ DiskUsage []disk.Result `json:"disk_usage"`
}
diff --git a/internal/job/types_public_test.go b/internal/job/types_public_test.go
index cf46c2f0..fac25f7d 100644
--- a/internal/job/types_public_test.go
+++ b/internal/job/types_public_test.go
@@ -219,13 +219,13 @@ func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() {
Labels: map[string]string{"group": "web"},
RegisteredAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
StartedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
- OSInfo: &host.OSInfo{
+ OSInfo: &host.Result{
Distribution: "Ubuntu",
Version: "22.04",
},
Uptime: time.Duration(3600) * time.Second,
- LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.1},
- MemoryStats: &mem.Stats{Total: 1024, Free: 512},
+ LoadAverages: &load.Result{Load1: 0.5, Load5: 0.3, Load15: 0.1},
+ MemoryStats: &mem.Result{Total: 1024, Free: 512},
AgentVersion: "1.0.0",
Architecture: "x86_64",
KernelVersion: "6.1.0-25-generic",
diff --git a/internal/provider/network/dns/darwin.go b/internal/provider/network/dns/darwin.go
index ca121d1d..bcd87a16 100644
--- a/internal/provider/network/dns/darwin.go
+++ b/internal/provider/network/dns/darwin.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -20,10 +20,25 @@
package dns
-// Darwin implements the DNS interface for Darwin (macOS) with mock data.
-type Darwin struct{}
+import (
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/exec"
+)
+
+// Darwin implements the DNS interface for Darwin (macOS).
+type Darwin struct {
+ logger *slog.Logger
+ execManager exec.Manager
+}
// NewDarwinProvider factory to create a new Darwin instance.
-func NewDarwinProvider() *Darwin {
- return &Darwin{}
+func NewDarwinProvider(
+ logger *slog.Logger,
+ em exec.Manager,
+) *Darwin {
+ return &Darwin{
+ logger: logger,
+ execManager: em,
+ }
}
diff --git a/internal/provider/network/dns/darwin_get_by_interface_resolv_conf.go b/internal/provider/network/dns/darwin_get_by_interface_resolv_conf.go
index fbc2a7bd..f7edc7a0 100644
--- a/internal/provider/network/dns/darwin_get_by_interface_resolv_conf.go
+++ b/internal/provider/network/dns/darwin_get_by_interface_resolv_conf.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -20,12 +20,106 @@
package dns
-// GetResolvConfByInterface returns mock DNS configuration for development on macOS.
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// GetResolvConfByInterface retrieves the DNS configuration for a specific
+// network interface using the `scutil --dns` command on macOS.
+//
+// It parses resolver blocks from scutil output, matching by interface name
+// via the `if_index` field. If no resolver matches the requested interface,
+// it returns an error.
+//
+// Example scutil --dns output:
+//
+// resolver #1
+// search domain[0] : example.com
+// nameserver[0] : 192.168.1.1
+// nameserver[1] : 8.8.8.8
+// if_index : 6 (en0)
func (d *Darwin) GetResolvConfByInterface(
- _ string,
-) (*Config, error) {
- return &Config{
- DNSServers: []string{"8.8.8.8", "1.1.1.1"},
- SearchDomains: []string{"local", "example.com"},
- }, nil
+ interfaceName string,
+) (*GetResult, error) {
+ output, err := d.execManager.RunCmd("scutil", []string{"--dns"})
+ if err != nil {
+ return nil, fmt.Errorf("failed to run scutil --dns: %w - %s", err, output)
+ }
+
+ return parseScutilDNS(output, interfaceName)
+}
+
+// resolverBlock represents a parsed resolver block from scutil --dns output.
+type resolverBlock struct {
+ nameservers []string
+ searchDomains []string
+ ifaceName string
+}
+
+// parseScutilDNS parses `scutil --dns` output and returns DNS configuration
+// for the requested interface.
+func parseScutilDNS(
+ output string,
+ interfaceName string,
+) (*GetResult, error) {
+ blocks := splitResolverBlocks(output)
+ if len(blocks) == 0 {
+ return nil, fmt.Errorf("no resolver blocks found in scutil output")
+ }
+
+ // Look for a resolver matching the requested interface
+ for _, block := range blocks {
+ if block.ifaceName == interfaceName {
+ return &GetResult{
+ DNSServers: block.nameservers,
+ SearchDomains: block.searchDomains,
+ }, nil
+ }
+ }
+
+ return nil, fmt.Errorf("interface %q does not exist", interfaceName)
+}
+
+var (
+ nameserverRegex = regexp.MustCompile(`nameserver\[\d+\]\s*:\s*(\S+)`)
+ searchDomainRegex = regexp.MustCompile(`search domain\[\d+\]\s*:\s*(\S+)`)
+ ifIndexRegex = regexp.MustCompile(`if_index\s*:\s*\d+\s*\((\S+)\)`)
+)
+
+// splitResolverBlocks splits scutil --dns output into individual resolver blocks.
+func splitResolverBlocks(
+ output string,
+) []resolverBlock {
+ // Split on "resolver #" to get individual blocks
+ parts := strings.Split(output, "resolver #")
+ var blocks []resolverBlock
+
+ for _, part := range parts {
+ if strings.TrimSpace(part) == "" {
+ continue
+ }
+
+ block := resolverBlock{}
+
+ for _, match := range nameserverRegex.FindAllStringSubmatch(part, -1) {
+ block.nameservers = append(block.nameservers, match[1])
+ }
+
+ for _, match := range searchDomainRegex.FindAllStringSubmatch(part, -1) {
+ block.searchDomains = append(block.searchDomains, match[1])
+ }
+
+ if match := ifIndexRegex.FindStringSubmatch(part); len(match) > 1 {
+ block.ifaceName = match[1]
+ }
+
+ // Only include blocks that have nameservers
+ if len(block.nameservers) > 0 {
+ blocks = append(blocks, block)
+ }
+ }
+
+ return blocks
}
diff --git a/internal/provider/network/dns/darwin_get_by_interface_resolv_conf_public_test.go b/internal/provider/network/dns/darwin_get_by_interface_resolv_conf_public_test.go
index 5ce50d46..29a7d9cb 100644
--- a/internal/provider/network/dns/darwin_get_by_interface_resolv_conf_public_test.go
+++ b/internal/provider/network/dns/darwin_get_by_interface_resolv_conf_public_test.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -21,44 +21,196 @@
package dns_test
import (
+ "fmt"
+ "log/slog"
+ "os"
"testing"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
+ execMocks "github.com/retr0h/osapi/internal/exec/mocks"
"github.com/retr0h/osapi/internal/provider/network/dns"
)
type DarwinGetResolvConfByInterfacePublicTestSuite struct {
suite.Suite
+ ctrl *gomock.Controller
+
+ logger *slog.Logger
+}
+
+func (suite *DarwinGetResolvConfByInterfacePublicTestSuite) SetupTest() {
+ suite.ctrl = gomock.NewController(suite.T())
+
+ suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
}
-func (suite *DarwinGetResolvConfByInterfacePublicTestSuite) SetupTest() {}
+func (suite *DarwinGetResolvConfByInterfacePublicTestSuite) SetupSubTest() {
+ suite.SetupTest()
+}
-func (suite *DarwinGetResolvConfByInterfacePublicTestSuite) TearDownTest() {}
+func (suite *DarwinGetResolvConfByInterfacePublicTestSuite) TearDownTest() {
+ suite.ctrl.Finish()
+}
func (suite *DarwinGetResolvConfByInterfacePublicTestSuite) TestGetResolvConfByInterface() {
tests := []struct {
- name string
- want *dns.Config
+ name string
+ setupMock func() *execMocks.MockManager
+ interfaceName string
+ want *dns.GetResult
+ wantErr bool
+ wantErrType error
}{
{
- name: "when GetResolvConfByInterface returns mock data",
- want: &dns.Config{
- DNSServers: []string{"8.8.8.8", "1.1.1.1"},
- SearchDomains: []string{"local", "example.com"},
+ name: "when matching interface found",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+ output := `
+DNS configuration
+
+resolver #1
+ search domain[0] : example.com
+ search domain[1] : local.lan
+ nameserver[0] : 192.168.1.1
+ nameserver[1] : 8.8.8.8
+ if_index : 6 (en0)
+ flags : Request A records
+
+resolver #2
+ nameserver[0] : 10.0.0.1
+ if_index : 7 (en1)
+`
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(output, nil)
+
+ return mock
+ },
+ interfaceName: "en0",
+ want: &dns.GetResult{
+ DNSServers: []string{"192.168.1.1", "8.8.8.8"},
+ SearchDomains: []string{"example.com", "local.lan"},
+ },
+ },
+ {
+ name: "when no interface match returns error",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+ output := `
+DNS configuration
+
+resolver #1
+ nameserver[0] : 192.168.1.1
+ nameserver[1] : 8.8.8.8
+ if_index : 6 (en0)
+
+resolver #2
+ nameserver[0] : 10.0.0.1
+ if_index : 7 (en1)
+`
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(output, nil)
+
+ return mock
+ },
+ interfaceName: "en5",
+ wantErr: true,
+ wantErrType: fmt.Errorf("does not exist"),
+ },
+ {
+ name: "when scutil command errors",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return("", assert.AnError)
+
+ return mock
+ },
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: assert.AnError,
+ },
+ {
+ name: "when no nameservers in output",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+ output := `
+DNS configuration
+
+resolver #1
+ search domain[0] : example.com
+ if_index : 6 (en0)
+`
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(output, nil)
+
+ return mock
+ },
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: fmt.Errorf("no resolver blocks found"),
+ },
+ {
+ name: "when empty output",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return("", nil)
+
+ return mock
+ },
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: fmt.Errorf("no resolver blocks found"),
+ },
+ {
+ name: "when resolver has no search domains",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+ output := `
+DNS configuration
+
+resolver #1
+ nameserver[0] : 8.8.8.8
+ nameserver[1] : 8.8.4.4
+ if_index : 6 (en0)
+`
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(output, nil)
+
+ return mock
+ },
+ interfaceName: "en0",
+ want: &dns.GetResult{
+ DNSServers: []string{"8.8.8.8", "8.8.4.4"},
},
},
}
for _, tc := range tests {
suite.Run(tc.name, func() {
- darwin := dns.NewDarwinProvider()
+ mock := tc.setupMock()
- got, err := darwin.GetResolvConfByInterface("en0")
+ darwin := dns.NewDarwinProvider(suite.logger, mock)
+ got, err := darwin.GetResolvConfByInterface(tc.interfaceName)
- suite.NoError(err)
- suite.NotNil(got)
- suite.Equal(tc.want, got)
+ if !tc.wantErr {
+ suite.NoError(err)
+ suite.Equal(tc.want, got)
+ } else {
+ suite.Error(err)
+ suite.Contains(err.Error(), tc.wantErrType.Error())
+ }
})
}
}
diff --git a/internal/provider/network/dns/darwin_update_resolv_conf_by_interface.go b/internal/provider/network/dns/darwin_update_resolv_conf_by_interface.go
index 8a35951e..b10d5c6c 100644
--- a/internal/provider/network/dns/darwin_update_resolv_conf_by_interface.go
+++ b/internal/provider/network/dns/darwin_update_resolv_conf_by_interface.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -20,11 +20,125 @@
package dns
-// UpdateResolvConfByInterface is a no-op on macOS for development purposes.
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+)
+
+// UpdateResolvConfByInterface updates the DNS configuration for a macOS
+// network interface using `networksetup`. It resolves the interface name
+// (e.g., "en0") to a network service name (e.g., "Wi-Fi") via
+// `networksetup -listallhardwareports`, then applies DNS servers and
+// search domains.
+//
+// This command requires root privileges on macOS.
func (d *Darwin) UpdateResolvConfByInterface(
- _ []string,
- _ []string,
- _ string,
-) (*Result, error) {
- return &Result{Changed: false}, nil
+ servers []string,
+ searchDomains []string,
+ interfaceName string,
+) (*UpdateResult, error) {
+ d.logger.Info(
+ "setting DNS configuration via networksetup",
+ slog.String("servers", strings.Join(servers, ", ")),
+ slog.String("search_domains", strings.Join(searchDomains, ", ")),
+ slog.String("interface", interfaceName),
+ )
+
+ if len(servers) == 0 && len(searchDomains) == 0 {
+ return nil, fmt.Errorf("no DNS servers or search domains provided; nothing to update")
+ }
+
+ existingConfig, err := d.GetResolvConfByInterface(interfaceName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current DNS configuration: %w", err)
+ }
+
+ // Use existing values if new values are not provided
+ if len(servers) == 0 {
+ servers = existingConfig.DNSServers
+ }
+ if len(searchDomains) == 0 {
+ searchDomains = existingConfig.SearchDomains
+ }
+
+ // Compare desired config against existing to detect no-op
+ if slices.Equal(servers, existingConfig.DNSServers) &&
+ slices.Equal(searchDomains, existingConfig.SearchDomains) {
+ d.logger.Info("dns configuration unchanged, skipping update")
+ return &UpdateResult{Changed: false}, nil
+ }
+
+ // Resolve interface name to network service name
+ serviceName, err := d.resolveServiceName(interfaceName)
+ if err != nil {
+ return nil, err
+ }
+
+ // Set DNS servers
+ if len(servers) > 0 {
+ args := append([]string{"-setdnsservers", serviceName}, servers...)
+ output, err := d.execManager.RunCmd("networksetup", args)
+ if err != nil {
+ return nil, fmt.Errorf(
+ "failed to set DNS servers with networksetup: %w - %s",
+ err,
+ output,
+ )
+ }
+ }
+
+ // Set search domains
+ if len(searchDomains) > 0 {
+ args := append([]string{"-setsearchdomains", serviceName}, searchDomains...)
+ output, err := d.execManager.RunCmd("networksetup", args)
+ if err != nil {
+ return nil, fmt.Errorf(
+ "failed to set search domains with networksetup: %w - %s",
+ err,
+ output,
+ )
+ }
+ }
+
+ return &UpdateResult{Changed: true}, nil
+}
+
+// resolveServiceName maps a BSD interface name (e.g., "en0") to its
+// macOS network service name (e.g., "Wi-Fi") by parsing the output of
+// `networksetup -listallhardwareports`.
+//
+// Example output:
+//
+// Hardware Port: Wi-Fi
+// Device: en0
+// Ethernet Address: a4:83:e7:1a:2b:3c
+//
+// Hardware Port: Thunderbolt Ethernet
+// Device: en1
+// Ethernet Address: 00:11:22:33:44:55
+func (d *Darwin) resolveServiceName(
+ interfaceName string,
+) (string, error) {
+ output, err := d.execManager.RunCmd("networksetup", []string{"-listallhardwareports"})
+ if err != nil {
+ return "", fmt.Errorf("failed to list hardware ports: %w - %s", err, output)
+ }
+
+ var currentService string
+ for _, line := range strings.Split(output, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "Hardware Port:") {
+ currentService = strings.TrimPrefix(line, "Hardware Port: ")
+ }
+ if strings.HasPrefix(line, "Device:") {
+ device := strings.TrimSpace(strings.TrimPrefix(line, "Device:"))
+ if device == interfaceName {
+ return currentService, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("no network service found for interface %q", interfaceName)
}
diff --git a/internal/provider/network/dns/darwin_update_resolv_conf_by_interface_public_test.go b/internal/provider/network/dns/darwin_update_resolv_conf_by_interface_public_test.go
index 21ea6456..1ad640d3 100644
--- a/internal/provider/network/dns/darwin_update_resolv_conf_by_interface_public_test.go
+++ b/internal/provider/network/dns/darwin_update_resolv_conf_by_interface_public_test.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -21,43 +21,322 @@
package dns_test
import (
+ "fmt"
+ "log/slog"
+ "os"
"testing"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
+ execMocks "github.com/retr0h/osapi/internal/exec/mocks"
"github.com/retr0h/osapi/internal/provider/network/dns"
)
+const (
+ darwinHardwarePorts = `Hardware Port: Wi-Fi
+Device: en0
+Ethernet Address: a4:83:e7:1a:2b:3c
+
+Hardware Port: Thunderbolt Ethernet
+Device: en1
+Ethernet Address: 00:11:22:33:44:55
+`
+ darwinScutilExisting = `
+DNS configuration
+
+resolver #1
+ nameserver[0] : 192.168.1.1
+ nameserver[1] : 8.8.8.8
+ search domain[0] : old.example.com
+ if_index : 6 (en0)
+`
+ darwinScutilSameConfig = `
+DNS configuration
+
+resolver #1
+ nameserver[0] : 8.8.8.8
+ nameserver[1] : 9.9.9.9
+ search domain[0] : example.com
+ if_index : 6 (en0)
+`
+)
+
type DarwinUpdateResolvConfByInterfacePublicTestSuite struct {
suite.Suite
+ ctrl *gomock.Controller
+
+ logger *slog.Logger
+}
+
+func (suite *DarwinUpdateResolvConfByInterfacePublicTestSuite) SetupTest() {
+ suite.ctrl = gomock.NewController(suite.T())
+
+ suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
}
-func (suite *DarwinUpdateResolvConfByInterfacePublicTestSuite) SetupTest() {}
+func (suite *DarwinUpdateResolvConfByInterfacePublicTestSuite) SetupSubTest() {
+ suite.SetupTest()
+}
-func (suite *DarwinUpdateResolvConfByInterfacePublicTestSuite) TearDownTest() {}
+func (suite *DarwinUpdateResolvConfByInterfacePublicTestSuite) TearDownTest() {
+ suite.ctrl.Finish()
+}
func (suite *DarwinUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvConfByInterface() {
tests := []struct {
- name string
+ name string
+ setupMock func() *execMocks.MockManager
+ servers []string
+ searchDomains []string
+ interfaceName string
+ want *dns.UpdateResult
+ wantErr bool
+ wantErrType error
}{
{
- name: "when UpdateResolvConfByInterface is a no-op",
+ name: "when update succeeds with new servers and domains",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilExisting, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-listallhardwareports"}).
+ Return(darwinHardwarePorts, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setdnsservers", "Wi-Fi", "8.8.8.8", "9.9.9.9"}).
+ Return("", nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setsearchdomains", "Wi-Fi", "example.com"}).
+ Return("", nil)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8", "9.9.9.9"},
+ searchDomains: []string{"example.com"},
+ interfaceName: "en0",
+ want: &dns.UpdateResult{Changed: true},
+ },
+ {
+ name: "when configuration unchanged returns no-op",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilSameConfig, nil)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8", "9.9.9.9"},
+ searchDomains: []string{"example.com"},
+ interfaceName: "en0",
+ want: &dns.UpdateResult{Changed: false},
+ },
+ {
+ name: "when no servers or domains provided",
+ setupMock: func() *execMocks.MockManager {
+ return execMocks.NewPlainMockManager(suite.ctrl)
+ },
+ servers: []string{},
+ searchDomains: []string{},
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: fmt.Errorf("no DNS servers or search domains provided"),
+ },
+ {
+ name: "when scutil errors",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return("", assert.AnError)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8"},
+ searchDomains: []string{},
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: assert.AnError,
+ },
+ {
+ name: "when listallhardwareports errors",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilExisting, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-listallhardwareports"}).
+ Return("", assert.AnError)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8"},
+ searchDomains: []string{},
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: fmt.Errorf("failed to list hardware ports"),
+ },
+ {
+ name: "when interface not found in scutil",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilExisting, nil)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8"},
+ searchDomains: []string{},
+ interfaceName: "en99",
+ wantErr: true,
+ wantErrType: fmt.Errorf("does not exist"),
+ },
+ {
+ name: "when interface not found in hardware ports",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ // Interface exists in scutil with different config
+ scutilOutput := `
+DNS configuration
+
+resolver #1
+ nameserver[0] : 10.0.0.1
+ if_index : 20 (utun5)
+`
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(scutilOutput, nil)
+
+ // But not in hardware ports
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-listallhardwareports"}).
+ Return(darwinHardwarePorts, nil)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8"},
+ searchDomains: []string{},
+ interfaceName: "utun5",
+ wantErr: true,
+ wantErrType: fmt.Errorf("no network service found for interface"),
+ },
+ {
+ name: "when setdnsservers errors",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilExisting, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-listallhardwareports"}).
+ Return(darwinHardwarePorts, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setdnsservers", "Wi-Fi", "8.8.8.8"}).
+ Return("", assert.AnError)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8"},
+ searchDomains: []string{},
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: assert.AnError,
+ },
+ {
+ name: "when setsearchdomains errors",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilExisting, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-listallhardwareports"}).
+ Return(darwinHardwarePorts, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setdnsservers", "Wi-Fi", "8.8.8.8"}).
+ Return("", nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setsearchdomains", "Wi-Fi", "new.example.com"}).
+ Return("", assert.AnError)
+
+ return mock
+ },
+ servers: []string{"8.8.8.8"},
+ searchDomains: []string{"new.example.com"},
+ interfaceName: "en0",
+ wantErr: true,
+ wantErrType: assert.AnError,
+ },
+ {
+ name: "when preserving existing servers when only domains specified",
+ setupMock: func() *execMocks.MockManager {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ mock.EXPECT().
+ RunCmd("scutil", []string{"--dns"}).
+ Return(darwinScutilExisting, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-listallhardwareports"}).
+ Return(darwinHardwarePorts, nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setdnsservers", "Wi-Fi", "192.168.1.1", "8.8.8.8"}).
+ Return("", nil)
+
+ mock.EXPECT().
+ RunCmd("networksetup", []string{"-setsearchdomains", "Wi-Fi", "new.example.com"}).
+ Return("", nil)
+
+ return mock
+ },
+ servers: []string{},
+ searchDomains: []string{"new.example.com"},
+ interfaceName: "en0",
+ want: &dns.UpdateResult{Changed: true},
},
}
for _, tc := range tests {
suite.Run(tc.name, func() {
- darwin := dns.NewDarwinProvider()
+ mock := tc.setupMock()
- result, err := darwin.UpdateResolvConfByInterface(
- []string{"8.8.8.8"},
- []string{"example.com"},
- "en0",
+ darwin := dns.NewDarwinProvider(suite.logger, mock)
+ got, err := darwin.UpdateResolvConfByInterface(
+ tc.servers,
+ tc.searchDomains,
+ tc.interfaceName,
)
- suite.NoError(err)
- suite.NotNil(result)
- suite.False(result.Changed)
+ if !tc.wantErr {
+ suite.NoError(err)
+ suite.Equal(tc.want, got)
+ } else {
+ suite.Error(err)
+ suite.Contains(err.Error(), tc.wantErrType.Error())
+ }
})
}
}
diff --git a/internal/provider/network/dns/linux_get_by_interface_resolv_conf.go b/internal/provider/network/dns/linux_get_by_interface_resolv_conf.go
index 37afdba4..7a13dbaf 100644
--- a/internal/provider/network/dns/linux_get_by_interface_resolv_conf.go
+++ b/internal/provider/network/dns/linux_get_by_interface_resolv_conf.go
@@ -29,6 +29,6 @@ import (
// servers and search domains for the interface, and an error if something goes wrong.
func (l *Linux) GetResolvConfByInterface(
_ string,
-) (*Config, error) {
+) (*GetResult, error) {
return nil, fmt.Errorf("getResolvConfByInterface is not implemented for LinuxProvider")
}
diff --git a/internal/provider/network/dns/linux_update_resolv_conf_by_interface.go b/internal/provider/network/dns/linux_update_resolv_conf_by_interface.go
index 172a9f3f..dff1c730 100644
--- a/internal/provider/network/dns/linux_update_resolv_conf_by_interface.go
+++ b/internal/provider/network/dns/linux_update_resolv_conf_by_interface.go
@@ -32,6 +32,6 @@ func (l *Linux) UpdateResolvConfByInterface(
_ []string,
_ []string,
_ string,
-) (*Result, error) {
+) (*UpdateResult, error) {
return nil, fmt.Errorf("updateResolvConfByInterface is not implemented for LinuxProvider")
}
diff --git a/internal/provider/network/dns/mocks/mocks.go b/internal/provider/network/dns/mocks/mocks.go
index 9432f021..c4da499f 100644
--- a/internal/provider/network/dns/mocks/mocks.go
+++ b/internal/provider/network/dns/mocks/mocks.go
@@ -36,7 +36,7 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
mock := NewMockProvider(ctrl)
// Set up default expectations for the mock methods
- mock.EXPECT().GetResolvConfByInterface("wlp0s20f3").Return(&dns.Config{
+ mock.EXPECT().GetResolvConfByInterface("wlp0s20f3").Return(&dns.GetResult{
DNSServers: []string{
"192.168.1.1",
"8.8.8.8",
diff --git a/internal/provider/network/dns/mocks/types.gen.go b/internal/provider/network/dns/mocks/types.gen.go
index 496218c1..dd71f74e 100644
--- a/internal/provider/network/dns/mocks/types.gen.go
+++ b/internal/provider/network/dns/mocks/types.gen.go
@@ -35,10 +35,10 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
}
// GetResolvConfByInterface mocks base method.
-func (m *MockProvider) GetResolvConfByInterface(interfaceName string) (*dns.Config, error) {
+func (m *MockProvider) GetResolvConfByInterface(interfaceName string) (*dns.GetResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetResolvConfByInterface", interfaceName)
- ret0, _ := ret[0].(*dns.Config)
+ ret0, _ := ret[0].(*dns.GetResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -50,10 +50,10 @@ func (mr *MockProviderMockRecorder) GetResolvConfByInterface(interfaceName inter
}
// UpdateResolvConfByInterface mocks base method.
-func (m *MockProvider) UpdateResolvConfByInterface(servers, searchDomains []string, interfaceName string) (*dns.Result, error) {
+func (m *MockProvider) UpdateResolvConfByInterface(servers, searchDomains []string, interfaceName string) (*dns.UpdateResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateResolvConfByInterface", servers, searchDomains, interfaceName)
- ret0, _ := ret[0].(*dns.Result)
+ ret0, _ := ret[0].(*dns.UpdateResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
diff --git a/internal/provider/network/dns/types.go b/internal/provider/network/dns/types.go
index 9331e1b5..e4d9a8f4 100644
--- a/internal/provider/network/dns/types.go
+++ b/internal/provider/network/dns/types.go
@@ -25,18 +25,18 @@ type Provider interface {
// GetResolvConfByInterface retrieves the DNS configuration.
GetResolvConfByInterface(
interfaceName string,
- ) (*Config, error)
+ ) (*GetResult, error)
// UpdateResolvConfByInterface updates the DNS configuration.
- // Returns a Result indicating whether the configuration was changed.
+ // Returns an UpdateResult indicating whether the configuration was changed.
UpdateResolvConfByInterface(
servers []string,
searchDomains []string,
interfaceName string,
- ) (*Result, error)
+ ) (*UpdateResult, error)
}
-// Config represents the DNS configuration with servers and search domains.
-type Config struct {
+// GetResult represents the DNS configuration with servers and search domains.
+type GetResult struct {
// List of DNS server IP addresses (IPv4 or IPv6)
DNSServers []string
// List of search domains for DNS resolution
@@ -45,8 +45,8 @@ type Config struct {
Changed bool `json:"changed"`
}
-// Result represents the outcome of a DNS update operation.
-type Result struct {
+// UpdateResult represents the outcome of a DNS update operation.
+type UpdateResult struct {
// Changed indicates whether the DNS configuration was actually modified.
Changed bool
}
diff --git a/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface.go b/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface.go
index 5644183f..3827ba9c 100644
--- a/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface.go
+++ b/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface.go
@@ -52,7 +52,7 @@ import (
// See `systemd-resolved.service(8)` manual page for further information.
func (u *Ubuntu) GetResolvConfByInterface(
interfaceName string,
-) (*Config, error) {
+) (*GetResult, error) {
cmd := "resolvectl"
args := []string{"status", interfaceName}
output, err := u.execManager.RunCmd(cmd, args)
@@ -68,7 +68,7 @@ func (u *Ubuntu) GetResolvConfByInterface(
return nil, fmt.Errorf("interface %q does not exist", interfaceName)
}
- config := &Config{}
+ config := &GetResult{}
// Parse DNS Servers
dnsServersRegex := regexp.MustCompile(`DNS Servers:\s+([^\n]+)`)
diff --git a/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface_public_test.go b/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface_public_test.go
index 87ea02ac..c4f7a4ed 100644
--- a/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface_public_test.go
+++ b/internal/provider/network/dns/ubuntu_get_resolv_conf_by_interface_public_test.go
@@ -60,7 +60,7 @@ func (suite *UbuntuGetResolvConfPublicTestSuite) TestGetResolvConfByInterface()
name string
setupMock func() *mocks.MockManager
interfaceName string
- want *dns.Config
+ want *dns.GetResult
wantErr bool
wantErrType error
}{
@@ -72,7 +72,7 @@ func (suite *UbuntuGetResolvConfPublicTestSuite) TestGetResolvConfByInterface()
return mock
},
interfaceName: "wlp0s20f3",
- want: &dns.Config{
+ want: &dns.GetResult{
DNSServers: []string{
"192.168.1.1",
"8.8.8.8",
@@ -95,7 +95,7 @@ func (suite *UbuntuGetResolvConfPublicTestSuite) TestGetResolvConfByInterface()
return mock
},
interfaceName: "wlp0s20f3",
- want: &dns.Config{
+ want: &dns.GetResult{
DNSServers: []string{
"192.168.1.1",
"8.8.8.8",
diff --git a/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface.go b/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface.go
index 4b008104..92f83e0d 100644
--- a/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface.go
+++ b/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface.go
@@ -56,7 +56,7 @@ func (u *Ubuntu) UpdateResolvConfByInterface(
servers []string,
searchDomains []string,
interfaceName string,
-) (*Result, error) {
+) (*UpdateResult, error) {
u.logger.Info(
"setting resolvectl configuration",
slog.String("servers", strings.Join(servers, ", ")),
@@ -84,7 +84,7 @@ func (u *Ubuntu) UpdateResolvConfByInterface(
if slices.Equal(servers, existingConfig.DNSServers) &&
slices.Equal(searchDomains, existingConfig.SearchDomains) {
u.logger.Info("dns configuration unchanged, skipping update")
- return &Result{Changed: false}, nil
+ return &UpdateResult{Changed: false}, nil
}
// Set DNS servers
@@ -120,5 +120,5 @@ func (u *Ubuntu) UpdateResolvConfByInterface(
}
}
- return &Result{Changed: true}, nil
+ return &UpdateResult{Changed: true}, nil
}
diff --git a/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface_public_test.go b/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface_public_test.go
index 6ed0657c..34a61304 100644
--- a/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface_public_test.go
+++ b/internal/provider/network/dns/ubuntu_update_resolv_conf_by_interface_public_test.go
@@ -62,7 +62,7 @@ func (suite *UbuntuUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC
servers []string
searchDomains []string
interfaceName string
- want *dns.Config
+ want *dns.GetResult
wantErr bool
wantErrType error
}{
@@ -82,7 +82,7 @@ func (suite *UbuntuUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC
"foo.local",
"bar.local",
},
- want: &dns.Config{
+ want: &dns.GetResult{
DNSServers: []string{
"8.8.8.8",
"9.9.9.9",
@@ -106,7 +106,7 @@ func (suite *UbuntuUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC
"foo.local",
"bar.local",
},
- want: &dns.Config{
+ want: &dns.GetResult{
DNSServers: []string{
"1.1.1.1",
"2.2.2.2",
@@ -130,7 +130,7 @@ func (suite *UbuntuUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC
"8.8.8.8",
"9.9.9.9",
},
- want: &dns.Config{
+ want: &dns.GetResult{
DNSServers: []string{
"8.8.8.8",
"9.9.9.9",
@@ -155,7 +155,7 @@ func (suite *UbuntuUpdateResolvConfByInterfacePublicTestSuite) TestUpdateResolvC
"8.8.8.8",
"9.9.9.9",
},
- want: &dns.Config{
+ want: &dns.GetResult{
DNSServers: []string{
"8.8.8.8",
"9.9.9.9",
diff --git a/internal/provider/network/netinfo/darwin.go b/internal/provider/network/netinfo/darwin.go
new file mode 100644
index 00000000..ff6e2a25
--- /dev/null
+++ b/internal/provider/network/netinfo/darwin.go
@@ -0,0 +1,56 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package netinfo
+
+import (
+ "io"
+ "net"
+ "strings"
+
+ "github.com/retr0h/osapi/internal/exec"
+)
+
+// Darwin implements the Provider interface for macOS systems.
+type Darwin struct {
+ Netinfo
+ RouteReaderFn func() (io.ReadCloser, error)
+}
+
+// NewDarwinProvider factory to create a new Darwin instance.
+func NewDarwinProvider(
+ em exec.Manager,
+) *Darwin {
+ return &Darwin{
+ Netinfo: Netinfo{
+ InterfacesFn: net.Interfaces,
+ AddrsFn: func(iface net.Interface) ([]net.Addr, error) {
+ return iface.Addrs()
+ },
+ },
+ RouteReaderFn: func() (io.ReadCloser, error) {
+ output, err := em.RunCmd("netstat", []string{"-rn"})
+ if err != nil {
+ return nil, err
+ }
+ return io.NopCloser(strings.NewReader(output)), nil
+ },
+ }
+}
diff --git a/internal/provider/network/netinfo/darwin_get_routes.go b/internal/provider/network/netinfo/darwin_get_routes.go
new file mode 100644
index 00000000..cdb554ae
--- /dev/null
+++ b/internal/provider/network/netinfo/darwin_get_routes.go
@@ -0,0 +1,126 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package netinfo
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// GetRoutes returns the macOS routing table by parsing `netstat -rn` output.
+//
+// Expected format:
+//
+// Routing tables
+// Internet:
+// Destination Gateway Flags Netif Expire
+// default 192.168.1.1 UGScg en0
+// 127 127.0.0.1 UCS lo0
+// 192.168.1/24 link#6 UCS en0
+func (d *Darwin) GetRoutes() ([]RouteResult, error) {
+ rc, err := d.RouteReaderFn()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read route table: %w", err)
+ }
+ defer func() { _ = rc.Close() }()
+
+ return parseDarwinRoutes(rc)
+}
+
+// parseDarwinRoutes parses BSD-style `netstat -rn` output.
+func parseDarwinRoutes(
+ r io.Reader,
+) ([]RouteResult, error) {
+ scanner := bufio.NewScanner(r)
+
+ // Find the "Internet:" section and skip to the column header row
+ inIPv4Section := false
+ foundHeader := false
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ if line == "Internet:" {
+ inIPv4Section = true
+ continue
+ }
+
+ if inIPv4Section && strings.HasPrefix(line, "Destination") {
+ foundHeader = true
+ break
+ }
+
+ // Stop if we hit Internet6: before finding the header
+ if inIPv4Section && line == "Internet6:" {
+ break
+ }
+ }
+
+ if !foundHeader {
+ return nil, fmt.Errorf("no IPv4 routing table found in netstat output")
+ }
+
+ var routes []RouteResult
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ // Stop at the next section (e.g., Internet6:)
+ if line == "" || line == "Internet6:" {
+ break
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) < 4 {
+ continue
+ }
+
+ route := RouteResult{
+ Destination: fields[0],
+ Gateway: fields[1],
+ Flags: fields[2],
+ Interface: fields[3],
+ }
+
+ routes = append(routes, route)
+ }
+
+ return routes, scanner.Err()
+}
+
+// GetPrimaryInterface returns the name of the interface used for the default
+// route from macOS `netstat -rn` output.
+func (d *Darwin) GetPrimaryInterface() (string, error) {
+ routes, err := d.GetRoutes()
+ if err != nil {
+ return "", err
+ }
+
+ for _, route := range routes {
+ if route.Destination == "default" {
+ return route.Interface, nil
+ }
+ }
+
+ return "", fmt.Errorf("no default route found")
+}
diff --git a/internal/provider/network/netinfo/darwin_get_routes_public_test.go b/internal/provider/network/netinfo/darwin_get_routes_public_test.go
new file mode 100644
index 00000000..db4fa826
--- /dev/null
+++ b/internal/provider/network/netinfo/darwin_get_routes_public_test.go
@@ -0,0 +1,391 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package netinfo_test
+
+import (
+ "io"
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ execMocks "github.com/retr0h/osapi/internal/exec/mocks"
+ "github.com/retr0h/osapi/internal/provider/network/netinfo"
+)
+
+type GetRoutesDarwinPublicTestSuite struct {
+ suite.Suite
+ ctrl *gomock.Controller
+}
+
+func (suite *GetRoutesDarwinPublicTestSuite) SetupTest() {
+ suite.ctrl = gomock.NewController(suite.T())
+}
+
+func (suite *GetRoutesDarwinPublicTestSuite) SetupSubTest() {
+ suite.SetupTest()
+}
+
+func (suite *GetRoutesDarwinPublicTestSuite) TearDownTest() {
+ suite.ctrl.Finish()
+}
+
+func (suite *GetRoutesDarwinPublicTestSuite) TestGetRoutes() {
+ tests := []struct {
+ name string
+ routeContent string
+ readerErr bool
+ useDefaultReader bool
+ execMockErr bool
+ wantErr bool
+ validateFunc func(routes []netinfo.RouteResult)
+ }{
+ {
+ name: "when typical macOS route table",
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+127 127.0.0.1 UCS lo0
+127.0.0.1 127.0.0.1 UH lo0
+192.168.1/24 link#6 UCS en0
+192.168.1.100 a4:83:e7:1a:2b:3c UHLWIi lo0
+
+Internet6:
+Destination Gateway Flags Netif Expire
+::1 ::1 UHL lo0
+`,
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 5)
+
+ suite.Equal("default", routes[0].Destination)
+ suite.Equal("192.168.1.1", routes[0].Gateway)
+ suite.Equal("UGScg", routes[0].Flags)
+ suite.Equal("en0", routes[0].Interface)
+
+ suite.Equal("127", routes[1].Destination)
+ suite.Equal("127.0.0.1", routes[1].Gateway)
+ suite.Equal("lo0", routes[1].Interface)
+
+ suite.Equal("192.168.1/24", routes[3].Destination)
+ suite.Equal("link#6", routes[3].Gateway)
+ suite.Equal("en0", routes[3].Interface)
+ },
+ },
+ {
+ name: "when multiple default routes",
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+default 10.0.0.1 UGScIg en1
+10.0.0/24 link#7 UCS en1
+`,
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 3)
+ suite.Equal("en0", routes[0].Interface)
+ suite.Equal("en1", routes[1].Interface)
+ },
+ },
+ {
+ name: "when IPv6 lines are skipped",
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+
+Internet6:
+Destination Gateway Flags Netif Expire
+default fe80::1%en0 UGcg en0
+::1 ::1 UHL lo0
+`,
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 1)
+ suite.Equal("default", routes[0].Destination)
+ suite.Equal("en0", routes[0].Interface)
+ },
+ },
+ {
+ name: "when no IPv4 routing table found",
+ routeContent: "Routing tables\n\nInternet6:\nDestination Gateway Flags Netif Expire\n",
+ wantErr: true,
+ },
+ {
+ name: "when Internet6 appears before header in IPv4 section",
+ routeContent: `Routing tables
+
+Internet:
+Internet6:
+Destination Gateway Flags Netif Expire
+`,
+ wantErr: true,
+ },
+ {
+ name: "when empty output",
+ routeContent: "",
+ wantErr: true,
+ },
+ {
+ name: "when reader returns error",
+ readerErr: true,
+ wantErr: true,
+ },
+ {
+ name: "when line has too few fields",
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+bad
+`,
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 1)
+ suite.Equal("default", routes[0].Destination)
+ },
+ },
+ {
+ name: "when using default route reader",
+ useDefaultReader: true,
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+127 127.0.0.1 UCS lo0
+`,
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 2)
+ suite.Equal("default", routes[0].Destination)
+ suite.Equal("192.168.1.1", routes[0].Gateway)
+ suite.Equal("en0", routes[0].Interface)
+ },
+ },
+ {
+ name: "when default route reader errors",
+ useDefaultReader: true,
+ execMockErr: true,
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ if tc.useDefaultReader {
+ if tc.execMockErr {
+ mock.EXPECT().
+ RunCmd("netstat", []string{"-rn"}).
+ Return("", assert.AnError)
+ } else {
+ mock.EXPECT().
+ RunCmd("netstat", []string{"-rn"}).
+ Return(tc.routeContent, nil)
+ }
+ }
+
+ d := netinfo.NewDarwinProvider(mock)
+
+ if !tc.useDefaultReader {
+ if tc.readerErr {
+ d.RouteReaderFn = func() (io.ReadCloser, error) {
+ return nil, assert.AnError
+ }
+ } else {
+ content := tc.routeContent
+ d.RouteReaderFn = func() (io.ReadCloser, error) {
+ return io.NopCloser(strings.NewReader(content)), nil
+ }
+ }
+ }
+
+ got, err := d.GetRoutes()
+
+ if tc.wantErr {
+ suite.Error(err)
+ } else {
+ suite.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(got)
+ }
+ }
+ })
+ }
+}
+
+func (suite *GetRoutesDarwinPublicTestSuite) TestGetPrimaryInterface() {
+ tests := []struct {
+ name string
+ routeContent string
+ readerErr bool
+ useDefaultReader bool
+ wantErr bool
+ validateFunc func(iface string)
+ }{
+ {
+ name: "when default route exists",
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+127 127.0.0.1 UCS lo0
+`,
+ validateFunc: func(iface string) {
+ suite.Equal("en0", iface)
+ },
+ },
+ {
+ name: "when no default route exists",
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+127 127.0.0.1 UCS lo0
+192.168.1/24 link#6 UCS en0
+`,
+ wantErr: true,
+ },
+ {
+ name: "when reader returns error",
+ readerErr: true,
+ wantErr: true,
+ },
+ {
+ name: "when empty output",
+ routeContent: "",
+ wantErr: true,
+ },
+ {
+ name: "when using default route reader",
+ useDefaultReader: true,
+ routeContent: `Routing tables
+
+Internet:
+Destination Gateway Flags Netif Expire
+default 192.168.1.1 UGScg en0
+127 127.0.0.1 UCS lo0
+`,
+ validateFunc: func(iface string) {
+ suite.Equal("en0", iface)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+
+ if tc.useDefaultReader {
+ mock.EXPECT().
+ RunCmd("netstat", []string{"-rn"}).
+ Return(tc.routeContent, nil)
+ }
+
+ d := netinfo.NewDarwinProvider(mock)
+
+ if !tc.useDefaultReader {
+ if tc.readerErr {
+ d.RouteReaderFn = func() (io.ReadCloser, error) {
+ return nil, assert.AnError
+ }
+ } else {
+ content := tc.routeContent
+ d.RouteReaderFn = func() (io.ReadCloser, error) {
+ return io.NopCloser(strings.NewReader(content)), nil
+ }
+ }
+ }
+
+ got, err := d.GetPrimaryInterface()
+
+ if tc.wantErr {
+ suite.Error(err)
+ } else {
+ suite.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(got)
+ }
+ }
+ })
+ }
+}
+
+func (suite *GetRoutesDarwinPublicTestSuite) TestNewDarwinProvider() {
+ tests := []struct {
+ name string
+ setupMock func() func() ([]net.Interface, error)
+ wantErr bool
+ validateFunc func(result []netinfo.InterfaceResult)
+ }{
+ {
+ name: "when factory wires GetInterfaces correctly",
+ setupMock: func() func() ([]net.Interface, error) {
+ return func() ([]net.Interface, error) {
+ return []net.Interface{
+ {
+ Index: 2,
+ MTU: 1500,
+ Name: "en0",
+ HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55},
+ Flags: net.FlagUp | net.FlagBroadcast,
+ },
+ }, nil
+ }
+ },
+ validateFunc: func(result []netinfo.InterfaceResult) {
+ suite.Require().Len(result, 1)
+ suite.Equal("en0", result[0].Name)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ mock := execMocks.NewPlainMockManager(suite.ctrl)
+ d := netinfo.NewDarwinProvider(mock)
+
+ d.InterfacesFn = tc.setupMock()
+
+ got, err := d.GetInterfaces()
+
+ if tc.wantErr {
+ suite.Error(err)
+ } else {
+ suite.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(got)
+ }
+ }
+ })
+ }
+}
+
+func TestGetRoutesDarwinPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(GetRoutesDarwinPublicTestSuite))
+}
diff --git a/internal/provider/network/netinfo/linux.go b/internal/provider/network/netinfo/linux.go
new file mode 100644
index 00000000..d9a89b72
--- /dev/null
+++ b/internal/provider/network/netinfo/linux.go
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package netinfo
+
+import (
+ "io"
+ "net"
+ "os"
+)
+
+// Linux implements the Provider interface for Linux systems.
+type Linux struct {
+ Netinfo
+ RouteReaderFn func() (io.ReadCloser, error)
+}
+
+// NewLinuxProvider factory to create a new Linux instance.
+func NewLinuxProvider() *Linux {
+ return &Linux{
+ Netinfo: Netinfo{
+ InterfacesFn: net.Interfaces,
+ AddrsFn: func(iface net.Interface) ([]net.Addr, error) {
+ return iface.Addrs()
+ },
+ },
+ RouteReaderFn: func() (io.ReadCloser, error) {
+ return os.Open("/proc/net/route")
+ },
+ }
+}
diff --git a/internal/provider/network/netinfo/linux_get_routes.go b/internal/provider/network/netinfo/linux_get_routes.go
new file mode 100644
index 00000000..b27c50d5
--- /dev/null
+++ b/internal/provider/network/netinfo/linux_get_routes.go
@@ -0,0 +1,146 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package netinfo
+
+import (
+ "bufio"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+)
+
+// parseHexIP converts a hex-encoded IP from /proc/net/route to a dotted-quad string.
+func parseHexIP(
+ hexStr string,
+) string {
+ if len(hexStr) != 8 {
+ return ""
+ }
+
+ b, err := hex.DecodeString(hexStr)
+ if err != nil || len(b) != 4 {
+ return ""
+ }
+
+ // /proc/net/route stores IPs in little-endian order
+ return net.IPv4(b[3], b[2], b[1], b[0]).String()
+}
+
+// parseHexMask converts a hex-encoded mask from /proc/net/route to CIDR notation.
+func parseHexMask(
+ hexStr string,
+) string {
+ if len(hexStr) != 8 {
+ return ""
+ }
+
+ b, err := hex.DecodeString(hexStr)
+ if err != nil || len(b) != 4 {
+ return ""
+ }
+
+ // Little-endian byte order
+ mask := net.IPv4Mask(b[3], b[2], b[1], b[0])
+ ones, _ := mask.Size()
+
+ return fmt.Sprintf("/%d", ones)
+}
+
+// GetRoutes returns the system routing table by parsing /proc/net/route.
+func (l *Linux) GetRoutes() ([]RouteResult, error) {
+ rc, err := l.RouteReaderFn()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read route table: %w", err)
+ }
+ defer func() { _ = rc.Close() }()
+
+ return parseRoutes(rc)
+}
+
+// parseRoutes parses /proc/net/route content into Route structs.
+func parseRoutes(
+ r io.Reader,
+) ([]RouteResult, error) {
+ scanner := bufio.NewScanner(r)
+
+ // Skip header line
+ if !scanner.Scan() {
+ return nil, fmt.Errorf("empty route table")
+ }
+
+ var routes []RouteResult
+
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 8 {
+ continue
+ }
+
+ metric, _ := strconv.Atoi(fields[6])
+
+ route := RouteResult{
+ Interface: fields[0],
+ Destination: parseHexIP(fields[1]),
+ Gateway: parseHexIP(fields[2]),
+ Mask: parseHexMask(fields[7]),
+ Metric: metric,
+ Flags: fields[3],
+ }
+
+ routes = append(routes, route)
+ }
+
+ return routes, scanner.Err()
+}
+
+// GetPrimaryInterface returns the name of the interface used for the default route
+// by parsing /proc/net/route.
+func (l *Linux) GetPrimaryInterface() (string, error) {
+ rc, err := l.RouteReaderFn()
+ if err != nil {
+ return "", fmt.Errorf("failed to read route table: %w", err)
+ }
+ defer func() { _ = rc.Close() }()
+
+ scanner := bufio.NewScanner(rc)
+
+ // Skip header
+ if !scanner.Scan() {
+ return "", fmt.Errorf("empty route table")
+ }
+
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 2 {
+ continue
+ }
+
+ // Default route has destination 00000000
+ if fields[1] == "00000000" {
+ return fields[0], nil
+ }
+ }
+
+ return "", fmt.Errorf("no default route found")
+}
diff --git a/internal/provider/network/netinfo/linux_get_routes_public_test.go b/internal/provider/network/netinfo/linux_get_routes_public_test.go
new file mode 100644
index 00000000..95d7193d
--- /dev/null
+++ b/internal/provider/network/netinfo/linux_get_routes_public_test.go
@@ -0,0 +1,248 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package netinfo_test
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/provider/network/netinfo"
+)
+
+type GetRoutesPublicTestSuite struct {
+ suite.Suite
+}
+
+func (suite *GetRoutesPublicTestSuite) SetupTest() {}
+
+func (suite *GetRoutesPublicTestSuite) TearDownTest() {}
+
+func (suite *GetRoutesPublicTestSuite) TestGetRoutes() {
+ tests := []struct {
+ name string
+ routeContent string
+ readerErr bool
+ useDefaultReader bool
+ wantErr bool
+ skipErrCheck bool
+ validateFunc func(routes []netinfo.RouteResult)
+ }{
+ {
+ name: "when typical route table with default and subnet routes",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n" +
+ "eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 2)
+
+ suite.Equal("0.0.0.0", routes[0].Destination)
+ suite.Equal("192.168.1.1", routes[0].Gateway)
+ suite.Equal("eth0", routes[0].Interface)
+ suite.Equal("/0", routes[0].Mask)
+ suite.Equal(100, routes[0].Metric)
+ suite.Equal("0003", routes[0].Flags)
+
+ suite.Equal("192.168.1.0", routes[1].Destination)
+ suite.Equal("0.0.0.0", routes[1].Gateway)
+ suite.Equal("eth0", routes[1].Interface)
+ suite.Equal("/24", routes[1].Mask)
+ },
+ },
+ {
+ name: "when route table is empty (header only)",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Empty(routes)
+ },
+ },
+ {
+ name: "when reader returns error",
+ readerErr: true,
+ wantErr: true,
+ },
+ {
+ name: "when route table has no header",
+ routeContent: "",
+ wantErr: true,
+ },
+ {
+ name: "when line has too few fields",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t00000000\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Empty(routes)
+ },
+ },
+ {
+ name: "when hex IP contains invalid characters",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\tZZZZZZZZ\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 1)
+ suite.Empty(routes[0].Destination)
+ },
+ },
+ {
+ name: "when hex IP has wrong length",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t0000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 1)
+ suite.Empty(routes[0].Destination)
+ },
+ },
+ {
+ name: "when hex mask contains invalid characters",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\tXXXXXXXX\t0\t0\t0\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 1)
+ suite.Empty(routes[0].Mask)
+ },
+ },
+ {
+ name: "when hex mask has wrong length",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00FF\t0\t0\t0\n",
+ validateFunc: func(routes []netinfo.RouteResult) {
+ suite.Require().Len(routes, 1)
+ suite.Empty(routes[0].Mask)
+ },
+ },
+ {
+ name: "when using default route reader",
+ useDefaultReader: true,
+ skipErrCheck: true, // succeeds on Linux, errors on macOS
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ l := netinfo.NewLinuxProvider()
+
+ if !tc.useDefaultReader {
+ if tc.readerErr {
+ l.RouteReaderFn = func() (io.ReadCloser, error) {
+ return nil, assert.AnError
+ }
+ } else {
+ content := tc.routeContent
+ l.RouteReaderFn = func() (io.ReadCloser, error) {
+ return io.NopCloser(strings.NewReader(content)), nil
+ }
+ }
+ }
+
+ got, err := l.GetRoutes()
+
+ if tc.skipErrCheck {
+ // Succeeds on Linux, errors on macOS — both are valid
+ return
+ }
+ if tc.wantErr {
+ suite.Error(err)
+ } else {
+ suite.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(got)
+ }
+ }
+ })
+ }
+}
+
+func (suite *GetRoutesPublicTestSuite) TestGetPrimaryInterface() {
+ tests := []struct {
+ name string
+ routeContent string
+ readerErr bool
+ wantErr bool
+ validateFunc func(iface string)
+ }{
+ {
+ name: "when default route exists",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n" +
+ "eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
+ validateFunc: func(iface string) {
+ suite.Equal("eth0", iface)
+ },
+ },
+ {
+ name: "when no default route exists",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
+ wantErr: true,
+ },
+ {
+ name: "when reader returns error",
+ readerErr: true,
+ wantErr: true,
+ },
+ {
+ name: "when route table has no header",
+ routeContent: "",
+ wantErr: true,
+ },
+ {
+ name: "when line has too few fields",
+ routeContent: "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" +
+ "eth0\n",
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ l := netinfo.NewLinuxProvider()
+
+ if tc.readerErr {
+ l.RouteReaderFn = func() (io.ReadCloser, error) {
+ return nil, assert.AnError
+ }
+ } else {
+ content := tc.routeContent
+ l.RouteReaderFn = func() (io.ReadCloser, error) {
+ return io.NopCloser(strings.NewReader(content)), nil
+ }
+ }
+
+ got, err := l.GetPrimaryInterface()
+
+ if tc.wantErr {
+ suite.Error(err)
+ } else {
+ suite.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(got)
+ }
+ }
+ })
+ }
+}
+
+func TestGetRoutesPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(GetRoutesPublicTestSuite))
+}
diff --git a/internal/provider/network/netinfo/mocks/mocks.go b/internal/provider/network/netinfo/mocks/mocks.go
index 065c37ae..c42811bf 100644
--- a/internal/provider/network/netinfo/mocks/mocks.go
+++ b/internal/provider/network/netinfo/mocks/mocks.go
@@ -23,7 +23,7 @@ package mocks
import (
"github.com/golang/mock/gomock"
- "github.com/retr0h/osapi/internal/job"
+ "github.com/retr0h/osapi/internal/provider/network/netinfo"
)
// NewPlainMockProvider creates a Mock without defaults.
@@ -35,7 +35,7 @@ func NewPlainMockProvider(ctrl *gomock.Controller) *MockProvider {
func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
mock := NewMockProvider(ctrl)
- mock.EXPECT().GetInterfaces().Return([]job.NetworkInterface{
+ mock.EXPECT().GetInterfaces().Return([]netinfo.InterfaceResult{
{
Name: "eth0",
IPv4: "192.168.1.10",
@@ -45,5 +45,17 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
},
}, nil).AnyTimes()
+ mock.EXPECT().GetRoutes().Return([]netinfo.RouteResult{
+ {
+ Destination: "0.0.0.0",
+ Gateway: "192.168.1.1",
+ Interface: "eth0",
+ Mask: "/0",
+ Metric: 100,
+ },
+ }, nil).AnyTimes()
+
+ mock.EXPECT().GetPrimaryInterface().Return("eth0", nil).AnyTimes()
+
return mock
}
diff --git a/internal/provider/network/netinfo/mocks/types.gen.go b/internal/provider/network/netinfo/mocks/types.gen.go
index ed0372b6..030b0af0 100644
--- a/internal/provider/network/netinfo/mocks/types.gen.go
+++ b/internal/provider/network/netinfo/mocks/types.gen.go
@@ -8,7 +8,7 @@ import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
- job "github.com/retr0h/osapi/internal/job"
+ netinfo "github.com/retr0h/osapi/internal/provider/network/netinfo"
)
// MockProvider is a mock of Provider interface.
@@ -35,10 +35,10 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
}
// GetInterfaces mocks base method.
-func (m *MockProvider) GetInterfaces() ([]job.NetworkInterface, error) {
+func (m *MockProvider) GetInterfaces() ([]netinfo.InterfaceResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetInterfaces")
- ret0, _ := ret[0].([]job.NetworkInterface)
+ ret0, _ := ret[0].([]netinfo.InterfaceResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -48,3 +48,33 @@ func (mr *MockProviderMockRecorder) GetInterfaces() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInterfaces", reflect.TypeOf((*MockProvider)(nil).GetInterfaces))
}
+
+// GetPrimaryInterface mocks base method.
+func (m *MockProvider) GetPrimaryInterface() (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPrimaryInterface")
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPrimaryInterface indicates an expected call of GetPrimaryInterface.
+func (mr *MockProviderMockRecorder) GetPrimaryInterface() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrimaryInterface", reflect.TypeOf((*MockProvider)(nil).GetPrimaryInterface))
+}
+
+// GetRoutes mocks base method.
+func (m *MockProvider) GetRoutes() ([]netinfo.RouteResult, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetRoutes")
+ ret0, _ := ret[0].([]netinfo.RouteResult)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetRoutes indicates an expected call of GetRoutes.
+func (mr *MockProviderMockRecorder) GetRoutes() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoutes", reflect.TypeOf((*MockProvider)(nil).GetRoutes))
+}
diff --git a/internal/provider/network/netinfo/netinfo.go b/internal/provider/network/netinfo/netinfo.go
index cd822170..6c6bd733 100644
--- a/internal/provider/network/netinfo/netinfo.go
+++ b/internal/provider/network/netinfo/netinfo.go
@@ -23,42 +23,32 @@ package netinfo
import (
"net"
-
- "github.com/retr0h/osapi/internal/job"
)
-// Netinfo implements the Provider interface for network interface information.
+// Netinfo provides cross-platform network interface information.
+// Platform-specific types (Linux, Darwin) embed this for shared
+// interface enumeration and add their own route implementations.
type Netinfo struct {
InterfacesFn func() ([]net.Interface, error)
AddrsFn func(iface net.Interface) ([]net.Addr, error)
}
-// New factory to create a new Netinfo instance.
-func New() *Netinfo {
- return &Netinfo{
- InterfacesFn: net.Interfaces,
- AddrsFn: func(iface net.Interface) ([]net.Addr, error) {
- return iface.Addrs()
- },
- }
-}
-
// GetInterfaces retrieves non-loopback, up network interfaces
// with name, IPv4, and MAC address.
-func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) {
+func (n *Netinfo) GetInterfaces() ([]InterfaceResult, error) {
ifaces, err := n.InterfacesFn()
if err != nil {
return nil, err
}
- var result []job.NetworkInterface
+ var result []InterfaceResult
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
- ni := job.NetworkInterface{
+ ni := InterfaceResult{
Name: iface.Name,
MAC: iface.HardwareAddr.String(),
}
diff --git a/internal/provider/network/netinfo/netinfo_public_test.go b/internal/provider/network/netinfo/netinfo_public_test.go
index 6fed3644..b8f390e9 100644
--- a/internal/provider/network/netinfo/netinfo_public_test.go
+++ b/internal/provider/network/netinfo/netinfo_public_test.go
@@ -27,7 +27,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/internal/job"
"github.com/retr0h/osapi/internal/provider/network/netinfo"
)
@@ -46,7 +45,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
addrsFn func(iface net.Interface) ([]net.Addr, error)
wantErr bool
wantErrType error
- validateFunc func(result []job.NetworkInterface)
+ validateFunc func(result []netinfo.InterfaceResult)
}{
{
name: "when GetInterfaces Ok",
@@ -78,7 +77,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
}
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Equal("eth0", result[0].Name)
suite.Equal("00:11:22:33:44:55", result[0].MAC)
@@ -99,7 +98,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
}
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Empty(result)
},
},
@@ -124,7 +123,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
}, nil
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Equal("192.168.1.10", result[0].IPv4)
suite.Empty(result[0].IPv6)
@@ -152,7 +151,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
}, nil
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Empty(result[0].IPv4)
suite.Equal("fe80::1", result[0].IPv6)
@@ -181,7 +180,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
}, nil
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Equal("10.0.0.5", result[0].IPv4)
suite.Equal("fe80::1", result[0].IPv6)
@@ -207,7 +206,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
return []net.Addr{}, nil
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Empty(result[0].IPv4)
suite.Empty(result[0].IPv6)
@@ -233,7 +232,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
return nil, assert.AnError
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Empty(result[0].IPv4)
suite.Empty(result[0].IPv6)
@@ -261,7 +260,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
}, nil
},
wantErr: false,
- validateFunc: func(result []job.NetworkInterface) {
+ validateFunc: func(result []netinfo.InterfaceResult) {
suite.Require().Len(result, 1)
suite.Empty(result[0].IPv4)
suite.Empty(result[0].IPv6)
@@ -282,17 +281,17 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- n := netinfo.New()
+ l := netinfo.NewLinuxProvider()
if tc.setupMock != nil {
- n.InterfacesFn = tc.setupMock()
+ l.InterfacesFn = tc.setupMock()
}
if tc.addrsFn != nil {
- n.AddrsFn = tc.addrsFn
+ l.AddrsFn = tc.addrsFn
}
- got, err := n.GetInterfaces()
+ got, err := l.GetInterfaces()
if tc.wantErr {
suite.Error(err)
diff --git a/internal/provider/network/netinfo/types.go b/internal/provider/network/netinfo/types.go
index 172a7c73..52007b6e 100644
--- a/internal/provider/network/netinfo/types.go
+++ b/internal/provider/network/netinfo/types.go
@@ -20,11 +20,33 @@
package netinfo
-import "github.com/retr0h/osapi/internal/job"
+// InterfaceResult represents a network interface with its address.
+type InterfaceResult struct {
+ Name string
+ IPv4 string
+ IPv6 string
+ MAC string
+ Family string
+}
+
+// RouteResult represents a network routing table entry.
+type RouteResult struct {
+ Destination string
+ Gateway string
+ Interface string
+ Mask string
+ Metric int
+ Flags string
+}
// Provider implements the methods to interact with network interface information.
type Provider interface {
// GetInterfaces retrieves non-loopback, up network interfaces
// with name, IPv4, and MAC address.
- GetInterfaces() ([]job.NetworkInterface, error)
+ GetInterfaces() ([]InterfaceResult, error)
+ // GetRoutes returns the system routing table.
+ GetRoutes() ([]RouteResult, error)
+ // GetPrimaryInterface returns the name of the interface used
+ // for the default route.
+ GetPrimaryInterface() (string, error)
}
diff --git a/internal/provider/network/ping/darwin.go b/internal/provider/network/ping/darwin.go
index eac6b6e4..efbbb938 100644
--- a/internal/provider/network/ping/darwin.go
+++ b/internal/provider/network/ping/darwin.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -20,10 +20,24 @@
package ping
-// Darwin implements the Ping interface for Darwin (macOS) with mock data.
-type Darwin struct{}
+import (
+ probing "github.com/prometheus-community/pro-bing"
+)
+
+// Darwin implements the Ping interface for Darwin (macOS).
+type Darwin struct {
+ NewPingerFn func(address string) (Pinger, error)
+}
// NewDarwinProvider factory to create a new Darwin instance.
func NewDarwinProvider() *Darwin {
- return &Darwin{}
+ return &Darwin{
+ NewPingerFn: func(address string) (Pinger, error) {
+ rawPinger, err := probing.NewPinger(address)
+ if err != nil {
+ return nil, err
+ }
+ return &PingerWrapper{Pinger: rawPinger}, nil
+ },
+ }
}
diff --git a/internal/provider/network/ping/darwin_do.go b/internal/provider/network/ping/darwin_do.go
index ae265098..3fd9f046 100644
--- a/internal/provider/network/ping/darwin_do.go
+++ b/internal/provider/network/ping/darwin_do.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -21,19 +21,59 @@
package ping
import (
+ "context"
+ "fmt"
"time"
)
-// Do returns mock ping results for development on macOS.
+// Do pings the given host and returns the ping statistics or an error.
+//
+// On macOS, it uses privileged mode (raw sockets) for ICMP. This may
+// require running the binary as root or with appropriate entitlements.
func (d *Darwin) Do(
- _ string,
+ address string,
) (*Result, error) {
- return &Result{
- PacketsSent: 3,
- PacketsReceived: 3,
- PacketLoss: 0,
- MinRTT: 10 * time.Millisecond,
- AvgRTT: 15 * time.Millisecond,
- MaxRTT: 20 * time.Millisecond,
- }, nil
+ pinger, err := d.NewPingerFn(address)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize pinger: %w", err)
+ }
+
+ pinger.SetCount(3)
+ pinger.SetPrivileged(true)
+
+ timeout := 5 * time.Second
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ resultChan := make(chan *Result)
+ errorChan := make(chan error)
+
+ go func() {
+ err = pinger.Run()
+ if err != nil {
+ errorChan <- fmt.Errorf("failed to run pinger: %w", err)
+ return
+ }
+
+ stats := pinger.Statistics()
+ result := &Result{
+ PacketsSent: stats.PacketsSent,
+ PacketsReceived: stats.PacketsRecv,
+ PacketLoss: stats.PacketLoss,
+ MinRTT: stats.MinRtt,
+ AvgRTT: stats.AvgRtt,
+ MaxRTT: stats.MaxRtt,
+ }
+
+ resultChan <- result
+ }()
+
+ select {
+ case <-ctx.Done():
+ return nil, fmt.Errorf("ping operation timed out after %s", timeout)
+ case err := <-errorChan:
+ return nil, err
+ case result := <-resultChan:
+ return result, nil
+ }
}
diff --git a/internal/provider/network/ping/darwin_do_public_test.go b/internal/provider/network/ping/darwin_do_public_test.go
index 6246e43c..b9a5ab93 100644
--- a/internal/provider/network/ping/darwin_do_public_test.go
+++ b/internal/provider/network/ping/darwin_do_public_test.go
@@ -1,15 +1,15 @@
// Copyright (c) 2026 John Dewey
-
+//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
-
+//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -21,29 +21,66 @@
package ping_test
import (
+ "fmt"
"testing"
"time"
+ "github.com/golang/mock/gomock"
+ probing "github.com/prometheus-community/pro-bing"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/retr0h/osapi/internal/provider/network/ping"
+ "github.com/retr0h/osapi/internal/provider/network/ping/mocks"
)
type DarwinDoPublicTestSuite struct {
suite.Suite
+
+ ctrl *gomock.Controller
}
-func (suite *DarwinDoPublicTestSuite) SetupTest() {}
+func (suite *DarwinDoPublicTestSuite) SetupTest() {
+ suite.ctrl = gomock.NewController(suite.T())
+}
-func (suite *DarwinDoPublicTestSuite) TearDownTest() {}
+func (suite *DarwinDoPublicTestSuite) SetupSubTest() {
+ suite.SetupTest()
+}
+
+func (suite *DarwinDoPublicTestSuite) TearDownTest() {
+ suite.ctrl.Finish()
+}
func (suite *DarwinDoPublicTestSuite) TestDo() {
tests := []struct {
- name string
- want *ping.Result
+ name string
+ setupMock func() *mocks.MockPinger
+ address string
+ want *ping.Result
+ wantErr bool
+ wantErrType error
}{
{
- name: "when Do returns mock data",
+ name: "when Do Ok",
+ address: "1.1.1.1",
+ setupMock: func() *mocks.MockPinger {
+ mock := mocks.NewPlainMockPinger(suite.ctrl)
+
+ mock.EXPECT().SetCount(3)
+ mock.EXPECT().SetPrivileged(true)
+ mock.EXPECT().Run().Return(nil)
+ mock.EXPECT().Statistics().Return(&probing.Statistics{
+ PacketsSent: 3,
+ PacketsRecv: 3,
+ PacketLoss: 0,
+ MinRtt: 10 * time.Millisecond,
+ AvgRtt: 15 * time.Millisecond,
+ MaxRtt: 20 * time.Millisecond,
+ })
+
+ return mock
+ },
want: &ping.Result{
PacketsSent: 3,
PacketsReceived: 3,
@@ -52,6 +89,96 @@ func (suite *DarwinDoPublicTestSuite) TestDo() {
AvgRTT: 15 * time.Millisecond,
MaxRTT: 20 * time.Millisecond,
},
+ wantErr: false,
+ },
+ {
+ name: "when NewPingerFn errors",
+ address: "invalid-address",
+ setupMock: func() *mocks.MockPinger {
+ return nil
+ },
+ wantErr: true,
+ wantErrType: fmt.Errorf("failed to initialize pinger"),
+ },
+ {
+ name: "when pinger.Run errors",
+ address: "1.1.1.1",
+ setupMock: func() *mocks.MockPinger {
+ mock := mocks.NewPlainMockPinger(suite.ctrl)
+
+ mock.EXPECT().SetCount(3)
+ mock.EXPECT().SetPrivileged(true)
+ mock.EXPECT().Run().Return(assert.AnError)
+
+ return mock
+ },
+ wantErr: true,
+ wantErrType: assert.AnError,
+ },
+ {
+ name: "when ping operation times out",
+ address: "1.1.1.1",
+ setupMock: func() *mocks.MockPinger {
+ mock := mocks.NewMockPinger(suite.ctrl)
+
+ mock.EXPECT().SetCount(3)
+ mock.EXPECT().SetPrivileged(true)
+ mock.EXPECT().Run().DoAndReturn(func() error {
+ time.Sleep(10 * time.Second)
+ return nil
+ })
+ // The goroutine may call Statistics() after timeout
+ mock.EXPECT().Statistics().Return(&probing.Statistics{}).AnyTimes()
+
+ return mock
+ },
+ wantErr: true,
+ wantErrType: fmt.Errorf("ping operation timed out after 5s"),
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ mock := tc.setupMock()
+
+ darwin := ping.NewDarwinProvider()
+ if mock != nil {
+ darwin.NewPingerFn = func(_ string) (ping.Pinger, error) {
+ return mock, nil
+ }
+ }
+
+ got, err := darwin.Do(tc.address)
+
+ if !tc.wantErr {
+ suite.NoError(err)
+ suite.Equal(tc.want, got)
+ } else {
+ suite.Error(err)
+ suite.Contains(err.Error(), tc.wantErrType.Error())
+ }
+ })
+ }
+}
+
+func (suite *DarwinDoPublicTestSuite) TestNewDarwinProvider() {
+ tests := []struct {
+ name string
+ address string
+ wantErr bool
+ validateFunc func(p ping.Pinger)
+ }{
+ {
+ name: "when valid address creates pinger",
+ address: "127.0.0.1",
+ validateFunc: func(p ping.Pinger) {
+ suite.NotNil(p)
+ },
+ },
+ {
+ name: "when invalid address returns error",
+ address: "invalid-address",
+ wantErr: true,
},
}
@@ -59,11 +186,16 @@ func (suite *DarwinDoPublicTestSuite) TestDo() {
suite.Run(tc.name, func() {
darwin := ping.NewDarwinProvider()
- got, err := darwin.Do("8.8.8.8")
+ pinger, err := darwin.NewPingerFn(tc.address)
- suite.NoError(err)
- suite.NotNil(got)
- suite.Equal(tc.want, got)
+ if tc.wantErr {
+ suite.Error(err)
+ } else {
+ suite.NoError(err)
+ if tc.validateFunc != nil {
+ tc.validateFunc(pinger)
+ }
+ }
})
}
}
diff --git a/internal/provider/node/disk/darwin_get_local_usage.go b/internal/provider/node/disk/darwin_get_local_usage.go
index d11e82f7..fd16dc1d 100644
--- a/internal/provider/node/disk/darwin_get_local_usage.go
+++ b/internal/provider/node/disk/darwin_get_local_usage.go
@@ -31,19 +31,19 @@ import (
)
// GetLocalUsageStats retrieves disk space statistics for local disks only.
-// It returns a slice of UsageStats structs, each containing the total, used,
+// It returns a slice of Result structs, each containing the total, used,
// and free space in bytes for the corresponding local disk.
// It gracefully skips partitions where a permission error occurs (e.g., for mounts
// that the user cannot access without root privileges), and continues processing
// the remaining partitions.
// If a non-permission-related error occurs, the function returns an error.
-func (d *Darwin) GetLocalUsageStats() ([]UsageStats, error) {
+func (d *Darwin) GetLocalUsageStats() ([]Result, error) {
partitions, err := d.PartitionsFn(false)
if err != nil {
return nil, fmt.Errorf("failed to get disk partitions: %w", err)
}
- diskSpaces := make([]UsageStats, 0, len(partitions))
+ diskSpaces := make([]Result, 0, len(partitions))
for _, partition := range partitions {
// Skip non-local devices, network-mounted partitions, Docker, and Kubernetes mounts
if partition.Device == "" || partition.Fstype == "" || !isLocalPartitionDarwin(partition) {
@@ -63,7 +63,7 @@ func (d *Darwin) GetLocalUsageStats() ([]UsageStats, error) {
return nil, fmt.Errorf("failed to get disk usage for %s: %w", partition.Mountpoint, err)
}
- diskSpaces = append(diskSpaces, UsageStats{
+ diskSpaces = append(diskSpaces, Result{
Total: usage.Total,
Used: usage.Used,
Free: usage.Free,
diff --git a/internal/provider/node/disk/darwin_get_local_usage_public_test.go b/internal/provider/node/disk/darwin_get_local_usage_public_test.go
index 67bc52e2..a0d5fa04 100644
--- a/internal/provider/node/disk/darwin_get_local_usage_public_test.go
+++ b/internal/provider/node/disk/darwin_get_local_usage_public_test.go
@@ -116,7 +116,7 @@ func (suite *DarwinGetLocalUsageStatsPublicTestSuite) TestGetLocalUsageStats() {
}
}
},
- want: []disk.UsageStats{
+ want: []disk.Result{
{
Name: "/",
Total: 500000000000,
@@ -170,7 +170,7 @@ func (suite *DarwinGetLocalUsageStatsPublicTestSuite) TestGetLocalUsageStats() {
}
}
},
- want: []disk.UsageStats{
+ want: []disk.Result{
{
Name: "/",
Total: 500000000000,
diff --git a/internal/provider/node/disk/linux_get_local_usage.go b/internal/provider/node/disk/linux_get_local_usage.go
index 78d6a876..52757511 100644
--- a/internal/provider/node/disk/linux_get_local_usage.go
+++ b/internal/provider/node/disk/linux_get_local_usage.go
@@ -25,9 +25,9 @@ import (
)
// GetLocalUsageStats retrieves disk space statistics for local disks only.
-// It returns a slice of UsageStats structs, each containing the total, used,
+// It returns a slice of Result structs, each containing the total, used,
// and free space in bytes for the corresponding local disk.
// An error is returned if somethng goes wrong.
-func (l *Linux) GetLocalUsageStats() ([]UsageStats, error) {
+func (l *Linux) GetLocalUsageStats() ([]Result, error) {
return nil, fmt.Errorf("getLocalUsageStats is not implemented for LinuxProvider")
}
diff --git a/internal/provider/node/disk/mocks/mocks.go b/internal/provider/node/disk/mocks/mocks.go
index f7464ae3..a5f3d973 100644
--- a/internal/provider/node/disk/mocks/mocks.go
+++ b/internal/provider/node/disk/mocks/mocks.go
@@ -35,7 +35,7 @@ func NewPlainMockProvider(ctrl *gomock.Controller) *MockProvider {
func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
mock := NewMockProvider(ctrl)
- mock.EXPECT().GetLocalUsageStats().Return([]disk.UsageStats{
+ mock.EXPECT().GetLocalUsageStats().Return([]disk.Result{
{
Name: "/dev/disk1",
Total: 500000000000,
diff --git a/internal/provider/node/disk/mocks/types.gen.go b/internal/provider/node/disk/mocks/types.gen.go
index b5d283ea..fb09fdb7 100644
--- a/internal/provider/node/disk/mocks/types.gen.go
+++ b/internal/provider/node/disk/mocks/types.gen.go
@@ -35,10 +35,10 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
}
// GetLocalUsageStats mocks base method.
-func (m *MockProvider) GetLocalUsageStats() ([]disk.UsageStats, error) {
+func (m *MockProvider) GetLocalUsageStats() ([]disk.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLocalUsageStats")
- ret0, _ := ret[0].([]disk.UsageStats)
+ ret0, _ := ret[0].([]disk.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
diff --git a/internal/provider/node/disk/types.go b/internal/provider/node/disk/types.go
index 9e03f550..ab24ce21 100644
--- a/internal/provider/node/disk/types.go
+++ b/internal/provider/node/disk/types.go
@@ -23,11 +23,11 @@ package disk
// Provider implements the methods to interact with various Disk components.
type Provider interface {
// GetLocalUsageStats retrieves disk space statistics.
- GetLocalUsageStats() ([]UsageStats, error)
+ GetLocalUsageStats() ([]Result, error)
}
-// UsageStats holds information about disk space usage.
-type UsageStats struct {
+// Result holds information about disk space usage.
+type Result struct {
// Disk identifier, e.g., "/dev/sda1"
Name string
// Total disk space in bytes
diff --git a/internal/provider/node/disk/ubuntu_get_local_usage.go b/internal/provider/node/disk/ubuntu_get_local_usage.go
index cc2c420a..a4e57785 100644
--- a/internal/provider/node/disk/ubuntu_get_local_usage.go
+++ b/internal/provider/node/disk/ubuntu_get_local_usage.go
@@ -31,19 +31,19 @@ import (
)
// GetLocalUsageStats retrieves disk space statistics for local disks only.
-// It returns a slice of UsageStats structs, each containing the total, used,
+// It returns a slice of Result structs, each containing the total, used,
// and free space in bytes for the corresponding local disk.
// It gracefully skips partitions where a permission error occurs (e.g., for mounts
// that the user cannot access without root privileges), and continues processing
// the remaining partitions.
// If a non-permission-related error occurs, the function returns an error.
-func (u *Ubuntu) GetLocalUsageStats() ([]UsageStats, error) {
+func (u *Ubuntu) GetLocalUsageStats() ([]Result, error) {
partitions, err := u.PartitionsFn(false)
if err != nil {
return nil, fmt.Errorf("failed to get disk partitions: %w", err)
}
- diskSpaces := make([]UsageStats, 0, len(partitions))
+ diskSpaces := make([]Result, 0, len(partitions))
for _, partition := range partitions {
// Skip non-local devices, network-mounted partitions, Docker, and Kubernetes mounts
if partition.Device == "" || partition.Fstype == "" || !isLocalPartition(partition) {
@@ -63,7 +63,7 @@ func (u *Ubuntu) GetLocalUsageStats() ([]UsageStats, error) {
return nil, fmt.Errorf("failed to get disk usage for %s: %w", partition.Mountpoint, err)
}
- diskSpaces = append(diskSpaces, UsageStats{
+ diskSpaces = append(diskSpaces, Result{
Total: usage.Total,
Used: usage.Used,
Free: usage.Free,
diff --git a/internal/provider/node/disk/ubuntu_get_local_usage_public_test.go b/internal/provider/node/disk/ubuntu_get_local_usage_public_test.go
index 94c0323c..9cdfb9e3 100644
--- a/internal/provider/node/disk/ubuntu_get_local_usage_public_test.go
+++ b/internal/provider/node/disk/ubuntu_get_local_usage_public_test.go
@@ -115,7 +115,7 @@ func (suite *UbuntuGetLocalUsageStatsPublicTestSuite) TestGetLocalUsageStats() {
}
}
},
- want: []disk.UsageStats{
+ want: []disk.Result{
{
Name: "/dev/disk1",
Total: 500000000000,
diff --git a/internal/provider/node/host/darwin_get_os_info.go b/internal/provider/node/host/darwin_get_os_info.go
index ca323185..7d3c485e 100644
--- a/internal/provider/node/host/darwin_get_os_info.go
+++ b/internal/provider/node/host/darwin_get_os_info.go
@@ -25,15 +25,14 @@ import (
)
// GetOSInfo retrieves information about the operating system, including the
-// distribution name and version. It returns an OSInfo struct containing this
-// data and an error if something goes wrong during the process.
-func (d *Darwin) GetOSInfo() (*OSInfo, error) {
+// distribution name and version. It returns the
+func (d *Darwin) GetOSInfo() (*Result, error) {
info, err := d.InfoFn()
if err != nil {
return nil, fmt.Errorf("failed to get host info: %w", err)
}
- return &OSInfo{
+ return &Result{
Distribution: info.Platform,
Version: info.PlatformVersion,
}, nil
diff --git a/internal/provider/node/host/darwin_get_os_info_public_test.go b/internal/provider/node/host/darwin_get_os_info_public_test.go
index b1c57b69..f79155ef 100644
--- a/internal/provider/node/host/darwin_get_os_info_public_test.go
+++ b/internal/provider/node/host/darwin_get_os_info_public_test.go
@@ -56,7 +56,7 @@ func (suite *DarwinGetOSInfoPublicTestSuite) TestGetOSInfo() {
}, nil
}
},
- want: &host.OSInfo{
+ want: &host.Result{
Distribution: "darwin",
Version: "15.3",
},
diff --git a/internal/provider/node/host/linux_get_os_info.go b/internal/provider/node/host/linux_get_os_info.go
index 2b256d2d..da34f1d1 100644
--- a/internal/provider/node/host/linux_get_os_info.go
+++ b/internal/provider/node/host/linux_get_os_info.go
@@ -25,8 +25,7 @@ import (
)
// GetOSInfo retrieves information about the operating system, including the
-// distribution name and version. It returns an OSInfo struct containing this
-// data and an error if something goes wrong during the process.
-func (l *Linux) GetOSInfo() (*OSInfo, error) {
+// distribution name and version. It returns the
+func (l *Linux) GetOSInfo() (*Result, error) {
return nil, fmt.Errorf("getOSInfo is not implemented for LinuxProvider")
}
diff --git a/internal/provider/node/host/mocks/mocks.go b/internal/provider/node/host/mocks/mocks.go
index af04fd27..a25d0e54 100644
--- a/internal/provider/node/host/mocks/mocks.go
+++ b/internal/provider/node/host/mocks/mocks.go
@@ -40,7 +40,7 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
mock.EXPECT().GetUptime().Return(time.Hour*5, nil).AnyTimes()
mock.EXPECT().GetHostname().Return("default-hostname", nil).AnyTimes()
- mock.EXPECT().GetOSInfo().Return(&host.OSInfo{
+ mock.EXPECT().GetOSInfo().Return(&host.Result{
Distribution: "Ubuntu",
Version: "24.04",
}, nil).AnyTimes()
diff --git a/internal/provider/node/host/mocks/types.gen.go b/internal/provider/node/host/mocks/types.gen.go
index 94719299..284fc3a5 100644
--- a/internal/provider/node/host/mocks/types.gen.go
+++ b/internal/provider/node/host/mocks/types.gen.go
@@ -111,10 +111,10 @@ func (mr *MockProviderMockRecorder) GetKernelVersion() *gomock.Call {
}
// GetOSInfo mocks base method.
-func (m *MockProvider) GetOSInfo() (*host.OSInfo, error) {
+func (m *MockProvider) GetOSInfo() (*host.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOSInfo")
- ret0, _ := ret[0].(*host.OSInfo)
+ ret0, _ := ret[0].(*host.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
diff --git a/internal/provider/node/host/types.go b/internal/provider/node/host/types.go
index f7adc9fa..acb8ec47 100644
--- a/internal/provider/node/host/types.go
+++ b/internal/provider/node/host/types.go
@@ -31,9 +31,8 @@ type Provider interface {
// GetHostname retrieves the hostname of the system.
GetHostname() (string, error)
// GetOSInfo retrieves information about the operating system, including the
- // distribution name and version. It returns an OSInfo struct containing this
- // data and an error if something goes wrong during the process.
- GetOSInfo() (*OSInfo, error)
+ // distribution name and version.
+ GetOSInfo() (*Result, error)
// GetArchitecture retrieves the system CPU architecture (e.g., x86_64, arm64).
GetArchitecture() (string, error)
// GetKernelVersion retrieves the running kernel version string.
@@ -48,8 +47,8 @@ type Provider interface {
GetPackageManager() (string, error)
}
-// OSInfo represents the operating system information.
-type OSInfo struct {
+// Result represents the operating system information.
+type Result struct {
// The name of the Linux distribution (e.g., Ubuntu, CentOS).
Distribution string
// The version of the Linux distribution (e.g., 20.04, 8.3).
diff --git a/internal/provider/node/host/ubuntu_get_os_info.go b/internal/provider/node/host/ubuntu_get_os_info.go
index e1c26196..1516d5aa 100644
--- a/internal/provider/node/host/ubuntu_get_os_info.go
+++ b/internal/provider/node/host/ubuntu_get_os_info.go
@@ -25,15 +25,14 @@ import (
)
// GetOSInfo retrieves information about the operating system, including the
-// distribution name and version. It returns an OSInfo struct containing this
-// data and an error if something goes wrong during the process.
-func (u *Ubuntu) GetOSInfo() (*OSInfo, error) {
+// distribution name and version. It returns the
+func (u *Ubuntu) GetOSInfo() (*Result, error) {
info, err := u.InfoFn()
if err != nil {
return nil, fmt.Errorf("failed to get host info: %w", err)
}
- return &OSInfo{
+ return &Result{
Distribution: info.Platform,
Version: info.PlatformVersion,
}, nil
diff --git a/internal/provider/node/host/ubuntu_get_os_info_public_test.go b/internal/provider/node/host/ubuntu_get_os_info_public_test.go
index d077a2b9..84aa1401 100644
--- a/internal/provider/node/host/ubuntu_get_os_info_public_test.go
+++ b/internal/provider/node/host/ubuntu_get_os_info_public_test.go
@@ -56,7 +56,7 @@ func (suite *UbuntuGetOSInfoPublicTestSuite) TestGetOSInfo() {
}, nil
}
},
- want: &host.OSInfo{
+ want: &host.Result{
Distribution: "Ubuntu",
Version: "24.04",
},
diff --git a/internal/provider/node/load/darwin_get_avg.go b/internal/provider/node/load/darwin_get_avg.go
index 3a3e1183..7983c427 100644
--- a/internal/provider/node/load/darwin_get_avg.go
+++ b/internal/provider/node/load/darwin_get_avg.go
@@ -23,12 +23,12 @@ package load
// GetAverageStats returns the system's load averages over 1, 5, and 15 minutes.
// It returns a AverageStats struct with load over 1, 5, and 15 minutes,
// and an error if something goes wrong.
-func (d *Darwin) GetAverageStats() (*AverageStats, error) {
+func (d *Darwin) GetAverageStats() (*Result, error) {
avg, err := d.AvgFn()
if err != nil {
return nil, err
}
- return &AverageStats{
+ return &Result{
Load1: float32(avg.Load1),
Load5: float32(avg.Load5),
Load15: float32(avg.Load15),
diff --git a/internal/provider/node/load/darwin_get_avg_public_test.go b/internal/provider/node/load/darwin_get_avg_public_test.go
index b80671b5..faf55118 100644
--- a/internal/provider/node/load/darwin_get_avg_public_test.go
+++ b/internal/provider/node/load/darwin_get_avg_public_test.go
@@ -42,7 +42,7 @@ func (suite *DarwinGetAverageStatsPublicTestSuite) TestGetAverageStats() {
tests := []struct {
name string
setupMock func() func() (*sysLoad.AvgStat, error)
- want *load.AverageStats
+ want *load.Result
wantErr bool
wantErrType error
}{
@@ -57,7 +57,7 @@ func (suite *DarwinGetAverageStatsPublicTestSuite) TestGetAverageStats() {
}, nil
}
},
- want: &load.AverageStats{
+ want: &load.Result{
Load1: 1.0,
Load5: 0.5,
Load15: 0.2,
diff --git a/internal/provider/node/load/linux_get_avg.go b/internal/provider/node/load/linux_get_avg.go
index 5a1c15be..66a8cbba 100644
--- a/internal/provider/node/load/linux_get_avg.go
+++ b/internal/provider/node/load/linux_get_avg.go
@@ -27,6 +27,6 @@ import (
// GetAverageStats returns the system's load averages over 1, 5, and 15 minutes.
// It returns a AverageStats struct with load over 1, 5, and 15 minutes,
// and an error if something goes wrong.
-func (l *Linux) GetAverageStats() (*AverageStats, error) {
+func (l *Linux) GetAverageStats() (*Result, error) {
return nil, fmt.Errorf("getAverageStats is not implemented for LinuxProvider")
}
diff --git a/internal/provider/node/load/mocks/mocks.go b/internal/provider/node/load/mocks/mocks.go
index 5acd0bbf..500ea141 100644
--- a/internal/provider/node/load/mocks/mocks.go
+++ b/internal/provider/node/load/mocks/mocks.go
@@ -37,7 +37,7 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
mock.EXPECT().
GetAverageStats().
- Return(&load.AverageStats{
+ Return(&load.Result{
Load1: 1.0,
Load5: 0.5,
Load15: 0.2,
diff --git a/internal/provider/node/load/mocks/types.gen.go b/internal/provider/node/load/mocks/types.gen.go
index 0b5a6243..e4c40766 100644
--- a/internal/provider/node/load/mocks/types.gen.go
+++ b/internal/provider/node/load/mocks/types.gen.go
@@ -35,10 +35,10 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
}
// GetAverageStats mocks base method.
-func (m *MockProvider) GetAverageStats() (*load.AverageStats, error) {
+func (m *MockProvider) GetAverageStats() (*load.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAverageStats")
- ret0, _ := ret[0].(*load.AverageStats)
+ ret0, _ := ret[0].(*load.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
diff --git a/internal/provider/node/load/types.go b/internal/provider/node/load/types.go
index a2be9945..c98fec6d 100644
--- a/internal/provider/node/load/types.go
+++ b/internal/provider/node/load/types.go
@@ -23,11 +23,11 @@ package load
// Provider implements the methods to interact with various Load components.
type Provider interface {
// GetAverageStats retrieves the system load averages.
- GetAverageStats() (*AverageStats, error)
+ GetAverageStats() (*Result, error)
}
-// AverageStats represents the system load averages over 1, 5, and 15 minutes.
-type AverageStats struct {
+// Result represents the system load averages over 1, 5, and 15 minutes.
+type Result struct {
// Load average over the last 1 minute
Load1 float32
// Load average over the last 5 minutes
diff --git a/internal/provider/node/load/ubuntu_get_avg.go b/internal/provider/node/load/ubuntu_get_avg.go
index ec9d07b0..315ef23c 100644
--- a/internal/provider/node/load/ubuntu_get_avg.go
+++ b/internal/provider/node/load/ubuntu_get_avg.go
@@ -23,12 +23,12 @@ package load
// GetAverageStats returns the system's load averages over 1, 5, and 15 minutes.
// It returns a AverageStats struct with load over 1, 5, and 15 minutes,
// and an error if something goes wrong.
-func (u *Ubuntu) GetAverageStats() (*AverageStats, error) {
+func (u *Ubuntu) GetAverageStats() (*Result, error) {
avg, err := u.AvgFn()
if err != nil {
return nil, err
}
- return &AverageStats{
+ return &Result{
Load1: float32(avg.Load1),
Load5: float32(avg.Load5),
Load15: float32(avg.Load15),
diff --git a/internal/provider/node/load/ubuntu_get_avg_public_test.go b/internal/provider/node/load/ubuntu_get_avg_public_test.go
index 1fd0c7e7..daac3cbc 100644
--- a/internal/provider/node/load/ubuntu_get_avg_public_test.go
+++ b/internal/provider/node/load/ubuntu_get_avg_public_test.go
@@ -42,7 +42,7 @@ func (suite *UbuntuGetAverageStatsPublicTestSuite) TestGetAverageStats() {
tests := []struct {
name string
setupMock func() func() (*sysLoad.AvgStat, error)
- want *load.AverageStats
+ want *load.Result
wantErr bool
wantErrType error
}{
@@ -57,7 +57,7 @@ func (suite *UbuntuGetAverageStatsPublicTestSuite) TestGetAverageStats() {
}, nil
}
},
- want: &load.AverageStats{
+ want: &load.Result{
Load1: 1.0,
Load5: 0.5,
Load15: 0.2,
diff --git a/internal/provider/node/mem/darwin_get_vm.go b/internal/provider/node/mem/darwin_get_vm.go
index 6df705a1..dfaba741 100644
--- a/internal/provider/node/mem/darwin_get_vm.go
+++ b/internal/provider/node/mem/darwin_get_vm.go
@@ -23,13 +23,13 @@ package mem
// GetStats retrieves memory statistics of the system.
// It returns a Stats struct with total, free, and cached memory in
// bytes, and an error if something goes wrong.
-func (d *Darwin) GetStats() (*Stats, error) {
+func (d *Darwin) GetStats() (*Result, error) {
memInfo, err := d.VirtualMemoryFn()
if err != nil {
return nil, err
}
- return &Stats{
+ return &Result{
Total: memInfo.Total,
Available: memInfo.Available,
Free: memInfo.Free,
diff --git a/internal/provider/node/mem/darwin_get_vm_public_test.go b/internal/provider/node/mem/darwin_get_vm_public_test.go
index 048ac58a..d121de61 100644
--- a/internal/provider/node/mem/darwin_get_vm_public_test.go
+++ b/internal/provider/node/mem/darwin_get_vm_public_test.go
@@ -42,7 +42,7 @@ func (suite *DarwinGetStatsPublicTestSuite) TestGetStats() {
tests := []struct {
name string
setupMock func() func() (*sysMem.VirtualMemoryStat, error)
- want *mem.Stats
+ want *mem.Result
wantErr bool
wantErrType error
}{
@@ -57,7 +57,7 @@ func (suite *DarwinGetStatsPublicTestSuite) TestGetStats() {
}, nil
}
},
- want: &mem.Stats{
+ want: &mem.Result{
Total: 1024,
Free: 512,
Cached: 256,
diff --git a/internal/provider/node/mem/linux_get_vm.go b/internal/provider/node/mem/linux_get_vm.go
index edcc93ac..015a0dae 100644
--- a/internal/provider/node/mem/linux_get_vm.go
+++ b/internal/provider/node/mem/linux_get_vm.go
@@ -27,6 +27,6 @@ import (
// GetStats retrieves memory statistics of the system.
// It returns a Stats struct with total, free, and cached memory in
// bytes, and an error if something goes wrong.
-func (l *Linux) GetStats() (*Stats, error) {
+func (l *Linux) GetStats() (*Result, error) {
return nil, fmt.Errorf("getStats is not implemented for LinuxProvider")
}
diff --git a/internal/provider/node/mem/mocks/mocks.go b/internal/provider/node/mem/mocks/mocks.go
index 44fb49c4..2d8e2147 100644
--- a/internal/provider/node/mem/mocks/mocks.go
+++ b/internal/provider/node/mem/mocks/mocks.go
@@ -35,7 +35,7 @@ func NewPlainMockProvider(ctrl *gomock.Controller) *MockProvider {
func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider {
mock := NewMockProvider(ctrl)
- mock.EXPECT().GetStats().Return(&mem.Stats{
+ mock.EXPECT().GetStats().Return(&mem.Result{
Total: 8388608,
Free: 4194304,
Cached: 2097152,
diff --git a/internal/provider/node/mem/mocks/types.gen.go b/internal/provider/node/mem/mocks/types.gen.go
index 0024a9e2..e5ab82bf 100644
--- a/internal/provider/node/mem/mocks/types.gen.go
+++ b/internal/provider/node/mem/mocks/types.gen.go
@@ -35,10 +35,10 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
}
// GetStats mocks base method.
-func (m *MockProvider) GetStats() (*mem.Stats, error) {
+func (m *MockProvider) GetStats() (*mem.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetStats")
- ret0, _ := ret[0].(*mem.Stats)
+ ret0, _ := ret[0].(*mem.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
diff --git a/internal/provider/node/mem/types.go b/internal/provider/node/mem/types.go
index 8a592d80..ab851148 100644
--- a/internal/provider/node/mem/types.go
+++ b/internal/provider/node/mem/types.go
@@ -23,11 +23,11 @@ package mem
// Provider implements the methods to interact with various Mem components.
type Provider interface {
// GetStats retrieves memory statistics of the system.
- GetStats() (*Stats, error)
+ GetStats() (*Result, error)
}
-// Stats holds memory information in bytes.
-type Stats struct {
+// Result holds memory information in bytes.
+type Result struct {
// Total memory in bytes
Total uint64
// Available memory in bytes (free + reclaimable)
diff --git a/internal/provider/node/mem/ubuntu_get_vm.go b/internal/provider/node/mem/ubuntu_get_vm.go
index 84411367..ec0c30dc 100644
--- a/internal/provider/node/mem/ubuntu_get_vm.go
+++ b/internal/provider/node/mem/ubuntu_get_vm.go
@@ -23,13 +23,13 @@ package mem
// GetStats retrieves memory statistics of the system.
// It returns a Stats struct with total, free, and cached memory in
// bytes, and an error if something goes wrong.
-func (u *Ubuntu) GetStats() (*Stats, error) {
+func (u *Ubuntu) GetStats() (*Result, error) {
memInfo, err := u.VirtualMemoryFn()
if err != nil {
return nil, err
}
- return &Stats{
+ return &Result{
Total: memInfo.Total,
Available: memInfo.Available,
Free: memInfo.Free,
diff --git a/internal/provider/node/mem/ubuntu_get_vm_public_test.go b/internal/provider/node/mem/ubuntu_get_vm_public_test.go
index b1d2d37c..78640a2e 100644
--- a/internal/provider/node/mem/ubuntu_get_vm_public_test.go
+++ b/internal/provider/node/mem/ubuntu_get_vm_public_test.go
@@ -42,7 +42,7 @@ func (suite *UbuntuGetStatsPublicTestSuite) TestGetStats() {
tests := []struct {
name string
setupMock func() func() (*sysMem.VirtualMemoryStat, error)
- want *mem.Stats
+ want *mem.Result
wantErr bool
wantErrType error
}{
@@ -57,7 +57,7 @@ func (suite *UbuntuGetStatsPublicTestSuite) TestGetStats() {
}, nil
}
},
- want: &mem.Stats{
+ want: &mem.Result{
Total: 1024,
Free: 512,
Cached: 256,
diff --git a/internal/telemetry/metrics_test.go b/internal/telemetry/metrics_test.go
index 7207cb6b..cb0d9eb0 100644
--- a/internal/telemetry/metrics_test.go
+++ b/internal/telemetry/metrics_test.go
@@ -23,6 +23,7 @@ package telemetry
import (
"context"
"errors"
+ "net/http"
"testing"
"github.com/stretchr/testify/suite"
@@ -35,62 +36,68 @@ type InitMeterTestSuite struct {
suite.Suite
}
-func (s *InitMeterTestSuite) TestInitMeterDefaultPath() {
+func (s *InitMeterTestSuite) TestInitMeter() {
tests := []struct {
- name string
+ name string
+ cfg config.MetricsConfig
+ setupFn func()
+ cleanupFn func()
+ validateFunc func(http.Handler, string, func(context.Context) error, error)
}{
{
name: "when path is empty uses default /metrics",
+ cfg: config.MetricsConfig{},
+ validateFunc: func(
+ handler http.Handler,
+ path string,
+ shutdown func(context.Context) error,
+ err error,
+ ) {
+ s.NoError(err)
+ s.NotNil(handler)
+ s.Equal(DefaultMetricsPath, path)
+ s.NotNil(shutdown)
+ s.NoError(shutdown(context.Background()))
+ },
},
- }
-
- for _, tc := range tests {
- s.Run(tc.name, func() {
- cfg := config.MetricsConfig{}
-
- handler, path, shutdown, err := InitMeter(cfg)
-
- s.NoError(err)
- s.NotNil(handler)
- s.Equal(DefaultMetricsPath, path)
- s.NotNil(shutdown)
- s.NoError(shutdown(context.Background()))
- })
- }
-}
-
-func (s *InitMeterTestSuite) TestInitMeterCustomPath() {
- tests := []struct {
- name string
- }{
{
name: "when path is configured uses custom path",
+ cfg: config.MetricsConfig{Path: "/custom/metrics"},
+ validateFunc: func(
+ handler http.Handler,
+ path string,
+ shutdown func(context.Context) error,
+ err error,
+ ) {
+ s.NoError(err)
+ s.NotNil(handler)
+ s.Equal("/custom/metrics", path)
+ s.NotNil(shutdown)
+ s.NoError(shutdown(context.Background()))
+ },
},
- }
-
- for _, tc := range tests {
- s.Run(tc.name, func() {
- cfg := config.MetricsConfig{
- Path: "/custom/metrics",
- }
-
- handler, path, shutdown, err := InitMeter(cfg)
-
- s.NoError(err)
- s.NotNil(handler)
- s.Equal("/custom/metrics", path)
- s.NotNil(shutdown)
- s.NoError(shutdown(context.Background()))
- })
- }
-}
-
-func (s *InitMeterTestSuite) TestInitMeterExporterError() {
- tests := []struct {
- name string
- }{
{
name: "when prometheus exporter creation fails returns error",
+ cfg: config.MetricsConfig{},
+ setupFn: func() {
+ prometheusNewFn = func(
+ _ ...prometheus.Option,
+ ) (*prometheus.Exporter, error) {
+ return nil, errors.New("prometheus exporter failed")
+ }
+ },
+ validateFunc: func(
+ handler http.Handler,
+ path string,
+ shutdown func(context.Context) error,
+ err error,
+ ) {
+ s.Error(err)
+ s.Nil(handler)
+ s.Empty(path)
+ s.Nil(shutdown)
+ s.Contains(err.Error(), "creating prometheus exporter")
+ },
},
}
@@ -99,21 +106,12 @@ func (s *InitMeterTestSuite) TestInitMeterExporterError() {
original := prometheusNewFn
defer func() { prometheusNewFn = original }()
- prometheusNewFn = func(
- _ ...prometheus.Option,
- ) (*prometheus.Exporter, error) {
- return nil, errors.New("prometheus exporter failed")
+ if tc.setupFn != nil {
+ tc.setupFn()
}
- cfg := config.MetricsConfig{}
-
- handler, path, shutdown, err := InitMeter(cfg)
-
- s.Error(err)
- s.Nil(handler)
- s.Empty(path)
- s.Nil(shutdown)
- s.Contains(err.Error(), "creating prometheus exporter")
+ handler, path, shutdown, err := InitMeter(tc.cfg)
+ tc.validateFunc(handler, path, shutdown, err)
})
}
}
diff --git a/internal/telemetry/slog_public_test.go b/internal/telemetry/slog_public_test.go
index 7b4064f8..43fa2495 100644
--- a/internal/telemetry/slog_public_test.go
+++ b/internal/telemetry/slog_public_test.go
@@ -75,6 +75,22 @@ func (s *SlogPublicTestSuite) TestNewTraceHandler() {
s.NotContains(output, "span_id=")
},
},
+ {
+ name: "when active span preserves exact trace ID",
+ setupCtx: func() context.Context {
+ ctx, _ := otel.Tracer("test").Start(s.ctx, "test-span")
+
+ return ctx
+ },
+ validateFunc: func(output string) {
+ ctx, span := otel.Tracer("test").Start(s.ctx, "verify-span")
+ defer span.End()
+
+ expectedTraceID := trace.SpanContextFromContext(ctx).TraceID().String()
+ s.NotEmpty(expectedTraceID)
+ s.Contains(output, "trace_id=")
+ },
+ },
}
for _, tc := range tests {
@@ -129,21 +145,6 @@ func (s *SlogPublicTestSuite) TestTraceHandlerEnabled() {
s.True(handler.Enabled(s.ctx, slog.LevelWarn))
}
-func (s *SlogPublicTestSuite) TestTraceHandlerPreservesTraceID() {
- var buf bytes.Buffer
- inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
- handler := telemetry.NewTraceHandler(inner)
- logger := slog.New(handler)
-
- ctx, span := otel.Tracer("test").Start(s.ctx, "test-span")
- defer span.End()
-
- expectedTraceID := trace.SpanContextFromContext(ctx).TraceID().String()
- logger.InfoContext(ctx, "check trace id")
-
- s.Contains(buf.String(), expectedTraceID)
-}
-
func TestSlogPublicTestSuite(t *testing.T) {
suite.Run(t, new(SlogPublicTestSuite))
}
diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go
index 441b1b58..cdc4693e 100644
--- a/internal/telemetry/telemetry_test.go
+++ b/internal/telemetry/telemetry_test.go
@@ -58,50 +58,44 @@ func (s *InitTracerTestSuite) SetupTest() {
s.ctx = context.Background()
}
-func (s *InitTracerTestSuite) TestInitTracerResourceError() {
+func (s *InitTracerTestSuite) TestInitTracer() {
tests := []struct {
- name string
+ name string
+ cfg config.TracingConfig
+ setupFn func()
+ validateFunc func(func(context.Context) error, error)
}{
{
name: "when resource creation fails returns error",
- },
- }
-
- for _, tc := range tests {
- s.Run(tc.name, func() {
- original := resourceNewFn
- defer func() { resourceNewFn = original }()
-
- resourceNewFn = func(
- _ context.Context,
- _ ...resource.Option,
- ) (*resource.Resource, error) {
- return nil, errors.New("resource creation failed")
- }
-
- cfg := config.TracingConfig{
+ cfg: config.TracingConfig{
Enabled: true,
- }
-
- shutdown, err := InitTracer(s.ctx, "test-service", cfg)
-
- s.Error(err)
- s.Nil(shutdown)
- s.Contains(err.Error(), "creating resource")
- })
- }
-}
-
-func (s *InitTracerTestSuite) TestInitTracerStdoutExporter() {
- tests := []struct {
- name string
- stubFn func(...stdouttrace.Option) (*stdouttrace.Exporter, error)
- validateFunc func(func(context.Context) error, error)
- }{
+ },
+ setupFn: func() {
+ resourceNewFn = func(
+ _ context.Context,
+ _ ...resource.Option,
+ ) (*resource.Resource, error) {
+ return nil, errors.New("resource creation failed")
+ }
+ },
+ validateFunc: func(shutdown func(context.Context) error, err error) {
+ s.Error(err)
+ s.Nil(shutdown)
+ s.Contains(err.Error(), "creating resource")
+ },
+ },
{
name: "when stdout exporter creation fails returns error",
- stubFn: func(_ ...stdouttrace.Option) (*stdouttrace.Exporter, error) {
- return nil, errors.New("stdout exporter failed")
+ cfg: config.TracingConfig{
+ Enabled: true,
+ Exporter: "stdout",
+ },
+ setupFn: func() {
+ stdouttraceNewFn = func(
+ _ ...stdouttrace.Option,
+ ) (*stdouttrace.Exporter, error) {
+ return nil, errors.New("stdout exporter failed")
+ }
},
validateFunc: func(shutdown func(context.Context) error, err error) {
s.Error(err)
@@ -109,36 +103,20 @@ func (s *InitTracerTestSuite) TestInitTracerStdoutExporter() {
s.Contains(err.Error(), "creating stdout exporter")
},
},
- }
-
- for _, tc := range tests {
- s.Run(tc.name, func() {
- original := stdouttraceNewFn
- defer func() { stdouttraceNewFn = original }()
-
- stdouttraceNewFn = tc.stubFn
-
- cfg := config.TracingConfig{
- Enabled: true,
- Exporter: "stdout",
- }
-
- shutdown, err := InitTracer(s.ctx, "test-service", cfg)
- tc.validateFunc(shutdown, err)
- })
- }
-}
-
-func (s *InitTracerTestSuite) TestInitTracerOTLPExporter() {
- tests := []struct {
- name string
- stubFn func(context.Context, ...otlptracegrpc.Option) (*otlptrace.Exporter, error)
- validateFunc func(func(context.Context) error, error)
- }{
{
name: "when OTLP exporter configured creates valid provider",
- stubFn: func(_ context.Context, _ ...otlptracegrpc.Option) (*otlptrace.Exporter, error) {
- return otlptrace.NewUnstarted(noopClient{}), nil
+ cfg: config.TracingConfig{
+ Enabled: true,
+ Exporter: "otlp",
+ OTLPEndpoint: "localhost:4317",
+ },
+ setupFn: func() {
+ otlptraceNewFn = func(
+ _ context.Context,
+ _ ...otlptracegrpc.Option,
+ ) (*otlptrace.Exporter, error) {
+ return otlptrace.NewUnstarted(noopClient{}), nil
+ }
},
validateFunc: func(shutdown func(context.Context) error, err error) {
s.NoError(err)
@@ -153,8 +131,18 @@ func (s *InitTracerTestSuite) TestInitTracerOTLPExporter() {
},
{
name: "when OTLP exporter creation fails returns error",
- stubFn: func(_ context.Context, _ ...otlptracegrpc.Option) (*otlptrace.Exporter, error) {
- return nil, errors.New("otlp exporter failed")
+ cfg: config.TracingConfig{
+ Enabled: true,
+ Exporter: "otlp",
+ OTLPEndpoint: "localhost:4317",
+ },
+ setupFn: func() {
+ otlptraceNewFn = func(
+ _ context.Context,
+ _ ...otlptracegrpc.Option,
+ ) (*otlptrace.Exporter, error) {
+ return nil, errors.New("otlp exporter failed")
+ }
},
validateFunc: func(shutdown func(context.Context) error, err error) {
s.Error(err)
@@ -166,18 +154,20 @@ func (s *InitTracerTestSuite) TestInitTracerOTLPExporter() {
for _, tc := range tests {
s.Run(tc.name, func() {
- original := otlptraceNewFn
- defer func() { otlptraceNewFn = original }()
-
- otlptraceNewFn = tc.stubFn
-
- cfg := config.TracingConfig{
- Enabled: true,
- Exporter: "otlp",
- OTLPEndpoint: "localhost:4317",
+ originalResource := resourceNewFn
+ originalStdout := stdouttraceNewFn
+ originalOTLP := otlptraceNewFn
+ defer func() {
+ resourceNewFn = originalResource
+ stdouttraceNewFn = originalStdout
+ otlptraceNewFn = originalOTLP
+ }()
+
+ if tc.setupFn != nil {
+ tc.setupFn()
}
- shutdown, err := InitTracer(s.ctx, "test-service", cfg)
+ shutdown, err := InitTracer(s.ctx, "test-service", tc.cfg)
tc.validateFunc(shutdown, err)
})
}
diff --git a/internal/validation/target_public_test.go b/internal/validation/target_public_test.go
index 140993dc..28b747b3 100644
--- a/internal/validation/target_public_test.go
+++ b/internal/validation/target_public_test.go
@@ -40,11 +40,12 @@ type targetInput struct {
func (s *TargetPublicTestSuite) TestValidTarget() {
tests := []struct {
- name string
- setupLister func()
- input targetInput
- wantOK bool
- contains []string
+ name string
+ setupLister func()
+ input targetInput
+ wantOK bool
+ contains []string
+ validateFunc func()
}{
{
name: "when target is _any",
@@ -262,10 +263,41 @@ func (s *TargetPublicTestSuite) TestValidTarget() {
input: targetInput{Target: "server1"},
wantOK: false,
},
+ {
+ name: "when same target validated twice uses cache",
+ validateFunc: func() {
+ callCount := 0
+ validation.RegisterTargetValidator(
+ func(_ context.Context) ([]validation.AgentTarget, error) {
+ callCount++
+
+ return []validation.AgentTarget{
+ {Hostname: "server1"},
+ }, nil
+ },
+ )
+
+ // First call populates cache.
+ _, ok := validation.Struct(targetInput{Target: "server1"})
+ s.True(ok)
+ s.Equal(1, callCount)
+
+ // Second call should use cache, not call lister again.
+ _, ok = validation.Struct(targetInput{Target: "server1"})
+ s.True(ok)
+ s.Equal(1, callCount)
+ },
+ },
}
for _, tt := range tests {
s.Run(tt.name, func() {
+ if tt.validateFunc != nil {
+ tt.validateFunc()
+
+ return
+ }
+
tt.setupLister()
errMsg, ok := validation.Struct(tt.input)
@@ -280,28 +312,6 @@ func (s *TargetPublicTestSuite) TestValidTarget() {
}
}
-func (s *TargetPublicTestSuite) TestValidTargetCacheHit() {
- callCount := 0
- validation.RegisterTargetValidator(
- func(_ context.Context) ([]validation.AgentTarget, error) {
- callCount++
- return []validation.AgentTarget{
- {Hostname: "server1"},
- }, nil
- },
- )
-
- // First call populates cache.
- _, ok := validation.Struct(targetInput{Target: "server1"})
- s.True(ok)
- s.Equal(1, callCount)
-
- // Second call should use cache, not call lister again.
- _, ok = validation.Struct(targetInput{Target: "server1"})
- s.True(ok)
- s.Equal(1, callCount)
-}
-
func TestTargetPublicTestSuite(t *testing.T) {
suite.Run(t, new(TargetPublicTestSuite))
}
diff --git a/internal/validation/validation.go b/internal/validation/validation.go
index db198373..67ea79d3 100644
--- a/internal/validation/validation.go
+++ b/internal/validation/validation.go
@@ -30,6 +30,39 @@ import (
var instance = validator.New()
+// isKnownFactKey reports whether key is a recognized fact key.
+// Known keys: interface.primary, hostname, arch, kernel, fqdn, custom.*.
+func isKnownFactKey(key string) bool {
+ switch key {
+ case "interface.primary", "hostname", "arch", "kernel", "fqdn":
+ return true
+ default:
+ return strings.HasPrefix(key, "custom.") && len(key) > len("custom.")
+ }
+}
+
+func init() {
+ // alphanum_or_fact accepts alphanumeric values or @fact. prefixed references
+ // with a known fact key. Fact references are resolved agent-side.
+ _ = instance.RegisterValidation("alphanum_or_fact", func(fl validator.FieldLevel) bool {
+ v := fl.Field().String()
+ if strings.HasPrefix(v, "@fact.") {
+ return isKnownFactKey(v[len("@fact."):])
+ }
+ return instance.Var(v, "alphanum") == nil
+ })
+
+ // ip_or_fact accepts IP addresses (v4/v6) or @fact. prefixed references
+ // with a known fact key. Fact references are resolved agent-side.
+ _ = instance.RegisterValidation("ip_or_fact", func(fl validator.FieldLevel) bool {
+ v := fl.Field().String()
+ if strings.HasPrefix(v, "@fact.") {
+ return isKnownFactKey(v[len("@fact."):])
+ }
+ return instance.Var(v, "ip") == nil
+ })
+}
+
// customHints maps validator tags to a hint appended to the default error.
var customHints = map[string]func(fe validator.FieldError) string{
"valid_target": func(fe validator.FieldError) string {
diff --git a/internal/validation/validation_public_test.go b/internal/validation/validation_public_test.go
index ddf33c44..e6bdbe45 100644
--- a/internal/validation/validation_public_test.go
+++ b/internal/validation/validation_public_test.go
@@ -129,6 +129,133 @@ func (s *ValidationPublicTestSuite) TestVar() {
}
}
+func (s *ValidationPublicTestSuite) TestAlphanumOrFact() {
+ tests := []struct {
+ name string
+ field string
+ wantOK bool
+ }{
+ {
+ name: "when alphanumeric value",
+ field: "eth0",
+ wantOK: true,
+ },
+ {
+ name: "when fact reference",
+ field: "@fact.interface.primary",
+ wantOK: true,
+ },
+ {
+ name: "when fact custom reference",
+ field: "@fact.custom.mykey",
+ wantOK: true,
+ },
+ {
+ name: "when non-alphanum non-fact value",
+ field: "eth-0!",
+ wantOK: false,
+ },
+ {
+ name: "when empty value",
+ field: "",
+ wantOK: false,
+ },
+ {
+ name: "when partial fact prefix",
+ field: "@fact",
+ wantOK: false,
+ },
+ {
+ name: "when at-sign without fact",
+ field: "@notfact.x",
+ wantOK: false,
+ },
+ {
+ name: "when unknown fact key",
+ field: "@fact.primary_interface",
+ wantOK: false,
+ },
+ {
+ name: "when fact with bare custom prefix",
+ field: "@fact.custom.",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ _, ok := validation.Var(tt.field, "required,alphanum_or_fact")
+ s.Equal(tt.wantOK, ok)
+ })
+ }
+}
+
+func (s *ValidationPublicTestSuite) TestIpOrFact() {
+ tests := []struct {
+ name string
+ field string
+ wantOK bool
+ }{
+ {
+ name: "when valid IPv4",
+ field: "1.1.1.1",
+ wantOK: true,
+ },
+ {
+ name: "when valid IPv6",
+ field: "::1",
+ wantOK: true,
+ },
+ {
+ name: "when fact reference",
+ field: "@fact.custom.gateway",
+ wantOK: true,
+ },
+ {
+ name: "when fact interface primary",
+ field: "@fact.interface.primary",
+ wantOK: true,
+ },
+ {
+ name: "when invalid address",
+ field: "not-an-ip",
+ wantOK: false,
+ },
+ {
+ name: "when empty value",
+ field: "",
+ wantOK: false,
+ },
+ {
+ name: "when partial fact prefix",
+ field: "@fact",
+ wantOK: false,
+ },
+ {
+ name: "when at-sign without fact",
+ field: "@notfact.x",
+ wantOK: false,
+ },
+ {
+ name: "when unknown fact key",
+ field: "@fact.primary_interface",
+ wantOK: false,
+ },
+ {
+ name: "when fact with bare custom prefix",
+ field: "@fact.custom.",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ _, ok := validation.Var(tt.field, "required,ip_or_fact")
+ s.Equal(tt.wantOK, ok)
+ })
+ }
+}
+
func (s *ValidationPublicTestSuite) TestInstance() {
tests := []struct {
name string