File size: 17,363 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
package syncer

import (
	"context"
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"time"
)

// Mode 决定这一轮允许往哪个方向写。
//
// 注意这里**没有** "status/dry-run" 这一档:只报告不写入是「算完不落地」,
// 不是「不许算」——两者混在一个枚举里会让 status 把该有的差异也算成"无事可做",
// 从而漏报真正的不一致(实现初期正是这么错的)。只读由 Apply 的 dryRun 参数表达。
type Mode string

const (
	// ModeSync 双向对齐:缺的补上、冲突按判据取新的一方。
	ModeSync Mode = "sync"
	// ModePull 只往下拉(远端 → 本地)。本地独有的文件不动。
	ModePull Mode = "pull"
	// ModePush 只往上推(本地 → 远端)。远端独有的文件不动。
	ModePush Mode = "push"
)

// ParseCommand 把子命令解析为 (同步模式, 是否只报告不写入)。
//
// "status" / "diff" / "plan" 一律按**双向**算一遍,只是不落地——这样它报出来的
// 差异和真正 sync 会做的事完全一致,可以直接当"预演"用。
func ParseCommand(s string) (Mode, bool, error) {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "", "sync", "all":
		return ModeSync, false, nil
	case "pull", "down", "download":
		return ModePull, false, nil
	case "push", "up", "upload":
		return ModePush, false, nil
	case "status", "diff", "plan", "dry-run", "dryrun":
		return ModeSync, true, nil
	default:
		return "", false, fmt.Errorf("未知子命令 %q(可选 sync/pull/push/status/watch)", s)
	}
}

// allowsUpload 报告该模式是否允许写远端。
func (m Mode) allowsUpload() bool { return m == ModeSync || m == ModePush }

// allowsDownload 报告该模式是否允许写本地。
func (m Mode) allowsDownload() bool { return m == ModeSync || m == ModePull }

// Op 是一个同步动作。
type Op string

const (
	// OpNoop 两端已一致或按模式不动。
	OpNoop Op = "noop"
	// OpUpload 本地 → 远端。
	OpUpload Op = "upload"
	// OpDownload 远端 → 本地。
	OpDownload Op = "download"
)

// Action 是计划里的一条动作。
type Action struct {
	Op       Op     `json:"op"`
	Path     string `json:"path"`
	Category string `json:"category"`
	Reason   string `json:"reason"`
	// Conflict 表示两端都有且内容不同(真正的冲突,而不是单边缺失)。
	Conflict bool       `json:"conflict"`
	Local    *FileState `json:"local,omitempty"`
	Remote   *FileState `json:"remote,omitempty"`

	// Applied / Err 由 Apply 回填。
	Applied bool   `json:"applied"`
	Err     string `json:"err,omitempty"`
}

// Plan 是一次对账的结果。
type Plan struct {
	Mode    Mode
	Actions []Action

	// Bucket / Writer 随计划带上,方便输出与排障时一眼看清"对的是哪个桶、会用哪条写入通道"。
	Bucket string
	Writer string

	LocalFiles  int
	RemoteFiles int
}

// Uploads 返回所有上传动作。
func (p *Plan) Uploads() []Action { return p.filter(OpUpload) }

// Downloads 返回所有下载动作。
func (p *Plan) Downloads() []Action { return p.filter(OpDownload) }

// Conflicts 返回所有真冲突(两端都有、内容不同、需要裁决)。
func (p *Plan) Conflicts() []Action {
	var out []Action
	for _, a := range p.Actions {
		if a.Conflict {
			out = append(out, a)
		}
	}
	return out
}

// Changes 报告计划里是否有实际改动。
func (p *Plan) Changes() int { return len(p.Uploads()) + len(p.Downloads()) }

func (p *Plan) filter(op Op) []Action {
	var out []Action
	for _, a := range p.Actions {
		if a.Op == op {
			out = append(out, a)
		}
	}
	return out
}

