Skip to content

Commit 85e0f4b

Browse files
committed
feat(ops): 节点主机定时任务全链路——协议常量/agent 处理/API 代理/前端面板 + node 守卫测试
1 parent 45e2f9e commit 85e0f4b

11 files changed

Lines changed: 325 additions & 2 deletions

File tree

cmd/server/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ func runServer() error {
229229
// 将 session resolver 注入到 Dispatcher
230230
if svcCtx.Dispatcher != nil {
231231
svcCtx.Dispatcher.SetSessionResolver(server.NewSessionResolverAdapter(sessionStore))
232+
}
233+
// ops 代理(主机 cron/服务列表)需要经会话表解析在线 Agent。
234+
svcCtx.AgentSessions = sessionStore
235+
if svcCtx.Dispatcher != nil {
232236
// 将 task event query 注入到 Dispatcher(用于 StreamTask 查询)
233237
taskRunModel := model.NewTaskRunModel(svcCtx.DB)
234238
taskEventModel := model.NewTaskEventModel(svcCtx.DB)

internal/agent/local_handler.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ func (h *LocalHandler) handleRequest(ctx context.Context, msgID uint32, data []b
218218
return h.handleListServices(ctx, data)
219219
case protocol.MsgGetServiceStatusRequest:
220220
return h.handleGetServiceStatus(ctx, data)
221+
case protocol.MsgListCronJobsRequest:
222+
return h.handleListCronJobs(ctx, data)
221223
case protocol.MsgRegisterCapabilitiesReq:
222224
return h.handleRegisterCapabilities(ctx, data)
223225

@@ -691,6 +693,18 @@ func (h *LocalHandler) handleListServices(ctx context.Context, data []byte) ([]b
691693
return ops.ListServicesJSON(ctx, data)
692694
}
693695

696+
// handleListCronJobs handles ListCronJobsRequest(Agent 所在主机的定时任务)。
697+
func (h *LocalHandler) handleListCronJobs(ctx context.Context, _ []byte) ([]byte, error) {
698+
h.mu.RLock()
699+
ops := h.opsServer
700+
h.mu.RUnlock()
701+
702+
if ops == nil {
703+
return nil, fmt.Errorf("ops server not configured")
704+
}
705+
return ops.ListCronJobsJSON(ctx)
706+
}
707+
694708
// handleGetServiceStatus handles GetServiceStatusRequest
695709
func (h *LocalHandler) handleGetServiceStatus(ctx context.Context, data []byte) ([]byte, error) {
696710
h.mu.RLock()

internal/api/node/handler.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ func (h *Handler) ListCommands(c *gin.Context) {
120120
response.Success(c, resp)
121121
}
122122

123+
// ListCronJobs 读取节点所在主机的定时任务(crontab + /etc/cron.d)。
124+
func (h *Handler) ListCronJobs(c *gin.Context) {
125+
id := c.Param("id")
126+
jobs, err := h.service.ListCronJobs(c.Request.Context(), id)
127+
if err != nil {
128+
response.Error(c, err)
129+
return
130+
}
131+
response.Success(c, gin.H{"items": jobs, "total": len(jobs)})
132+
}
133+
123134
// Commands alias for route compatibility
124135
func (h *Handler) Commands(c *gin.Context) {
125136
h.ListCommands(c)

internal/api/node/node_extra_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/cuihairu/croupier/internal/model"
1111
"github.com/cuihairu/croupier/internal/svc"
12+
"github.com/cuihairu/croupier/internal/transport"
1213
"github.com/gin-gonic/gin"
1314
"github.com/stretchr/testify/assert"
1415
"github.com/stretchr/testify/require"
@@ -188,3 +189,22 @@ func TestHandler_Restart_ServiceError(t *testing.T) {
188189
handler.Restart(ctx)
189190
assert.NotEqual(t, http.StatusOK, rec.Code, rec.Body.String())
190191
}
192+
193+
// ListCronJobs:会话表未初始化 / 节点不在线 → 错误分支。
194+
func TestService_ListCronJobs_Guards(t *testing.T) {
195+
db := newNodeTestDB(t)
196+
svc := NewService(&svc.ServiceContext{DB: db}) // AgentSessions 为 nil
197+
198+
_, err := svc.ListCronJobs(context.Background(), "agent-x")
199+
assert.ErrorContains(t, err, "会话表未初始化")
200+
201+
svc.svcCtx.AgentSessions = fakeSessionResolver{}
202+
_, err = svc.ListCronJobs(context.Background(), "agent-offline")
203+
assert.ErrorContains(t, err, "节点不在线")
204+
}
205+
206+
type fakeSessionResolver struct{}
207+
208+
func (fakeSessionResolver) ResolveSessionCaller(string) (transport.SessionCaller, bool) {
209+
return nil, false
210+
}

internal/api/node/service.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"fmt"
77
"strings"
88

9+
"encoding/json"
910
"gorm.io/datatypes"
1011

1112
"github.com/cuihairu/croupier/internal/logic/utils"
1213
"github.com/cuihairu/croupier/internal/model"
1314
"github.com/cuihairu/croupier/internal/svc"
15+
"github.com/cuihairu/croupier/pkg/protocol"
1416
)
1517

1618
type Service struct {
@@ -145,6 +147,37 @@ func (s *Service) Restart(ctx context.Context, req *NodeActionRequest) error {
145147
return s.svcCtx.NodeModel.UpdateStatus(ctx, nodeID, "restarting")
146148
}
147149

150+
// NodeCronJob 主机定时任务条目(agent 侧解析 crontab + /etc/cron.d)。
151+
type NodeCronJob struct {
152+
Schedule string `json:"schedule"`
153+
Command string `json:"command"`
154+
User string `json:"user"`
155+
SourceFile string `json:"sourceFile"`
156+
Enabled bool `json:"enabled"`
157+
}
158+
159+
// ListCronJobs 经会话表代理到在线 Agent,读取其所在主机的定时任务。
160+
func (s *Service) ListCronJobs(ctx context.Context, nodeID string) ([]NodeCronJob, error) {
161+
if s.svcCtx.AgentSessions == nil {
162+
return nil, errors.New("会话表未初始化")
163+
}
164+
caller, ok := s.svcCtx.AgentSessions.ResolveSessionCaller(nodeID)
165+
if !ok {
166+
return nil, errors.New("节点不在线")
167+
}
168+
_, respBody, err := caller.Call(ctx, protocol.MsgListCronJobsRequest, []byte("{}"))
169+
if err != nil {
170+
return nil, fmt.Errorf("调用 agent 失败: %w", err)
171+
}
172+
var resp struct {
173+
Jobs []NodeCronJob `json:"jobs"`
174+
}
175+
if err := json.Unmarshal(respBody, &resp); err != nil {
176+
return nil, fmt.Errorf("解析 agent 响应失败: %w", err)
177+
}
178+
return resp.Jobs, nil
179+
}
180+
148181
// ListCommands returns the list of available node commands
149182
func (s *Service) ListCommands(ctx context.Context, req *NodeCommandsRequest) (*NodeCommandsResponse, error) {
150183
commands, err := s.svcCtx.NodeModel.ListCommands(ctx)

internal/handler/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ func registerNodeRoutes(g *gin.RouterGroup, ctx *svc.ServiceContext) {
471471
g.POST("/:id/undrain", nodeHandler.Undrain)
472472
g.POST("/:id/restart", nodeHandler.Restart)
473473
g.GET("/commands", nodeHandler.Commands)
474+
g.GET("/:id/cron-jobs", nodeHandler.ListCronJobs)
474475
}
475476

476477
// ============================================================================

internal/svc/service_context.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,17 @@ import (
4040
"github.com/cuihairu/croupier/internal/service/permission"
4141
scheduler "github.com/cuihairu/croupier/internal/tasks/scheduler"
4242
"github.com/cuihairu/croupier/internal/telemetry"
43+
"github.com/cuihairu/croupier/internal/transport"
4344
"github.com/gin-gonic/gin"
4445
"gorm.io/gorm"
4546
"log/slog"
4647
)
4748

49+
// AgentSessionResolver 解析在线 Agent 的会话调用器(ops 代理调用入口)。
50+
type AgentSessionResolver interface {
51+
ResolveSessionCaller(agentID string) (transport.SessionCaller, bool)
52+
}
53+
4854
type ServiceContext struct {
4955
Config config.Config
5056
Authority gin.HandlerFunc
@@ -57,8 +63,10 @@ type ServiceContext struct {
5763
PermissionService *permission.PermissionService
5864
RegistryStore *reg.Store
5965
Dispatcher *dispatch.Dispatcher
60-
Cache cache.CacheStore
61-
CacheHelper *cache.CacheHelper
66+
// AgentSessions 解析在线 Agent 会话(ops 代理调用:cron/服务列表)。
67+
AgentSessions AgentSessionResolver
68+
Cache cache.CacheStore
69+
CacheHelper *cache.CacheHelper
6270

6371
AnalyticsFiltersLock *sync.RWMutex
6472

pkg/protocol/message.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ const (
8080
MsgListServicesResponse = 0x040112
8181
MsgGetServiceStatusRequest = 0x040113
8282
MsgGetServiceStatusResponse = 0x040114
83+
// Host cron jobs (Agent 自身应答,读 crontab + /etc/cron.d + systemd timers)
84+
MsgListCronJobsRequest = 0x040115
85+
MsgListCronJobsResponse = 0x040116
8386

8487
// ProviderSessionService (0x05xx) - SDK <-> Agent provider session control
8588
MsgProviderConnectRequest = 0x050101
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// F:文件下发原语(hotpatch P1 传输层)——wire 编解码 + 校验 + 暂存落盘。
2+
//
3+
// 安全链全部强制:总开关 → 大小上限 → 仅 basename(拒穿越)→
4+
// sha256 → 原子落盘暂存目录。**不自动应用**——应用由后续
5+
// hotpatch runner 单独编排。
6+
//
7+
// wire(protobuf 兼容,与 Go/Java/JS/Python 手写编解码同构):
8+
// FilePushRequest { 1: transferId, 2: fileName, 3: contentSha256(hex), 4: data }
9+
// FilePushResponse { 1: transferId, 2: ok, 3: storedPath, 4: error }
10+
11+
#pragma once
12+
13+
#include "croupier/sdk/croupier_client.h"
14+
15+
#include <nlohmann/json.hpp>
16+
#include <openssl/sha.h>
17+
18+
#include <cstdio>
19+
#include <string>
20+
#include <vector>
21+
22+
namespace croupier::sdk {
23+
24+
struct FilePushRequest {
25+
std::string transfer_id;
26+
std::string file_name;
27+
std::string content_sha256;
28+
std::vector<uint8_t> data;
29+
};
30+
31+
struct FilePushResponse {
32+
std::string transfer_id;
33+
bool ok = false;
34+
std::string stored_path;
35+
std::string error;
36+
};
37+
38+
inline void AppendVarint(std::vector<uint8_t>& out, uint64_t value) {
39+
while (value >= 0x80) {
40+
out.push_back(static_cast<uint8_t>(value) | 0x80);
41+
value >>= 7;
42+
}
43+
out.push_back(static_cast<uint8_t>(value));
44+
}
45+
46+
inline std::string Sha256Hex(const std::vector<uint8_t>& data) {
47+
unsigned char digest[SHA256_DIGEST_LENGTH];
48+
SHA256(data.data(), data.size(), digest);
49+
static const char* hex = "0123456789abcdef";
50+
std::string out;
51+
out.reserve(SHA256_DIGEST_LENGTH * 2);
52+
for (unsigned char byte : digest) {
53+
out.push_back(hex[byte >> 4]);
54+
out.push_back(hex[byte & 0x0F]);
55+
}
56+
return out;
57+
}
58+
59+
// 手写 protobuf wire 解码(length-delimited 四字段,未知字段跳过)。
60+
inline FilePushRequest DecodeFilePushRequest(const std::vector<uint8_t>& body) {
61+
FilePushRequest req;
62+
size_t idx = 0;
63+
auto readVarint = [&](uint64_t& value) -> bool {
64+
value = 0;
65+
int shift = 0;
66+
while (idx < body.size()) {
67+
uint8_t byte = body[idx++];
68+
value |= static_cast<uint64_t>(byte & 0x7F) << shift;
69+
if (!(byte & 0x80)) return true;
70+
shift += 7;
71+
if (shift > 63) return false;
72+
}
73+
return false;
74+
};
75+
auto readBytes = [&](std::vector<uint8_t>& value) -> bool {
76+
uint64_t length = 0;
77+
if (!readVarint(length)) return false;
78+
if (idx + length > body.size()) return false;
79+
value.assign(body.begin() + static_cast<long>(idx),
80+
body.begin() + static_cast<long>(idx + length));
81+
idx += length;
82+
return true;
83+
};
84+
while (idx < body.size()) {
85+
uint64_t tag = 0;
86+
if (!readVarint(tag)) return req;
87+
const uint64_t field = tag >> 3;
88+
std::vector<uint8_t> value;
89+
if (!readBytes(value)) return req;
90+
switch (field) {
91+
case 1:
92+
req.transfer_id.assign(value.begin(), value.end());
93+
break;
94+
case 2:
95+
req.file_name.assign(value.begin(), value.end());
96+
break;
97+
case 3:
98+
req.content_sha256.assign(value.begin(), value.end());
99+
break;
100+
case 4:
101+
req.data = value;
102+
break;
103+
default:
104+
break; // 未知字段跳过
105+
}
106+
}
107+
return req;
108+
}
109+
110+
inline std::vector<uint8_t> EncodeFilePushResponse(const FilePushResponse& resp) {
111+
auto fieldString = [](uint64_t field, const std::string& value) {
112+
std::vector<uint8_t> out;
113+
if (value.empty()) return out;
114+
AppendVarint(out, (field << 3) | 2);
115+
AppendVarint(out, value.size());
116+
out.insert(out.end(), value.begin(), value.end());
117+
return out;
118+
};
119+
std::vector<uint8_t> out;
120+
auto transfer = fieldString(1, resp.transfer_id);
121+
out.insert(out.end(), transfer.begin(), transfer.end());
122+
if (resp.ok) {
123+
out.push_back(0x10); // field 2 varint
124+
out.push_back(0x01);
125+
}
126+
auto stored = fieldString(3, resp.stored_path);
127+
out.insert(out.end(), stored.begin(), stored.end());
128+
auto error = fieldString(4, resp.error);
129+
out.insert(out.end(), error.begin(), error.end());
130+
return out;
131+
}
132+
133+
// 校验文件名仅含 basename 且落点仍在暂存目录内。
134+
inline bool SafeStagingPath(const std::string& stagingDir, const std::string& fileName,
135+
std::string& outPath) {
136+
if (fileName.empty() || fileName == "." || fileName == "..") return false;
137+
if (fileName.find('/') != std::string::npos ||
138+
fileName.find('\\') != std::string::npos ||
139+
fileName.find("..") != std::string::npos) {
140+
return false;
141+
}
142+
outPath = stagingDir + "/" + fileName;
143+
return true;
144+
}
145+
146+
// 原子写:同目录临时文件 + rename。
147+
inline bool AtomicWriteFile(const std::string& target, const std::vector<uint8_t>& data) {
148+
const std::string tmp = target + ".push-tmp";
149+
std::FILE* file = std::fopen(tmp.c_str(), "wb");
150+
if (!file) return false;
151+
if (!data.empty()) {
152+
if (std::fwrite(data.data(), 1, data.size(), file) != data.size()) {
153+
std::fclose(file);
154+
std::remove(tmp.c_str());
155+
return false;
156+
}
157+
}
158+
if (std::fclose(file) != 0) {
159+
std::remove(tmp.c_str());
160+
return false;
161+
}
162+
return std::rename(tmp.c_str(), target.c_str()) == 0;
163+
}
164+
165+
} // namespace croupier::sdk

0 commit comments

Comments
 (0)