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