diff --git a/README.md b/README.md index ad4fc31..2ee0207 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,60 @@ logs: { } ``` +## Testing + +### Running Tests + +Run all tests in the project: + +```bash +go test ./... +``` + +Run tests with verbose output: + +```bash +go test -v ./... +``` + +Run specific test package: + +```bash +go test -v ./internal/config +``` + +Run specific test: + +```bash +go test -v -run TestPortValidation ./internal/config +``` + +### Test Coverage + +Run tests with coverage: + +```bash +go test -cover ./... +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +### Test Status + +**Phase 1 (Completed):** +- ✅ Config loading and parsing (valid config, parsing errors) +- ✅ Port validation (range: 1-65535, invalid ports, port conflicts) +- ✅ Port conflict detection (duplicate local ports across forwards) + +**Phase 2 (Planned):** +- Locator implementations (Pod, Service, Deployment, StatefulSet, DaemonSet) +- Forward name uniqueness validation +- Configuration hot-reload + +**Phase 3 (Planned):** +- Runner lifecycle (startup, shutdown, error handling) +- Forwarder reconnection behavior + ## Development ### Project Structure diff --git a/go.mod b/go.mod index d223f08..f73fd35 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,19 @@ module github.com/codozor/fwkeeper go 1.25.1 -require github.com/spf13/cobra v1.10.1 +require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 +) require ( github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/emicklei/proto v1.14.2 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -32,6 +37,7 @@ require ( github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20251016062345-16587c79cd91 // indirect github.com/samber/go-type-to-string v1.8.0 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -47,8 +53,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.1 // indirect - k8s.io/apimachinery v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect diff --git a/go.sum b/go.sum index 39dee54..8f6c25b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084 h1:4k1yAtPvZJZQTu8DRY8muBo0LHv6TqtrE0AO5n6IPYs= +cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084/go.mod h1:4WWeZNxUO1vRoZWAHIG0KZOd6dA25ypyWuwD3ti0Tdc= cuelang.org/go v0.15.0 h1:0jlWNxLp1In6dWJtywTXei7w0cqfHSTiCk/6Z+FUvxI= cuelang.org/go v0.15.0/go.mod h1:NYw6n4akZcTjA7QQwJ1/gqWrrhsN4aZwhcAL0jv9rZE= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -26,12 +30,20 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -45,9 +57,16 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -69,13 +88,24 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/txtpbfmt v0.0.0-20251016062345-16587c79cd91 h1:s1LvMaU6mVwoFtbxv/rCZKE7/fwDmDY684FfUe4c1Io= github.com/protocolbuffers/txtpbfmt v0.0.0-20251016062345-16587c79cd91/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -88,17 +118,20 @@ github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -112,6 +145,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -123,6 +158,8 @@ golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -143,15 +180,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= diff --git a/internal/app/runner.go b/internal/app/runner.go index 4434fed..2225db5 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -367,3 +367,4 @@ func (r *Runner) Shutdown() { log.Info().Msg(`fwkeeper Stopped`) log.Info().Msg(`------------------------------------------------------------------`) } + diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go new file mode 100644 index 0000000..589d8ac --- /dev/null +++ b/internal/app/runner_test.go @@ -0,0 +1,1906 @@ +package app + +import ( + "os" + "os/signal" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" + + "github.com/codozor/fwkeeper/internal/config" +) + +// TestRunnerStart tests basic runner initialization +func TestRunnerStart(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + // Note: Using nil client since we're testing runner lifecycle, not forwarder + // In real scenarios, forwarder would need valid client + // This tests that runner can initialize with empty forwards + runner := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + + err := runner.Start() + defer runner.Shutdown() + + // Should not panic during start with no forwarders + assert.NoError(t, err) +} + +// TestRunnerShutdown tests graceful shutdown +func TestRunnerShutdown(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + + err := runner.Start() + require.NoError(t, err) + + // Should shutdown without panic + runner.Shutdown() + assert.True(t, true) // If we reach here, shutdown succeeded +} + +// TestRunnerContextCancellation tests that runner respects context cancellation +func TestRunnerContextCancellation(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + + err := runner.Start() + require.NoError(t, err) + + // Give it time to fully start + time.Sleep(100 * time.Millisecond) + + // Shutdown should complete without hanging + runner.Shutdown() +} + +// TestRunnerMultipleStartStop tests that runner can start and stop cleanly +func TestRunnerMultipleStartStop(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + // Create and start runner + runner1 := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner1.Start() + require.NoError(t, err) + runner1.Shutdown() + + // Create and start another runner instance to test clean state + runner2 := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err = runner2.Start() + defer runner2.Shutdown() + + require.NoError(t, err) +} + +// TestRunnerConfigChangeDetection tests that runner can detect configuration changes +func TestRunnerConfigChangeDetection(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + }, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + client := fake.NewClientset() // Use fake client instead of nil + + runner := New(cfg, "", logger, client, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + // Test that runner stores configuration + assert.Equal(t, 1, len(runner.configuration.Forwards)) + assert.Equal(t, "forward-1", runner.configuration.Forwards[0].Name) +} + +// TestRunnerEmptyConfiguration tests runner with no forwarders +func TestRunnerEmptyConfiguration(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + defer runner.Shutdown() + + require.NoError(t, err) + assert.Equal(t, 0, len(runner.configuration.Forwards)) +} + +// TestRunnerConfigPathStorage tests that runner stores the config path +func TestRunnerConfigPathStorage(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + configPath := "testdata/config.cue" + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, configPath, logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + defer runner.Shutdown() + + require.NoError(t, err) + assert.Equal(t, configPath, runner.configPath) +} + +// TestRunnerForwarderMapInitialization tests that forwarder maps are properly initialized +func TestRunnerForwarderMapInitialization(t *testing.T) { + cfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, "", logger, nil, restCfg, "mock-source", "mock-context") + + // Before start, maps should exist but be empty + assert.NotNil(t, runner.forwarders) + assert.NotNil(t, runner.forwarderCancel) + assert.Equal(t, 0, len(runner.forwarders)) + assert.Equal(t, 0, len(runner.forwarderCancel)) + + err := runner.Start() + defer runner.Shutdown() + + require.NoError(t, err) +} + +// TestConfigChanged tests the configChanged helper function +func TestConfigChanged(t *testing.T) { + tests := []struct { + name string + oldCfg config.PortForwardConfiguration + newCfg config.PortForwardConfiguration + expected bool + }{ + { + name: "identical configs", + oldCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + newCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + expected: false, + }, + { + name: "namespace changed", + oldCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + newCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "kube-system", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + expected: true, + }, + { + name: "resource changed", + oldCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + newCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-2", + Ports: []string{"8080"}, + }, + expected: true, + }, + { + name: "ports added", + oldCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + newCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080", "9000"}, + }, + expected: true, + }, + { + name: "ports changed", + oldCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080", "9000"}, + }, + newCfg: config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080", "9001"}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := configChanged(tt.oldCfg, tt.newCfg) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestReloadConfigAddForwarder tests adding new forwarders during reload +func TestReloadConfigAddForwarder(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + // Give the watcher goroutine time to start + time.Sleep(50 * time.Millisecond) + + // Update configuration with new forwarders + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + { + Name: "forward-2", + Namespace: "default", + Resource: "pod-2", + Ports: []string{"9000"}, + }, + }, + } + + // Manually update config (simulating reload) + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify configuration was updated + runner.mu.Lock() + assert.Equal(t, 2, len(runner.configuration.Forwards)) + assert.Equal(t, "forward-1", runner.configuration.Forwards[0].Name) + assert.Equal(t, "forward-2", runner.configuration.Forwards[1].Name) + runner.mu.Unlock() +} + +// TestReloadConfigRemoveForwarder tests removing forwarders during reload +func TestReloadConfigRemoveForwarder(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + // Give the watcher goroutine time to start + time.Sleep(50 * time.Millisecond) + + // Simulate previous config with multiple forwarders + runner.mu.Lock() + runner.configuration = config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + { + Name: "forward-2", + Namespace: "default", + Resource: "pod-2", + Ports: []string{"9000"}, + }, + }, + } + runner.mu.Unlock() + + // Now update to remove forward-2 + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + }, + } + + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify configuration was updated + runner.mu.Lock() + assert.Equal(t, 1, len(runner.configuration.Forwards)) + assert.Equal(t, "forward-1", runner.configuration.Forwards[0].Name) + runner.mu.Unlock() +} + +// TestReloadConfigChangedPorts tests configuration reload with changed ports +func TestReloadConfigChangedPorts(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Simulate previous config with one port + oldForward := config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + } + + // Update with changed ports + newForward := config.PortForwardConfiguration{ + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080", "9000"}, + } + + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{newForward}, + } + + // Verify configChanged detects the difference + assert.True(t, configChanged(oldForward, newForward)) + + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify configuration was updated + runner.mu.Lock() + assert.Equal(t, 2, len(runner.configuration.Forwards[0].Ports)) + runner.mu.Unlock() +} + +// TestReloadConfigMutexProtection tests that config reloads are mutex-protected +func TestReloadConfigMutexProtection(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Simulate concurrent access to configuration + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + }, + } + + // Update configuration with mutex protection + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Read configuration with mutex protection + runner.mu.Lock() + cfgCopy := runner.configuration + runner.mu.Unlock() + + // Verify read succeeded + assert.Equal(t, 1, len(cfgCopy.Forwards)) + assert.Equal(t, "forward-1", cfgCopy.Forwards[0].Name) +} + +// TestReloadConfigMultipleForwarders tests reload with multiple forwarders +func TestReloadConfigMultipleForwarders(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Simulate previous config with 2 forwarders + runner.mu.Lock() + runner.configuration = config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + { + Name: "forward-2", + Namespace: "default", + Resource: "pod-2", + Ports: []string{"9000"}, + }, + }, + } + runner.mu.Unlock() + + // Update with different forwarders (add one, keep one, remove one) + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "forward-1", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + { + Name: "forward-3", + Namespace: "default", + Resource: "pod-3", + Ports: []string{"7000"}, + }, + }, + } + + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify configuration was updated + runner.mu.Lock() + assert.Equal(t, 2, len(runner.configuration.Forwards)) + forwardNames := []string{ + runner.configuration.Forwards[0].Name, + runner.configuration.Forwards[1].Name, + } + assert.Contains(t, forwardNames, "forward-1") + assert.Contains(t, forwardNames, "forward-3") + runner.mu.Unlock() +} + +// Phase 5 Tests - Hot-reload and Signal Handling + +// TestBaseName tests the baseName helper function +func TestBaseName(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "unix absolute path", + path: "/home/user/config.cue", + expected: "config.cue", + }, + { + name: "unix relative path", + path: "config/app.cue", + expected: "app.cue", + }, + { + name: "windows absolute path", + path: "C:\\config\\test.cue", + expected: "test.cue", + }, + { + name: "filename only", + path: "config.cue", + expected: "config.cue", + }, + { + name: "empty string", + path: "", + expected: "", + }, + { + name: "path with trailing slash", + path: "/home/user/", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := baseName(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestStopForwarderRemovesFromMaps tests that stopForwarder removes entries from maps +func TestStopForwarderRemovesFromMaps(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Manually add a forwarder entry to the maps (simulating a running forwarder) + runner.mu.Lock() + runner.forwarders["test-forward"] = nil + runner.forwarderCancel["test-forward"] = func() {} + runner.mu.Unlock() + + // Verify it was added + runner.mu.Lock() + assert.Equal(t, 1, len(runner.forwarders)) + assert.Equal(t, 1, len(runner.forwarderCancel)) + runner.mu.Unlock() + + // Stop the forwarder + runner.mu.Lock() + runner.stopForwarder("test-forward") + runner.mu.Unlock() + + // Verify it was removed + runner.mu.Lock() + assert.Equal(t, 0, len(runner.forwarders)) + assert.Equal(t, 0, len(runner.forwarderCancel)) + _, existsForwarder := runner.forwarders["test-forward"] + _, existsCancel := runner.forwarderCancel["test-forward"] + runner.mu.Unlock() + + assert.False(t, existsForwarder, "forwarder should be removed") + assert.False(t, existsCancel, "cancel function should be removed") +} + +// TestStopForwarderNonExistent tests stopForwarder with non-existent forwarder +func TestStopForwarderNonExistent(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Try to stop non-existent forwarder (should not panic) + runner.mu.Lock() + runner.stopForwarder("non-existent") + runner.mu.Unlock() + + // Should complete without panic + assert.True(t, true) +} + +// TestReloadConfigUpdateState tests that configuration state is properly updated +func TestReloadConfigUpdateState(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "testdata/config1.cue", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Verify initial state + runner.mu.Lock() + assert.Equal(t, 0, len(runner.configuration.Forwards)) + runner.mu.Unlock() + + // Simulate config reload with new configuration + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + { + Name: "test-forward", + Namespace: "default", + Resource: "pod-1", + Ports: []string{"8080"}, + }, + }, + } + + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify state was updated + runner.mu.Lock() + assert.Equal(t, 1, len(runner.configuration.Forwards)) + assert.Equal(t, "test-forward", runner.configuration.Forwards[0].Name) + runner.mu.Unlock() +} + +// TestReloadConfigStateTransition tests complex state transitions during reload +func TestReloadConfigStateTransition(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Simulate initial config with 3 forwarders + runner.mu.Lock() + runner.configuration = config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + {Name: "forward-1", Namespace: "ns1", Resource: "pod-1", Ports: []string{"8080"}}, + {Name: "forward-2", Namespace: "ns2", Resource: "pod-2", Ports: []string{"9000"}}, + {Name: "forward-3", Namespace: "ns3", Resource: "pod-3", Ports: []string{"7000"}}, + }, + } + runner.mu.Unlock() + + // Reload with new configuration + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{ + {Name: "forward-1", Namespace: "ns1", Resource: "pod-1", Ports: []string{"8080"}}, + {Name: "forward-2", Namespace: "ns2-modified", Resource: "pod-2", Ports: []string{"9000"}}, + {Name: "forward-4", Namespace: "ns4", Resource: "pod-4", Ports: []string{"6000"}}, + }, + } + + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify state transition + runner.mu.Lock() + assert.Equal(t, 3, len(runner.configuration.Forwards)) + runner.mu.Unlock() +} + +// TestReloadConfigPreservesLogConfiguration tests that log config is preserved +func TestReloadConfigPreservesLogConfiguration(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Reload with new log config + newCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "debug", + Pretty: true, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + runner.mu.Lock() + runner.configuration = newCfg + runner.mu.Unlock() + + // Verify log config was updated + runner.mu.Lock() + assert.Equal(t, "debug", runner.configuration.Logs.Level) + assert.Equal(t, true, runner.configuration.Logs.Pretty) + runner.mu.Unlock() +} + +// TestFileWatcherPathComparison tests the file path comparison logic +func TestFileWatcherPathComparison(t *testing.T) { + tests := []struct { + name string + configPath string + eventPath string + expected bool + }{ + { + name: "exact match", + configPath: "fwkeeper.cue", + eventPath: "fwkeeper.cue", + expected: true, + }, + { + name: "absolute paths match", + configPath: "/home/user/fwkeeper.cue", + eventPath: "/home/user/fwkeeper.cue", + expected: true, + }, + { + name: "different files", + configPath: "fwkeeper.cue", + eventPath: "other.cue", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test baseName comparison + configBaseName := baseName(tt.configPath) + eventBaseName := baseName(tt.eventPath) + result := configBaseName == eventBaseName && configBaseName != "" + + if tt.expected { + assert.True(t, result, "paths should match") + } else { + assert.False(t, result, "paths should not match") + } + }) + } +} + +// TestReloadConfigMultipleSequentialReloads tests multiple successive reloads +func TestReloadConfigMultipleSequentialReloads(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // First reload + cfg1 := config.Configuration{ + Logs: config.LogsConfiguration{Level: "info", Pretty: false}, + Forwards: []config.PortForwardConfiguration{ + {Name: "forward-1", Namespace: "ns1", Resource: "pod-1", Ports: []string{"8080"}}, + }, + } + runner.mu.Lock() + runner.configuration = cfg1 + runner.mu.Unlock() + + runner.mu.Lock() + assert.Equal(t, 1, len(runner.configuration.Forwards)) + runner.mu.Unlock() + + // Second reload + cfg2 := config.Configuration{ + Logs: config.LogsConfiguration{Level: "info", Pretty: false}, + Forwards: []config.PortForwardConfiguration{ + {Name: "forward-1", Namespace: "ns1", Resource: "pod-1", Ports: []string{"8080"}}, + {Name: "forward-2", Namespace: "ns2", Resource: "pod-2", Ports: []string{"9000"}}, + }, + } + runner.mu.Lock() + runner.configuration = cfg2 + runner.mu.Unlock() + + runner.mu.Lock() + assert.Equal(t, 2, len(runner.configuration.Forwards)) + runner.mu.Unlock() + + // Third reload + cfg3 := config.Configuration{ + Logs: config.LogsConfiguration{Level: "debug", Pretty: true}, + Forwards: []config.PortForwardConfiguration{}, + } + runner.mu.Lock() + runner.configuration = cfg3 + runner.mu.Unlock() + + runner.mu.Lock() + assert.Equal(t, 0, len(runner.configuration.Forwards)) + assert.Equal(t, "debug", runner.configuration.Logs.Level) + runner.mu.Unlock() +} + +// Phase 6 Tests - File Watcher Integration + +// TestConfigReloadFromRealFile tests loading configuration from a real file +func TestConfigReloadFromRealFile(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "test-config.cue") + + configContent := ` +logs: { + level: "info" + pretty: false +} + +forwards: [ + { + name: "test-forward" + namespace: "default" + resource: "pod-1" + ports: ["8080"] + } +] +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + // Load config from the file + cfg, err := config.ReadConfiguration(configPath) + require.NoError(t, err) + + // Verify configuration was loaded correctly + assert.Equal(t, "info", cfg.Logs.Level) + assert.Equal(t, 1, len(cfg.Forwards)) + assert.Equal(t, "test-forward", cfg.Forwards[0].Name) + assert.Equal(t, "default", cfg.Forwards[0].Namespace) + assert.Equal(t, "pod-1", cfg.Forwards[0].Resource) + assert.Equal(t, 1, len(cfg.Forwards[0].Ports)) + assert.Equal(t, "8080", cfg.Forwards[0].Ports[0]) +} + +// TestConfigReloadMultipleForwards tests loading config with multiple forwarders from file +func TestConfigReloadMultipleForwards(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "multi-config.cue") + + configContent := ` +logs: { + level: "debug" + pretty: true +} + +forwards: [ + { + name: "api-server" + namespace: "prod" + resource: "api-deployment" + ports: ["8080", "8443"] + }, + { + name: "database" + namespace: "prod" + resource: "postgres-pod" + ports: ["5432"] + }, + { + name: "cache" + namespace: "prod" + resource: "redis-pod" + ports: ["6379:6380"] + } +] +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + cfg, err := config.ReadConfiguration(configPath) + require.NoError(t, err) + + assert.Equal(t, 3, len(cfg.Forwards)) + assert.Equal(t, "api-server", cfg.Forwards[0].Name) + assert.Equal(t, "database", cfg.Forwards[1].Name) + assert.Equal(t, "cache", cfg.Forwards[2].Name) + assert.Equal(t, 2, len(cfg.Forwards[0].Ports)) + assert.Equal(t, 1, len(cfg.Forwards[1].Ports)) +} + +// TestWatcherDetectsFileModification tests that file modification can be detected +func TestWatcherDetectsFileModification(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "watch-config.cue") + + // Create initial config + initialConfig := ` +logs: { + level: "info" + pretty: false +} + +forwards: [ + { + name: "forward-1" + namespace: "ns1" + resource: "pod-1" + ports: ["8080"] + } +] +` + + err := os.WriteFile(configPath, []byte(initialConfig), 0644) + require.NoError(t, err) + + // Load initial config + cfg1, err := config.ReadConfiguration(configPath) + require.NoError(t, err) + assert.Equal(t, 1, len(cfg1.Forwards)) + + // Modify the config file + modifiedConfig := ` +logs: { + level: "debug" + pretty: true +} + +forwards: [ + { + name: "forward-1" + namespace: "ns1" + resource: "pod-1" + ports: ["8080"] + }, + { + name: "forward-2" + namespace: "ns2" + resource: "pod-2" + ports: ["9000"] + } +] +` + + // Wait a moment to ensure file system timestamp differs + time.Sleep(10 * time.Millisecond) + + err = os.WriteFile(configPath, []byte(modifiedConfig), 0644) + require.NoError(t, err) + + // Load the modified config + cfg2, err := config.ReadConfiguration(configPath) + require.NoError(t, err) + + // Verify configuration was updated + assert.Equal(t, "debug", cfg2.Logs.Level) + assert.Equal(t, 2, len(cfg2.Forwards)) + assert.Equal(t, "forward-2", cfg2.Forwards[1].Name) +} + +// TestConfigReloadWithInvalidFile tests error handling for invalid config file +func TestConfigReloadWithInvalidFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "invalid-config.cue") + + invalidConfig := ` +logs: { + level: "invalid_level" // Invalid level + pretty: false +} + +forwards: [ + { + name: "forward-1" + namespace: "ns1" + resource: "pod-1" + ports: ["invalid_port"] // Invalid port + } +] +` + + err := os.WriteFile(configPath, []byte(invalidConfig), 0644) + require.NoError(t, err) + + // Loading should fail due to validation errors + _, err = config.ReadConfiguration(configPath) + assert.Error(t, err, "should error on invalid configuration") +} + +// TestConfigReloadMissingFile tests error handling for missing config file +func TestConfigReloadMissingFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "nonexistent-config.cue") + + // Try to load from non-existent file + _, err := config.ReadConfiguration(configPath) + assert.Error(t, err, "should error when config file does not exist") +} + +// TestConfigFilePathParsing tests extracting directory from config path +func TestConfigFilePathParsing(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "absolute path", + path: "/home/user/config/fwkeeper.cue", + expected: "/home/user/config", + }, + { + name: "relative path", + path: "config/fwkeeper.cue", + expected: "config", + }, + { + name: "current directory", + path: "fwkeeper.cue", + expected: ".", + }, + { + name: "nested path", + path: "/etc/fwkeeper/config/app.cue", + expected: "/etc/fwkeeper/config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := "." + for i := len(tt.path) - 1; i >= 0; i-- { + if tt.path[i] == '/' || tt.path[i] == '\\' { + dir = tt.path[:i] + break + } + } + if dir == "" { + dir = "." + } + + assert.Equal(t, tt.expected, dir) + }) + } +} + +// TestRunnerWithConfigFile tests runner initialization with a config file +func TestRunnerWithConfigFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "runner-test.cue") + + configContent := ` +logs: { + level: "info" + pretty: false +} + +forwards: [] +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + // Load the configuration + cfg, err := config.ReadConfiguration(configPath) + require.NoError(t, err) + + // Create runner with the config file + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, configPath, logger, nil, restCfg, "mock-source", "mock-context") + err = runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + // Verify runner configuration + runner.mu.Lock() + assert.Equal(t, configPath, runner.configPath) + assert.Equal(t, "info", runner.configuration.Logs.Level) + runner.mu.Unlock() +} + +// TestFileWatcherConfigPath tests the config path is correctly stored +func TestFileWatcherConfigPath(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "test-config.cue") + + // Create a dummy config file + err := os.WriteFile(configPath, []byte("logs: {level: \"info\", pretty: false}\nforwards: []"), 0644) + require.NoError(t, err) + + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + // Create runner with the config path + runner := New(initialCfg, configPath, logger, nil, restCfg, "mock-source", "mock-context") + err = runner.Start() + require.NoError(t, err) + defer runner.Shutdown() + + time.Sleep(50 * time.Millisecond) + + // Verify the config path is correctly stored + runner.mu.Lock() + assert.Equal(t, configPath, runner.configPath) + runner.mu.Unlock() +} + +// Phase 7 Tests - Signal Handling and Graceful Shutdown + +// TestRunnerGracefulShutdownCompletes tests that Shutdown completes without hanging +func TestRunnerGracefulShutdownCompletes(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Shutdown should complete quickly without hanging + done := make(chan bool, 1) + go func() { + runner.Shutdown() + done <- true + }() + + select { + case <-done: + // Shutdown completed successfully + assert.True(t, true) + case <-time.After(2 * time.Second): + t.Fatal("Shutdown timed out - appears to be hanging") + } +} + +// TestRunnerContextCancelledOnShutdown tests that runner context is cancelled +func TestRunnerContextCancelledOnShutdown(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Verify context is active before shutdown + select { + case <-runner.ctx.Done(): + t.Fatal("Context should be active before shutdown") + default: + // Context is active - good + } + + // Shutdown the runner + runner.Shutdown() + + // Verify context is cancelled after shutdown + select { + case <-runner.ctx.Done(): + // Context is cancelled - correct + assert.True(t, true) + case <-time.After(100 * time.Millisecond): + t.Fatal("Context should be cancelled after shutdown") + } +} + +// TestRunnerShutdownStopsWatcherGoroutine tests that watcher goroutine stops +func TestRunnerShutdownStopsWatcherGoroutine(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Shutdown should stop the watcher goroutine + runner.Shutdown() + + // Wait a moment for goroutine to clean up + time.Sleep(50 * time.Millisecond) + + // Try to shutdown again - should not panic + runner.Shutdown() + + assert.True(t, true) +} + +// TestRunnerShutdownMultipleCalls tests that multiple shutdown calls are safe +func TestRunnerShutdownMultipleCalls(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Call shutdown multiple times - should not panic + runner.Shutdown() + runner.Shutdown() + runner.Shutdown() + + assert.True(t, true) +} + +// TestRunnerCancelFunctionExists tests that cancel function is set +func TestRunnerCancelFunctionExists(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + + // Before start, cancel should be nil + assert.Nil(t, runner.cancel) + + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // After start, cancel should be set + assert.NotNil(t, runner.cancel) + + runner.Shutdown() +} + +// TestRunnerWaitGroupSynchronization tests WaitGroup synchronization +func TestRunnerWaitGroupSynchronization(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // WaitGroup should be in use (watcher goroutine) + // When we shutdown, it should wait for all goroutines + + shutdown := make(chan bool, 1) + go func() { + runner.Shutdown() + shutdown <- true + }() + + // Shutdown should complete + select { + case <-shutdown: + assert.True(t, true) + case <-time.After(1 * time.Second): + t.Fatal("WaitGroup.Wait() timed out") + } +} + +// TestRunnerShutdownWithForwardersMaps tests cleanup of forwarder maps +func TestRunnerShutdownWithForwardersMaps(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Manually add forwarders to maps + runner.mu.Lock() + runner.forwarders["test-1"] = nil + runner.forwarders["test-2"] = nil + runner.forwarderCancel["test-1"] = func() {} + runner.forwarderCancel["test-2"] = func() {} + runner.mu.Unlock() + + // Shutdown should not clear the maps (that's app responsibility) + runner.Shutdown() + + // Maps should still exist (not nil) + assert.NotNil(t, runner.forwarders) + assert.NotNil(t, runner.forwarderCancel) +} + +// TestRunnerLoggerAccessDuringShudown tests logger is accessible during shutdown +func TestRunnerLoggerAccessDuringShudown(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Logger should be accessible + assert.NotNil(t, runner.logger) + + runner.Shutdown() + + // Logger should still be accessible after shutdown + assert.NotNil(t, runner.logger) +} + +// TestRunnerShutdownMessageLogging tests that shutdown logs messages +func TestRunnerShutdownMessageLogging(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Should not panic during shutdown logging + runner.Shutdown() + + assert.True(t, true) +} + +// TestRunnerContextIntegration tests context flows through the runner +func TestRunnerContextIntegration(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Get the context + ctx := runner.ctx + assert.NotNil(t, ctx) + + // Context should not be done yet + select { + case <-ctx.Done(): + t.Fatal("Context should not be done yet") + default: + // Good, context is still active + } + + // Shutdown + runner.Shutdown() + + // Context should be done now + select { + case <-ctx.Done(): + // Good, context is done + assert.True(t, true) + case <-time.After(100 * time.Millisecond): + t.Fatal("Context should be done after shutdown") + } +} + +// Phase 8 Tests - Real OS Signal Handling + +// TestSignalNotification tests that signal notification can be set up +func TestSignalNotification(t *testing.T) { + // Create a signal channel + sigChan := make(chan os.Signal, 1) + + // Setup signal handling for SIGHUP + signal.Notify(sigChan, syscall.SIGHUP) + + // Send signal to ourselves (this is a basic test) + // Note: In actual tests, we can't reliably send signals to ourselves + // This test validates the signal channel setup + + // Stop the signal notifications + signal.Stop(sigChan) + + assert.True(t, true) +} + +// TestSignalChannelCreation tests signal channel creation and cleanup +func TestSignalChannelCreation(t *testing.T) { + // Create a signal channel with buffer + sigChan := make(chan os.Signal, 2) + + // Verify channel is not nil + assert.NotNil(t, sigChan) + + // Verify we can send signals to the channel (simulated) + testSignal := syscall.SIGHUP + select { + case sigChan <- os.Signal(testSignal): + // Successfully sent signal to channel + assert.True(t, true) + default: + t.Fatal("Could not send signal to channel") + } + + // Verify we can receive from channel + select { + case sig := <-sigChan: + assert.Equal(t, sig, os.Signal(testSignal)) + case <-time.After(100 * time.Millisecond): + t.Fatal("Did not receive signal from channel") + } +} + +// TestSignalHandlingSetup tests that signal handling can be configured +func TestSignalHandlingSetup(t *testing.T) { + sigChan := make(chan os.Signal, 1) + + // Register for SIGHUP (reload signal) + signal.Notify(sigChan, syscall.SIGHUP) + + // Give signal registration time to settle + time.Sleep(10 * time.Millisecond) + + // Clean up + signal.Stop(sigChan) + close(sigChan) + + assert.True(t, true) +} + +// TestSignalChannelWithRunner tests signal handling in runner context +func TestSignalChannelWithRunner(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Create a signal channel + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM) + + // Simulate signal reception (without actually sending signal) + // In real scenario, OS would send the signal + testComplete := make(chan bool, 1) + go func() { + // Simulate signal handler logic + select { + case sig := <-sigChan: + // Signal received - verify it's expected type + if sig == syscall.SIGHUP || sig == syscall.SIGTERM { + testComplete <- true + } + case <-time.After(100 * time.Millisecond): + // Timeout - no signal (expected in test) + testComplete <- true + } + }() + + // Wait for test completion + <-testComplete + + signal.Stop(sigChan) + runner.Shutdown() + + assert.True(t, true) +} + +// TestSIGHUPConfigReload tests that SIGHUP should trigger config reload +func TestSIGHUPConfigReload(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "sighup-test.cue") + + configContent := ` +logs: { + level: "info" + pretty: false +} + +forwards: [] +` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + cfg, err := config.ReadConfiguration(configPath) + require.NoError(t, err) + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(cfg, configPath, logger, nil, restCfg, "mock-source", "mock-context") + err = runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Create signal channel for SIGHUP + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGHUP) + + // In real scenario, SIGHUP would trigger reloadConfig() + // Here we test that the signal infrastructure is in place + + signal.Stop(sigChan) + runner.Shutdown() + + assert.True(t, true) +} + +// TestSIGTERMShutdown tests that SIGTERM should trigger shutdown +func TestSIGTERMShutdown(t *testing.T) { + initialCfg := config.Configuration{ + Logs: config.LogsConfiguration{ + Level: "info", + Pretty: false, + }, + Forwards: []config.PortForwardConfiguration{}, + } + + restCfg := &rest.Config{} + logger := zerolog.New(nil) + + runner := New(initialCfg, "", logger, nil, restCfg, "mock-source", "mock-context") + err := runner.Start() + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + // Create signal channel for SIGTERM + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM) + + // In real scenario, SIGTERM would call runner.Shutdown() + // Here we verify the infrastructure is ready + + signal.Stop(sigChan) + runner.Shutdown() + + assert.True(t, true) +} + +// TestSignalChannelBuffering tests signal channel can buffer signals +func TestSignalChannelBuffering(t *testing.T) { + // Create buffered channel for 2 signals + sigChan := make(chan os.Signal, 2) + + // Send multiple signals + sigChan <- syscall.SIGHUP + sigChan <- syscall.SIGTERM + + // Verify we can receive both + sig1 := <-sigChan + sig2 := <-sigChan + + assert.Equal(t, sig1, os.Signal(syscall.SIGHUP)) + assert.Equal(t, sig2, os.Signal(syscall.SIGTERM)) +} + +// TestSignalStopCleansUp tests signal.Stop() cleans up properly +func TestSignalStopCleansUp(t *testing.T) { + sigChan := make(chan os.Signal, 1) + + // Register for signals + signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM) + + // Stop signal notifications + signal.Stop(sigChan) + + // After Stop, channel should not receive new signals + // (This is tested implicitly - no panic should occur) + + assert.True(t, true) +} + +// TestMultipleSignalChannels tests multiple signal channels can coexist +func TestMultipleSignalChannels(t *testing.T) { + sigChan1 := make(chan os.Signal, 1) + sigChan2 := make(chan os.Signal, 1) + + // Register both channels (each will get signals) + signal.Notify(sigChan1, syscall.SIGHUP) + signal.Notify(sigChan2, syscall.SIGTERM) + + // Clean up + signal.Stop(sigChan1) + signal.Stop(sigChan2) + + assert.NotNil(t, sigChan1) + assert.NotNil(t, sigChan2) +} diff --git a/internal/app/testdata/config1.cue b/internal/app/testdata/config1.cue new file mode 100644 index 0000000..beaf4dd --- /dev/null +++ b/internal/app/testdata/config1.cue @@ -0,0 +1,13 @@ +logs: { + level: "info" + pretty: false +} + +forwards: [ + { + name: "test-forward" + namespace: "default" + resource: "test-pod" + ports: ["8080"] + }, +] diff --git a/internal/app/testdata/config2.cue b/internal/app/testdata/config2.cue new file mode 100644 index 0000000..251d9cd --- /dev/null +++ b/internal/app/testdata/config2.cue @@ -0,0 +1,19 @@ +logs: { + level: "info" + pretty: false +} + +forwards: [ + { + name: "test-forward-1" + namespace: "default" + resource: "pod-1" + ports: ["8080"] + }, + { + name: "test-forward-2" + namespace: "default" + resource: "pod-2" + ports: ["9000"] + }, +] diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..52444e1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,99 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReadConfigurationValid tests loading a valid configuration +func TestReadConfigurationValid(t *testing.T) { + cfg, err := ReadConfiguration("testdata/valid.cue") + + require.NoError(t, err) + assert.Equal(t, 2, len(cfg.Forwards)) + assert.Equal(t, "api", cfg.Forwards[0].Name) + assert.Equal(t, "default", cfg.Forwards[0].Namespace) + assert.Equal(t, "api-server", cfg.Forwards[0].Resource) + assert.Equal(t, []string{"8080:8080", "9000:9000"}, cfg.Forwards[0].Ports) + assert.Equal(t, "database", cfg.Forwards[1].Name) + assert.Equal(t, []string{"5432"}, cfg.Forwards[1].Ports) +} + +// TestReadConfigurationPortConflict tests detection of duplicate local ports +func TestReadConfigurationPortConflict(t *testing.T) { + _, err := ReadConfiguration("testdata/port-conflict.cue") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "port conflict") + assert.Contains(t, err.Error(), "8080") +} + +// TestReadConfigurationInvalidPorts tests validation of port ranges +func TestReadConfigurationInvalidPorts(t *testing.T) { + _, err := ReadConfiguration("testdata/invalid-ports.cue") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid port") +} + +// TestReadConfigurationDuplicateNames tests handling of duplicate forward names +// Note: CUE allows duplicate keys and the last one wins (map behavior) +// This is a known limitation - should be caught at config validation level +func TestReadConfigurationDuplicateNames(t *testing.T) { + t.Skip("CUE allows duplicate keys (last wins). Should add explicit validation for unique names.") +} + +// TestPortValidation tests port number range validation +func TestPortValidation(t *testing.T) { + testCases := []struct { + name string + port string + expectErr bool + }{ + {"valid single port", "8080", false}, + {"valid port mapping", "8080:9000", false}, + {"port 1", "1", false}, + {"port 65535", "65535", false}, + {"port 0", "0", true}, + {"port 65536", "65536", true}, + {"port 99999", "99999", true}, + {"negative port", "-1", true}, + {"non-numeric", "abc", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configStr := ` +logs: { + level: "info" + pretty: false +} +forwards: [{ + name: "test" + ports: ["` + tc.port + `"] + namespace: "default" + resource: "pod" +}] +` + tempFile := t.TempDir() + "/test.cue" + err := writeTestFile(tempFile, configStr) + require.NoError(t, err) + + _, err = ReadConfiguration(tempFile) + + if tc.expectErr { + assert.Error(t, err, "expected error for port %s", tc.port) + } else { + assert.NoError(t, err, "expected success for port %s", tc.port) + } + }) + } +} + +// Helper function to write test files +func writeTestFile(path string, content string) error { + return os.WriteFile(path, []byte(content), 0644) +} diff --git a/internal/config/testdata/duplicate-names.cue b/internal/config/testdata/duplicate-names.cue new file mode 100644 index 0000000..1dbbb8d --- /dev/null +++ b/internal/config/testdata/duplicate-names.cue @@ -0,0 +1,19 @@ +logs: { + level: "info" + pretty: true +} + +forwards: [ + { + name: "api" + ports: ["8080"] + namespace: "default" + resource: "api-server-1" + }, + { + name: "api" + ports: ["9000"] + namespace: "default" + resource: "api-server-2" + } +] diff --git a/internal/config/testdata/invalid-ports.cue b/internal/config/testdata/invalid-ports.cue new file mode 100644 index 0000000..2798bcf --- /dev/null +++ b/internal/config/testdata/invalid-ports.cue @@ -0,0 +1,13 @@ +logs: { + level: "info" + pretty: true +} + +forwards: [ + { + name: "api" + ports: ["99999:8080"] + namespace: "default" + resource: "api-server" + } +] diff --git a/internal/config/testdata/port-conflict.cue b/internal/config/testdata/port-conflict.cue new file mode 100644 index 0000000..6f0e113 --- /dev/null +++ b/internal/config/testdata/port-conflict.cue @@ -0,0 +1,19 @@ +logs: { + level: "info" + pretty: true +} + +forwards: [ + { + name: "api" + ports: ["8080:8080"] + namespace: "default" + resource: "api-server" + }, + { + name: "api2" + ports: ["8080:9000"] + namespace: "default" + resource: "api-server-2" + } +] diff --git a/internal/config/testdata/valid.cue b/internal/config/testdata/valid.cue new file mode 100644 index 0000000..2a1d3f3 --- /dev/null +++ b/internal/config/testdata/valid.cue @@ -0,0 +1,19 @@ +logs: { + level: "info" + pretty: true +} + +forwards: [ + { + name: "api" + ports: ["8080:8080", "9000:9000"] + namespace: "default" + resource: "api-server" + }, + { + name: "database" + ports: ["5432"] + namespace: "default" + resource: "postgres" + } +] diff --git a/internal/forwarder/forwarder_test.go b/internal/forwarder/forwarder_test.go new file mode 100644 index 0000000..f4a516d --- /dev/null +++ b/internal/forwarder/forwarder_test.go @@ -0,0 +1,363 @@ +package forwarder + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/codozor/fwkeeper/internal/config" +) + +// Phase 9 Tests - Forwarder Logic (No Kubernetes dependency) + +// TestDefaultRetryConfig tests default retry configuration +func TestDefaultRetryConfig(t *testing.T) { + rc := DefaultRetryConfig() + + assert.Equal(t, 100*time.Millisecond, rc.InitialDelay) + assert.Equal(t, 30*time.Second, rc.MaxDelay) + assert.Equal(t, 1.5, rc.Multiplier) + assert.True(t, rc.Jitter) +} + +// TestRetryConfigExponentialBackoff tests exponential backoff calculation +func TestRetryConfigExponentialBackoff(t *testing.T) { + rc := DefaultRetryConfig() + + // Test delay calculation for different attempt numbers + tests := []struct { + attempt uint + minDuration time.Duration + maxDuration time.Duration + }{ + { + attempt: 0, + minDuration: 100 * time.Millisecond, + maxDuration: 150 * time.Millisecond, // With multiplier, rough estimate + }, + { + attempt: 1, + minDuration: 150 * time.Millisecond, + maxDuration: 300 * time.Millisecond, + }, + { + attempt: 2, + minDuration: 225 * time.Millisecond, + maxDuration: 500 * time.Millisecond, + }, + } + + for _, tt := range tests { + baseDelay := rc.InitialDelay + for i := uint(0); i < tt.attempt; i++ { + baseDelay = time.Duration(float64(baseDelay) * rc.Multiplier) + } + if baseDelay > rc.MaxDelay { + baseDelay = rc.MaxDelay + } + + // Verify delay is within expected range + assert.GreaterOrEqual(t, baseDelay, tt.minDuration) + } +} + +// TestRetryConfigMaxDelayEnforced tests that max delay is enforced +func TestRetryConfigMaxDelayEnforced(t *testing.T) { + rc := DefaultRetryConfig() + + // Calculate delay for many attempts (should hit max) + delay := rc.InitialDelay + for i := 0; i < 100; i++ { + delay = time.Duration(float64(delay) * rc.Multiplier) + if delay > rc.MaxDelay { + delay = rc.MaxDelay + } + } + + // Should be capped at MaxDelay + assert.LessOrEqual(t, delay, rc.MaxDelay) +} + +// TestRetryConfigJitterOption tests jitter option +func TestRetryConfigJitterOption(t *testing.T) { + rcWithJitter := DefaultRetryConfig() + assert.True(t, rcWithJitter.Jitter) + + rcNoJitter := RetryConfig{ + InitialDelay: 100 * time.Millisecond, + MaxDelay: 30 * time.Second, + Multiplier: 1.5, + Jitter: false, + } + assert.False(t, rcNoJitter.Jitter) +} + +// TestPortForwardConfigurationValid tests valid port configurations +func TestPortForwardConfigurationValid(t *testing.T) { + tests := []struct { + name string + ports []string + valid bool + }{ + { + name: "single port", + ports: []string{"8080"}, + valid: true, + }, + { + name: "mapped port", + ports: []string{"8080:3000"}, + valid: true, + }, + { + name: "multiple ports", + ports: []string{"8080", "9000", "5432"}, + valid: true, + }, + { + name: "mixed mapped and unmapped", + ports: []string{"8080", "9000:3000", "5432"}, + valid: true, + }, + { + name: "empty ports", + ports: []string{}, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.PortForwardConfiguration{ + Name: "test", + Namespace: "default", + Resource: "pod-1", + Ports: tt.ports, + } + + if tt.valid { + assert.NotEmpty(t, cfg.Ports) + } else { + assert.Empty(t, cfg.Ports) + } + }) + } +} + +// TestPortParsingLogic tests parsing of port specifications +func TestPortParsingLogic(t *testing.T) { + tests := []struct { + name string + portSpec string + localPort string + remotePort string + }{ + { + name: "single port", + portSpec: "8080", + localPort: "8080", + remotePort: "8080", + }, + { + name: "mapped port", + portSpec: "8080:3000", + localPort: "8080", + remotePort: "3000", + }, + { + name: "IPv6 address", + portSpec: "[::1]:8080:3000", + localPort: "8080", + remotePort: "3000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simple port parsing logic + parts := splitPort(tt.portSpec) + + if len(parts) == 1 { + assert.Equal(t, tt.localPort, parts[0]) + } else if len(parts) == 2 { + assert.Equal(t, tt.localPort, parts[0]) + assert.Equal(t, tt.remotePort, parts[1]) + } + }) + } +} + +// TestForwarderConfigurationCreation tests creating valid configurations +func TestForwarderConfigurationCreation(t *testing.T) { + cfg := config.PortForwardConfiguration{ + Name: "api-server", + Namespace: "default", + Resource: "api-pod", + Ports: []string{"8080", "8443:443"}, + } + + assert.Equal(t, "api-server", cfg.Name) + assert.Equal(t, "default", cfg.Namespace) + assert.Equal(t, "api-pod", cfg.Resource) + assert.Equal(t, 2, len(cfg.Ports)) +} + +// TestForwarderConfigurationInfo tests generating info string +func TestForwarderConfigurationInfo(t *testing.T) { + cfg := config.PortForwardConfiguration{ + Name: "database", + Namespace: "prod", + Resource: "postgres-pod", + Ports: []string{"5432", "5433:5432"}, + } + + // Test that we can format configuration info + info := cfg.Name + "(" + cfg.Namespace + " " + cfg.Resource + ")" + assert.Contains(t, info, "database") + assert.Contains(t, info, "prod") + assert.Contains(t, info, "postgres-pod") +} + +// TestRetryConfigCustomization tests custom retry configurations +func TestRetryConfigCustomization(t *testing.T) { + customRC := RetryConfig{ + InitialDelay: 50 * time.Millisecond, + MaxDelay: 5 * time.Second, + Multiplier: 2.0, + Jitter: false, + } + + assert.Equal(t, 50*time.Millisecond, customRC.InitialDelay) + assert.Equal(t, 5*time.Second, customRC.MaxDelay) + assert.Equal(t, 2.0, customRC.Multiplier) + assert.False(t, customRC.Jitter) +} + +// TestMultiplePortConfigurations tests handling multiple port configurations +func TestMultiplePortConfigurations(t *testing.T) { + configs := []config.PortForwardConfiguration{ + { + Name: "frontend", + Namespace: "prod", + Resource: "frontend-app", + Ports: []string{"80:3000", "443:3001"}, + }, + { + Name: "backend", + Namespace: "prod", + Resource: "backend-api", + Ports: []string{"8080:8080", "8443:8443"}, + }, + { + Name: "database", + Namespace: "prod", + Resource: "postgres", + Ports: []string{"5432:5432"}, + }, + } + + require.Equal(t, 3, len(configs)) + assert.Equal(t, "frontend", configs[0].Name) + assert.Equal(t, "backend", configs[1].Name) + assert.Equal(t, "database", configs[2].Name) + assert.Equal(t, 2, len(configs[0].Ports)) + assert.Equal(t, 2, len(configs[1].Ports)) + assert.Equal(t, 1, len(configs[2].Ports)) +} + +// TestPortMappingEdgeCases tests edge cases in port mapping +func TestPortMappingEdgeCases(t *testing.T) { + tests := []struct { + name string + port string + isValid bool + }{ + { + name: "high port number", + port: "65535", + isValid: true, + }, + { + name: "low port number", + port: "1", + isValid: true, + }, + { + name: "mapped to same port", + port: "8080:8080", + isValid: true, + }, + { + name: "mapped to different port", + port: "9000:8080", + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.PortForwardConfiguration{ + Name: "test", + Namespace: "default", + Resource: "pod", + Ports: []string{tt.port}, + } + + if tt.isValid { + assert.Equal(t, 1, len(cfg.Ports)) + assert.Equal(t, tt.port, cfg.Ports[0]) + } + }) + } +} + +// TestRetryConfigComparison tests comparing retry configurations +func TestRetryConfigComparison(t *testing.T) { + rc1 := DefaultRetryConfig() + rc2 := DefaultRetryConfig() + + // Should have same values + assert.Equal(t, rc1.InitialDelay, rc2.InitialDelay) + assert.Equal(t, rc1.MaxDelay, rc2.MaxDelay) + assert.Equal(t, rc1.Multiplier, rc2.Multiplier) + assert.Equal(t, rc1.Jitter, rc2.Jitter) +} + +// Helper function for port parsing (mimics port format parsing) +func splitPort(portSpec string) []string { + // Remove IPv6 bracket notation if present + if portSpec[0] == '[' { + // Format: [::1]:8080:3000 -> extract 8080:3000 + bracketEnd := 0 + for i, ch := range portSpec { + if ch == ']' { + bracketEnd = i + break + } + } + if bracketEnd > 0 && bracketEnd+1 < len(portSpec) && portSpec[bracketEnd+1] == ':' { + portSpec = portSpec[bracketEnd+2:] + } + } + + // Split on colon + var parts []string + var current string + for _, ch := range portSpec { + if ch == ':' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(ch) + } + } + if current != "" { + parts = append(parts, current) + } + + return parts +} diff --git a/internal/locator/locator.go b/internal/locator/locator.go index a428b92..65188b4 100644 --- a/internal/locator/locator.go +++ b/internal/locator/locator.go @@ -22,6 +22,10 @@ type Locator interface { // - "sts/statefulset-name" or "statefulset/statefulset-name" - statefulset reference // - "ds/daemonset-name" or "daemonset/daemonset-name" - daemonset reference func BuildLocator(resource string, namespace string, ports []string, client kubernetes.Interface) (Locator, error) { + if client == nil { + return nil, fmt.Errorf("kubernetes client is required") + } + parts := strings.Split(resource, "/") if len(parts) == 1 { diff --git a/internal/locator/locator_test.go b/internal/locator/locator_test.go new file mode 100644 index 0000000..391dd2d --- /dev/null +++ b/internal/locator/locator_test.go @@ -0,0 +1,438 @@ +package locator + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestMockClient creates a fake Kubernetes client for testing +func newTestMockClient(objects ...runtime.Object) *fake.Clientset { + return fake.NewClientset(objects...) +} + +// TestPodLocatorFound tests that a running pod is found +func TestPodLocatorFound(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-server", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(pod) + locator, err := NewPodLocator("api-server", "default", []string{"8080"}, client) + require.NoError(t, err) + + podName, ports, err := locator.Locate(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "api-server", podName) + assert.Equal(t, []string{"8080"}, ports) +} + +// TestPodLocatorNotFound tests error when pod doesn't exist +func TestPodLocatorNotFound(t *testing.T) { + client := newTestMockClient() + locator, err := NewPodLocator("nonexistent", "default", []string{"8080"}, client) + require.NoError(t, err) + + _, _, err = locator.Locate(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get pod") + assert.Contains(t, err.Error(), "nonexistent") +} + +// TestPodLocatorNotRunning tests error when pod is not in running state +func TestPodLocatorNotRunning(t *testing.T) { + testCases := []struct { + name string + phase corev1.PodPhase + }{ + {"pending", corev1.PodPending}, + {"failed", corev1.PodFailed}, + {"succeeded", corev1.PodSucceeded}, + {"unknown", corev1.PodUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-server", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: tc.phase, + }, + } + + client := newTestMockClient(pod) + locator, err := NewPodLocator("api-server", "default", []string{"8080"}, client) + require.NoError(t, err) + + _, _, err = locator.Locate(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not running") + assert.Contains(t, err.Error(), string(tc.phase)) + }) + } +} + +// TestServiceLocatorFound tests that a service with running pods is found +func TestServiceLocatorFound(t *testing.T) { + selector := map[string]string{"app": "api"} + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-svc", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: selector, + Ports: []corev1.ServicePort{ + { + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-server-1", + Namespace: "default", + Labels: selector, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(svc, pod) + locator, err := NewServiceLocator("api-svc", "default", []string{"8080"}, client) + require.NoError(t, err) + + podName, ports, err := locator.Locate(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "api-server-1", podName) + assert.Equal(t, []string{"8080"}, ports) +} + +// TestServiceLocatorNotFound tests error when service doesn't exist +func TestServiceLocatorNotFound(t *testing.T) { + client := newTestMockClient() + locator, err := NewServiceLocator("nonexistent-svc", "default", []string{"8080"}, client) + require.NoError(t, err) + + _, _, err = locator.Locate(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get service") +} + +// TestServiceLocatorNoRunningPods tests error when service has no running pods +func TestServiceLocatorNoRunningPods(t *testing.T) { + selector := map[string]string{"app": "api"} + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-svc", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: selector, + Ports: []corev1.ServicePort{ + { + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-server-1", + Namespace: "default", + Labels: selector, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + } + + client := newTestMockClient(svc, pod) + locator, err := NewServiceLocator("api-svc", "default", []string{"8080"}, client) + require.NoError(t, err) + + _, _, err = locator.Locate(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no running pod") +} + +// TestDeploymentLocatorFound tests that a deployment with running pods is found +func TestDeploymentLocatorFound(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "api"}, + } + + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-deploy", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Selector: selector, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-deploy-abc123", + Namespace: "default", + Labels: selector.MatchLabels, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(deploy, pod) + locator, err := NewSelectorBasedLocator("deployment", "api-deploy", "default", []string{"8080"}, client) + require.NoError(t, err) + + podName, ports, err := locator.Locate(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "api-deploy-abc123", podName) + assert.Equal(t, []string{"8080"}, ports) +} + +// TestStatefulSetLocatorFound tests that a statefulset with running pods is found +func TestStatefulSetLocatorFound(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "postgres"}, + } + + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "postgres-sts", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Selector: selector, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "postgres-sts-0", + Namespace: "default", + Labels: selector.MatchLabels, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(sts, pod) + locator, err := NewSelectorBasedLocator("statefulset", "postgres-sts", "default", []string{"5432"}, client) + require.NoError(t, err) + + podName, ports, err := locator.Locate(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "postgres-sts-0", podName) + assert.Equal(t, []string{"5432"}, ports) +} + +// TestDaemonSetLocatorFound tests that a daemonset with running pods is found +func TestDaemonSetLocatorFound(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "monitoring"}, + } + + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus-ds", + Namespace: "default", + }, + Spec: appsv1.DaemonSetSpec{ + Selector: selector, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus-ds-node1", + Namespace: "default", + Labels: selector.MatchLabels, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(ds, pod) + locator, err := NewSelectorBasedLocator("daemonset", "prometheus-ds", "default", []string{"9090"}, client) + require.NoError(t, err) + + podName, ports, err := locator.Locate(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "prometheus-ds-node1", podName) + assert.Equal(t, []string{"9090"}, ports) +} + +// TestBuildLocatorPodFormat tests BuildLocator with pod format +func TestBuildLocatorPodFormat(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-server", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(pod) + locator, err := BuildLocator("api-server", "default", []string{"8080"}, client) + + require.NoError(t, err) + assert.NotNil(t, locator) + + podName, _, err := locator.Locate(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "api-server", podName) +} + +// TestBuildLocatorServiceFormats tests BuildLocator with various service formats +func TestBuildLocatorServiceFormats(t *testing.T) { + testCases := []struct { + name string + resource string + }{ + {"short format", "svc/api-svc"}, + {"long format", "service/api-svc"}, + {"plural format", "services/api-svc"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + selector := map[string]string{"app": "api"} + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-svc", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: selector, + Ports: []corev1.ServicePort{ + {Port: 8080, TargetPort: intstr.FromInt(8080)}, + }, + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-server-1", + Namespace: "default", + Labels: selector, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(svc, pod) + locator, err := BuildLocator(tc.resource, "default", []string{"8080"}, client) + require.NoError(t, err) + + _, _, err = locator.Locate(context.Background()) + assert.NoError(t, err) + }) + } +} + +// TestBuildLocatorDeploymentFormats tests BuildLocator with various deployment formats +func TestBuildLocatorDeploymentFormats(t *testing.T) { + testCases := []struct { + name string + resource string + }{ + {"short format", "dep/api-deploy"}, + {"long format", "deployment/api-deploy"}, + {"plural format", "deployments/api-deploy"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "api"}, + } + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-deploy", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Selector: selector, + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-deploy-abc", + Namespace: "default", + Labels: selector.MatchLabels, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + client := newTestMockClient(deploy, pod) + locator, err := BuildLocator(tc.resource, "default", []string{"8080"}, client) + require.NoError(t, err) + + _, _, err = locator.Locate(context.Background()) + assert.NoError(t, err) + }) + } +} + +// TestBuildLocatorInvalidFormat tests BuildLocator with invalid format +func TestBuildLocatorInvalidFormat(t *testing.T) { + client := newTestMockClient() + + testCases := []struct { + name string + resource string + }{ + {"invalid type", "invalid/pod-name"}, + {"too many slashes", "dep/name/extra"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildLocator(tc.resource, "default", []string{"8080"}, client) + assert.Error(t, err) + }) + } +} diff --git a/internal/locator/selector_based_locator.go b/internal/locator/selector_based_locator.go index 6923b24..181df24 100644 --- a/internal/locator/selector_based_locator.go +++ b/internal/locator/selector_based_locator.go @@ -5,8 +5,8 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" )