// Result 是一次执行的统计。
type Result struct {
	Mode       Mode
	Uploaded   int
	Downloaded int
	Skipped    int
	Failed     int
	Actions    []Action
	Errors     []string
}

// Config 是同步配置。字段与 config.json 的 "sync" 段一一对应。
type Config struct {
	// Bucket 桶 ID,形如 "a3216/wb2api-storage"。
	Bucket string `json:"bucket"`
	// Token 具写权限的 HF token。为空时回落到 TokenFile。
	Token string `json:"token"`
	// TokenFile 存放 token 的文件(token 不进 config.json 时用)。
	TokenFile string `json:"token_file"`
	// Endpoint HF 端点,默认 https://huggingface.co。
	Endpoint string `json:"endpoint"`
	// AuthDir 本地凭证目录,与网关 auth_dir 同值。
	AuthDir string `json:"auth_dir"`
	// DataDir 本地数据目录(state_file 所在目录)。
	DataDir string `json:"data_dir"`
	// IncludeData 除 auths/ 之外还要同步的 data/ 顶层文件名白名单。
	// 默认空 = 只同步凭证。理由见 docs:state.json 是两个进程各自的运行态,
	// 互相搬运会把对端的冷却/熔断状态带进来。
	IncludeData []string `json:"include_data"`
	// RelayURL 中转网关地址(如 https://cli.3216gemini.dpdns.org),relay 写入通道用。
	RelayURL string `json:"relay_url"`
	// RelayKey 中转网关的 api_key(Bearer)。为空且配置了桶时可自动从桶的
	// config.json 读取。
	RelayKey string `json:"relay_key"`
	// Writer 写入通道:auto(默认)/ hf / relay。
	Writer string `json:"writer"`
	// HFBin hf 可执行文件名/路径,默认 "hf"。
	HFBin string `json:"hf_bin"`
	// RestartSpace 形如 "a3216/gcli2api"。配置后,每当本轮向 auths/ 推了新凭证
	// 就请求重启该 Space——Space 上的网关只在启动时扫凭证目录,不重启就加载不到新账号。
	// 空 = 从不重启(省下 30~60s 冷启动,代价是新账号要等下次 Space 重启才生效)。
	RestartSpace string `json:"restart_space"`
	// CABundle 额外的 CA 证书包(PEM)路径。
	//
	// 本机跑抓包代理(DevSidecar / Reqable / ProxyPin 之类)时会出现一种很别扭的
	// 不对称:Go 在 Windows 上吃系统信任库,所以**读**同步一切正常;但写通道用的
	// hf CLI 底层是 httpx,只认 certifi,会把代理的中间人证书判成不可信而失败。
	// 把代理根证书导出成 PEM 指到这里,就会同时喂给 Go 客户端(追加信任,不替换
	// 系统库)与 hf 子进程(SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE)。
	CABundle string `json:"ca_bundle"`
	// Timeout 单次网络操作超时,默认 60s。
	Timeout string `json:"timeout"`
	// InsecureSkipVerify 关闭 TLS 校验(仅本地抓包代理排障用,默认 false)。
	InsecureSkipVerify bool `json:"insecure_skip_verify"`
}

// Normalize 补默认值。
func (c *Config) Normalize() {
	if strings.TrimSpace(c.Endpoint) == "" {
		c.Endpoint = DefaultEndpoint
	}
	if strings.TrimSpace(c.Writer) == "" {
		c.Writer = "auto"
	}
	if strings.TrimSpace(c.HFBin) == "" {
		c.HFBin = "hf"
	}
	for i, d := range c.IncludeData {
		c.IncludeData[i] = strings.TrimSpace(d)
	}
	c.Bucket = strings.Trim(strings.TrimSpace(c.Bucket), "/")
	c.Endpoint = strings.TrimRight(strings.TrimSpace(c.Endpoint), "/")
	c.RelayURL = strings.TrimRight(strings.TrimSpace(c.RelayURL), "/")
}

// timeout 解析 Timeout 字段。
func (c *Config) timeout() time.Duration {
	if strings.TrimSpace(c.Timeout) == "" {
		return 60 * time.Second
	}
	d, err := time.ParseDuration(c.Timeout)
	if err != nil || d <= 0 {
		return 60 * time.Second
	}
	return d
}

// Engine 把配置、桶客户端与写入通道绑在一起。
type Engine struct {
	cfg    Config
	bucket *BucketClient
	writer Writer
}

// New 构造 Engine。桶只读客户端必建;写入通道按 writer 配置选,选不出来也不
// 立刻失败——只读(pull/status)依然可用,真正需要写时 Apply 会报错。
func New(cfg Config) (*Engine, error) {
	cfg.Normalize()
	if cfg.Bucket == "" {
		return nil, fmt.Errorf("sync.bucket 未配置")
	}
	token, err := cfg.resolveToken()
	if err != nil {
		return nil, err
	}
	cfg.Token = token

	e := &Engine{
		cfg:    cfg,
		bucket: NewBucketClient(cfg.Endpoint, cfg.Bucket, token, cfg.timeout(), cfg.InsecureSkipVerify, cfg.CABundle),
	}
	if w, werr := NewWriter(cfg); werr == nil {
		e.writer = w
	}
	return e, nil
}

// Bucket 暴露只读客户端(cmd/sync 读远端 api_key 时用)。
func (e *Engine) Bucket() *BucketClient { return e.bucket }

// WriterName 返回当前写入通道名(未选出时返回空串)。
func (e *Engine) WriterName() string {
	if e.writer == nil {
		return ""
	}
	return e.writer.Name()
}

// Config 返回归一化后的配置副本。
func (e *Engine) Config() Config { return e.cfg }

// resolveToken 取 token:字段优先,其次 token_file,其次 HF_TOKEN 环境变量。
func (c *Config) resolveToken() (string, error) {
	if t := strings.TrimSpace(c.Token); t != "" {
		return t, nil
	}
	if fp := strings.TrimSpace(c.TokenFile); fp != "" {
		raw, err := readTrimmedFile(fp)
		if err != nil {
			return "", err
		}
		return raw, nil
	}
	return "", nil // 允许匿名读公开桶;写的时候才会因 401 报错
}

// FetchRemoteAPIKey 从桶里的 config.json 读取中转网关的 api_key。
//
// 存在的意义:relay 写入通道要带 Bearer,而那个 key 就躺在(私有的)桶里。
// 桶已经要凭 token 才能读了,再去手工抄一遍 key 没有意义。
func (e *Engine) FetchRemoteAPIKey(ctx context.Context) (string, error) {
	raw, err := e.bucket.Get(ctx, "config.json")
	if err != nil {
		return "", err
	}
	var cfg struct {
		APIKey string `json:"api_key"`
	}
	if err := json.Unmarshal(raw, &cfg); err != nil {
		return "", fmt.Errorf("解析远端 config.json 失败: %w", err)
	}
	if strings.TrimSpace(cfg.APIKey) == "" {
		return "", fmt.Errorf("远端 config.json 里 api_key 为空")
	}
	return cfg.APIKey, nil
}

// ---------------------------------------------------------------------------
// 对账
// ---------------------------------------------------------------------------

// Plan 计算这一轮要做什么,不产生任何副作用。
func (e *Engine) Plan(ctx context.Context, mode Mode) (*Plan, error) {
	local, err := e.scanLocal()
	if err != nil {
		return nil, fmt.Errorf("扫描本地失败: %w", err)
	}
	remote, err := e.collectRemote(ctx)
	if err != nil {
		return nil, err
	}
	p := buildPlan(mode, local, remote)
	p.Bucket = e.cfg.Bucket
	p.Writer = e.WriterName()
	return p, nil
}

// buildPlan 是纯函数形式的对账逻辑,便于单测覆盖各种组合。
func buildPlan(mode Mode, local, remote map[string]*FileState) *Plan {
	p := &Plan{Mode: mode, LocalFiles: len(local), RemoteFiles: len(remote)}

	paths := make([]string, 0, len(local)+len(remote))
	seen := map[string]bool{}
	for k := range local {
		paths = append(paths, k)
		seen[k] = true
	}
	for k := range remote {
		if !seen[k] {
			paths = append(paths, k)
		}
	}
	sort.Strings(paths)

	for _, path := range paths {
		l, r := local[path], remote[path]
		p.Actions = append(p.Actions, decideAction(mode, path, l, r))
	}
	return p
}

// decideAction 判定单个路径的动作。
func decideAction(mode Mode, path string, l, r *FileState) Action {
	category := catAuth
	if strings.HasPrefix(path, "data/") {
		category = catData
	}
	a := Action{Path: path, Category: category, Local: l, Remote: r}

	switch {
	case l == nil && r == nil:
		a.Op = OpNoop
		a.Reason = "两端均不存在(不应发生)"

	case r == nil:
		if mode.allowsUpload() {
			a.Op, a.Reason = OpUpload, "远端缺失,本端独有"
		} else {
			a.Op, a.Reason = OpNoop, "本端独有;当前模式不允许写远端"
		}

	case l == nil:
		if mode.allowsDownload() {
			a.Op, a.Reason = OpDownload, "本地缺失,远端独有"
		} else {
			a.Op, a.Reason = OpNoop, "远端独有;当前模式不允许写本地"
		}

	default:
		if l.SHA256 == r.SHA256 {
			a.Op, a.Reason = OpNoop, "两端内容一致"
			return a
		}
		a.Conflict = true

		var winner, reason string
		if category == catAuth {
			winner, reason = decideAuthWinner(l, r)
		} else {
			winner, reason = decideDataWinner(l, r)
		}
		switch {
		case winner == sideLocal && mode.allowsUpload():
			a.Op, a.Reason = OpUpload, reason
		case winner == sideRemote && mode.allowsDownload():
			a.Op, a.Reason = OpDownload, reason
		case winner == sideLocal:
			a.Op, a.Reason = OpNoop, reason+";但当前模式不允许写远端"
		default:
			a.Op, a.Reason = OpNoop, reason+";但当前模式不允许写本地"
		}
	}
	return a
}

// collectRemote 列出桶内候选文件并下载内容。
//
// 为什么要下载才算得出结论:桶 tree 里的 xetHash 是 Xet 的内容哈希,
// **不是**文件的 SHA-256(已实测:同一文件两者不同)。所以"是否一致"只能靠
// 拉下来自己算。候选集很小(凭证各 ~2.3KB、可选数据 ~2.7KB),这个代价可以忽略;
// 反过来,用 xetHash 当 SHA-256 会误判成"永远不一致",每轮都白传一遍。
func (e *Engine) collectRemote(ctx context.Context) (map[string]*FileState, error) {
	files, err := e.bucket.List(ctx, "")
	if err != nil {
		return nil, fmt.Errorf("列出桶目录失败: %w", err)
	}
	out := map[string]*FileState{}
	for _, rf := range files {
		if !e.isCandidate(rf.Path) {
			continue
		}
		raw, err := e.bucket.Get(ctx, rf.Path)
		if err != nil {
			return nil, fmt.Errorf("下载 %s 失败: %w", rf.Path, err)
		}
		category := catAuth
		if strings.HasPrefix(rf.Path, "data/") {
			category = catData
		}
		out[rf.Path] = newFileState(rf.Path, raw, rf.Mtime, category)
	}
	return out, nil
}

