controlplane

package
v0.35.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

audit.go —— 介入留痕契约与进程内参考实现。

留痕是介入的硬前提,不是事后增强:没有留痕的介入无法回答「谁在什么时候 依据什么改了运行状态」,出问题时与「存储被绕过直接改」无法区分。 故契约层面 Auditor 缺位 ⇒ 介入一律拒绝(ErrNoAuditor),实现层面 MemAuditor 对不完整的留痕拒收——两层各挡一个方向。

Package controlplane 控制平面的**最小契约 + 进程内参考实现 + HTTP 适配面**。

生命周期: Experimental(v0.35.0 定级)——本包仍会改、删、拆,不承诺任何 兼容,升级风险自负;定级理由见 api-lifecycle.yaml,分级规则见 docs/api-compat.md。

它补的是哪一段(issue #115)

本仓此前最接近控制面的是 taskboard.Check——判一份快照的不变量,宿主随时 可跑。但它不回答「现在跑到哪了」(运行状态查询),更不提供任何**人工介入** 动作:强制释放、裁决未知结果、终止运行。缺这一面的代价是:线上出问题时 宿主只能自己拼命令去查、自己去改存储——这些动作**绕过了运行时的判据**, 改完可能与运行中的持有者打架。

契约给什么

  • **查询**:任务 / 运行的状态视图 + 不变量视图(taskboard.Check 的 投影,TaskView / RunView);
  • **介入**:强制释放(ForceRelease)、裁决未知结果(Adjudicate)、 终止运行(Terminate);
  • **权限边界**:介入动作必须走**与运行时同一套判据**,不绕闸—— 强制释放是「经闸换持有者」(旧令牌当场作废,迟到提交凭 taskboard.ErrLeaseLost 被拒;下一次认领铸造的代次严格大于被释放的 那一代,下游栅栏据此拒收被释放者的迟到写入);裁决走 sideeffect 的 对账语义(凭下游事实不凭持有权,先到者为准);终止凭**运行代次** (ExpectedGen 不匹配即 ErrStaleRun——「改完与运行中的持有者打架」 正是这条要拦的形态)。租户上下文经 tenant.Require fail-closed: 介入是写路径,必须可归责到租户与主体(issue #114 判据面的落点);
  • **留痕**:每次介入必须产生一条 AuditEntry(谁、什么时候、依据什么 结论),留痕面缺位 ⇒ 介入一律拒绝(ErrNoAuditor)——没有留痕的 介入无法回答「谁在什么时候依据什么改了运行状态」。

边界

控制平面是**接口面**,不是 UI;具体界面归宿主。本包给三样:

  1. Plane 契约(宿主包着自己的 taskboard.Board / sideeffect Runner / 运行状态存储实现它);
  2. 进程内参考实现(MemPlane + MemAuditor——判据怎么落的演示体, 也是测试与演练的最小运行时面);
  3. net/http JSON 适配面(httpapi.go——鉴权钩子由宿主注入,内核不鉴权)。

httpapi.go —— 控制面的 net/http JSON 适配面。

鉴权边界:内核不鉴权

scope 词表、租户体系、会话形态全是宿主资产。Handler 持有 Authorizer 钩子 (宿主实现):它从请求解析主体与租户、判定该动作是否可执行,返回的 tenant.Context 被注入 request context 传给 Plane——Plane 侧的 tenant.Require(介入写路径的 fail-closed 判据)据此有据可判。 这是「HTTP 面 ↔ 机制面」的唯一接缝:宿主在钩子里做完自己的判定, 机制面拿到的已经是判定过的上下文。

钩子缺位 ⇒ 全部请求 401(fail-closed):控制面不开无鉴权的口子。

路由(Go 1.22+ 模式路由)

GET  /tasks/{id}                 → TaskView
GET  /runs/{id}                  → RunView
POST /tasks/{id}/force-release   body: ForceRelease
POST /effects/{key}/adjudicate   body: Adjudication
POST /runs/{id}/terminate        body: Termination

路径参数是介入目标的**唯一来源**:请求体里同名字段被忽略——同一个目标 不允许两条来源(一个真相)。

memplane.go —— 控制面的进程内参考实现。

MemPlane 同时是两面

  • **控制面**:实现 Plane 契约(查询 + 三类介入),演示介入判据怎么落在 「与运行时同一份状态」上——留痕与状态变更在同一个临界区里完成, 留痕失败即整体失败(没有「先改后补痕迹」的窗口);
  • **最小运行时面**:Claim / Complete / Release / Seed* 驱动场景—— 控制面的介入判据必须对着运行时的租约与代次演示(强释之后旧令牌 提交被拒、下一次认领代次严格递增),没有运行时面就没有可演示的 「同一套判据」。

参考实现用单调计数器铸令牌(可读、可断言),**不承诺随机性**:宿主 实现存储时按 taskboard 的契约自行铸造(不可预测性是宿主的安全需求, 参考实现只演示判据)。进程内存储重启即失效——与 ticket 同一条理由: 控制面参考实现的价值在判据演示,不在持久化。

runtime.go —— MemPlane 的**最小运行时面**(建场景与认领/提交/归还)。

控制面的介入判据必须对着运行时的租约与代次演示(强释之后旧令牌提交被拒、 下一次认领代次严格递增),没有运行时面就没有可演示的「同一套判据」。 这些方法不是 Plane 契约的一部分——它们是「运行时那一侧」的参考实现: 宿主的真实运行时是自己的 taskboard.Board / sideeffect Runner / 状态存储。

Seed* 先整单校验再落账:部分落账的 run 是半成品状态,视图会把 「建了一半」当成「建成了」。

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound 查询目标不存在(任务 / 运行)。
	ErrNotFound = errors.New("controlplane: 目标不存在")
	// ErrInvalidRequest 介入请求不完整(Operator / Basis / 结论词表不合格)。
	// 无归责不介入。
	ErrInvalidRequest = errors.New("controlplane: 介入请求不完整——无归责不介入")
	// ErrNoAuditor 留痕面未装配。留痕是介入的硬前提:没有留痕的介入
	// 无法回答「谁在什么时候依据什么改了运行状态」,一律拒绝(fail-closed)。
	ErrNoAuditor = errors.New("controlplane: 留痕面未装配,拒绝介入")
	// ErrStaleRun 运行代次已前进:介入基于过期视图(Terminate 的乐观并发闸)。
	ErrStaleRun = errors.New("controlplane: 运行代次已前进,介入基于过期视图")
	// ErrTerminalRun 运行已是终态:无需也无法终止(介入重试撞上它说明
	// 第一次介入已生效)。
	ErrTerminalRun = errors.New("controlplane: 运行已是终态,无需终止")
)

错误哨兵(宿主判定走 errors.Is,不要各造一套)。

View Source
var ErrAlreadyExists = errors.New("controlplane: 目标已存在")

ErrAlreadyExists 目标已存在(Seed* 的重复登记;介入类动作不会报它—— 介入对已存在目标的语义是执行,不是登记)。

View Source
var ErrInvalidAudit = errors.New("controlplane: 留痕不完整——谁/动作/目标/依据缺一不可")

ErrInvalidAudit 留痕不完整(谁 / 动作 / 目标 / 依据缺一不可)。 MemAuditor 对不完整的留痕拒收;宿主实现应复用同一判据。

Functions

This section is empty.

Types

type Action

type Action string

Action 介入/查询动作种类(留痕的 Action 字段;httpapi 鉴权钩子的判定对象)。

const (
	// ActionQuery 查询(运行状态视图 / 不变量视图)。
	ActionQuery Action = "query"
	// ActionForceRelease 强制释放。
	ActionForceRelease Action = "force_release"
	// ActionAdjudicate 裁决未知结果。
	ActionAdjudicate Action = "adjudicate"
	// ActionTerminate 终止运行。
	ActionTerminate Action = "terminate"
)

type Adjudication

type Adjudication struct {
	// EffectKey 副作用幂等键(sideeffect.Action.Key;宿主映射)。
	EffectKey string
	// Verdict 裁决结论(词表见 Verdict)。
	Verdict Verdict
	// ForceRetryable 与 sideeffect.Resolution.ForceRetryable 同口径:
	// 不可重试(Unsafe)的动作凭「未生效」的裁决不自动开门——重新打开
	// 施加的门必须是显式决策,不能是「对账流程跑完了」的自然结果。
	ForceRetryable bool
	// Operator 谁在介入(留痕必填)。
	Operator string
	// Basis 依据什么结论(留痕必填:通常是「查了下游的什么、看到什么」)。
	Basis string
}

Adjudication 裁决未知结果(sideeffect.Reconcile 同口径)。

契约:裁决凭下游事实,不凭持有权

裁决**不校验栅栏 / 令牌**:它的依据是宿主查到的下游事实——原持有者可能 已经死亡、租约早已过期,而对账恰恰要在这种情况下进行(sideeffect 同一条契约)。同一 Key 的多次裁决**先到者为准**:结论已定后再来裁决 → sideeffect.ErrNotUncertain(不覆盖先到的结论)。

func (Adjudication) Validate

func (r Adjudication) Validate() error

Validate 介入请求的留痕完整性 + 结论词表判定。

type AuditEntry

type AuditEntry struct {
	// At 留痕时刻。由**留痕实现侧**补(实现收到零值时取自己的当前时刻)——
	// 介入方的时钟不可信,留痕时刻以实现侧为准。
	At time.Time
	// Operator 谁在介入(必填)。
	Operator string
	// Action 动作种类(必填,词表见 Action* 常量)。
	Action Action
	// Target 目标(任务 id / 效果键 / run id,必填)。
	Target string
	// Basis 依据什么结论(必填)。
	Basis string
	// Detail 结果摘要(可空:成功 / 被拒原因)。
	Detail string
}

AuditEntry 一条人工介入留痕:谁、什么时候、做了什么、对谁、依据什么、结果如何。

type Auditor

type Auditor interface {
	// Append 追加一条介入留痕。
	Append(ctx context.Context, e AuditEntry) error
	// Entries 按目标取留痕(查询面)。target 为空取全部;按追加序返回。
	Entries(ctx context.Context, target string) ([]AuditEntry, error)
}

Auditor 留痕契约:**只增不改**(append-only)。

实现方必须保证:

  • Append 成功后条目永不修改、永不删除(留痕可被追加,不可被改写—— 可改写的留痕不是留痕);
  • 收到零值 At 时补实现侧当前时刻;
  • 对缺 Operator / Action / Target / Basis 的条目拒绝(ErrInvalidAudit), 不静默补空。

持久化形态归宿主(表 / 文件 / 只读副本皆可);MemAuditor 是进程内参考实现。

type Authorizer

type Authorizer interface {
	Authorize(r *http.Request, act Action, target string) (tenant.Context, error)
}

Authorizer 鉴权钩子(宿主实现):解析请求 → 判定动作是否可执行 → 返回注入 request context 的租户/权限上下文。返回错误 ⇒ 403,不触达 Plane。

type ForceRelease

type ForceRelease struct {
	// TaskID 要释放的任务。
	TaskID taskboard.TaskID
	// Operator 谁在介入(留痕必填)。
	Operator string
	// Basis 依据什么结论(留痕必填:证据 / 判据 / 单号,宿主词表)。
	Basis string
}

ForceRelease 强制释放任务认领。

契约:不是绕过闸,是经闸换持有者

实现必须按「接管」处理:被释放任务的旧令牌**当场作废**,任务回到可认领 态。于是旧持有者(崩溃的进程、失联的 worker)迟到的 Complete / Renew 一切动作都凭 taskboard.ErrLeaseLost 被拒;下一次认领铸造的接管代次 (taskboard.Fence)严格大于被释放的那一代——宿主的下游资源凭代次比较 拒收被释放者的迟到写入(与 taskboard / sideeffect 的栅栏同一纪律)。

危险实现(契约禁止):只改状态不清租约——旧令牌仍然匹配,幽灵持有者的 迟到提交会照常落账。

对不在认领态的任务强释 → 包装 taskboard.ErrLeaseLost 的错误(该任务 的租约已经不在了;介入重试撞上它说明第一次介入已生效)。

func (ForceRelease) Validate

func (r ForceRelease) Validate() error

Validate 介入请求的留痕完整性判定(Operator 与 Basis 必填——无归责不介入)。

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler 控制面的 JSON 适配面。经 NewHandler 构造,直接挂到任意 http.Server。

func NewHandler

func NewHandler(p Plane, a Authorizer) *Handler

NewHandler 构造适配面。auth 可为 nil——此时全部请求 401(见包内 httpapi 头注:控制面不开无鉴权的口子)。

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP 实现 http.Handler。

type MemAuditor

type MemAuditor struct {
	// contains filtered or unexported fields
}

MemAuditor 进程内参考留痕面。零值可用,并发安全。

func (*MemAuditor) Append

func (a *MemAuditor) Append(_ context.Context, e AuditEntry) error

Append 追加留痕(先判完整性与动作词表,再补时刻,再落账)。

func (*MemAuditor) Entries

func (a *MemAuditor) Entries(_ context.Context, target string) ([]AuditEntry, error)

Entries 按目标取留痕(target 空 = 全部;按追加序)。 返回的是拷贝:调用方改写条目不影响留痕面(只增不改的可观测面同样只读)。

type MemPlane

type MemPlane struct {
	// contains filtered or unexported fields
}

MemPlane 进程内参考实现。经 NewMemPlane 构造;并发安全。

func NewMemPlane

func NewMemPlane(aud Auditor) *MemPlane

NewMemPlane 构造参考实现。aud 可为 nil——留痕面缺位时介入一律拒绝 (ErrNoAuditor),这正是契约要演示的 fail-closed 形态。

func (*MemPlane) Adjudicate

func (m *MemPlane) Adjudicate(ctx context.Context, req Adjudication) error

Adjudicate 实现 Plane:裁决未知结果(契约见 Adjudication;sideeffect 同口径)。

func (*MemPlane) Claim

Claim 运行时侧:认领(铸造令牌与接管代次,语义对齐 taskboard.Board.Claim ——被占用 ErrTaken / 终态 ErrTerminal / 依赖未完成 ErrBlocked)。

func (*MemPlane) Complete

func (m *MemPlane) Complete(_ context.Context, id taskboard.TaskID, token string) error

Complete 运行时侧:凭 token 完成(旧令牌 / 非认领态 → taskboard.ErrLeaseLost)。

func (*MemPlane) ForceRelease

func (m *MemPlane) ForceRelease(ctx context.Context, req ForceRelease) error

ForceRelease 实现 Plane:经闸换持有者(契约见 ForceRelease)。

留痕与状态变更在同一临界区:留痕失败 ⇒ 状态不变 ⇒ 整体失败—— 不存在「改完了但没留痕」的窗口。

func (*MemPlane) Release

func (m *MemPlane) Release(_ context.Context, id taskboard.TaskID, token string) error

Release 运行时侧:凭 token 归还认领(旧令牌 → taskboard.ErrLeaseLost)。

func (*MemPlane) RunView

func (m *MemPlane) RunView(_ context.Context, id RunID) (RunView, error)

RunView 实现 Plane:运行视图(状态 / 代次 / 全部任务 / 全 run 不变量)。

func (*MemPlane) SeedEffect

func (m *MemPlane) SeedEffect(key string, unsafe bool) error

SeedEffect 运行时侧:登记一个处于「结果未知」态的副作用键。 unsafe 对应 sideeffect 的 RetrySafetyUnsafe(未生效裁决不自动开门)。

func (*MemPlane) SeedRun

func (m *MemPlane) SeedRun(id RunID, status string, tasks []taskboard.Task) error

SeedRun 运行时侧:建一个运行及其任务(taskID 全局唯一)。 status 为空取 contract.StatusCreated。重复 run / 重复任务 → ErrAlreadyExists。 先整单校验再落账:部分落账的 run 是半成品状态,视图会把「建了一半」 当成「建成了」。

func (*MemPlane) TaskView

func (m *MemPlane) TaskView(_ context.Context, id taskboard.TaskID) (TaskView, error)

TaskView 实现 Plane:任务视图 + run 级不变量在该任务上的投影。

func (*MemPlane) Terminate

func (m *MemPlane) Terminate(ctx context.Context, req Termination) error

Terminate 实现 Plane:凭运行代次终止(契约见 Termination)。

type Plane

type Plane interface {

	// TaskView 任务的运行状态视图(快照 + 该任务身上的不变量违反)。
	// 不存在 → ErrNotFound。
	TaskView(ctx context.Context, id taskboard.TaskID) (TaskView, error)
	// RunView 运行的状态视图(状态、代次、全部任务、全 run 不变量视图)。
	// 不存在 → ErrNotFound。
	RunView(ctx context.Context, id RunID) (RunView, error)

	// ForceRelease 强制释放任务认领(契约见 ForceRelease)。
	ForceRelease(ctx context.Context, req ForceRelease) error
	// Adjudicate 裁决未知结果(契约见 Adjudication)。
	Adjudicate(ctx context.Context, req Adjudication) error
	// Terminate 终止运行(契约见 Termination)。
	Terminate(ctx context.Context, req Termination) error
}

Plane 控制面契约:查询 + 介入。存储中立——实现方是宿主。

实现方必须保证(三条硬契约,缺一即破):

  • 介入**走运行时的同一套判据**(令牌 / 代次 / 栅栏),不绕闸;
  • 介入**留痕**:Auditor 缺位时一律拒绝(ErrNoAuditor),不允许 「先介入、留痕以后补」——补的留痕无法证明发生在动作之前;
  • 介入请求**可归责**:Operator 与 Basis 必填(各请求的 Validate 判)。

查询面无此约束:查询不改变状态,未注入租户上下文时由宿主自行裁剪 (httpapi 的 Authorizer 是宿主裁剪的落点)。

type RunID

type RunID string

RunID 运行标识(一轮编排;宿主生成,与 contract.RunContext.ID 同源)。

type RunView

type RunView struct {
	// RunID 运行标识。
	RunID RunID
	// Status 运行状态。取值建议用 contract 的状态常量(内核不解释词表;
	// 终态词表与 contract 保持一致,宿主自定义状态自行登记)。
	Status string
	// Gen 运行代次:运行级介入的乐观并发判据。Terminate 携带的
	// ExpectedGen 必须等于它,不等即 ErrStaleRun——介入方先查视图再介入,
	// 两次介入之间状态若已前进,旧视图上的动作必须重查。
	Gen uint64
	// Tasks 该 run 的全部任务快照(按任务 id 排序,确定性输出)。
	Tasks []taskboard.Task
	// Problems 全 run 的不变量视图(taskboard.Check)。
	Problems []taskboard.Problem
}

RunView 运行的状态视图。

type TaskView

type TaskView struct {
	// Task 任务快照(状态 / owner / 租约 / 代次——与运行时读到的同一份)。
	Task taskboard.Task
	// Problems 该任务身上的不变量违反(run 级 taskboard.Check 的投影;
	// 空 = 干净)。控制面不止报状态,还报「这份状态是否自相矛盾」——
	// 人工介入的结论(Basis)往往正是一条 Problem。
	Problems []taskboard.Problem
}

TaskView 任务的运行状态视图。

type Termination

type Termination struct {
	// RunID 要终止的运行。
	RunID RunID
	// ExpectedGen 介入方视图里的运行代次(来自 RunView.Gen)。
	ExpectedGen uint64
	// Operator 谁在介入(留痕必填)。
	Operator string
	// Basis 依据什么结论(留痕必填)。
	Basis string
}

Termination 终止运行。

契约:终止凭运行代次(乐观并发)

ExpectedGen 必须等于当前 RunView.Gen:不等即 ErrStaleRun。终止本身就是 一次代次前进——两个介入者拿着同一份旧视图并发终止,先到者生效,后到者 凭代次被拒,不会出现「两次终止都以为自己终了这次运行」。终止是运行级 动作:任务租约的清理走 ForceRelease,两级判据不混。

func (Termination) Validate

func (r Termination) Validate() error

Validate 介入请求的留痕完整性判定。

type Verdict

type Verdict string

Verdict 对「结果未知」副作用的裁决结论(与 sideeffect.Resolution 同口径)。

const (
	// VerdictApplied 已生效:Key 记为完成并留存结果,之后同一 Key 的执行
	// 拿回这份结果(重放)。
	VerdictApplied Verdict = "applied"
	// VerdictNotApplied 未生效:清除未知标记,该 Key 重新可被执行。
	VerdictNotApplied Verdict = "not_applied"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL