From fccaf319acabebb542e4165e1f21bf75e07b8e16 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 20 Sep 2026 18:29:26 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20client-go=20Pod=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design-principles.md | 33 +++-- go.mod | 44 ++++++ go.sum | 106 +++++++++++++++ internal/podbackend/backend.go | 202 ++++++++++++++++++++++++++++ internal/podbackend/backend_test.go | 120 +++++++++++++++++ internal/podbackend/client.go | 163 ++++++++++++++++++++++ internal/podbackend/client_test.go | 72 ++++++++++ 7 files changed, 728 insertions(+), 12 deletions(-) create mode 100644 internal/podbackend/backend.go create mode 100644 internal/podbackend/backend_test.go create mode 100644 internal/podbackend/client.go create mode 100644 internal/podbackend/client_test.go diff --git a/docs/design-principles.md b/docs/design-principles.md index 61746a2..3c39cae 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -21,18 +21,20 @@ runs-on: [self-hosted, vm] ## 一个 job,一个环境 -Controller 根据 Gitea `workflow_job` webhook 创建执行环境。每个 Pod 或 VM 注册一个 -ephemeral runner,只执行一个 job;任务结束后注销 runner,并删除计算环境及其全部 -本地状态。 +Controller 通过 Gitea RunnerService 原子领取具体 task,再把版本化 assignment 交给 +一个一次性 Pod 或 VM。executor 直接执行已经领取的 task,不再注册临时 runner 去 +二次竞争任务;任务结束并回报 Gitea 后删除计算环境及其全部本地状态。 `job_id` 仅用于消息去重、状态追踪、实例关联和失败清理,不进入 workload 身份,也 不参与资源授权。 -Gitea 不保证由某次 `queued` webhook 创建的 runner 一定领取该 webhook 对应的 job。 -因此创建环境时只赋予无业务权限的启动身份。runner 实际领取任务后,controller 根据 -`in_progress` webhook 返回的 `runner_name` 和真实 job 名称绑定业务身份;环境中的 -job-start hook 必须等目标 SVID 可用后才放行 workflow 的第一步。不能依据 queued -事件提前赋予任务权限。 +assignment ID 只用于消息去重、状态追踪、实例关联和失败清理,不进入 workload 身份, +也不参与资源授权。worker 通过 assignment ID 从 Kubernetes labels 或 OpenSandbox +metadata 恢复 executor;JetStream 不保存 executor 生命周期状态。 + +Pod executor 创建后,controller 使用实际 Pod UID 创建幂等 `ClusterStaticEntry`,将 +SPIFFE ID绑定到该 Pod 的 workload selector。executor 必须等目标 SVID 可用后才执行 +workflow 的第一步。 ## 环境只提供运行边界 @@ -54,15 +56,15 @@ kind 等工具由 pipeline 按需安装和启动,而不是由 controller 预 SPIFFE ID 由具有业务意义且稳定的 workflow 上下文派生: ```text -spiffe://ddupan.top/ci/// +spiffe://ddupan.top/ci/// ``` 同一种任务在不同运行中使用相同的逻辑 SPIFFE ID;每次运行取得独立、短期的 SVID。 Pod 与 VM 是可替换的执行实现,因此默认不写入 SPIFFE ID。 -job 名称必须经过确定性的路径规范化。规范化结果必须保留仓库边界,并在发生冲突时 -拒绝创建环境,不能静默地让两个任务共享身份。同一仓库内需要不同权限的任务应使用 -不同的 job 名称;workflow 文件只是编排载体,不进入权限身份。 +job key 必须满足 `[A-Za-z_][A-Za-z0-9_-]*`,展示名称 `name` 不参与身份计算。同一 +仓库内需要不同权限的任务应使用不同的 job key;workflow 文件只是编排载体,不进入 +权限身份。 ## Self-service 与授权边界 @@ -86,3 +88,10 @@ OpenBao 或其他资源的特殊权限。资源所有者在资源端按照有意 仓库中的 `jwt-broker` 是早期方案的实验实现,在 Pod/VM 动态执行环境完成迁移后不应 部署。 + +## 实现依赖原则 + +基础设施协议优先使用上游维护的成熟客户端,不在 controller 内重复实现认证、连接、 +资源编码或错误语义。Kubernetes 使用 `client-go`,NATS JetStream 使用 `nats.go`, +Gitea RunnerService 使用 `actionslib`;自定义代码只保留领域模型、reconcile 规则及 +上游客户端未覆盖的最小适配层。 diff --git a/go.mod b/go.mod index 22a1391..737922b 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,58 @@ require ( gitea.dev/actionslib v1.0.0 github.com/nats-io/nats.go v1.54.0 google.golang.org/protobuf v1.36.12 + k8s.io/api v0.37.0 + k8s.io/apimachinery v0.37.0 + k8s.io/client-go v0.37.0 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.1 // indirect + github.com/go-openapi/swag/loading v0.27.1 // indirect + github.com/go-openapi/swag/mangling v0.27.1 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.1 // indirect + github.com/go-openapi/swag/stringutils v0.27.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.20.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/sirupsen/logrus v1.10.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 66a9465..9e3dbde 100644 --- a/go.sum +++ b/go.sum @@ -2,27 +2,133 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY= gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= +github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM= +github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA= +github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +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.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= +k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= +k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/podbackend/backend.go b/internal/podbackend/backend.go new file mode 100644 index 0000000..4ad0489 --- /dev/null +++ b/internal/podbackend/backend.go @@ -0,0 +1,202 @@ +// Package podbackend implements the native homelab Kubernetes executor backend. +package podbackend + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment" + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity" + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskworker" +) + +const assignmentLabel = "ci.ddupan.top/assignment-id" + +// Pod is the backend state required by the reconciler, not an in-memory lifecycle record. +type Pod struct { + Name string + Namespace string + UID string + Phase string + Labels map[string]string + Annotations map[string]string +} + +// PodManifest leaves task execution wiring to the executor image while fixing +// the metadata contract needed for recovery. +type PodManifest struct { + Name string + Namespace string + Labels map[string]string + Annotations map[string]string + Image string + ServiceAccount string + Args []string +} + +// IdentityEntry is a ClusterStaticEntry pinned to one concrete Pod UID. +type IdentityEntry struct { + Name string + Labels map[string]string + ClassName string + ParentID string + SPIFFEID string + Selectors []string +} + +// API is the narrow Kubernetes boundary used by the backend adapter. +type API interface { + ListPods(context.Context, string, string) ([]Pod, error) + CreatePod(context.Context, PodManifest) (Pod, error) + DeletePod(context.Context, string, string) error + EnsureIdentityEntry(context.Context, IdentityEntry) error + DeleteIdentityEntry(context.Context, string) error +} + +type Config struct { + Namespace string + Image string + ServiceAccount string + ExecutorArgs []string + TrustDomain string + SPIRECluster string + SPIREClass string + ExecutorUID int +} + +type Backend struct { + API API + Config Config +} + +func (b Backend) Find(ctx context.Context, assignmentID string) (*taskworker.Executor, error) { + if err := b.validate(); err != nil { + return nil, err + } + pods, err := b.API.ListPods(ctx, b.Config.Namespace, assignmentLabel+"="+assignmentID) + if err != nil { + return nil, fmt.Errorf("list assignment Pods: %w", err) + } + if len(pods) > 1 { + return nil, fmt.Errorf("assignment %s owns %d Pods", assignmentID, len(pods)) + } + if len(pods) == 0 { + return nil, nil + } + return executor(pods[0]), nil +} + +func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, metadata taskworker.Metadata) (*taskworker.Executor, error) { + if err := b.validate(); err != nil { + return nil, err + } + if assignment.Backend != taskassignment.BackendPod { + return nil, fmt.Errorf("Pod backend cannot create %q assignment", assignment.Backend) + } + labels := clone(metadata.Labels) + labels["app.kubernetes.io/name"] = "gitea-dynamic-runner" + labels["app.kubernetes.io/component"] = "executor" + pod, err := b.API.CreatePod(ctx, PodManifest{ + Name: assignment.ID, + Namespace: b.Config.Namespace, + Labels: labels, + Annotations: clone(metadata.Annotations), + Image: b.Config.Image, + ServiceAccount: b.Config.ServiceAccount, + Args: append(append([]string{}, b.Config.ExecutorArgs...), assignment.ID), + }) + if err != nil { + return nil, fmt.Errorf("create assignment Pod: %w", err) + } + return executor(pod), nil +} + +func (b Backend) BindIdentity(ctx context.Context, executor *taskworker.Executor, identity taskidentity.Identity) error { + if err := b.validate(); err != nil { + return err + } + if executor == nil || executor.Name == "" || executor.IdentityTarget == "" { + return errors.New("Pod name and UID are required for identity binding") + } + if _, err := identityPath(identity.SPIFFEID, b.Config.TrustDomain); err != nil { + return err + } + return b.API.EnsureIdentityEntry(ctx, IdentityEntry{ + Name: executor.Name, + Labels: map[string]string{ + "app.kubernetes.io/name": "gitea-dynamic-runner", + "app.kubernetes.io/component": "pod-identity", + assignmentLabel: executor.Name, + }, + ClassName: b.Config.SPIREClass, + ParentID: fmt.Sprintf( + "spiffe://%s/spire/agent/k8s_psat/%s/pod/%s", + b.Config.TrustDomain, b.Config.SPIRECluster, executor.IdentityTarget, + ), + SPIFFEID: identity.SPIFFEID, + Selectors: []string{fmt.Sprintf("unix:uid:%d", b.Config.ExecutorUID)}, + }) +} + +func identityPath(spiffeID, trustDomain string) (string, error) { + parsed, err := url.Parse(spiffeID) + if err != nil || parsed.Scheme != "spiffe" || parsed.Host != trustDomain || !strings.HasPrefix(parsed.Path, "/ci/") { + return "", fmt.Errorf("invalid CI SPIFFE ID %q", spiffeID) + } + return strings.TrimPrefix(parsed.Path, "/ci/"), nil +} + +func (b Backend) Delete(ctx context.Context, executor *taskworker.Executor) error { + if err := b.validate(); err != nil { + return err + } + if executor == nil || executor.Name == "" { + return nil + } + if err := b.API.DeleteIdentityEntry(ctx, executor.Name); err != nil { + return fmt.Errorf("delete Pod identity entry: %w", err) + } + if err := b.API.DeletePod(ctx, b.Config.Namespace, executor.Name); err != nil { + return fmt.Errorf("delete assignment Pod: %w", err) + } + return nil +} + +func (b Backend) validate() error { + if b.API == nil || b.Config.Namespace == "" || b.Config.Image == "" || b.Config.ServiceAccount == "" || b.Config.TrustDomain == "" || b.Config.SPIRECluster == "" || b.Config.SPIREClass == "" || b.Config.ExecutorUID < 1 { + return errors.New("Pod API and complete executor/SPIRE configuration are required") + } + return nil +} + +func executor(pod Pod) *taskworker.Executor { + return &taskworker.Executor{ + Name: pod.Name, + IdentityTarget: pod.UID, + Phase: phase(pod.Phase), + } +} + +func phase(value string) taskworker.Phase { + switch value { + case "Succeeded": + return taskworker.PhaseSucceeded + case "Failed": + return taskworker.PhaseFailed + case "Running": + return taskworker.PhaseRunning + default: + return taskworker.PhasePending + } +} + +func clone(source map[string]string) map[string]string { + target := make(map[string]string, len(source)) + for key, value := range source { + target[key] = value + } + return target +} diff --git a/internal/podbackend/backend_test.go b/internal/podbackend/backend_test.go new file mode 100644 index 0000000..6ae80d0 --- /dev/null +++ b/internal/podbackend/backend_test.go @@ -0,0 +1,120 @@ +package podbackend + +import ( + "context" + "testing" + + runnerv1 "gitea.dev/actionslib/runner/v1" + + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment" + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity" + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskworker" +) + +type fakeAPI struct { + pods []Pod + selector string + created PodManifest + entry IdentityEntry + entryGone string + deleted string +} + +func (a *fakeAPI) ListPods(_ context.Context, _ string, selector string) ([]Pod, error) { + a.selector = selector + return a.pods, nil +} +func (a *fakeAPI) CreatePod(_ context.Context, manifest PodManifest) (Pod, error) { + a.created = manifest + return Pod{Name: manifest.Name, Namespace: manifest.Namespace, UID: "pod-uid", Phase: "Pending"}, nil +} +func (a *fakeAPI) DeletePod(_ context.Context, _, name string) error { + a.deleted = name + return nil +} +func (a *fakeAPI) EnsureIdentityEntry(_ context.Context, entry IdentityEntry) error { + a.entry = entry + return nil +} +func (a *fakeAPI) DeleteIdentityEntry(_ context.Context, name string) error { + a.entryGone = name + return nil +} + +func backend(api API) Backend { + return Backend{API: api, Config: Config{ + Namespace: "gitea-actions", Image: "zot/ci-executor:main", + ServiceAccount: "gitea-task-executor", ExecutorArgs: []string{"execute"}, + TrustDomain: "ddupan.top", SPIRECluster: "homelab", + SPIREClass: "spire-mgmt-spire", ExecutorUID: 2000, + }} +} + +func assignment() taskassignment.Assignment { + return taskassignment.Assignment{ + ID: "gitea-task-42", Backend: taskassignment.BackendPod, + Task: &runnerv1.Task{Id: 42}, + Identity: taskidentity.Identity{ + Repository: "owner/repo", Task: "publish", + SPIFFEID: "spiffe://ddupan.top/ci/owner/repo/publish", + }, + } +} + +func TestFindRecoversPodByAssignmentLabel(t *testing.T) { + api := &fakeAPI{pods: []Pod{{Name: "gitea-task-42", UID: "uid", Phase: "Running"}}} + executor, err := backend(api).Find(context.Background(), "gitea-task-42") + if err != nil { + t.Fatal(err) + } + if api.selector != "ci.ddupan.top/assignment-id=gitea-task-42" || executor.Name != "gitea-task-42" || executor.IdentityTarget != "uid" || executor.Phase != taskworker.PhaseRunning { + t.Fatalf("selector=%q executor=%#v", api.selector, executor) + } +} + +func TestCreateUsesDeterministicNameAndRecoveryMetadata(t *testing.T) { + api := &fakeAPI{} + metadata := taskworker.BackendMetadata(assignment()) + executor, err := backend(api).Create(context.Background(), assignment(), metadata) + if err != nil { + t.Fatal(err) + } + if api.created.Name != "gitea-task-42" || api.created.Labels[assignmentLabel] != "gitea-task-42" { + t.Fatalf("manifest = %#v", api.created) + } + if api.created.Annotations["ci.ddupan.top/spiffe-id"] != assignment().Identity.SPIFFEID { + t.Fatalf("annotations = %#v", api.created.Annotations) + } + if len(api.created.Args) != 2 || api.created.Args[1] != "gitea-task-42" || executor.IdentityTarget != "pod-uid" { + t.Fatalf("args=%v executor=%#v", api.created.Args, executor) + } +} + +func TestBindIdentityCreatesEntryPinnedToPodUID(t *testing.T) { + api := &fakeAPI{} + executor := &taskworker.Executor{Name: "gitea-task-42", IdentityTarget: "pod-uid"} + if err := backend(api).BindIdentity(context.Background(), executor, assignment().Identity); err != nil { + t.Fatal(err) + } + if api.entry.Name != executor.Name || api.entry.SPIFFEID != assignment().Identity.SPIFFEID || api.entry.ParentID != "spiffe://ddupan.top/spire/agent/k8s_psat/homelab/pod/pod-uid" || len(api.entry.Selectors) != 1 || api.entry.Selectors[0] != "unix:uid:2000" { + t.Fatalf("entry=%#v", api.entry) + } +} + +func TestDeleteRemovesIdentityBeforePod(t *testing.T) { + api := &fakeAPI{} + executor := &taskworker.Executor{Name: "gitea-task-42", IdentityTarget: "pod-uid"} + if err := backend(api).Delete(context.Background(), executor); err != nil { + t.Fatal(err) + } + if api.entryGone != executor.Name || api.deleted != executor.Name { + t.Fatalf("entry=%q pod=%q", api.entryGone, api.deleted) + } +} + +func TestFindRejectsDuplicatePods(t *testing.T) { + api := &fakeAPI{pods: []Pod{{Name: "one"}, {Name: "two"}}} + if _, err := backend(api).Find(context.Background(), "gitea-task-42"); err == nil { + t.Fatal("expected duplicate executor error") + } +} diff --git a/internal/podbackend/client.go b/internal/podbackend/client.go new file mode 100644 index 0000000..36f0fef --- /dev/null +++ b/internal/podbackend/client.go @@ -0,0 +1,163 @@ +package podbackend + +import ( + "context" + "fmt" + "reflect" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +var identityEntryResource = schema.GroupVersionResource{ + Group: "spire.spiffe.io", Version: "v1alpha1", Resource: "clusterstaticentries", +} + +// Client uses client-go's typed client for Pods and its dynamic client for the +// SPIRE Operator CRD. +type Client struct { + Kubernetes kubernetes.Interface + Dynamic dynamic.Interface +} + +func NewClient(config *rest.Config) (*Client, error) { + kubernetesClient, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("create Kubernetes client: %w", err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("create Kubernetes dynamic client: %w", err) + } + return &Client{Kubernetes: kubernetesClient, Dynamic: dynamicClient}, nil +} + +func NewInClusterClient() (*Client, error) { + config, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("load in-cluster Kubernetes config: %w", err) + } + return NewClient(config) +} + +func (c *Client) ListPods(ctx context.Context, namespace, selector string) ([]Pod, error) { + list, err := c.Kubernetes.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return nil, err + } + pods := make([]Pod, 0, len(list.Items)) + for _, item := range list.Items { + pods = append(pods, podFromKubernetes(item)) + } + return pods, nil +} + +func (c *Client) CreatePod(ctx context.Context, manifest PodManifest) (Pod, error) { + document := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: manifest.Name, Namespace: manifest.Namespace, + Labels: manifest.Labels, Annotations: manifest.Annotations, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: manifest.ServiceAccount, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{{ + Name: "executor", Image: manifest.Image, Args: manifest.Args, + SecurityContext: &corev1.SecurityContext{Privileged: boolPointer(true)}, + VolumeMounts: []corev1.VolumeMount{{ + Name: "spire-agent-socket", MountPath: "/run/spire/agent-sockets", ReadOnly: true, + }}, + }}, + Volumes: []corev1.Volume{{ + Name: "spire-agent-socket", + VolumeSource: corev1.VolumeSource{CSI: &corev1.CSIVolumeSource{ + Driver: "csi.spiffe.io", ReadOnly: boolPointer(true), + }}, + }}, + }, + } + created, err := c.Kubernetes.CoreV1().Pods(manifest.Namespace).Create(ctx, document, metav1.CreateOptions{}) + if err != nil { + return Pod{}, err + } + return podFromKubernetes(*created), nil +} + +func (c *Client) DeletePod(ctx context.Context, namespace, name string) error { + policy := metav1.DeletePropagationBackground + err := c.Kubernetes.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{PropagationPolicy: &policy}) + if apierrors.IsNotFound(err) { + return nil + } + return err +} + +func (c *Client) EnsureIdentityEntry(ctx context.Context, entry IdentityEntry) error { + resource := c.Dynamic.Resource(identityEntryResource) + existing, err := resource.Get(ctx, entry.Name, metav1.GetOptions{}) + if err == nil { + existingSpec, _, nestedErr := unstructured.NestedMap(existing.Object, "spec") + if nestedErr != nil { + return nestedErr + } + if !reflect.DeepEqual(existingSpec, identityEntryObject(entry).Object["spec"]) { + return fmt.Errorf("identity entry %s exists with different selectors or SPIFFE ID", entry.Name) + } + return nil + } + if !apierrors.IsNotFound(err) { + return err + } + _, err = resource.Create(ctx, identityEntryObject(entry), metav1.CreateOptions{}) + return err +} + +func (c *Client) DeleteIdentityEntry(ctx context.Context, name string) error { + policy := metav1.DeletePropagationBackground + err := c.Dynamic.Resource(identityEntryResource).Delete(ctx, name, metav1.DeleteOptions{PropagationPolicy: &policy}) + if apierrors.IsNotFound(err) { + return nil + } + return err +} + +func identityEntryObject(entry IdentityEntry) *unstructured.Unstructured { + selectors := make([]any, len(entry.Selectors)) + for index, selector := range entry.Selectors { + selectors[index] = selector + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "spire.spiffe.io/v1alpha1", + "kind": "ClusterStaticEntry", + "metadata": map[string]any{ + "name": entry.Name, "labels": stringMap(entry.Labels), + }, + "spec": map[string]any{ + "className": entry.ClassName, "parentID": entry.ParentID, + "spiffeID": entry.SPIFFEID, "selectors": selectors, + }, + }} +} + +func podFromKubernetes(pod corev1.Pod) Pod { + return Pod{ + Name: pod.Name, Namespace: pod.Namespace, UID: string(pod.UID), + Phase: string(pod.Status.Phase), Labels: pod.Labels, Annotations: pod.Annotations, + } +} + +func boolPointer(value bool) *bool { return &value } + +func stringMap(values map[string]string) map[string]any { + result := make(map[string]any, len(values)) + for key, value := range values { + result[key] = value + } + return result +} diff --git a/internal/podbackend/client_test.go b/internal/podbackend/client_test.go new file mode 100644 index 0000000..fc07259 --- /dev/null +++ b/internal/podbackend/client_test.go @@ -0,0 +1,72 @@ +package podbackend + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/fake" +) + +func testClient(objects ...runtime.Object) *Client { + return &Client{ + Kubernetes: fake.NewSimpleClientset(objects...), + Dynamic: dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()), + } +} + +func TestClientPodLifecycleUsesTypedClient(t *testing.T) { + client := testClient() + created, err := client.CreatePod(context.Background(), PodManifest{ + Name: "gitea-task-42", Namespace: "gitea-actions", + Labels: map[string]string{assignmentLabel: "gitea-task-42"}, + Image: "zot/ci-executor:main", ServiceAccount: "gitea-task-executor", + Args: []string{"execute", "gitea-task-42"}, + }) + if err != nil { + t.Fatal(err) + } + pod, err := client.Kubernetes.CoreV1().Pods("gitea-actions").Get(context.Background(), created.Name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + pod.UID = types.UID("pod-uid") + pod.Status.Phase = corev1.PodRunning + if _, err := client.Kubernetes.CoreV1().Pods("gitea-actions").Update(context.Background(), pod, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + pods, err := client.ListPods(context.Background(), "gitea-actions", assignmentLabel+"=gitea-task-42") + if err != nil || len(pods) != 1 || pods[0].UID != "pod-uid" || pods[0].Phase != "Running" { + t.Fatalf("pods=%#v err=%v", pods, err) + } + if err := client.DeletePod(context.Background(), "gitea-actions", created.Name); err != nil { + t.Fatal(err) + } +} + +func TestClientEnsuresIdempotentClusterStaticEntry(t *testing.T) { + client := testClient() + entry := IdentityEntry{ + Name: "gitea-task-42", Labels: map[string]string{assignmentLabel: "gitea-task-42"}, + ClassName: "spire-mgmt-spire", + ParentID: "spiffe://ddupan.top/spire/agent/k8s_psat/homelab/pod/pod-uid", + SPIFFEID: "spiffe://ddupan.top/ci/owner/repo/publish", + Selectors: []string{"unix:uid:2000"}, + } + if err := client.EnsureIdentityEntry(context.Background(), entry); err != nil { + t.Fatal(err) + } + if err := client.EnsureIdentityEntry(context.Background(), entry); err != nil { + t.Fatal(err) + } + if err := client.DeleteIdentityEntry(context.Background(), entry.Name); err != nil { + t.Fatal(err) + } + if err := client.DeleteIdentityEntry(context.Background(), entry.Name); err != nil { + t.Fatal(err) + } +}