// isCandidate 判定桶内一个路径是否属于同步范围。
//
// 明确排除:
//   - config.json —— 两端的 listen / auth_dir / state_file / api_key 本来就不同,
//     互相覆盖会把对端跑不起来。配置是"每端自己的",不是要同步的数据。
//   - data/request-logs/** —— 本机请求归档,搬来搬去只撑大桶。
func (e *Engine) isCandidate(relPath string) bool {
	p := strings.ReplaceAll(relPath, `\`, "/")
	if p == "config.json" || strings.HasPrefix(p, "data/request-logs/") {
		return false
	}
	return PathAllowed(p, e.cfg.IncludeData)
}

// ---------------------------------------------------------------------------
// 执行
// ---------------------------------------------------------------------------

// Apply 执行计划。dryRun 为真时只回填"将会做什么",不落盘也不上传。
func (e *Engine) Apply(ctx context.Context, p *Plan, dryRun bool) (*Result, error) {
	res := &Result{Mode: p.Mode}

	// 批量上传:relay 通道一次 HTTP 调用推完,省掉冷启动后的多次往返。
	var batch []PushFile
	batchable := false
	if !dryRun {
		if _, ok := e.writer.(interface {
			PutBatch(context.Context, []PushFile, bool) error
		}); ok {
			batchable = true
		}
	}

	for i := range p.Actions {
		a := &p.Actions[i]
		switch a.Op {
		case OpNoop:
			res.Skipped++
			continue

		case OpDownload:
			if dryRun {
				a.Applied = true
				res.Downloaded++
				continue
			}
			if err := e.applyDownload(ctx, a, res); err != nil {
				a.Err = err.Error()
				res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", a.Path, err))
				res.Failed++
				continue
			}
			a.Applied = true
			res.Downloaded++

		case OpUpload:
			if e.writer == nil {
				err := fmt.Errorf("没有可用的写入通道(配置 sync.writer / relay_url,或安装 hf CLI)")
				a.Err = err.Error()
				res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", a.Path, err))
				res.Failed++
				continue
			}
			if dryRun {
				a.Applied = true
				res.Uploaded++
				continue
			}
			if batchable {
				batch = append(batch, PushFile{Path: a.Path, Content: a.Local.Content, Mtime: a.Local.Mtime})
				a.Applied = true
				res.Uploaded++
				continue
			}
			if err := e.writer.Put(ctx, a.Path, a.Local.Content); err != nil {
				a.Err = err.Error()
				res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", a.Path, err))
				res.Failed++
				continue
			}
			a.Applied = true
			res.Uploaded++
		}
	}

	if len(batch) > 0 {
		type batchWriter interface {
			PutBatch(context.Context, []PushFile, bool) error
		}
		if bw, ok := e.writer.(batchWriter); ok {
			if err := bw.PutBatch(ctx, batch, true); err != nil {
				// 批量失败:把已计入的动作全部回落为失败,避免"报告成功其实没上去"。
				res.Errors = append(res.Errors, err.Error())
				failed := map[string]bool{}
				for _, bf := range batch {
					failed[bf.Path] = true
				}
				for i := range p.Actions {
					if p.Actions[i].Op == OpUpload && failed[p.Actions[i].Path] {
						p.Actions[i].Applied = false
						p.Actions[i].Err = err.Error()
						res.Uploaded--
						res.Failed++
					}
				}
			}
		}
	}

	res.Actions = p.Actions
	return res, nil
}

// applyDownload 把远端内容落到本地。
func (e *Engine) applyDownload(ctx context.Context, a *Action, res *Result) error {
	content := a.Remote.Content
	if content == nil {
		raw, err := e.bucket.Get(ctx, a.Path)
		if err != nil {
			return err
		}
		content = raw
	}
	fp, err := e.localPath(a.Path)
	if err != nil {
		return err
	}
	if err := writeLocalAtomic(fp, content, a.Remote.Mtime); err != nil {
		return err
	}
	_ = res
	return nil
}

// Sync 是 Plan + Apply 的便捷组合。
func (e *Engine) Sync(ctx context.Context, mode Mode, dryRun bool) (*Plan, *Result, error) {
	p, err := e.Plan(ctx, mode)
	if err != nil {
		return nil, nil, err
	}
	res, err := e.Apply(ctx, p, dryRun)
	if err != nil {
		return p, res, err
	}
	return p, res, nil
}