Documentation
¶
Overview ¶
Package backend 任务式执行后端的**契约面**:隔离声明、能力问询与选择判据。
生命周期: Experimental(v0.35.0 定级)——本包仍会改、删、拆,不承诺任何 兼容,升级风险自负;定级理由见 api-lifecycle.yaml,分级规则见 docs/api-compat.md。
它补的是哪一段(issue #109) ¶
本仓的执行面此前只有「程序式」一条:@@ptc@@ 的两个后端(进程内解释器 / 子进程)跑的是 JS 子集程序,档位词表在上游 `guardx/sandbox.Level` (none / process / os / machine)。Docker、Kubernetes Job 这类**任务式** 执行——按镜像 + 参数跑一个完整作业——在这张表上没有位置:
- 上游 Level 词表是**冻结的**:往中间插一档(container)要么重排枚举值 (跨仓破坏性变更),要么追加在 machine 之后(强弱序立刻说谎);
- 任务式执行与程序式执行的请求形状不同:前者是「镜像 + 参数 + 挂载 + 资源上限」,后者是「程序源码 + 预算」。
故本包给任务式执行定**自己的契约面**,三条与既有执行面同源的纪律:
- **能力是后端自报的,沉默按最弱处理**——后端没声明能强制哪一档隔离 / 出站 / 内存,判据就当它兜不住(与 @@sandbox@@ 的 Capable / EgressEnforcer 同一条方向:漏声明得到的是「兜不住」,不是「兜得住」)。
- **要求无满足者即拒绝,绝不降级**——登记簿选不到满足要求的后端就报错, 不挑一个「差不多的」(与 sandbox.Registry.Resolve 同一条纪律)。
- **满足者取最弱**——多个后端都满足时取隔离最弱者,同档按 ID 字典序 (与 delegate.Route 的最小能力原则同源:按登记顺序取第一个是错的, 一次无关的配置重排就会换掉执行方)。
隔离轴为什么在本包重立而出口词表复用 ¶
出站档位(unrestricted / proxy / denied)的词表已经长在上游 tier 0 (`sandbox.EgressMode`),本包直接引用——跨模块共享的词表必须低于所有 实现方,另立一份就是第二真相。隔离轴不同:它的词表被上游枚举值**钉死** (插入即破坏强弱序),没有可复用的扩展点,故本包给任务式执行立自己的轴, 并在 Isolation 的注释里写清与 sandbox.Level 的对应关系与为什么不共用。
为什么容器适配的参考实现不在内核里 ¶
Docker Engine / Kubernetes Job 的接入牵涉宿主的部署形态(socket 位置、 集群凭据、镜像仓库策略),都是宿主的事——内核只给契约与判据。参考实现 在 `examples/containerbackend`(Docker Engine API 逐字实现本契约 + Kubernetes Job 接入占位),宿主抄它、不 import 它(与 examples/pgstore 对 taskboard.Board 的分工同构:内核证契约,示例证接线)。
contract.go —— 后端契约与「要求 vs 实际」的对照判据。
本文件的立论:隔离强度的声明必须有人签字,且签字必须**可被问询**。 此前「声明了容器隔离」在执行侧没有任何对照物——宿主写了一档要求, 没有任何东西能回答「这个后端做不做得到」。判据在这里,不判就在报告里读。
inline.go —— 进程内兜底后端:契约的**可满足性证明**与单进程编排的最小执行件。
它不是容器隔离的实现——它声明 IsolationNone(显式最弱一档),存在的意义 有三:① 让契约在零外部依赖下可测(判据跑在它身上);② 给单进程编排一个 不引任何容器运行时的保底执行件;③ 作为「如何实现 Backend」的最小样板。 真实的容器隔离见 examples/containerbackend(Docker Engine 参考实现)。
registry.go —— 后端登记簿:登记、对照与选择。
选择的两条纪律与 sandbox.Registry.Resolve / delegate.Route 同源: 无满足者即拒绝(不降级),满足者取最弱(同档按 ID 字典序)。
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotSatisfied 要求未被该后端满足。包装它给出未满足的轴与两侧值。 ErrNotSatisfied = errors.New("backend: 要求未被该后端满足") // ErrNoBackend 登记簿里没有能满足要求的后端。 // // 无满足者即拒绝,不降级:降级会让「宿主声明的强度」与「实际执行的 // 强度」之间没有信号,档位整套机制就成了装饰。 ErrNoBackend = errors.New("backend: 没有能满足要求的后端") // ErrBadSpec 要求自身不合法(越界档位 / 负预算)。 ErrBadSpec = errors.New("backend: 要求不合法") // ErrSilent 后端对某条能力面保持沉默(未声明 = 兜不住)。 // // 它与 ErrNotSatisfied 的区别是归因:一个是「声明了但不够强」, // 一个是「根本没声明」——修法不同(换后端 vs 补声明),报错必须分得开。 ErrSilent = errors.New("backend: 后端未声明该能力面(沉默按最弱处理)") )
错误哨兵(判定方按 errors.Is 认人;实现方复用,不要各造一套)。
var ErrBadBackend = errors.New("backend: 后端不合法")
ErrBadBackend 试图登记不合法的后端(nil / 空 ID)。
var ErrDuplicateID = errors.New("backend: 后端 ID 重复登记")
ErrDuplicateID 同一 ID 被登记两次。
var ErrUnknownProgram = errors.New("backend: 兜底后端没有登记这个名字的处理函数")
ErrUnknownProgram 兜底后端没有登记这个名字的处理函数。
兜底后端把 Job.Image 读作**处理函数名**(进程内没有镜像可拉): 名字对不上即拒绝,不静默跑一个空操作——「跑了但什么都没发生」是最贵的 失败形态。
Functions ¶
This section is empty.
Types ¶
type Backend ¶
type Backend interface {
// ID 后端标识,在登记簿里唯一。
ID() string
// Isolation 该后端**能强制**的隔离档位。
//
// 说的不是「我期望这一档」,而是「我能强制这一档」——作业真的跑在
// 这个边界之内,而不是「配了一组看起来很严的参数」。
Isolation() Isolation
// Exec 执行一次作业。
//
// 返回 error = 后端基础设施故障(作业没跑起来);返回 Result 且
// OK=false = 作业自己失败(见 Result 注释)。两层失败判据不同,
// 不许混在一个通道里。
Exec(ctx context.Context, j Job) (Result, error)
}
Backend 一个任务式执行后端。
后端自己解释 Job 的执行语义,但**必须**如实自报隔离档位:档位是宿主做 风险判断的依据,说谎的成本落在宿主身上(与 sandbox.Providers 登记 门禁同一条纪律——「档位声明要有人签字」)。
type Capable ¶
Capable 可选能力面:后端声明它能**兜住**哪组预算。
与 sandbox.Capable 同构(同一条纪律的同一形状):不实现的后端只兑现 基本闸(墙钟经 context 兜底),不兑现内存与输出截断。内存是唯一 「兜不住就出事」的项——任务式后端跑的是完整作业,一个无界分配能撑爆 宿主;声明不了就别被选中。
type EgressEnforcer ¶
type EgressEnforcer interface {
// Egress 返回该后端实际能兑现的出站档位。
Egress() sandbox.EgressMode
}
EgressEnforcer 可选能力面:后端声明它能**强制**哪一档出站。
与 sandbox.EgressEnforcer 同构同词表(sandbox.EgressMode):任何满足 那个接口的后端自动满足本接口(Go 是结构化类型)。**不实现即视为没有 做出任何声明**——在判据里沉默按最弱处理:一个没实现本接口的后端连 「我能禁网」都说不出口,它当然兜不住禁网。
为什么是可选接口而不是 Backend 的必选方法:进程内兜底后端(Inline)没有 任何出站强制面,必选方法会逼它撒谎或报错——可选 + 沉默按最弱,两个都不用。
type Handler ¶
Handler 兜底后端的执行体:拿到作业请求,返回结果。
返回 error 的语义与 Backend.Exec 一致:基础设施故障走 error, 作业自身失败写在 Result.OK / ExitCode 里。
type Inline ¶
type Inline struct {
// contains filtered or unexported fields
}
Inline 进程内兜底后端。
三条自报(都与判据对齐,不占便宜):
- Isolation() = IsolationNone:处理函数跑在宿主进程里,能触达宿主 能触达的一切。它是显式声明,不是漏报——漏报零值也是 none,但那 是「没签字」,这是「签了字的最弱档」;
- 不实现 EgressEnforcer:进程内执行没有任何出站强制面,沉默 = 兜不住;
- 不实现 Capable:内存与输出截断兜不住(Go 运行时没有「只给一个 处理函数限内存」的开关),沉默 = 兜不住。
于是任何高于「要求=none、出站=unrestricted、不要求预算」的 Spec 都不会 选中它——判据替它守住诚实。
type Isolation ¶
type Isolation uint8
Isolation 任务式执行的隔离轴(序即强弱:档位越大,被执行的作业能触达的 宿主面越少)。
为什么不复用 sandbox.Level ¶
上游 `guardx/sandbox.Level` 的枚举值即强弱序(none=0 … machine=3),往 中间插「容器」一档必然重排既有值——那是对全部既有实现的跨仓破坏性变更; 追加在 machine 之后则让「容器弱于虚机」的强弱序说谎。任务式执行因此 立自己的轴,对应关系**只写在注释里、不写进代码**(代码级映射会变成 第二真相,两份迟早漂移):
IsolationNone ↔ sandbox.LevelNone 与宿主同权限
IsolationProcess ↔ sandbox.LevelProcess 进程内解释器边界
IsolationContainer ↔ sandbox.LevelOS 容器边界(namespace/cgroup,
共享内核——内核级裁剪,
但逃逸面大于独立内核)
IsolationVM ↔ sandbox.LevelMachine 独立内核(硬件虚拟化)
零值是最弱的一档,这是刻意的 ¶
未声明档位的后端零值即 IsolationNone,只会被「要求=none」选中;任何更高 要求的解析都会因「无满足者」而拒绝。方向是安全的那一侧:漏声明的后端拿到 的是最弱的选择,不是最强的豁免。
const ( // IsolationNone 无隔离:作业与宿主同权限,能触达宿主能触达的一切。 // // 它是**显式的一档**,不是「缺省即无」:把「没有隔离」写成档位,宿主 // 必须显式登记一个这样的后端,而不是让「没登记」悄悄等于「没隔离」。 IsolationNone Isolation = iota // IsolationProcess 进程内边界:作业跑在宿主进程内的解释器/闭包里, // 能力面靠语言子集或注册白名单裁剪。不是内核级边界——解释器自身的缺陷 // 可以越过它。 IsolationProcess // IsolationContainer 容器边界:namespace / cgroup / seccomp 由内核裁剪 // 文件、网络与系统调用视图。共享宿主内核,逃逸面大于独立内核。 IsolationContainer // IsolationVM 独立内核边界:作业有自己的内核与虚拟设备。 IsolationVM )
type Job ¶
type Job struct {
// Image 镜像引用。带 digest 的引用(name@sha256:…)必须**原样携带**:
// 后端不得把 digest 换成浮动 tag——「钉住什么就跑什么」是作业级可复现
// 的前提。
Image string
// Args 位置参数(原样传给镜像入口点)。
Args []string
// Env 环境变量,K=V 形态,**白名单**:由宿主显式构造,后端不得替它补全
// (与 egress_paths 的同一条纪律——整份环境交给被执行的作业,等于把
// 凭据也交出去)。
Env []string
// Mounts 挂载声明(后端可拒绝不认识的形态,见各实现说明)。
Mounts []Mount
// WorkDir 作业内工作目录(空 = 镜像缺省)。
WorkDir string
// Budget 资源预算(sandbox.Budget 逐字复用——资源词表只有一份)。
//
// MaxCalls 在任务式执行里没有对应物(作业不是逐调用解释的),要求了它
// 的 Spec 不会被声明 Capable 的任务式后端满足(见 Spec.SatisfiedBy)。
Budget sandbox.Budget
}
Job 一次任务式执行的请求。
与 sandbox.Request(程序源码)不同:任务式执行的请求形状是「镜像 + 参数 + 挂载 + 预算」。Image 是必填锚点——没有镜像的作业无从执行,也无从隔离。
type Mount ¶
type Mount struct {
// Source 宿主侧来源路径(后端语义内的引用,原样携带)。
Source string
// Target 作业侧目标路径。
Target string
// ReadOnly 是否只读。可写挂载是暴露面,声明面要能区分它。
ReadOnly bool
}
Mount 一次挂载声明:宿主路径 → 容器内路径。
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry 任务式执行后端的登记簿。
并发安全:登记与解析都可能来自不同 goroutine,内部加锁。
func (*Registry) Candidates ¶
Candidates 返回满足要求的全部后端,按「最弱优先」排序: 隔离档位升序,同档按 ID 字典序。
顺序即选择的依据,且**不依赖登记顺序**——登记顺序通常来自配置文件的 书写次序,一次无关的重排就会换掉执行方(delegate.Route 的同源教训)。
type Result ¶
type Result struct {
// OK 作业是否成功(退出码为 0 且未被引擎判定失败)。
OK bool
// ExitCode 退出码(作业没有跑起来时无值)。
ExitCode int
// Stdout 标准输出(后端可按预算截断——截断是能力声明,见 Capable)。
Stdout string
// Stderr 标准错误。
Stderr string
// Detail 后端私有明细(引擎 id、节点标识、OOM 标记…)。
//
// 与 sandbox.Outcome.Detail 同一条纪律:不得成为任何可判定事实的第二份
// 真相——成败以 OK / ExitCode 为准,Detail 只是明细。
Detail any
}
Result 一次任务式执行的结果。
失败分两层,判据不同:
- Exec 返回 error:**后端基础设施故障**(引擎不可达、镜像不存在、 创建失败)——作业根本没跑起来,重试与修复的对象是后端;
- Result.OK == false:**作业自己失败**(非零退出)——作业跑过了, 结论在 ExitCode / Stderr 里,重试与修复的对象是作业。
把两层混进一个 error,调用方就分不清「该换后端」还是「该改作业」。
type Spec ¶
type Spec struct {
// MinIsolation 隔离档位下限(满足下限者才有资格)。
MinIsolation Isolation
// Egress 要求的出站档位(Unrestricted = 不要求)。
Egress sandbox.EgressMode
// Budget 要求兜住的预算(只看非零项;MaxMemory > 0 ⇒ 要求后端兜内存)。
Budget sandbox.Budget
}
Spec 一份执行要求:隔离下限、出站档位与必须兜住的预算。
零值的语义由字段各自承担:MinIsolation 零值 = IsolationNone(不要求隔离, 显式的一档);Egress 零值 = sandbox.EgressUnrestricted(不要求出站); Budget 零值 = 不要求兜任何预算。三个零值合起来 = 「随便找个后端」—— 这也是显式的:没有一项被要求,判据全过。
func (Spec) SatisfiedBy ¶
SatisfiedBy 判该后端是否满足要求。
判定顺序固定(isolation → egress → budget),报错与轴一一对应:
- 隔离:Isolation() 达不到下限 → ErrNotSatisfied; 档位越界(词表外)→ ErrNotSatisfied(词表外不是「更强」,是声明缺陷);
- 出站:要求高于 unrestricted 时,后端必须实现 EgressEnforcer 且其档位 合法且 ≥ 要求;未实现 → ErrSilent;实现了但不满足 → ErrNotSatisfied;
- 预算:MaxMemory > 0 时后端必须实现 Capable 且自报兜得住; 未实现 → ErrSilent;实现了但报兜不住 → ErrNotSatisfied。 MaxCalls > 0 同理(任务式执行通常兜不住逐调用计数——如实自报, 不要假装支持)。
墙钟不在问询之列:context 截止是所有后端的公共义务,没有「兜不住墙钟」 的合法声明。