用证据排障、验收并清理|Kubernetes 项目课 | 自在学
用证据排障、验收并清理
到这里,TaskBoard 已经经历了从镜像、Pod、Deployment、Service、配置、发布、扩缩容,到 StatefulSet、PVC、Job 和安全策略的完整过程。开始本章前,Deployment 应为两个 Ready 副本,Redis StatefulSet 应为 1/1,PVC 应为 Bound,Job 应为 Complete,HPA 应已回落到两个副本;TaskBoard 使用专属 ServiceAccount,Namespace 也执行 restricted:v1.36。这是一条健康基线。
最后一步不是再加一个长期对象,而是学会在期望状态与现实不一致时,沿控制器关系找到断点。Kubernetes 的对象很多,背命令表很快会失效;更稳定的方法是先问“谁声明了什么期望”,再看对象 condition、事件、进程日志和外部依赖,用相互独立的证据排除假设。
这一章会主动制造两次可恢复故障:一次发布不存在的镜像,一次把 Service 选择器改错。每次都先说明为什么制造、预期哪些对象变化、什么结果算成功,再用前面已经实际出现过的输出复盘。修复完成后执行控制面、存储和业务三层验收。只有验收通过,才删除 taskboard Namespace、具名 kind 集群、课程镜像和专用配置,并逐项确认没有残留。
完成后不再保留课程运行资源。预期终态是:taskboard Namespace 为 NotFound,删除前记录的精确 PV 为 NotFound,welearn-course 不在 kind 集群列表且无对应节点容器,课程创建的应用镜像、节点镜像引用和 kind 网络已删除,带归属标记的课程目录已删除。如果节点镜像或 kind 网络在课程前就存在,基线账本会要求原样保留。我们不会执行全局 prune,因为“清干净”不等于“删除所有看起来没在用的东西”。
声明式系统为什么需要证据链
传统部署脚本常按顺序执行动作:复制文件、启动进程、返回退出码。Kubernetes 更接近声明式系统:我们把 Deployment、Service 或 PVC 的期望规格交给 API Server,多个控制器异步观察并不断调和,也就是 reconcile,直到现实尽量接近期望。
异步带来一个重要后果:kubectl apply 成功,只代表 API 请求被接受并持久化,不代表调度、拉镜像、挂卷、启动进程、通过探针和加入 Service 后端都已完成。不同组件看到状态的时间也可能有先后。排障的核心就是定位这条收敛链在哪一步停住。
我们采用下面这条从抽象到具体的证据链:
期望状态 :先读控制器 spec 与 status,明确期望副本、镜像、选择器、更新策略和当前差值。
对象条件 :condition 是控制器对可用、完成、就绪或失败等状态的结构化判断,通常带 reason、message 和更新时间。
事件 :调度器、kubelet、控制器等组件记录近期发生的高层原因,例如调度失败、拉取失败和挂载失败。
日志 :只有容器已经启动,应用日志才可能回答配置、连接或业务代码为什么失败;未启动的容器没有有意义的应用日志。
依赖证据 :Service 要追到 EndpointSlice,PVC 要追到 StorageClass/PV,应用请求要追到 Redis。对象自身健康不等于下游链路健康。
顺序不是死规定,而是一种降低搜索空间的方法。比如 Pod 为 ImagePullBackOff 时,事件已经说明容器未启动,先查应用日志没有意义;Service 没有 EndpointSlice 地址时,先改 CoreDNS也偏离证据。每一步都应回答一个具体假设,再决定下一条命令。
重启是一项会改变现场的操作,不是一种诊断。它可能清除当前容器、改变 Pod UID、重置重启计数的观察窗口或让问题短暂消失。先保存状态、事件和日志,再根据证据决定是否回滚、重建或修改配置。
排障的顺序比命令数量更重要
Kubernetes 出现问题时,最容易做错的事是先重启。重启可能暂时改变现象,却会丢掉现场,还可能让我们误以为问题已经解决。
更可靠的顺序是从外向内收集证据:
用 kubectl get 看当前状态,先确定是哪个对象没有达到期望状态。
用 kubectl describe 看对象条件和最近事件,找调度、拉取镜像、挂载卷、探针等控制面信号。
用 kubectl logs 看应用进程说了什么;容器重启过时加 --previous 看上一个实例。
用 kubectl get events --sort-by=.lastTimestamp 还原事件时间线。
只有需要验证进程内部状态时才用 kubectl exec,不要把它当成修改运行中容器的常规入口。
这份简表可以再精确一点。kubectl get 默认表格是常用字段的摘要,不一定包含真正的 condition;需要时用 -o yaml、JSONPath 或 custom-columns 读取结构化字段。describe 会把对象详情与相关事件放在一起,适合快速定位,但它是面向人的聚合输出,不适合作为长期稳定的脚本接口。
condition 通常包含 type、status、reason、message 和 lastTransitionTime。例如 Deployment 的 Available=True 说明达到最小可用条件,却不自动等于所有新副本已经更新;还要结合 updatedReplicas、availableReplicas 和 rollout 状态。Pod 的 phase 为 Running 只表示至少一个容器正在运行或启动,不代表 readiness 为真。理解字段语义,比记住绿色文本更重要。
事件是组件对对象发生事情的短期记录。它可能聚合重复事件,保存时间由集群配置决定,老事件会过期,同一故障也不保证永远有一条事件可查。因此事件适合解释“近期为什么没有收敛”,不应被当作永久审计日志。生产系统需要把事件与日志集中收集到独立后端,并保留统一时间基准。
日志同样有生命周期。kubectl logs 默认读取当前容器日志;容器在同一 Pod 内重启后,--previous 可读上一个实例,但 Pod 被删除后,节点上的日志不适合作为可靠保留方案。多容器 Pod 要用 -c 指定容器。应用最好把结构化日志写到标准输出,并带上请求 ID、版本、Pod 和依赖错误,不要打印 Secret。
get 回答“现在是什么状态”,describe 和事件回答“它为什么变成这样”,日志回答“应用内部发生了什么”。把这三类证据对在一起,通常比反复删除 Pod 更快。
每条命令都要带着问题执行
有效排障不是收集越多越好,而是让每条命令区分假设:
kubectl get deployment taskboard -n taskboard -o yaml 用来比较期望镜像、副本与控制器状态;
kubectl get pods -n taskboard -l app=taskboard 用来判断问题影响一个实例还是整个副本集;
kubectl describe pod <Pod 名> -n taskboard 用来确认调度、镜像、卷和探针事件;
kubectl logs <Pod 名> -n taskboard --previous 只有容器已经启动并重启过时才有价值;
kubectl get endpointslice -n taskboard 用来判断 Service 是否发现 Ready 后端;
从 toolbox-safe 发请求用来验证真实 DNS、Service 和应用链路,而不是只验证进程的 loopback。
执行前先写下预期结果也很有用。如果你预期新镜像拉取失败,那么应看到新 Pod 未 Ready、旧 Pod 仍 Ready、事件出现拉取原因;若实际所有旧 Pod 也消失,说明更新策略或其他条件与假设不同,需要先停止继续操作。
常见症状与第一条证据
表格给的是第一条证据,不是固定答案。比如 Pending 既可能是 CPU 不足,也可能是 PVC 的拓扑限制;CrashLoopBackOff 是重启退避状态,不是根因,真正原因常在上一次容器日志和退出码中。把状态名当根因,是初学排障最常见的误区之一。
先保存现场,再做修复
生产事故中,可以在修复前保存一个最小证据包:对象 YAML、相关 Pod 列表、describe、事件、当前与 previous 日志、版本与时间范围。若有敏感数据,要在受控位置保存并脱敏。这样即使回滚恢复服务,团队仍能复盘为什么失败。
本章为了保持真实路径,不另外创建证据文件,也不执行集群快照。我们会直接展示已实际执行的输出,并在每次变更后立即读取相关对象。读者在自己的生产流程中,应把证据采集自动化,并与变更记录、监控时间线和审计日志关联。
实操:从失败发布找到根因
前面已经把故障发布回滚到稳定版本。为了让这条排障证据链可以独立重复,验收前再发布一次不存在的 taskboard:9.9.9。maxUnavailable: 0 会保留两个旧版可用副本,而新 Pod 应停在镜像拉取阶段。
这项操作会修改 Deployment Pod 模板的 api 容器镜像,Deployment 控制器因而创建新 ReplicaSet。maxUnavailable: 0 要求滚动期间不能主动减少可用副本,maxSurge 允许先创建额外 Pod。因此预期不是整个服务立即中断,而是两个 2.0.0 Pod继续 Ready,一个 9.9.9 Pod 因镜像不存在而等待。这个安全网依赖旧副本本来就健康,也依赖更新策略与资源容量。
先发起发布并给它 15 秒观察窗口。这里期望的不是“任意非零”,而是在正确上下文、具备读取权限的前提下,rollout 因新 Pod 迟迟不 Ready 而超时。如果把任何失败都无条件转为成功,连“证书过期”、“当前上下文不存在”或“Forbidden”也会被伪装成实验成功。
kubectl set image 中的 api=taskboard:9.9.9 左侧 api 必须匹配 Deployment 容器名,右侧是故意不存在的镜像引用。rollout status --timeout=15s 只限制客户端等待时间,不会在超时后自动回滚或停止控制器。下面先断言课程 context 与 RBAC,再捕获标准错误和退出码;只有输出明确包含等待条件超时才继续。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
deployment.apps/taskboard image updated
Waiting for deployment "taskboard" rollout to finish: 1 out of 2 new replicas have been updated...
error: timed out waiting for the condition
expected_rollout_timeout rc=1
第一行只表示模板已更新;等待文本说明控制器开始新修订;expected_rollout_timeout 同时证明命令确实失败,且失败类型与实验预期一致。退出码在 kubectl 版本间不应当作唯一契约,所以还同时校验消息语义。timeout 没有说明具体根因,也没有恢复系统;下一步仍要查 Pod 和事件。若 rollout 反而成功,说明 taskboard:9.9.9 在当前运行时中意外存在,不能继续把它当作拉取失败案例。
先看 Pod 的等待原因和镜像。custom-columns 可以把排障时最关心的字段放在一张表里。
.status.containerStatuses[0].ready 读取第一个容器的 Ready 布尔值,.state.waiting.reason 读取等待原因,.spec.containers[0].image 读取声明镜像。这里 TaskBoard Pod 只有一个业务容器,所以索引 0 有明确含义;多容器 Pod 应按名称选择,避免读错容器。命令只读取标签为 app=taskboard 的 Pod,不修改状态:
kubectl get pods -n taskboard -l app=taskboard \
-o custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[0].ready,STATUS:.status.containerStatuses[0].state.waiting.reason,IMAGE:.spec.containers[0].image'
结果展示
NAME READY STATUS IMAGE
taskboard-<稳定哈希>-<后缀 A> true <none> taskboard:2.0.0
taskboard-<稳定哈希>-<后缀 B> true <none> taskboard:2.0.0
taskboard-<失败哈希>-<后缀> false ImagePullBackOff taskboard:9.9.9
两个 2.0.0 Pod 仍然 Ready,只有新 ReplicaSet 创建的 9.9.9 Pod 失败。这说明 Deployment 的滚动策略保住了旧版本,问题集中在新镜像。
ImagePullBackOff 中的 BackOff 表示 kubelet 在连续拉取失败后延长重试间隔,它是表现,不是根因。ErrImagePull、仓库返回信息或认证错误才更接近原因。如果旧 Pod 数量不是 2,先看 Deployment 的 replicas、maxUnavailable、HPA 当前副本和资源调度事件;如果新 Pod 甚至没有出现,则应回到 ReplicaSet 与 Deployment condition,而不是查镜像仓库。
接着按时间查看 Warning 事件:
field selector 把事件限制为 type=Warning,--sort-by=.lastTimestamp 按最后发生时间排序,便于把重试放回时间线。事件是 Namespace 范围的,所以输出也可能包含前面探针或其他对象的 Warning;我们要关注对象名对应失败哈希的行:
kubectl get events -n taskboard \
--field-selector type=Warning \
--sort-by=.lastTimestamp
结果展示
TYPE REASON OBJECT MESSAGE
Warning Failed pod/taskboard-<失败哈希>-<后缀> Error: ErrImagePull
Warning Failed pod/taskboard-<失败哈希>-<后缀> Error: ImagePullBackOff
Warning Failed pod/taskboard-<失败哈希>-<后缀> pull access denied, repository does not exist or may require authorization
事件把根因缩小到镜像引用:仓库中没有这个标签,或者当前身份没有拉取权限。此时不需要进入容器,因为容器根本没有启动。
三行的关系是:第一次拉取返回错误,容器状态出现 ErrImagePull,随后 kubelet 进入退避并显示 ImagePullBackOff。消息中的 “repository does not exist or may require authorization” 还不能区分标签不存在与私有仓库凭据失败。生产诊断要核对完整镜像地址和 digest、imagePullSecret、节点到仓库的网络、仓库审计日志,并避免把凭据直接贴进事件或终端历史。
若事件列表为空,不代表从未失败:事件可能尚未到达或已经过期。下一条可以执行 kubectl describe pod <失败 Pod 名> -n taskboard 读取当前容器状态和相关事件;再检查 kubelet或运行时日志需要节点管理员权限。因为容器未启动,kubectl logs 报没有日志是符合机制的结果。
修复方式是回滚到上一条成功修订:
rollout undo 会把 Deployment Pod 模板恢复到上一修订,并触发控制器重新收敛;它不是把时间倒流,ConfigMap、Secret 或外部数据库变化不会随 Deployment 修订一起回滚。随后等待 120 秒,确保失败 ReplicaSet 缩下去且稳定模板恢复可用。理解恢复边界后再执行:
kubectl rollout undo deployment/taskboard -n taskboard
kubectl rollout status deployment/taskboard -n taskboard --timeout=120s
结果展示
deployment.apps/taskboard rolled back
deployment "taskboard" successfully rolled out
回滚命令被接受后,第二行才证明当前 Deployment 达到发布完成条件。稳妥起见,还应在下一节正常基线读取中确认 EndpointSlice 和业务请求;如果等待超时,继续查看新旧 ReplicaSet、Pod 与事件,不能连续 undo 猜版本。生产发布应记录修订对应的镜像 digest 和配置版本,避免“上一版”语义随多次操作改变。
Pod 名称、哈希和事件时间会随执行变化,排障时应比较 STATUS、IMAGE、REASON 与事件消息,不要依赖固定后缀。
实操:从 Service 失败追到选择器
如果 Service 有 ClusterIP,DNS 也能解析,但请求仍然失败,下一步不是重启 CoreDNS,而是检查它有没有可用后端。本节会先确认正常基线,再主动把选择器改错,最后根据 EndpointSlice 修复;修复完成后才进入结课验收。
Service 是稳定入口,Pod 是变化后端。控制面根据 Service selector 匹配 Pod 标签,EndpointSlice 控制器把匹配端点和 Ready 条件写成 EndpointSlice,服务代理或相应网络实现再据此编程数据面。DNS 能把 taskboard 解析为 ClusterIP,只证明名称与 Service 入口存在;它不证明 selector 找到了 Pod,也不证明端口映射和应用进程正确。
先看 Service 的选择器,再看 EndpointSlice 中每个地址的就绪条件:
第一条 JSONPath 直接读取 .spec.selector 并附加换行。第二条先用标准标签 kubernetes.io/service-name=taskboard 找到属于该 Service 的 EndpointSlice,再遍历 .items[*].endpoints[*],输出地址与 .conditions.ready。命令只建立健康基线,不修改对象。正常时 selector 应为 app=taskboard,并且至少有与 Ready Pod 对应的地址:
kubectl get service taskboard -n taskboard -o jsonpath='{.spec.selector}{"\n"}'
kubectl get endpointslice -n taskboard \
-l kubernetes.io/service-name=taskboard \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{" ready="}{.conditions.ready}{"\n"}{end}'
正常结果会有两个 ready=true 的 Pod 地址:
{"app":"taskboard"}
10.244.0.10 ready=true
10.244.0.11 ready=true
IP 地址是 Pod 网络动态值,不应复制为配置。稳定证据是 selector 与 Pod 标签一致、端点数量符合可用副本、每个端点 ready=true。如果这里已经为空,就不应继续主动改错;先比较 kubectl get pods -n taskboard -l app=taskboard --show-labels,再检查 Pod readiness 和 Service selector,把原有故障修好后才能建立实验基线。
现在把选择器改成不存在的标签,然后同时查看 EndpointSlice 和真实请求。预期 EndpointSlice 对象仍在,但地址为空;请求被拒绝。
merge patch 只把 .spec.selector.app 改为 wrong-label,不会删除 Service 或更换 ClusterIP。控制器异步更新 EndpointSlice,所以 shell 循环最多查询 30 次:JSONPath 拼接所有端点地址,变量为空时立即结束,每次未收敛就等待一秒。如果 API 读取本身失败,循环立即停止,不会把“没有拿到地址”误判成“地址确实为空”。
最后的 HTTP 请求也不能只要非零就算通过。远端 shell 会把预期的 wget 失败转换为专用退出码 42;如果请求意外成功则返回 90;kubectl 的认证、context 或 Pod exec 错误通常返回另一个值。外层只接受 42,所以三类结果不会被混为一谈。确认影响范围和恢复命令都在下方后,再执行故障注入:
if [ "$( kubectl config current-context)" != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
if [ "$( kubectl auth can-i patch services -n taskboard)" != 'yes' ] || \
[ "$( kubectl auth can-i create pods/exec -n taskboard)" != 'yes' ]; then
echo '停止:当前身份缺少 Service patch 或 Pod exec 权限' >&2
exit
结果展示
service/taskboard patched
endpoints=0
NAME ADDRESSTYPE PORTS ENDPOINTS
taskboard-xqwzk IPv4 <unset> <unset>
wget: can't connect to remote host (10.96.250.32): Connection refused
expected_backend_failure wget_exit=1
command terminated with exit code 42
service_negative_probe=passed rc=42
这组证据说明网络入口对象没有消失,断点在“Service 选择器没有匹配到 Pod”。command terminated with exit code 42 是 kubectl 转述的远端退出状态,后一行才是外层断言通过。修复选择器后再看 EndpointSlice,比直接删除 Service 更能确认原因。
service/taskboard patched 证明入口对象仍在;endpoints=0 和表格中的 <unset> 证明当前没有后端地址;wget 仍然连接 10.96.250.32,说明 DNS 和 ClusterIP 解析链路走到了 Service,但没有后端承接请求。连接错误文本可能因服务代理实现而表现为 refused 或 timeout,不能把具体措辞写成跨集群保证。
如果循环结束后没有 endpoints=0,说明端点未按预期清空。先执行 kubectl get service taskboard -n taskboard -o yaml 检查 patch 是否生效,再看是否存在手工管理、没有 selector 的其他 Service 形态;不要继续宣称故障已制造。如果请求仍成功,可能是 EndpointSlice 尚未收敛、连接复用或实现差异;此时远端会返回 90,外层将停止,而不是把一次成功隐藏掉。
恢复命令把 selector 改回 app=taskboard。kubectl wait 使用 JSONPath 等待第一个端点的 ready 条件为 true,最长 60 秒;随后完整列出 EndpointSlice,确认端口和所有地址。这里等待第一个端点适合恢复最低服务能力,最终验收仍要检查 Deployment 有两个可用副本:
kubectl patch service taskboard -n taskboard \
-p '{"spec":{"selector":{"app":"taskboard"}}}'
kubectl wait -n taskboard \
--for=jsonpath= '{.endpoints[0].conditions.ready}' =true \
endpointslice \
-l kubernetes.io/service-name=taskboard \
--timeout=60s
kubectl get endpointslice -n taskboard \
-l kubernetes.io/service-name=taskboard -o wide
结果展示
service/taskboard patched
endpointslice.discovery.k8s.io/taskboard-xqwzk condition met
NAME ADDRESSTYPE PORTS ENDPOINTS
taskboard-xqwzk IPv4 8080 10.244.0.10,10.244.0.11
condition met 说明至少一个端点已经 Ready,最后一行则展示两个地址和目标端口 8080。EndpointSlice 名、Pod IP 都是动态值。若 wait 超时,比较 Pod 标签、readiness 与 Service selector;若端点存在但请求仍失败,继续核对 Service 的 port/targetPort、容器监听地址和进程日志。
Service 的旧 Endpoints API 已经弃用。新的排障流程应查看 EndpointSlice;看到对象存在也不等于其中一定有 Ready 后端。
旧 Endpoints API 难以扩展到大量后端,EndpointSlice 把端点分片并携带更丰富条件。弃用不代表所有旧命令立刻消失,但新的自动化与排障手册应围绕 EndpointSlice 编写,避免把一个可能被截断或逐步移除的兼容对象当作唯一证据。
实操:执行结课验收
清理前先做验收,否则删除完成后就无法分辨“项目成功后被清理”和“项目从未成功”。验收分成控制器状态、持久化对象和业务响应三部分。
验收与排障的区别在于,验收先写明可接受条件,再用证据判定通过或失败。我们不要求所有动态值与示例相同,而是要求关键不变量成立:Deployment 两个可用副本、Redis 一个 Ready 成员、PVC Bound、Job Complete、HPA 回到最小副本,业务返回 v2.0.0 和已有任务。
先等待 Deployment 与 StatefulSet 达到期望状态,再列出关键对象:
kubectl wait --for=condition=Available 等 Deployment 至少达到可用条件,rollout status statefulset/redis 等有状态副本完成更新。第三条用资源类型列表做一次 Namespace 清点,它会读取多个对象,不创建资源。为什么仍要同时列出?因为单个 Available 条件不能替 PVC、Job 或 HPA 证明状态:
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
deployment.apps/taskboard condition met
partitioned roll out complete: 1 new pods have been updated...
NAME READY UP-TO-DATE AVAILABLE
deployment.apps/taskboard 2/2 2 2
NAME READY
statefulset.apps/redis 1/1
NAME TYPE CLUSTER-IP PORT(S)
service/redis ClusterIP None 6379/TCP
service/taskboard ClusterIP 10.96.250.32 80/TCP
NAME STATUS CAPACITY ACCESS MODES STORAGECLASS
persistentvolumeclaim/data-redis-0 Bound 256Mi RWO standard
NAME STATUS COMPLETIONS
job.batch/taskboard-init Complete 1/1
NAME TARGETS MINPODS MAXPODS REPLICAS
horizontalpodautoscaler.autoscaling/taskboard cpu: 4%/50% 2 5 2
这份结果证明:API 有两个可用副本,Redis 有一个稳定实例,PVC 已绑定,初始化 Job 已完成,HPA 在无负载时回到两个副本。
逐项对账:Deployment 的 READY、UP-TO-DATE 和 AVAILABLE 都是 2,说明不是仅靠旧副本维持可用;StatefulSet 为 1/1;Redis Service 的 ClusterIP 为 None,符合 Headless 设计;PVC 为 Bound 256Mi RWO;Job 为 Complete 1/1;HPA REPLICAS 为 2。CPU 当前值 4% 和所有年龄、IP 都是动态观察值,不是验收常量。
如果 HPA 还显示 5 个副本,不必为了匹配截图立即手工缩容:HPA 有稳定窗口,先确认负载生成器已停止、CPU 已下降并等待控制器回落。如果 TARGETS 为 <unknown>,回到 Metrics API、kubectl top 和容器 CPU request。PVC 若不是 Bound 或 Job 不是 Complete,则项目还没有通过,必须在删除前保留现场诊断。
最后从集群内访问健康接口、版本信息和任务列表:
三条请求都从 toolbox-safe 出发并访问 Service DNS,覆盖真实的集群内入口。/healthz 只验证进程活着;/api/info 验证版本、配置、Pod 与 Redis 配置状态;/api/tasks 验证应用到 Redis 的数据路径。这里不能只把响应打印出来让读者目测:命令分别捕获退出状态与响应体,断言健康值、版本、Namespace、Redis 配置、初始化标题,并要求 count 是 1 到 10000 的十进制整数。
所有业务断言通过后,命令读取当前 Deployment 和 StatefulSet 的 UID,把两者写入 .final-validation-owned。UID 是对象实例身份:删除并重建同名对象会得到新 UID。已有验收账本绝不会被覆盖;它必须与当前两个 UID 逐字节一致。下一节在 Namespace 删除前再次核对这份证据,从而把“验收通过的对象”和“即将删除的对象”绑定起来。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
controllerAssertions=passed
{"status":"alive"}
{"service":"taskboard","version":"2.0.0","message":"你好,配置已经更新","pod":"taskboard-5d8b985b-dqrrb","namespace":"taskboard","redisConfigured":true}
{"items":[{"title":"由 Job 初始化","done":false}],"count":1}
finalValidation=passed objectUIDsRecorded=yes
Pod 后缀会变化,但版本必须为 2.0.0,Namespace 必须为 taskboard,Redis 配置必须为 true,任务数量必须落在 1 到 10000。最后一行表示所有条件已由命令判定,并且两个控制器 UID 已记账;缺少它时不得进入清理。
示例中的 taskboard-5d8b985b-dqrrb 是一次执行的动态 Pod 名。message 应反映前文 ConfigMap 更新后的中文内容,redisConfigured:true 表示应用加载了 Redis 配置,任务标题证明 Job 写入的数据仍可读。若 health 成功而 tasks 失败,优先查 readiness、Redis 与应用依赖日志;若版本仍是 1.0.0,则回到 Deployment 镜像与 rollout history;若任务为空,则回到 Job、Redis AOF 和数据键。
历史 Warning 不等于当前仍失败
此时再查事件,仍可能看到前面主动制造的探针失败和镜像拉取失败:
Warning Unhealthy pod/taskboard-... Readiness probe failed: HTTP probe failed with statuscode: 503
Warning Failed pod/taskboard-... Error: ImagePullBackOff
事件保存的是一段时间内发生过的事情。判断当前是否健康,要把事件时间与 get、rollout 状态和业务响应一起看。只因为列表里还有 Warning 就继续重启,会把历史记录误当成当前故障。
反过来也成立:事件消失不能证明从未发生故障。事件有保留期限,并可能合并重复次数。生产验收应把当前 condition、业务 SLI 和集中日志作为主要证据,把事件用于解释时间线;对需要长期追责的操作,使用 API 审计日志,而不是依赖 Namespace 事件列表。
清理前的删除清单与边界
清理也是一次有期望状态的变更。我们先明确只删除课程拥有的对象:
命令应幂等:对象已不存在时,清理流程把它视为已达到终态;对象还存在但归属标记缺失或不匹配时,流程必须安全停止。“名称相同”、“当前没有容器使用”和“列表为空”都是状态证据,不是所有权证据。因此不使用全局 docker system prune --volumes,也不根据标签名盲删镜像。
清理前若业务验收失败,先停在这里。删除故障现场会让根因更难查,也可能把尚未备份的数据一起回收。生产系统还需要变更审批、备份验证、保留策略和责任人确认;课程的标记与具名对象,是同一原则的最小实现。
实操:删除课程 Namespace
Namespace 是本课程应用资源的第一层边界。删除它会级联删除其中的 Deployment、StatefulSet、Service、Secret、ConfigMap、Job、HPA、Pod 和 PVC。我们先删这一层,并等待控制器完成回收。
API Server 会给 Namespace 设置删除时间,Namespace 控制器和各资源 finalizer 负责清理内容;只有相关清理完成后,Namespace 才真正消失。--wait=true 让 kubectl 等待删除,--timeout=180s 防止终端无限阻塞。命令会删除业务数据,因此必须在上面的业务与持久化验收通过、并确认不再需要保留数据后执行。
这一步不会直接删除集群范围对象。PVC 属于 Namespace,但 PV 属于集群范围;PVC 删除后,动态 PV 是否消失取决于回收策略和供应器执行。所以必须在 PVC 还存在时读取 .spec.volumeName,把精确 PV 名写入课程账本。删除后我们查的是这一个 PV,不是要求整个集群的 PV 列表为空。
先锁定文件边界和 API 边界。字面路径要精确等于 $HOME/welearn-kubernetes-course,pwd -P 解析后的物理路径也要一致,这会拒绝指向别处的符号链接。.course-owned 用 cmp 逐字节校验,context 必须是课程集群。任一条不成立都在破坏性命令前停止。
下面的账本处理也支持中断后恢复:Namespace 存在时从 PVC 建立记录;Namespace 已不存在时,必须使用上次留下的记录。已有记录若与当前 PVC 不同,不会被覆盖。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
EXPECTED_COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ " $COURSE_ROOT " != " $EXPECTED_COURSE_ROOT " ]; then
echo '停止:COURSE_ROOT 不是固定课程路径' >&2
exit 1
fi
if [ ! -d " $COURSE_ROOT " ]; then
echo '停止:课程目录不存在' >&2
exit 1
结果展示
course_pv=pvc-<动态 UID>
namespace "taskboard" deleted
这一行表示删除请求及等待已经完成,不列出被级联删除的每个对象。--ignore-not-found 让重跑时已不存在的 Namespace 算作达到期望终态。它不会忽略认证、网络或 API 错误。如果命令超时,执行 kubectl get namespace taskboard -o yaml 查看删除时间和 finalizers;不要在不理解后果时手工清空 finalizer。
不要只相信 deleted 。下面先断言 Namespace 的错误确实是 NotFound,再轮询账本中那一个 PV。轮询期间 PV 还能读取,表示供应器尚在回收;读取失败时只接受 NotFound,所以 Forbidden、证书错误或 context 错误不会被当作“PV 已删除”。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
namespace=NotFound
pv/pvc-<动态 UID>=NotFound
练习集群的 standard StorageClass 使用 Delete 回收策略,所以 PVC 删除、PV 解除绑定后,供应器会回收 PV 及其后端。如果回收策略是 Retain,轮询会超时并保留账本;这是要求进入数据备份和存储处置流程的安全停止,不应手工删除未确认的后端数据。
kubectl get all -n taskboard 不能作为强验收:all 只是一组常用资源类型的聚合别名,不包含 Secret、ConfigMap、PVC、NetworkPolicy 等全部 namespaced 对象。Namespace 本身读取为 NotFound 才是“这个命名边界已终止”的最强 API 证据;精确 PV 的 NotFound 则独立覆盖集群范围存储对象。
实操:删除 kind 集群
Namespace 已经清空,下一层边界是 kind 集群。删除具名集群只会处理 welearn-course,不会把其他名字的集群混进来。
kind delete cluster --name welearn-course 的接口仍然按名称工作,但名称本身不证明归属。第 2 章的 .kind-cluster-owned 用四行账本绑定集群名、节点名、完整 64 位 Docker 容器 ID 和 kind 标签;创建失败时 .kind-create-attempt 使用同样格式保存能够确认的残留 ID。清理只接受这两类精确证据。
执行 kind 删除前,命令要求 kind get clusters 含课程名称,按名称和按标签查询都恰好得到同一个 ID,docker inspect 又证明精确名称 /welearn-course-control-plane 和标签值 welearn-course。如果 kind 列表已经没有集群,但失败或中断留下了账本所记录的孤立容器,则只按完整 ID 删除它。pending 没有 ID,不能授权删除任何已出现的资源。
这套流程是幂等的:资源和有效账本都已不存在时直接报告终态;删除完成但账本尚未移除时,重新验证空状态后收尾。任一同名、同标签或同 ID 事实不一致都停止,不会通过全局 prune 扩大范围。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
Deleting cluster "welearn-course" ...
Deleted nodes: ["welearn-course-control-plane"]
cluster/welearn-course=absent
前两行由 kind 输出,最后一行是在 kind 列表、Docker 名称、Docker 标签和完整 ID 四类证据都达到终态后才出现。失败创建的孤立容器会改为显示 removedResidualNode=<完整 ID>。其他名称的 kind 集群不属于课程残留,不能为了匹配示例而删除。Docker daemon 不可用时查询返回非零,流程不会把它误当作“容器列表为空”。
实操:只删除课程创建的镜像与配置
kind 会删除节点容器,但课程构建的应用镜像和下载的节点镜像可能仍在容器运行时中。不要执行全局 docker system prune --volumes,因为它会把不属于本课程的缓存、卷和镜像也纳入删除范围。
镜像与容器是不同对象。删除 kind 节点容器不会自动删除 Docker 中的 kindest/node 镜像,也不会删除我们构建的 taskboard 标签。但 tag 也不是所有权证明:另一个项目可能在课程之后让同名 tag 指向新 image ID。因此第 3 章和第 8 章在构建成功后分别记录完整 sha256:<64 位> image ID,删除前必须与 tag 当前 ID 逐字节比较。
删除镜像前先兜底清理第 3 章的 taskboard-standalone。健康断言中途退出时,它可能仍在使用 taskboard:1.0.0;命令同时识别 .taskboard-standalone-owned 和已经写入完整 ID 的 .taskboard-standalone-attempt,只在容器 ID、精确名称与镜像引用全部一致时按 ID 删除。只有 pending 时不能授权一个已出现的容器。随后 remove_owned_tag 处理三种情况:标记和 tag 均存在且 ID 相同时删除;标记存在但 tag 已经不存在时视为已到终态;标记缺失但 tag 还存在,或两个 ID 不同时安全停止。Docker 连接失败与 No such image 也分开处理。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
removedStandalone=ab3c48bfb17c7f4cd9580fab56dc99f04eeff2522e943c36f087805b9efa2604
container/taskboard-standalone=absent
Untagged: taskboard:1.0.0
image/taskboard:1.0.0=absent
Untagged: taskboard:2.0.0
image/taskboard:2.0.0=absent
removedStandalone 后的 64 位 ID 每次都可能不同;稳定证据是它与 owned 或完整 attempt 账本一致,并且后续再按该 ID inspect 已经返回 NotFound。若第 3 章已正常收尾,第一行会改为 container/taskboard-standalone=already-absent。Untagged 是 Docker 的动态输出;课程自己的稳定终态是两行 image/...=absent。
删除课程专属 BuildKit builder 与缓存卷
TaskBoard 的两个版本都由 welearn-course-builder 构建。docker-container driver 把缓存放在专属状态卷中,所以删除 builder 才是真正回收构建缓存;仅删除最终镜像不会删除这个卷。
下面接受 .buildx-builder-owned,也接受一次失败创建留下且已经写入完整事实的 .buildx-builder-attempt。容器存在时,完整 ID、精确名称、driver 镜像 ID 和挂载卷必须全部匹配;卷存在时,创建时间也必须与账本逐字节一致。pending 没有容器 ID,因此一旦容器或卷已经出现就停止。docker buildx rm 正常会同时移除管理容器和状态卷;命令仍会独立复核,并只对已经授权且仍匹配的残留执行精确删除。
builder 账本暂时保留到下一块 driver 镜像清理完成,因为其中还保存了 driver image ID。这样删除 builder 之后,镜像清理仍有不可变身份可以比较。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
welearn-course-builder removed
builder/welearn-course-builder=absent cacheVolume=absent
docker buildx rm 的第一行会随 buildx 版本略有不同,也可能不输出文本;第二行才是课程在重新查询 builder 元数据、管理容器和状态卷之后给出的稳定断言。首次删除和重跑清理都以 builder/welearn-course-builder=absent cacheVolume=absent 为终态。
按创建前基线处理 driver 镜像、节点镜像和 kind 网络
BuildKit driver 镜像、kind 节点镜像和名为 kind 的 Docker 网络都是共享资产,课程开始前就可能存在。删除时只看“当前没有容器”是不够的:那会把一个预先存在、此刻刚好空闲的资源误删。第 2、3 章分别在首次创建前把基线固化为严格的 yes\n 或 no\n,这是历史所有权证据。
基线为 yes 时无条件保留;基线为 no 时才处理课程创建的精确 driver 引用。BuildKit driver 镜像还要确认专属 builder、容器和卷均已消失,当前 image ID 等于八行账本记录,且没有其他容器使用。Docker Desktop 28.5 的实际结果会把 tag@digest 规范化为 moby/buildkit@sha256:...,并同时把这个规范引用列入 .RepoTags 与 .RepoDigests;因此“RepoTags 必须为空”不是可移植的删除条件。命令只删除创建前明确不存在的固定摘要引用;若同一 image ID 后来有其他 tag,这些引用会原样保留。节点镜像同样要确认没有容器以它为 ancestor;网络还要确认没有其他 kind 集群,且 .Containers 精确为 {}。若课程运行期间出现新的共享使用者,命令会保留账本并停止,而不会强制拆除共享资源。固定摘要的 Python 基础镜像只存在于已经删除的 builder 缓存卷中,没有在 Docker 镜像存储建立独立 tag,因此无需按名称另删。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ ! -d " $COURSE_ROOT " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程根目录或归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
结果展示
Untagged: moby/buildkit@sha256:0168606be2315b7c807a03b3d8aa79beefdb31c98740cebdffdfeebf31190c9f
buildkit_driver_reference=removed_course_created
buildkit_driver_image_id=absent
Untagged: kindest/node@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
node_image=removed_course_created
kind
kind_network=removed_course_created
示例对应三个基线都为 no 的首次清理。Untagged 和网络名由 Docker 输出,是动态附加信息;三组带等号的状态行才是课程检查后输出的稳定结果。如果 driver 镜像、节点镜像与 kind 网络在创建课程资源前已经存在,正确的另一类稳定输出是:
buildkit_driver_image=preserved_preexisting
node_image=preserved_preexisting
kind_network=preserved_preexisting
这三行表示“保留创建前资产”,不是清理失败。如果课程的 driver 摘要引用已删,但同一 image ID 还被其他 tag 引用,则第三行会改为 buildkit_driver_image_id=preserved_shared_reference;这正是只删课程引用、不拆共享层的证据。
最后删除课程目录与 shell 引用
最后才删除包含 TaskBoard 源码、kubectl、kind 和独立 kubeconfig 的课程目录。这一步不能只做一次字符串比较后就 rm -rf:我们再次断言字面路径精确、物理路径精确和 .course-owned 逐字节匹配。所有资源账本必须已经处理完;最终业务验收账本必须仍然存在且格式有效,它会在删除目录前作为最后一道门槛移除。
PATH 可能因为多次进入课程而出现多个 $COURSE_ROOT/bin 分量。下面的循环按冒号分解 PATH,删除每一个与课程 bin 完全相等 的分量,保留其他顺序和空分量。它不使用子串替换,所以不会损坏名称相似的目录。
KUBECONFIG 不能一律取消:读者进入课程前可能已经设置了一个包含空格、冒号或换行的完整值。命令在删除目录前验证 .kubeconfig-env-before-course 是当前用户拥有、权限精确 0600 的普通文件,核对头部与字节长度,再用末尾哨兵避免命令替换吞掉原值末尾换行。目录删除成功后,原状态为 set 就精确 export 原值,原状态为 unset 才执行 unset;原值不会打印。
课程命令还在当前 shell 定义了账本读写、身份核对和故障客户收尾等 30 个函数。最后一段对每个名称显式执行 Bash 与 Zsh 都支持的 unset -f,随后用 typeset -f 验证它们已不存在;课程设置的普通变量也会逐一 unset 并立即验证。这些 shell 操作只作用于执行本段命令的当前 shell,不会修改其他已打开终端中的函数或变量。
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
EXPECTED_COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if [ " $COURSE_ROOT " != " $EXPECTED_COURSE_ROOT " ]; then
echo '停止:COURSE_ROOT 字面路径不匹配' >&2
exit 1
fi
if [ ! -d " $COURSE_ROOT " ]; then
echo '停止:课程目录不存在' >&2
exit 1
结果展示
course_root=absent path_entry=removed kubeconfigRestored=unset
shell_state=course_functions_and_variables_removed
第一行同时表示路径删除断言、PATH 精确分量处理和 KUBECONFIG 原状态恢复已完成。示例中的 unset 取决于课程开始前的真实状态;原先为 set 时会显示 set,但不显示值。第二行只在 30 个课程函数与列出的课程变量逐一通过“已不存在”验证后才输出。若任何一步输出“停止”,不要手工删标记来绕过保护;标记存在正是为了让中断后能继续核对原资源。完整清理结束后,Namespace、精确 PV、课程 kind 集群、独立验收容器、专属 builder 与缓存卷、课程创建的镜像和网络引用、项目源码、课程工具及专用 kubeconfig 均已逐层验证。课程开始前已有的共享镜像或 kind 网络会按基线原样保留。
为什么清理要幂等而且不能全局 prune
清理脚本经常需要重跑:网络中断、控制器等待超时或进程被终止,都可能让一部分对象已删除、另一部分还在。幂等清理会先判断对象是否已经处于目标终态,存在则按精确标识删除,不存在则继续核对其他边界。它不会因为某个 tag 已不存在就扩大到“删除所有悬空镜像”。
全局 prune 的风险来自归属不明。未被当前容器使用的镜像可能是另一个项目的构建缓存,未挂载卷可能包含尚未恢复的数据,停止的容器也可能是他人的调试现场。Docker 的“unused”是运行时引用状态,不是组织所有权。课程通过 Namespace 名、kind 集群名、镜像 tag、固定 digest 和文件标记建立归属,所有删除都应沿这些标识进行。
把这套方法带到下一套系统
完成课程后,你已经实际走过下面这条路径:
从源码和 Dockerfile 构建带版本的应用镜像;
用 Pod 理解 Kubernetes API 对象和 kubectl 证据;
用 Deployment 管理副本、自愈、探针、资源与滚动发布;
用 Service、DNS 和 EndpointSlice 建立稳定入口;
用 ConfigMap、Secret 与 Downward API 注入运行配置;
用 Metrics Server 和 HPA 根据 CPU 利用率扩缩容;
用 StatefulSet、PVC 和 Job 保存并初始化数据;
用 securityContext、RBAC 与 Pod Security Admission 收紧边界;
用状态、条件、事件和日志定位故障;
按 Namespace、集群、镜像和配置的边界完成清理。
下一次部署不同应用时,对象名称和镜像会变,控制器的思路不会变:先声明期望状态,再观察 Kubernetes 如何靠控制循环逼近它;结果不一致时,沿着对象关系收集证据。
在生产系统中,这条方法还要接上可观测性与运行制度。指标告诉你错误率、延迟、饱和度和副本趋势,集中日志跨越 Pod 生命周期保留应用证据,分布式追踪把一次请求穿过多个服务,API 审计日志记录谁修改了对象。值班手册应把症状、第一条证据、升级条件和安全回滚写清楚,并通过故障演练确认命令仍适用于当前版本。
排障也有权限边界。kubectl exec、读取日志、模拟身份和查看 Secret 都可能接触敏感信息,不能为了方便给所有值班人员永久 cluster-admin。更稳妥的做法是按职责授予只读证据权限,对高风险操作使用短期审批,并保留审计。紧急恢复速度与最小权限并不冲突,前提是提前设计路径。
最终状态与验收清单
课程结束时,既要确认项目曾经成功,也要确认资源已经按所有权清理。完整证据分为两个时间点。
清理前已经满足:
清理后已经满足:
如果你能解释每个勾选项为什么需要独立证据,这门课的目标就不只是“命令跑通”。你已经能够把 Kubernetes 看成一组相互关联的 API 对象和控制循环,并知道在变更前预测状态、在变更后验证状态、在偏离时沿证据链找到责任边界。
结课小测
1 Service 有 ClusterIP,但请求被拒绝。哪一项最适合作为第一条后端证据?
A 重新安装 kubectl B 查看 EndpointSlice 的地址和 ready 条件 C 删除整个集群 D 把副本数改为零再改回来
2 最终验收已经成功,因此事件列表中的历史 Warning 可以单独证明当前仍有故障。
4 Pod 显示 ImagePullBackOff 时,为什么通常不先执行 kubectl logs?
A 日志只能用于 StatefulSet B 容器尚未成功启动,应用日志还不存在,事件更接近拉取根因 C kubectl logs 会自动删除 Pod D RBAC 永远禁止读取日志
5 删除 Namespace 返回 deleted 后,就不必再检查 PV,因为 PV 一定属于该 Namespace。
资料依据与继续阅读
本章的方法和命令边界以项目一手文档为依据:
官方排障文档提供对象级入口,实际系统还需要结合所用 CNI、CSI、云平台、容器运行时和应用自身文档。始终从当前版本和真实输出出发,不要把一次环境中的错误文本当作所有实现的固定答案。
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
cd " $COURSE_ROOT /taskboard"
if [ "$( kubectl auth can-i get deployments -n taskboard)" != 'yes' ] || \
[ "$( kubectl auth can-i patch deployments -n taskboard)" != 'yes' ]; then
echo '停止:当前身份缺少 Deployment get/patch 权限' >&2
exit 1
fi
if ! kubectl set image deployment/taskboard -n taskboard \
api=taskboard:9.9.9 ; then
echo '停止:无法提交故障镜像变更' >&2
exit 1
fi
if ROLLOUT_OUTPUT = "$(
kubectl rollout status deployment/taskboard \
-n taskboard --timeout=15s 2>&1
)" ; then
ROLLOUT_RC = 0
else
ROLLOUT_RC = $?
fi
printf '%s\n' " $ROLLOUT_OUTPUT "
if [ " $ROLLOUT_RC " -eq 0 ]; then
echo '停止:故障发布意外成功' >&2
exit 1
fi
case " $ROLLOUT_OUTPUT " in
* 'timed out waiting for the condition' * )
printf 'expected_rollout_timeout rc=%s\n' " $ROLLOUT_RC "
;;
*)
echo '停止:失败不是预期的 rollout 超时,请检查认证、上下文与 RBAC' >&2
exit 1
;;
esac
1
fi
if ! kubectl patch service taskboard -n taskboard \
-p '{"spec":{"selector":{"app":"wrong-label"}}}' ; then
echo '停止:无法提交 Service 故障变更' >&2
exit 1
fi
ADDRESSES = 'not-converged'
for attempt in $( seq 1 30 ); do
if ! ADDRESSES = "$( kubectl get endpointslice -n taskboard \
-l kubernetes.io/service-name=taskboard \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{end}')" ; then
echo '停止:无法读取 EndpointSlice' >&2
exit 1
fi
[ -z " $ADDRESSES " ] && break
sleep 1
done
if [ -n " $ADDRESSES " ]; then
echo '停止:EndpointSlice 在 30 秒内未收敛为空' >&2
exit 1
fi
echo 'endpoints=0'
kubectl get endpointslice -n taskboard \
-l kubernetes.io/service-name=taskboard -o wide
if SERVICE_PROBE_OUTPUT = "$(
kubectl exec -n taskboard toolbox-safe -- sh -c '
if wget -T 3 -qO- http://taskboard/api/info; then
echo unexpected_http_success >&2
exit 90
else
probe_exit=$?
echo "expected_backend_failure wget_exit=$probe_exit" >&2
exit 42
fi
' 2>&1
)" ; then
SERVICE_PROBE_RC = 0
else
SERVICE_PROBE_RC = $?
fi
printf '%s\n' " $SERVICE_PROBE_OUTPUT "
if [ " $SERVICE_PROBE_RC " -ne 42 ]; then
printf '停止:期望远端业务失败码 42,实际为 %s\n' \
" $SERVICE_PROBE_RC " >&2
exit 1
fi
case " $SERVICE_PROBE_OUTPUT " in
* 'expected_backend_failure wget_exit=' * ) ;;
*)
echo '停止:缺少容器内 wget 失败证据' >&2
exit 1
;;
esac
echo 'service_negative_probe=passed rc=42'
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
cd " $COURSE_ROOT /taskboard"
kubectl wait --for=condition=Available deployment/taskboard \
-n taskboard --timeout=60s
kubectl rollout status statefulset/redis \
-n taskboard --timeout=60s
kubectl get deployment,statefulset,service,pvc,job,hpa -n taskboard
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
cd " $COURSE_ROOT /taskboard"
if DEPLOYMENT_ASSERTION = "$(
kubectl get deployment taskboard -n taskboard \
-o jsonpath='{.spec.replicas}|{.status.readyReplicas}|{.status.updatedReplicas}|{.status.availableReplicas}' 2>&1
)" ; then
:
else
ASSERTION_RC = $?
printf '停止:无法读取 Deployment 验收字段,rc=%s\n%s\n' \
" $ASSERTION_RC " " $DEPLOYMENT_ASSERTION " >&2
exit 1
fi
if [ " $DEPLOYMENT_ASSERTION " != '2|2|2|2' ]; then
printf '停止:Deployment spec/ready/updated/available 不是 2/2/2/2\n%s\n' \
" $DEPLOYMENT_ASSERTION " >&2
exit 1
fi
if STATEFULSET_ASSERTION = "$(
kubectl get statefulset redis -n taskboard \
-o jsonpath='{.spec.replicas}|{.status.readyReplicas}|{.status.currentReplicas}|{.status.updatedReplicas}' 2>&1
)" ; then
:
else
ASSERTION_RC = $?
printf '停止:无法读取 StatefulSet 验收字段,rc=%s\n%s\n' \
" $ASSERTION_RC " " $STATEFULSET_ASSERTION " >&2
exit 1
fi
if [ " $STATEFULSET_ASSERTION " != '1|1|1|1' ]; then
printf '停止:StatefulSet spec/ready/current/updated 不是 1/1/1/1\n%s\n' \
" $STATEFULSET_ASSERTION " >&2
exit 1
fi
if PVC_ASSERTION = "$(
kubectl get pvc data-redis-0 -n taskboard \
-o jsonpath='{.status.phase}' 2>&1
)" ; then
:
else
ASSERTION_RC = $?
printf '停止:无法读取 PVC 验收字段,rc=%s\n%s\n' \
" $ASSERTION_RC " " $PVC_ASSERTION " >&2
exit 1
fi
if [ " $PVC_ASSERTION " != 'Bound' ]; then
printf '停止:PVC phase 不是 Bound:%s\n' " $PVC_ASSERTION " >&2
exit 1
fi
if JOB_ASSERTION = "$(
kubectl get job taskboard-init -n taskboard \
-o jsonpath='{.status.conditions[?(@.type=="Complete")].status}|{.status.succeeded}' 2>&1
)" ; then
:
else
ASSERTION_RC = $?
printf '停止:无法读取 Job 验收字段,rc=%s\n%s\n' \
" $ASSERTION_RC " " $JOB_ASSERTION " >&2
exit 1
fi
if [ " $JOB_ASSERTION " != 'True|1' ]; then
printf '停止:Job Complete/succeeded 不是 True/1\n%s\n' \
" $JOB_ASSERTION " >&2
exit 1
fi
if HPA_ASSERTION = "$(
kubectl get hpa taskboard -n taskboard \
-o jsonpath='{.spec.minReplicas}|{.spec.maxReplicas}|{.status.currentReplicas}' 2>&1
)" ; then
:
else
ASSERTION_RC = $?
printf '停止:无法读取 HPA 验收字段,rc=%s\n%s\n' \
" $ASSERTION_RC " " $HPA_ASSERTION " >&2
exit 1
fi
if [ " $HPA_ASSERTION " != '2|5|2' ]; then
printf '停止:HPA min/max/current 不是 2/5/2\n%s\n' \
" $HPA_ASSERTION " >&2
exit 1
fi
echo 'controllerAssertions=passed'
if HEALTH_OUTPUT = "$(
kubectl exec -n taskboard toolbox-safe -- \
wget -qO- http://taskboard/healthz 2>&1
)" ; then
:
else
HEALTH_RC = $?
printf '停止:健康请求失败,rc=%s\n%s\n' \
" $HEALTH_RC " " $HEALTH_OUTPUT " >&2
exit 1
fi
if ! printf '%s\n' " $HEALTH_OUTPUT " | \
grep -Eq '"status"[[:space:]]*:[[:space:]]*"alive"' ; then
printf '停止:健康响应没有 status=alive\n%s\n' \
" $HEALTH_OUTPUT " >&2
exit 1
fi
if INFO_OUTPUT = "$(
kubectl exec -n taskboard toolbox-safe -- \
wget -qO- http://taskboard/api/info 2>&1
)" ; then
:
else
INFO_RC = $?
printf '停止:信息请求失败,rc=%s\n%s\n' \
" $INFO_RC " " $INFO_OUTPUT " >&2
exit 1
fi
for INFO_ASSERTION in \
'"version"[[:space:]]*:[[:space:]]*"2\.0\.0"' \
'"namespace"[[:space:]]*:[[:space:]]*"taskboard"' \
'"redisConfigured"[[:space:]]*:[[:space:]]*true'
do
if ! printf '%s\n' " $INFO_OUTPUT " | grep -Eq " $INFO_ASSERTION " ; then
printf '停止:信息响应缺少验收字段 %s\n%s\n' \
" $INFO_ASSERTION " " $INFO_OUTPUT " >&2
exit 1
fi
done
if TASKS_OUTPUT = "$(
kubectl exec -n taskboard toolbox-safe -- \
wget -qO- http://taskboard/api/tasks 2>&1
)" ; then
:
else
TASKS_RC = $?
printf '停止:任务请求失败,rc=%s\n%s\n' \
" $TASKS_RC " " $TASKS_OUTPUT " >&2
exit 1
fi
if ! printf '%s\n' " $TASKS_OUTPUT " | \
grep -Fq '"title":"由 Job 初始化"' ; then
printf '停止:任务响应缺少初始化标题\n%s\n' \
" $TASKS_OUTPUT " >&2
exit 1
fi
TASK_COUNT = "$(
printf '%s\n' " $TASKS_OUTPUT " | \
sed -n 's/.*"count"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p'
)"
case " $TASK_COUNT " in
'' |* [!0-9] * )
echo '停止:任务 count 不是十进制整数' >&2
exit 1
;;
esac
if [ " $TASK_COUNT " -lt 1 ] || [ " $TASK_COUNT " -gt 10000 ]; then
printf '停止:任务 count 超出验收边界:%s\n' " $TASK_COUNT " >&2
exit 1
fi
if DEPLOYMENT_UID = "$(
kubectl get deployment taskboard -n taskboard \
-o jsonpath='{.metadata.uid}' 2>&1
)" && STATEFULSET_UID = "$(
kubectl get statefulset redis -n taskboard \
-o jsonpath='{.metadata.uid}' 2>&1
)" ; then
:
else
UID_RC = $?
printf '停止:无法读取验收对象 UID,rc=%s\ndeployment=%s\nstatefulset=%s\n' \
" $UID_RC " " $DEPLOYMENT_UID " " $STATEFULSET_UID " >&2
exit 1
fi
if ! printf '%s\n' " $DEPLOYMENT_UID " | \
grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || \
! printf '%s\n' " $STATEFULSET_UID " | \
grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ; then
echo '停止:验收对象 UID 格式不正确' >&2
exit 1
fi
FINAL_VALIDATION_MARKER = " $COURSE_ROOT /.final-validation-owned"
if [ -e " $FINAL_VALIDATION_MARKER " ]; then
if ! {
printf 'deployment_uid=%s\n' " $DEPLOYMENT_UID "
printf 'statefulset_uid=%s\n' " $STATEFULSET_UID "
} | cmp -s - " $FINAL_VALIDATION_MARKER " ; then
echo '停止:现有最终验收账本绑定了其他对象实例' >&2
exit 1
fi
else
{
printf 'deployment_uid=%s\n' " $DEPLOYMENT_UID "
printf 'statefulset_uid=%s\n' " $STATEFULSET_UID "
} > " $FINAL_VALIDATION_MARKER "
fi
printf '%s\n%s\n%s\n' \
" $HEALTH_OUTPUT " " $INFO_OUTPUT " " $TASKS_OUTPUT "
echo 'finalValidation=passed objectUIDsRecorded=yes'
fi
HOME_PHYSICAL = "$( cd " $HOME " && pwd -P )"
COURSE_ROOT_PHYSICAL = "$( cd " $COURSE_ROOT " && pwd -P )"
EXPECTED_PHYSICAL = " $HOME_PHYSICAL /welearn-kubernetes-course"
if [ " $COURSE_ROOT_PHYSICAL " != " $EXPECTED_PHYSICAL " ]; then
echo '停止:课程物理路径不匹配' >&2
exit 1
fi
if ! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:课程归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
FINAL_VALIDATION_MARKER = " $COURSE_ROOT /.final-validation-owned"
if [ ! -f " $FINAL_VALIDATION_MARKER " ]; then
echo '停止:缺少最终业务验收账本,不执行破坏性清理' >&2
exit 1
fi
VALIDATED_DEPLOYMENT_UID = "$(
sed -n '1s/^deployment_uid=//p' " $FINAL_VALIDATION_MARKER "
)"
VALIDATED_STATEFULSET_UID = "$(
sed -n '2s/^statefulset_uid=//p' " $FINAL_VALIDATION_MARKER "
)"
if ! printf '%s\n' " $VALIDATED_DEPLOYMENT_UID " | \
grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || \
! printf '%s\n' " $VALIDATED_STATEFULSET_UID " | \
grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || \
! {
printf 'deployment_uid=%s\n' " $VALIDATED_DEPLOYMENT_UID "
printf 'statefulset_uid=%s\n' " $VALIDATED_STATEFULSET_UID "
} | cmp -s - " $FINAL_VALIDATION_MARKER " ; then
echo '停止:最终业务验收账本格式不正确' >&2
exit 1
fi
PV_MARKER = " $COURSE_ROOT /.taskboard-pv-owned"
if NS_OUTPUT = "$( kubectl get namespace taskboard -o name 2>&1 )" ; then
if [ " $NS_OUTPUT " != 'namespace/taskboard' ]; then
echo '停止:Namespace 读回值异常' >&2
exit 1
fi
if CURRENT_DEPLOYMENT_UID = "$(
kubectl get deployment taskboard -n taskboard \
-o jsonpath='{.metadata.uid}' 2>&1
)" && CURRENT_STATEFULSET_UID = "$(
kubectl get statefulset redis -n taskboard \
-o jsonpath='{.metadata.uid}' 2>&1
)" ; then
:
else
UID_RC = $?
printf '停止:删除前无法读取控制器 UID,rc=%s\ndeployment=%s\nstatefulset=%s\n' \
" $UID_RC " " $CURRENT_DEPLOYMENT_UID " \
" $CURRENT_STATEFULSET_UID " >&2
exit 1
fi
if [ " $CURRENT_DEPLOYMENT_UID " != " $VALIDATED_DEPLOYMENT_UID " ] || \
[ " $CURRENT_STATEFULSET_UID " != " $VALIDATED_STATEFULSET_UID " ]; then
echo '停止:当前控制器不是最终验收账本绑定的对象实例' >&2
exit 1
fi
if ! COURSE_PV_NAME = "$(
kubectl get pvc data-redis-0 -n taskboard \
-o jsonpath='{.spec.volumeName}'
)" ; then
echo '停止:无法读取课程 PVC' >&2
exit 1
fi
if [ -z " $COURSE_PV_NAME " ]; then
echo '停止:PVC 尚未绑定 PV' >&2
exit 1
fi
if ! PV_CLAIM = "$(
kubectl get pv " $COURSE_PV_NAME " \
-o jsonpath='{.spec.claimRef.namespace}/{.spec.claimRef.name}'
)" ; then
echo '停止:无法读取 PVC 绑定的 PV' >&2
exit 1
fi
if [ " $PV_CLAIM " != 'taskboard/data-redis-0' ]; then
echo '停止:PV claimRef 不属于课程 PVC' >&2
exit 1
fi
if [ -e " $PV_MARKER " ]; then
if ! printf '%s\n' " $COURSE_PV_NAME " | cmp -s - " $PV_MARKER " ; then
echo '停止:PV 账本与当前绑定不一致' >&2
exit 1
fi
else
printf '%s\n' " $COURSE_PV_NAME " > " $PV_MARKER "
fi
else
NS_RC = $?
case " $NS_OUTPUT " in
* '(NotFound)' * ) ;;
*)
printf '停止:读取 Namespace 失败,rc=%s\n%s\n' \
" $NS_RC " " $NS_OUTPUT " >&2
exit 1
;;
esac
if [ ! -f " $PV_MARKER " ]; then
echo '停止:Namespace 已不存在且没有 PV 账本' >&2
exit 1
fi
COURSE_PV_NAME = "$( cat " $PV_MARKER ")"
if [ -z " $COURSE_PV_NAME " ] || \
! printf '%s\n' " $COURSE_PV_NAME " | cmp -s - " $PV_MARKER " ; then
echo '停止:PV 账本格式不正确' >&2
exit 1
fi
fi
printf 'course_pv=%s\n' " $COURSE_PV_NAME "
if ! kubectl delete namespace taskboard \
--ignore-not-found --wait=true --timeout=180s ; then
echo '停止:Namespace 删除未在限定时间内完成' >&2
exit 1
fi
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
PV_MARKER = " $COURSE_ROOT /.taskboard-pv-owned"
if [ ! -f " $PV_MARKER " ]; then
echo '停止:缺少上一清理步骤写入的精确 PV 账本' >&2
exit 1
fi
COURSE_PV_NAME = "$( cat " $PV_MARKER ")"
if [ -z " $COURSE_PV_NAME " ] || \
! printf '%s\n' " $COURSE_PV_NAME " | cmp -s - " $PV_MARKER " ; then
echo '停止:PV 账本格式不正确' >&2
exit 1
fi
if NS_VERIFY = "$( kubectl get namespace taskboard -o name 2>&1 )" ; then
echo '停止:Namespace 仍然存在' >&2
exit 1
else
NS_VERIFY_RC = $?
fi
case " $NS_VERIFY " in
* '(NotFound)' * ) echo 'namespace=NotFound' ;;
*)
printf '停止:Namespace 验证失败,rc=%s\n%s\n' \
" $NS_VERIFY_RC " " $NS_VERIFY " >&2
exit 1
;;
esac
PV_GONE = 'no'
for attempt in $( seq 1 60 ); do
if PV_VERIFY = "$( kubectl get pv " $COURSE_PV_NAME " -o name 2>&1 )" ; then
sleep 1
else
PV_VERIFY_RC = $?
case " $PV_VERIFY " in
* '(NotFound)' * )
PV_GONE = 'yes'
break
;;
*)
printf '停止:PV 验证失败,rc=%s\n%s\n' \
" $PV_VERIFY_RC " " $PV_VERIFY " >&2
exit 1
;;
esac
fi
done
if [ " $PV_GONE " != 'yes' ]; then
kubectl get pv " $COURSE_PV_NAME " \
-o custom-columns='NAME:.metadata.name,PHASE:.status.phase,RECLAIM:.spec.persistentVolumeReclaimPolicy,CLAIM_NS:.spec.claimRef.namespace,CLAIM:.spec.claimRef.name'
echo '停止:精确 PV 在 60 秒后仍存在,不删除账本' >&2
exit 1
fi
printf 'pv/%s=NotFound\n' " $COURSE_PV_NAME "
rm -f -- " $PV_MARKER "
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
CLUSTER_NAME = 'welearn-course'
NODE_NAME = 'welearn-course-control-plane'
CLUSTER_LABEL = 'io.x-k8s.kind.cluster=welearn-course'
CLUSTER_MARKER = " $COURSE_ROOT /.kind-cluster-owned"
ATTEMPT_MARKER = " $COURSE_ROOT /.kind-create-attempt"
is_full_container_id () {
case " $1 " in
'' |* [!0-9a-f] * ) return 1 ;;
esac
[ "${ # 1 }" -eq 64 ]
}
read_cluster_cleanup_ledger () {
RCCL_FILE = " $1 "
RCCL_ALLOW_PENDING = " $2 "
RCCL_NODE_ID = "$( sed -n '3s/^node_id=//p' " $RCCL_FILE ")"
if [ " $RCCL_ALLOW_PENDING " = 'yes' ] && \
[ " $RCCL_NODE_ID " = 'pending' ]; then
:
elif ! is_full_container_id " $RCCL_NODE_ID " ; then
echo "停止:集群账本没有完整容器 ID: $RCCL_FILE " >&2
return 1
fi
if ! {
printf 'cluster=%s\n' " $CLUSTER_NAME "
printf 'node=%s\n' " $NODE_NAME "
printf 'node_id=%s\n' " $RCCL_NODE_ID "
printf 'label=%s\n' " $CLUSTER_LABEL "
} | cmp -s - " $RCCL_FILE " ; then
echo "停止:集群账本字段、顺序或换行无效: $RCCL_FILE " >&2
return 1
fi
}
read_cluster_cleanup_state () {
if CLEANUP_KIND_CLUSTERS = "$( kind get clusters 2>&1 )" ; then
:
else
RCCS_RC = $?
printf '停止:无法读取 kind 集群,rc=%s\n%s\n' \
" $RCCS_RC " " $CLEANUP_KIND_CLUSTERS " >&2
return 1
fi
if CLEANUP_LABEL_IDS = "$(
docker ps -aq --no-trunc --filter "label= $CLUSTER_LABEL " 2>&1
)" ; then
:
else
RCCS_RC = $?
printf '停止:无法按标签查询节点容器,rc=%s\n%s\n' \
" $RCCS_RC " " $CLEANUP_LABEL_IDS " >&2
return 1
fi
if CLEANUP_NAME_IDS = "$(
docker ps -aq --no-trunc --filter "name= $NODE_NAME " 2>&1
)" ; then
:
else
RCCS_RC = $?
printf '停止:无法按名称查询节点容器,rc=%s\n%s\n' \
" $RCCS_RC " " $CLEANUP_NAME_IDS " >&2
return 1
fi
CLEANUP_LABEL_COUNT = "$(
printf '%s\n' " $CLEANUP_LABEL_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
CLEANUP_NAME_COUNT = "$(
printf '%s\n' " $CLEANUP_NAME_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
if printf '%s\n' " $CLEANUP_KIND_CLUSTERS " | \
grep -Fqx " $CLUSTER_NAME " ; then
CLEANUP_KIND_HAS_CLUSTER = 'yes'
else
CLEANUP_KIND_HAS_CLUSTER = 'no'
fi
}
validate_cleanup_node () {
if [ " $CLEANUP_LABEL_COUNT " -ne 1 ] || \
[ " $CLEANUP_NAME_COUNT " -ne 1 ] || \
[ " $CLEANUP_LABEL_IDS " != " $CLEANUP_NAME_IDS " ]; then
printf '停止:同名与同标签容器集合不唯一或不一致\nlabel_ids=%s\nname_ids=%s\n' \
" $CLEANUP_LABEL_IDS " " $CLEANUP_NAME_IDS " >&2
return 1
fi
CLEANUP_NODE_ID = " $CLEANUP_LABEL_IDS "
if ! is_full_container_id " $CLEANUP_NODE_ID " ; then
echo '停止:Docker 没有返回完整节点容器 ID' >&2
return 1
fi
if CLEANUP_NODE_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{index .Config.Labels "io.x-k8s.kind.cluster"}}' \
" $CLEANUP_NODE_ID " 2>&1
)" ; then
:
else
VCN_RC = $?
printf '停止:无法读取节点容器事实,rc=%s\n%s\n' \
" $VCN_RC " " $CLEANUP_NODE_FACTS " >&2
return 1
fi
EXPECTED_CLEANUP_FACTS = " $CLEANUP_NODE_ID |/ $NODE_NAME | $CLUSTER_NAME "
if [ " $CLEANUP_NODE_FACTS " != " $EXPECTED_CLEANUP_FACTS " ]; then
printf '停止:节点 ID、精确名称或标签不匹配\n%s\n' \
" $CLEANUP_NODE_FACTS " >&2
return 1
fi
}
HAS_CLUSTER_MARKER = 'no'
HAS_ATTEMPT_MARKER = 'no'
if [ -e " $CLUSTER_MARKER " ]; then
read_cluster_cleanup_ledger " $CLUSTER_MARKER " 'no' || exit 1
OWNED_CLEANUP_NODE_ID = " $RCCL_NODE_ID "
HAS_CLUSTER_MARKER = 'yes'
fi
if [ -e " $ATTEMPT_MARKER " ]; then
read_cluster_cleanup_ledger " $ATTEMPT_MARKER " 'yes' || exit 1
ATTEMPT_CLEANUP_NODE_ID = " $RCCL_NODE_ID "
HAS_ATTEMPT_MARKER = 'yes'
fi
if [ " $HAS_CLUSTER_MARKER " = 'yes' ] && \
[ " $HAS_ATTEMPT_MARKER " = 'yes' ] && \
[ " $OWNED_CLEANUP_NODE_ID " != " $ATTEMPT_CLEANUP_NODE_ID " ]; then
echo '停止:正式集群账本与创建尝试账本 ID 不一致' >&2
exit 1
fi
AUTHORIZED_NODE_ID = ''
if [ " $HAS_CLUSTER_MARKER " = 'yes' ]; then
AUTHORIZED_NODE_ID = " $OWNED_CLEANUP_NODE_ID "
elif [ " $HAS_ATTEMPT_MARKER " = 'yes' ] && \
[ " $ATTEMPT_CLEANUP_NODE_ID " != 'pending' ]; then
AUTHORIZED_NODE_ID = " $ATTEMPT_CLEANUP_NODE_ID "
fi
read_cluster_cleanup_state || exit 1
if [ " $CLEANUP_KIND_HAS_CLUSTER " = 'yes' ] || \
[ " $CLEANUP_LABEL_COUNT " -ne 0 ] || \
[ " $CLEANUP_NAME_COUNT " -ne 0 ]; then
if [ -z " $AUTHORIZED_NODE_ID " ]; then
echo '停止:现有同名或同标签资源没有完整 ID 授权' >&2
exit 1
fi
validate_cleanup_node || exit 1
if [ " $CLEANUP_NODE_ID " != " $AUTHORIZED_NODE_ID " ]; then
echo '停止:当前节点完整 ID 与课程账本不一致' >&2
exit 1
fi
if [ " $CLEANUP_KIND_HAS_CLUSTER " = 'yes' ]; then
if KIND_DELETE_OUTPUT = "$(
kind delete cluster --name " $CLUSTER_NAME " 2>&1
)" ; then
printf '%s\n' " $KIND_DELETE_OUTPUT "
else
KIND_DELETE_RC = $?
printf '停止:kind 删除失败,rc=%s\n%s\n' \
" $KIND_DELETE_RC " " $KIND_DELETE_OUTPUT " >&2
exit 1
fi
else
if RESIDUAL_DELETE_OUTPUT = "$(
docker rm -f " $AUTHORIZED_NODE_ID " 2>&1
)" ; then
printf 'removedResidualNode=%s\n' " $RESIDUAL_DELETE_OUTPUT "
else
RESIDUAL_DELETE_RC = $?
printf '停止:精确残留节点删除失败,rc=%s\n%s\n' \
" $RESIDUAL_DELETE_RC " " $RESIDUAL_DELETE_OUTPUT " >&2
exit 1
fi
if RESIDUAL_VERIFY = "$(
docker inspect " $AUTHORIZED_NODE_ID " 2>&1
)" ; then
echo '停止:残留节点删除后仍可读取' >&2
exit 1
fi
case " $RESIDUAL_VERIFY " in
* 'No such object:' *|* 'No such container:' * ) ;;
*)
printf '停止:残留节点删除后验证失败\n%s\n' \
" $RESIDUAL_VERIFY " >&2
exit 1
;;
esac
fi
fi
read_cluster_cleanup_state || exit 1
if [ " $CLEANUP_KIND_HAS_CLUSTER " = 'yes' ] || \
[ " $CLEANUP_LABEL_COUNT " -ne 0 ] || \
[ " $CLEANUP_NAME_COUNT " -ne 0 ]; then
printf '停止:删除后仍有课程集群候选资源\nclusters=%s\nlabel_ids=%s\nname_ids=%s\n' \
" $CLEANUP_KIND_CLUSTERS " " $CLEANUP_LABEL_IDS " \
" $CLEANUP_NAME_IDS " >&2
exit 1
fi
rm -f -- " $CLUSTER_MARKER " " $ATTEMPT_MARKER "
echo 'cluster/welearn-course=absent'
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
STANDALONE_NAME = 'taskboard-standalone'
STANDALONE_IMAGE = 'taskboard:1.0.0'
STANDALONE_MARKER = " $COURSE_ROOT /.taskboard-standalone-owned"
STANDALONE_ATTEMPT = " $COURSE_ROOT /.taskboard-standalone-attempt"
if STANDALONE_IDS = "$(
docker ps -aq --no-trunc --filter "name= $STANDALONE_NAME " 2>&1
)" ; then
:
else
STANDALONE_RC = $?
printf '停止:无法查询独立容器,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_IDS " >&2
exit 1
fi
STANDALONE_COUNT = "$(
printf '%s\n' " $STANDALONE_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
RECORDED_STANDALONE_ID = ''
if [ -e " $STANDALONE_MARKER " ]; then
OWNED_STANDALONE_ID = "$( cat " $STANDALONE_MARKER ")"
if ! printf '%s\n' " $OWNED_STANDALONE_ID " | \
grep -Eq '^[0-9a-f]{64}$' || \
! printf '%s\n' " $OWNED_STANDALONE_ID " | \
cmp -s - " $STANDALONE_MARKER " ; then
echo '停止:独立容器 owned 账本格式不正确' >&2
exit 1
fi
RECORDED_STANDALONE_ID = " $OWNED_STANDALONE_ID "
fi
if [ -e " $STANDALONE_ATTEMPT " ]; then
ATTEMPT_STANDALONE_ID = "$( cat " $STANDALONE_ATTEMPT ")"
if [ " $ATTEMPT_STANDALONE_ID " = 'pending' ]; then
if ! printf 'pending\n' | cmp -s - " $STANDALONE_ATTEMPT " ; then
echo '停止:独立容器 attempt 账本格式不正确' >&2
exit 1
fi
if [ " $STANDALONE_COUNT " -ne 0 ]; then
echo '停止:pending attempt 不能授权已有同名容器' >&2
exit 1
fi
elif ! printf '%s\n' " $ATTEMPT_STANDALONE_ID " | \
grep -Eq '^[0-9a-f]{64}$' || \
! printf '%s\n' " $ATTEMPT_STANDALONE_ID " | \
cmp -s - " $STANDALONE_ATTEMPT " ; then
echo '停止:独立容器 attempt 账本格式不正确' >&2
exit 1
else
if [ -n " $RECORDED_STANDALONE_ID " ] && \
[ " $RECORDED_STANDALONE_ID " != " $ATTEMPT_STANDALONE_ID " ]; then
echo '停止:独立容器 owned 与 attempt ID 不一致' >&2
exit 1
fi
RECORDED_STANDALONE_ID = " $ATTEMPT_STANDALONE_ID "
fi
fi
if [ -z " $RECORDED_STANDALONE_ID " ]; then
if [ " $STANDALONE_COUNT " -ne 0 ]; then
echo '停止:同名独立容器存在但没有课程归属账本' >&2
exit 1
fi
rm -f -- " $STANDALONE_ATTEMPT "
echo 'container/taskboard-standalone=already-absent'
else
if [ " $STANDALONE_COUNT " -eq 0 ]; then
rm -f -- " $STANDALONE_MARKER " " $STANDALONE_ATTEMPT "
echo 'container/taskboard-standalone=already-absent'
elif [ " $STANDALONE_COUNT " -ne 1 ] || \
[ " $STANDALONE_IDS " != " $RECORDED_STANDALONE_ID " ]; then
echo '停止:当前同名容器完整 ID 与课程账本不一致' >&2
exit 1
else
if STANDALONE_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{.Config.Image}}' \
" $RECORDED_STANDALONE_ID " 2>&1
)" ; then
:
else
STANDALONE_RC = $?
printf '停止:无法读取独立容器事实,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_FACTS " >&2
exit 1
fi
if [ " $STANDALONE_FACTS " != \
" $RECORDED_STANDALONE_ID |/ $STANDALONE_NAME | $STANDALONE_IMAGE " ]; then
echo '停止:独立容器名称或镜像事实不匹配' >&2
exit 1
fi
if STANDALONE_REMOVE_OUTPUT = "$(
docker rm -f " $RECORDED_STANDALONE_ID " 2>&1
)" ; then
printf 'removedStandalone=%s\n' " $STANDALONE_REMOVE_OUTPUT "
else
STANDALONE_RC = $?
printf '停止:独立容器删除失败,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_REMOVE_OUTPUT " >&2
exit 1
fi
if STANDALONE_VERIFY = "$(
docker inspect " $RECORDED_STANDALONE_ID " 2>&1
)" ; then
echo '停止:独立容器删除后仍可读取' >&2
exit 1
fi
case " $STANDALONE_VERIFY " in
* 'No such object:' *|* 'No such container:' * ) ;;
*)
printf '停止:独立容器删除后验证失败\n%s\n' \
" $STANDALONE_VERIFY " >&2
exit 1
;;
esac
rm -f -- " $STANDALONE_MARKER " " $STANDALONE_ATTEMPT "
echo 'container/taskboard-standalone=absent'
fi
fi
remove_owned_tag () {
IMAGE_TAG = " $1 "
IMAGE_OWNERSHIP_MARKER = " $2 "
if [ ! -e " $IMAGE_OWNERSHIP_MARKER " ]; then
if IMAGE_INSPECT = "$( docker image inspect " $IMAGE_TAG " 2>&1 )" ; then
echo "停止: $IMAGE_TAG 存在但没有归属标记" >&2
exit 1
fi
case " $IMAGE_INSPECT " in
* 'No such image' * )
echo "image/ $IMAGE_TAG =already-absent"
return
;;
*)
printf '停止:无法查询 %s\n%s\n' \
" $IMAGE_TAG " " $IMAGE_INSPECT " >&2
exit 1
;;
esac
fi
OWNED_IMAGE_ID = "$( cat " $IMAGE_OWNERSHIP_MARKER ")"
if ! printf '%s\n' " $OWNED_IMAGE_ID " | \
grep -Eq '^sha256:[0-9a-f]{64}$' || \
! printf '%s\n' " $OWNED_IMAGE_ID " | \
cmp -s - " $IMAGE_OWNERSHIP_MARKER " ; then
echo "停止: $IMAGE_TAG 归属标记格式不正确" >&2
exit 1
fi
if CURRENT_IMAGE_ID = "$(
docker image inspect --format '{{.Id}}' " $IMAGE_TAG " 2>&1
)" ; then
if [ " $CURRENT_IMAGE_ID " != " $OWNED_IMAGE_ID " ]; then
echo "停止: $IMAGE_TAG 已指向非课程镜像" >&2
exit 1
fi
docker image rm " $IMAGE_TAG "
else
case " $CURRENT_IMAGE_ID " in
* 'No such image' * ) ;;
*)
printf '停止:无法查询 %s\n%s\n' \
" $IMAGE_TAG " " $CURRENT_IMAGE_ID " >&2
exit 1
;;
esac
fi
if IMAGE_INSPECT = "$( docker image inspect " $IMAGE_TAG " 2>&1 )" ; then
echo "停止: $IMAGE_TAG 删除后仍可读取" >&2
exit 1
fi
case " $IMAGE_INSPECT " in
* 'No such image' * ) ;;
*)
printf '停止:删除后验证 %s 失败\n%s\n' \
" $IMAGE_TAG " " $IMAGE_INSPECT " >&2
exit 1
;;
esac
rm -f -- " $IMAGE_OWNERSHIP_MARKER "
echo "image/ $IMAGE_TAG =absent"
}
remove_owned_tag \
'taskboard:1.0.0' \
" $COURSE_ROOT /.taskboard-1.0.0-owned"
remove_owned_tag \
'taskboard:2.0.0' \
" $COURSE_ROOT /.taskboard-2.0.0-owned"
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
BUILDER_NAME = 'welearn-course-builder'
BUILDER_NODE = 'welearn-course-builder0'
BUILDER_CONTAINER = 'buildx_buildkit_welearn-course-builder0'
BUILDER_VOLUME = 'buildx_buildkit_welearn-course-builder0_state'
BUILDKIT_IMAGE = 'moby/buildkit:buildx-stable-1@sha256:0168606be2315b7c807a03b3d8aa79beefdb31c98740cebdffdfeebf31190c9f'
BUILDER_OWNED = " $COURSE_ROOT /.buildx-builder-owned"
BUILDER_ATTEMPT = " $COURSE_ROOT /.buildx-builder-attempt"
is_builder_container_id () {
case " $1 " in
'' |* [!0-9a-f] * ) return 1 ;;
esac
[ "${ # 1 }" -eq 64 ]
}
is_builder_image_id () {
printf '%s\n' " $1 " | grep -Eq '^sha256:[0-9a-f]{64}$'
}
read_builder_cleanup_ledger () {
RBCL_FILE = " $1 "
RBCL_ALLOW_PENDING = " $2 "
RBCL_CONTAINER_ID = "$( sed -n '4s/^container_id=//p' " $RBCL_FILE ")"
RBCL_VOLUME_CREATED = "$( sed -n '6s/^volume_created_at=//p' " $RBCL_FILE ")"
RBCL_IMAGE_ID = "$( sed -n '8s/^driver_image_id=//p' " $RBCL_FILE ")"
if [ " $RBCL_ALLOW_PENDING " = 'yes' ] && \
[ " $RBCL_CONTAINER_ID " = 'pending' ] && \
[ " $RBCL_VOLUME_CREATED " = 'pending' ] && \
[ " $RBCL_IMAGE_ID " = 'pending' ]; then
:
elif ! is_builder_container_id " $RBCL_CONTAINER_ID " || \
[ -z " $RBCL_VOLUME_CREATED " ] || \
! is_builder_image_id " $RBCL_IMAGE_ID " ; then
echo "停止:builder 账本的 ID 或卷时间无效: $RBCL_FILE " >&2
return 1
fi
if ! {
printf 'builder=%s\n' " $BUILDER_NAME "
printf 'node=%s\n' " $BUILDER_NODE "
printf 'container=%s\n' " $BUILDER_CONTAINER "
printf 'container_id=%s\n' " $RBCL_CONTAINER_ID "
printf 'volume=%s\n' " $BUILDER_VOLUME "
printf 'volume_created_at=%s\n' " $RBCL_VOLUME_CREATED "
printf 'driver_image_ref=%s\n' " $BUILDKIT_IMAGE "
printf 'driver_image_id=%s\n' " $RBCL_IMAGE_ID "
} | cmp -s - " $RBCL_FILE " ; then
echo "停止:builder 账本字段、顺序或换行无效: $RBCL_FILE " >&2
return 1
fi
}
read_builder_cleanup_state () {
if CLEANUP_BUILDER_INSPECT = "$(
docker buildx inspect " $BUILDER_NAME " 2>&1
)" ; then
CLEANUP_BUILDER_EXISTS = 'yes'
else
RBCS_RC = $?
case " $CLEANUP_BUILDER_INSPECT " in
* 'no builder "' * '" found' * ) CLEANUP_BUILDER_EXISTS = 'no' ;;
*)
printf '停止:无法读取 buildx builder,rc=%s\n%s\n' \
" $RBCS_RC " " $CLEANUP_BUILDER_INSPECT " >&2
return 1
;;
esac
fi
if CLEANUP_BUILDER_IDS = "$(
docker ps -aq --no-trunc --filter "name= $BUILDER_CONTAINER " 2>&1
)" ; then
:
else
RBCS_RC = $?
printf '停止:无法查询 builder 容器,rc=%s\n%s\n' \
" $RBCS_RC " " $CLEANUP_BUILDER_IDS " >&2
return 1
fi
CLEANUP_BUILDER_COUNT = "$(
printf '%s\n' " $CLEANUP_BUILDER_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
if CLEANUP_BUILDER_VOLUME_CREATED = "$(
docker volume inspect \
--format '{{.CreatedAt}}' " $BUILDER_VOLUME " 2>&1
)" ; then
CLEANUP_BUILDER_VOLUME_EXISTS = 'yes'
else
RBCS_RC = $?
case " $CLEANUP_BUILDER_VOLUME_CREATED " in
* 'No such volume:' *|* 'no such volume' * )
CLEANUP_BUILDER_VOLUME_EXISTS = 'no'
CLEANUP_BUILDER_VOLUME_CREATED = ''
;;
*)
printf '停止:无法查询 builder 状态卷,rc=%s\n%s\n' \
" $RBCS_RC " " $CLEANUP_BUILDER_VOLUME_CREATED " >&2
return 1
;;
esac
fi
}
HAS_BUILDER_OWNED = 'no'
HAS_BUILDER_ATTEMPT = 'no'
if [ -e " $BUILDER_OWNED " ]; then
read_builder_cleanup_ledger " $BUILDER_OWNED " 'no' || exit 1
CLEANUP_OWNED_CONTAINER_ID = " $RBCL_CONTAINER_ID "
CLEANUP_OWNED_VOLUME_CREATED = " $RBCL_VOLUME_CREATED "
CLEANUP_OWNED_IMAGE_ID = " $RBCL_IMAGE_ID "
HAS_BUILDER_OWNED = 'yes'
fi
if [ -e " $BUILDER_ATTEMPT " ]; then
read_builder_cleanup_ledger " $BUILDER_ATTEMPT " 'yes' || exit 1
CLEANUP_ATTEMPT_CONTAINER_ID = " $RBCL_CONTAINER_ID "
CLEANUP_ATTEMPT_VOLUME_CREATED = " $RBCL_VOLUME_CREATED "
CLEANUP_ATTEMPT_IMAGE_ID = " $RBCL_IMAGE_ID "
HAS_BUILDER_ATTEMPT = 'yes'
fi
AUTHORIZED_BUILDER_ID = ''
AUTHORIZED_VOLUME_CREATED = ''
AUTHORIZED_BUILDKIT_IMAGE_ID = ''
if [ " $HAS_BUILDER_OWNED " = 'yes' ]; then
AUTHORIZED_BUILDER_ID = " $CLEANUP_OWNED_CONTAINER_ID "
AUTHORIZED_VOLUME_CREATED = " $CLEANUP_OWNED_VOLUME_CREATED "
AUTHORIZED_BUILDKIT_IMAGE_ID = " $CLEANUP_OWNED_IMAGE_ID "
fi
if [ " $HAS_BUILDER_ATTEMPT " = 'yes' ] && \
[ " $CLEANUP_ATTEMPT_CONTAINER_ID " != 'pending' ]; then
if [ -n " $AUTHORIZED_BUILDER_ID " ] && \
{ [ " $AUTHORIZED_BUILDER_ID " != \
" $CLEANUP_ATTEMPT_CONTAINER_ID " ] || \
[ " $AUTHORIZED_VOLUME_CREATED " != \
" $CLEANUP_ATTEMPT_VOLUME_CREATED " ] || \
[ " $AUTHORIZED_BUILDKIT_IMAGE_ID " != \
" $CLEANUP_ATTEMPT_IMAGE_ID " ]; }; then
echo '停止:builder 正式账本与尝试账本不一致' >&2
exit 1
fi
AUTHORIZED_BUILDER_ID = " $CLEANUP_ATTEMPT_CONTAINER_ID "
AUTHORIZED_VOLUME_CREATED = " $CLEANUP_ATTEMPT_VOLUME_CREATED "
AUTHORIZED_BUILDKIT_IMAGE_ID = " $CLEANUP_ATTEMPT_IMAGE_ID "
fi
read_builder_cleanup_state || exit 1
if [ " $HAS_BUILDER_ATTEMPT " = 'yes' ] && \
[ " $CLEANUP_ATTEMPT_CONTAINER_ID " = 'pending' ] && \
{ [ " $CLEANUP_BUILDER_COUNT " -ne 0 ] || \
[ " $CLEANUP_BUILDER_VOLUME_EXISTS " = 'yes' ]; }; then
echo '停止:pending builder 账本不能授权已有容器或卷' >&2
exit 1
fi
if [ -z " $AUTHORIZED_BUILDER_ID " ] && \
{ [ " $CLEANUP_BUILDER_COUNT " -ne 0 ] || \
[ " $CLEANUP_BUILDER_VOLUME_EXISTS " = 'yes' ]; }; then
echo '停止:builder 容器或卷存在,但没有完整 ID 账本' >&2
exit 1
fi
if [ " $HAS_BUILDER_OWNED " = 'no' ] && \
[ " $HAS_BUILDER_ATTEMPT " = 'no' ] && \
[ " $CLEANUP_BUILDER_EXISTS " = 'yes' ]; then
echo '停止:同名 builder 元数据存在但没有课程账本' >&2
exit 1
fi
if [ " $CLEANUP_BUILDER_EXISTS " = 'yes' ]; then
if ! printf '%s\n' " $CLEANUP_BUILDER_INSPECT " | \
grep -Eq '^Name:[[:space:]]+welearn-course-builder$' || \
! printf '%s\n' " $CLEANUP_BUILDER_INSPECT " | \
grep -Eq '^Driver:[[:space:]]+docker-container$' ; then
echo '停止:同名 builder 元数据的名称或 driver 不匹配' >&2
exit 1
fi
fi
if [ " $CLEANUP_BUILDER_COUNT " -ne 0 ]; then
if [ " $CLEANUP_BUILDER_COUNT " -ne 1 ] || \
[ " $CLEANUP_BUILDER_IDS " != " $AUTHORIZED_BUILDER_ID " ]; then
echo '停止:当前 builder 容器完整 ID 与账本不一致' >&2
exit 1
fi
if CLEANUP_BUILDER_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{.Config.Image}}|{{.Image}}|{{range .Mounts}}{{if eq .Destination "/var/lib/buildkit"}}{{.Name}}{{end}}{{end}}' \
" $AUTHORIZED_BUILDER_ID " 2>&1
)" ; then
:
else
CLEANUP_BUILDER_RC = $?
printf '停止:无法读取 builder 容器事实,rc=%s\n%s\n' \
" $CLEANUP_BUILDER_RC " " $CLEANUP_BUILDER_FACTS " >&2
exit 1
fi
EXPECTED_CLEANUP_BUILDER_FACTS = " $AUTHORIZED_BUILDER_ID |/ $BUILDER_CONTAINER | $BUILDKIT_IMAGE | $AUTHORIZED_BUILDKIT_IMAGE_ID | $BUILDER_VOLUME "
if [ " $CLEANUP_BUILDER_FACTS " != \
" $EXPECTED_CLEANUP_BUILDER_FACTS " ]; then
printf '停止:builder 容器事实与账本不一致\n%s\n' \
" $CLEANUP_BUILDER_FACTS " >&2
exit 1
fi
fi
if [ " $CLEANUP_BUILDER_VOLUME_EXISTS " = 'yes' ] && \
[ " $CLEANUP_BUILDER_VOLUME_CREATED " != \
" $AUTHORIZED_VOLUME_CREATED " ]; then
echo '停止:builder 状态卷创建时间与账本不一致' >&2
exit 1
fi
if [ " $CLEANUP_BUILDER_EXISTS " = 'yes' ]; then
if BUILDER_RM_OUTPUT = "$( docker buildx rm " $BUILDER_NAME " 2>&1 )" ; then
[ -n " $BUILDER_RM_OUTPUT " ] && printf '%s\n' " $BUILDER_RM_OUTPUT "
else
BUILDER_RM_RC = $?
printf '停止:buildx rm 失败,rc=%s\n%s\n' \
" $BUILDER_RM_RC " " $BUILDER_RM_OUTPUT " >&2
exit 1
fi
fi
read_builder_cleanup_state || exit 1
if [ " $CLEANUP_BUILDER_COUNT " -ne 0 ]; then
if [ " $CLEANUP_BUILDER_COUNT " -ne 1 ] || \
[ " $CLEANUP_BUILDER_IDS " != " $AUTHORIZED_BUILDER_ID " ]; then
echo '停止:buildx rm 后出现未知 builder 容器' >&2
exit 1
fi
docker rm -f " $AUTHORIZED_BUILDER_ID "
fi
read_builder_cleanup_state || exit 1
if [ " $CLEANUP_BUILDER_VOLUME_EXISTS " = 'yes' ]; then
if [ " $CLEANUP_BUILDER_VOLUME_CREATED " != \
" $AUTHORIZED_VOLUME_CREATED " ]; then
echo '停止:buildx rm 后状态卷身份发生变化' >&2
exit 1
fi
docker volume rm " $BUILDER_VOLUME "
fi
read_builder_cleanup_state || exit 1
if [ " $CLEANUP_BUILDER_EXISTS " = 'yes' ] || \
[ " $CLEANUP_BUILDER_COUNT " -ne 0 ] || \
[ " $CLEANUP_BUILDER_VOLUME_EXISTS " = 'yes' ]; then
echo '停止:专属 builder、容器或缓存卷仍存在' >&2
exit 1
fi
echo 'builder/welearn-course-builder=absent cacheVolume=absent'
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
BUILDER_NAME = 'welearn-course-builder'
BUILDER_CONTAINER = 'buildx_buildkit_welearn-course-builder0'
BUILDER_VOLUME = 'buildx_buildkit_welearn-course-builder0_state'
BUILDKIT_IMAGE = 'moby/buildkit:buildx-stable-1@sha256:0168606be2315b7c807a03b3d8aa79beefdb31c98740cebdffdfeebf31190c9f'
BUILDKIT_BASELINE = " $COURSE_ROOT /.buildkit-driver-image-preexisting"
BUILDER_OWNED = " $COURSE_ROOT /.buildx-builder-owned"
BUILDER_ATTEMPT = " $COURSE_ROOT /.buildx-builder-attempt"
if BUILDER_STATE = "$( docker buildx inspect " $BUILDER_NAME " 2>&1 )" ; then
echo '停止:专属 builder 元数据仍存在,先完成上一清理块' >&2
exit 1
else
BUILDER_STATE_RC = $?
case " $BUILDER_STATE " in
* 'no builder "' * '" found' * ) ;;
*)
printf '停止:无法验证 builder 终态,rc=%s\n%s\n' \
" $BUILDER_STATE_RC " " $BUILDER_STATE " >&2
exit 1
;;
esac
fi
if BUILDER_CONTAINER_IDS = "$(
docker ps -aq --no-trunc --filter "name= $BUILDER_CONTAINER " 2>&1
)" ; then
:
else
BUILDER_STATE_RC = $?
printf '停止:无法验证 builder 容器终态,rc=%s\n%s\n' \
" $BUILDER_STATE_RC " " $BUILDER_CONTAINER_IDS " >&2
exit 1
fi
if [ -n " $BUILDER_CONTAINER_IDS " ]; then
printf '停止:builder 容器仍存在\n%s\n' \
" $BUILDER_CONTAINER_IDS " >&2
exit 1
fi
if BUILDER_VOLUME_STATE = "$(
docker volume inspect " $BUILDER_VOLUME " 2>&1
)" ; then
echo '停止:builder 缓存卷仍存在' >&2
exit 1
else
BUILDER_STATE_RC = $?
case " $BUILDER_VOLUME_STATE " in
* 'No such volume:' *|* 'no such volume' * ) ;;
*)
printf '停止:无法验证 builder 缓存卷终态,rc=%s\n%s\n' \
" $BUILDER_STATE_RC " " $BUILDER_VOLUME_STATE " >&2
exit 1
;;
esac
fi
read_buildkit_ledger_identity () {
RBLI_FILE = " $1 "
RBLI_ALLOW_PENDING = " $2 "
RBLI_CONTAINER_ID = "$( sed -n '4s/^container_id=//p' " $RBLI_FILE ")"
RBLI_VOLUME_CREATED = "$( sed -n '6s/^volume_created_at=//p' " $RBLI_FILE ")"
RBLI_IMAGE_ID = "$( sed -n '8s/^driver_image_id=//p' " $RBLI_FILE ")"
if [ " $RBLI_ALLOW_PENDING " = 'yes' ] && \
[ " $RBLI_CONTAINER_ID " = 'pending' ] && \
[ " $RBLI_VOLUME_CREATED " = 'pending' ] && \
[ " $RBLI_IMAGE_ID " = 'pending' ]; then
:
elif ! printf '%s\n' " $RBLI_CONTAINER_ID " | \
grep -Eq '^[0-9a-f]{64}$' || \
[ -z " $RBLI_VOLUME_CREATED " ] || \
! printf '%s\n' " $RBLI_IMAGE_ID " | \
grep -Eq '^sha256:[0-9a-f]{64}$' ; then
echo "停止:builder 镜像身份账本格式无效: $RBLI_FILE " >&2
return 1
fi
if ! {
printf 'builder=welearn-course-builder\n'
printf 'node=welearn-course-builder0\n'
printf 'container=buildx_buildkit_welearn-course-builder0\n'
printf 'container_id=%s\n' " $RBLI_CONTAINER_ID "
printf 'volume=buildx_buildkit_welearn-course-builder0_state\n'
printf 'volume_created_at=%s\n' " $RBLI_VOLUME_CREATED "
printf 'driver_image_ref=%s\n' " $BUILDKIT_IMAGE "
printf 'driver_image_id=%s\n' " $RBLI_IMAGE_ID "
} | cmp -s - " $RBLI_FILE " ; then
echo "停止:builder 镜像身份账本字节不匹配: $RBLI_FILE " >&2
return 1
fi
}
RECORDED_BUILDKIT_IMAGE_ID = ''
if [ -e " $BUILDER_OWNED " ]; then
read_buildkit_ledger_identity " $BUILDER_OWNED " 'no' || exit 1
RECORDED_BUILDKIT_IMAGE_ID = " $RBLI_IMAGE_ID "
fi
if [ -e " $BUILDER_ATTEMPT " ]; then
read_buildkit_ledger_identity " $BUILDER_ATTEMPT " 'yes' || exit 1
if [ " $RBLI_IMAGE_ID " != 'pending' ]; then
if [ -n " $RECORDED_BUILDKIT_IMAGE_ID " ] && \
[ " $RECORDED_BUILDKIT_IMAGE_ID " != " $RBLI_IMAGE_ID " ]; then
echo '停止:两个 builder 账本的 driver image ID 不一致' >&2
exit 1
fi
RECORDED_BUILDKIT_IMAGE_ID = " $RBLI_IMAGE_ID "
fi
fi
if [ -e " $BUILDKIT_BASELINE " ]; then
if printf 'yes\n' | cmp -s - " $BUILDKIT_BASELINE " ; then
echo 'buildkit_driver_image=preserved_preexisting'
elif printf 'no\n' | cmp -s - " $BUILDKIT_BASELINE " ; then
if CURRENT_BUILDKIT_IMAGE_ID = "$(
docker image inspect --format '{{.Id}}' " $BUILDKIT_IMAGE " 2>&1
)" ; then
if [ -z " $RECORDED_BUILDKIT_IMAGE_ID " ] || \
[ " $CURRENT_BUILDKIT_IMAGE_ID " != \
" $RECORDED_BUILDKIT_IMAGE_ID " ]; then
echo '停止:BuildKit driver 镜像缺少匹配的 image ID 账本' >&2
exit 1
fi
if BUILDKIT_IMAGE_USERS = "$(
docker ps -aq --filter ancestor=" $CURRENT_BUILDKIT_IMAGE_ID " 2>&1
)" ; then
:
else
BUILDKIT_IMAGE_RC = $?
printf '停止:无法查询 BuildKit 镜像使用者,rc=%s\n%s\n' \
" $BUILDKIT_IMAGE_RC " " $BUILDKIT_IMAGE_USERS " >&2
exit 1
fi
if [ -n " $BUILDKIT_IMAGE_USERS " ]; then
printf '停止:BuildKit driver 镜像仍被容器使用\n%s\n' \
" $BUILDKIT_IMAGE_USERS " >&2
exit 1
fi
if BUILDKIT_RM_OUTPUT = "$(
docker image rm " $BUILDKIT_IMAGE " 2>&1
)" ; then
[ -n " $BUILDKIT_RM_OUTPUT " ] && \
printf '%s\n' " $BUILDKIT_RM_OUTPUT "
else
BUILDKIT_IMAGE_RC = $?
printf '停止:删除课程 BuildKit 摘要引用失败,rc=%s\n%s\n' \
" $BUILDKIT_IMAGE_RC " " $BUILDKIT_RM_OUTPUT " >&2
exit 1
fi
if BUILDKIT_IMAGE_VERIFY = "$(
docker image inspect " $BUILDKIT_IMAGE " 2>&1
)" ; then
echo '停止:BuildKit driver 课程摘要引用删除后仍可读取' >&2
exit 1
fi
case " $BUILDKIT_IMAGE_VERIFY " in
* 'No such image:' * ) ;;
*)
printf '停止:BuildKit driver 镜像删除后验证失败\n%s\n' \
" $BUILDKIT_IMAGE_VERIFY " >&2
exit 1
;;
esac
echo 'buildkit_driver_reference=removed_course_created'
if BUILDKIT_ID_VERIFY = "$(
docker image inspect \
--format '{{.Id}}' " $CURRENT_BUILDKIT_IMAGE_ID " 2>&1
)" ; then
if [ " $BUILDKIT_ID_VERIFY " != " $CURRENT_BUILDKIT_IMAGE_ID " ]; then
printf '停止:BuildKit image ID 删除后解析结果异常\n%s\n' \
" $BUILDKIT_ID_VERIFY " >&2
exit 1
fi
echo 'buildkit_driver_image_id=preserved_shared_reference'
else
case " $BUILDKIT_ID_VERIFY " in
* 'No such image:' * ) echo 'buildkit_driver_image_id=absent' ;;
*)
printf '停止:无法验证 BuildKit image ID 的最终状态\n%s\n' \
" $BUILDKIT_ID_VERIFY " >&2
exit 1
;;
esac
fi
else
case " $CURRENT_BUILDKIT_IMAGE_ID " in
* 'No such image:' * ) echo 'buildkit_driver_image=already_absent' ;;
*)
printf '停止:无法查询 BuildKit driver 镜像\n%s\n' \
" $CURRENT_BUILDKIT_IMAGE_ID " >&2
exit 1
;;
esac
fi
else
echo '停止:BuildKit driver 镜像基线不是严格 yes/no' >&2
exit 1
fi
rm -f -- " $BUILDKIT_BASELINE " " $BUILDER_OWNED " " $BUILDER_ATTEMPT "
elif DRIVER_IMAGE_STATE = "$( docker image inspect " $BUILDKIT_IMAGE " 2>&1 )" ; then
echo '停止:BuildKit driver 镜像存在但没有创建前基线' >&2
exit 1
else
case " $DRIVER_IMAGE_STATE " in
* 'No such image:' * )
if [ -e " $BUILDER_OWNED " ] || [ -e " $BUILDER_ATTEMPT " ]; then
echo '停止:builder 账本存在但 driver 镜像基线缺失' >&2
exit 1
fi
echo 'buildkit_driver_image=no_ledger_no_action'
;;
*)
printf '停止:无法查询 BuildKit driver 镜像\n%s\n' \
" $DRIVER_IMAGE_STATE " >&2
exit 1
;;
esac
fi
KIND_NODE_IMAGE = 'kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5'
NODE_BASELINE = " $COURSE_ROOT /.kind-node-image-preexisting"
NETWORK_BASELINE = " $COURSE_ROOT /.kind-network-preexisting"
if [ -e " $NODE_BASELINE " ]; then
if printf 'yes\n' | cmp -s - " $NODE_BASELINE " ; then
echo 'node_image=preserved_preexisting'
elif printf 'no\n' | cmp -s - " $NODE_BASELINE " ; then
if NODE_IMAGE_ID = "$(
docker image inspect --format '{{.Id}}' " $KIND_NODE_IMAGE " 2>&1
)" ; then
if ! NODE_IMAGE_USERS = "$(
docker ps -aq --filter ancestor=" $NODE_IMAGE_ID "
)" ; then
echo '停止:无法检查节点镜像使用者' >&2
exit 1
fi
if [ -n " $NODE_IMAGE_USERS " ]; then
printf '停止:节点镜像正被容器使用\n%s\n' \
" $NODE_IMAGE_USERS " >&2
exit 1
fi
docker image rm " $KIND_NODE_IMAGE "
if NODE_IMAGE_VERIFY = "$(
docker image inspect " $KIND_NODE_IMAGE " 2>&1
)" ; then
echo '停止:节点镜像删除后仍可读取' >&2
exit 1
fi
case " $NODE_IMAGE_VERIFY " in
* 'No such image' * ) ;;
*)
printf '停止:节点镜像删除后验证失败\n%s\n' \
" $NODE_IMAGE_VERIFY " >&2
exit 1
;;
esac
echo 'node_image=removed_course_created'
else
case " $NODE_IMAGE_ID " in
* 'No such image' * ) echo 'node_image=already_absent' ;;
*)
printf '停止:无法查询节点镜像\n%s\n' \
" $NODE_IMAGE_ID " >&2
exit 1
;;
esac
fi
else
echo '停止:节点镜像基线不是严格的 yes/no' >&2
exit 1
fi
rm -f -- " $NODE_BASELINE "
else
echo 'node_image=no_ledger_no_action'
fi
if [ -e " $NETWORK_BASELINE " ]; then
if printf 'yes\n' | cmp -s - " $NETWORK_BASELINE " ; then
echo 'kind_network=preserved_preexisting'
elif printf 'no\n' | cmp -s - " $NETWORK_BASELINE " ; then
if ! KIND_CLUSTERS = "$( kind get clusters 2>&1 )" ; then
echo '停止:无法读取 kind 集群' >&2
exit 1
fi
case " $KIND_CLUSTERS " in
'' | 'No kind clusters found.' ) ;;
*)
printf '停止:仍有 kind 集群,不删除共享网络\n%s\n' \
" $KIND_CLUSTERS " >&2
exit 1
;;
esac
if NETWORK_CONTAINERS = "$(
docker network inspect kind --format '{{json .Containers}}' 2>&1
)" ; then
if [ " $NETWORK_CONTAINERS " != '{}' ]; then
printf '停止:kind 网络仍有连接容器\n%s\n' \
" $NETWORK_CONTAINERS " >&2
exit 1
fi
docker network rm kind
if NETWORK_VERIFY = "$( docker network inspect kind 2>&1 )" ; then
echo '停止:kind 网络删除后仍可读取' >&2
exit 1
fi
case " $NETWORK_VERIFY " in
* 'network kind not found' * ) ;;
*)
printf '停止:kind 网络删除后验证失败\n%s\n' \
" $NETWORK_VERIFY " >&2
exit 1
;;
esac
echo 'kind_network=removed_course_created'
else
case " $NETWORK_CONTAINERS " in
* 'network kind not found' * ) echo 'kind_network=already_absent' ;;
*)
printf '停止:无法查询 kind 网络\n%s\n' \
" $NETWORK_CONTAINERS " >&2
exit 1
;;
esac
fi
else
echo '停止:kind 网络基线不是严格的 yes/no' >&2
exit 1
fi
rm -f -- " $NETWORK_BASELINE "
else
echo 'kind_network=no_ledger_no_action'
fi
fi
HOME_PHYSICAL = "$( cd " $HOME " && pwd -P )"
COURSE_ROOT_PHYSICAL = "$( cd " $COURSE_ROOT " && pwd -P )"
EXPECTED_PHYSICAL = " $HOME_PHYSICAL /welearn-kubernetes-course"
if [ " $COURSE_ROOT_PHYSICAL " != " $EXPECTED_PHYSICAL " ] || \
! printf 'welearn-kubernetes-course\n' | \
cmp -s - " $COURSE_ROOT /.course-owned" ; then
echo '停止:物理路径或课程归属标记不匹配' >&2
exit 1
fi
export PATH = " $COURSE_ROOT /bin: $PATH "
export KUBECONFIG = " $COURSE_ROOT /welearn-course.kubeconfig"
if ACTUAL_CONTEXT = "$( kubectl config current-context 2>&1 )" ; then
:
else
CONTEXT_RC = $?
printf '停止:无法读取 current context,rc=%s\n%s\n' \
" $CONTEXT_RC " " $ACTUAL_CONTEXT " >&2
exit 1
fi
if [ " $ACTUAL_CONTEXT " != 'kind-welearn-course' ]; then
echo '停止:当前 context 不是课程集群' >&2
exit 1
fi
KUBECONFIG_ENV_MARKER = " $COURSE_ROOT /.kubeconfig-env-before-course"
if [ ! -f " $KUBECONFIG_ENV_MARKER " ] || \
[ -L " $KUBECONFIG_ENV_MARKER " ] || \
[ ! -r " $KUBECONFIG_ENV_MARKER " ]; then
echo '停止:KUBECONFIG 原状态账本不是可读普通文件' >&2
exit 1
fi
if KUBECONFIG_MARKER_MODE = "$(
stat -f '%Lp' " $KUBECONFIG_ENV_MARKER " 2> /dev/null
)" && KUBECONFIG_MARKER_UID = "$(
stat -f '%u' " $KUBECONFIG_ENV_MARKER " 2> /dev/null
)" ; then
:
elif KUBECONFIG_MARKER_MODE = "$(
stat -c '%a' " $KUBECONFIG_ENV_MARKER " 2> /dev/null
)" && KUBECONFIG_MARKER_UID = "$(
stat -c '%u' " $KUBECONFIG_ENV_MARKER " 2> /dev/null
)" ; then
:
else
echo '停止:无法读取 KUBECONFIG 原状态账本权限' >&2
exit 1
fi
if [ " $KUBECONFIG_MARKER_MODE " != '600' ] || \
[ " $KUBECONFIG_MARKER_UID " != "$( id -u )" ]; then
echo '停止:KUBECONFIG 原状态账本权限或所有者不正确' >&2
exit 1
fi
KUBECONFIG_PREVIOUS_STATE = "$(
sed -n '1s/^state=//p' " $KUBECONFIG_ENV_MARKER "
)"
KUBECONFIG_PREVIOUS_BYTES = "$(
sed -n '2s/^bytes=//p' " $KUBECONFIG_ENV_MARKER "
)"
case " $KUBECONFIG_PREVIOUS_STATE " in
set | unset ) ;;
*)
echo '停止:KUBECONFIG 原状态字段无效' >&2
exit 1
;;
esac
case " $KUBECONFIG_PREVIOUS_BYTES " in
'' |* [!0-9] * )
echo '停止:KUBECONFIG 原值字节数字段无效' >&2
exit 1
;;
esac
if [ " $KUBECONFIG_PREVIOUS_STATE " = 'unset' ] && \
[ " $KUBECONFIG_PREVIOUS_BYTES " -ne 0 ]; then
echo '停止:unset 状态不应携带 KUBECONFIG 值' >&2
exit 1
fi
KUBECONFIG_HEADER_BYTES = "$(
printf 'state=%s\nbytes=%s\n' \
" $KUBECONFIG_PREVIOUS_STATE " " $KUBECONFIG_PREVIOUS_BYTES " | \
wc -c | tr -d '[:space:]'
)"
KUBECONFIG_MARKER_TOTAL_BYTES = "$(
wc -c < " $KUBECONFIG_ENV_MARKER " | tr -d '[:space:]'
)"
if [ " $KUBECONFIG_MARKER_TOTAL_BYTES " -ne \
$(( KUBECONFIG_HEADER_BYTES + KUBECONFIG_PREVIOUS_BYTES )) ]; then
echo '停止:KUBECONFIG 原状态账本长度不完整' >&2
exit 1
fi
KUBECONFIG_ACTUAL_HEADER = "$(
dd if=" $KUBECONFIG_ENV_MARKER " bs= 1 \
count=" $KUBECONFIG_HEADER_BYTES " 2> /dev/null
printf '.'
)"
KUBECONFIG_EXPECTED_HEADER = "$(
printf 'state=%s\nbytes=%s\n.' \
" $KUBECONFIG_PREVIOUS_STATE " " $KUBECONFIG_PREVIOUS_BYTES "
)"
if [ " $KUBECONFIG_ACTUAL_HEADER " != " $KUBECONFIG_EXPECTED_HEADER " ]; then
echo '停止:KUBECONFIG 原状态账本头部不匹配' >&2
exit 1
fi
if [ " $KUBECONFIG_PREVIOUS_STATE " = 'set' ]; then
KUBECONFIG_WITH_SENTINEL = "$(
dd if=" $KUBECONFIG_ENV_MARKER " bs= 1 \
skip=" $KUBECONFIG_HEADER_BYTES " \
count=" $KUBECONFIG_PREVIOUS_BYTES " 2> /dev/null
printf '.'
)"
KUBECONFIG_RESTORE_VALUE = "${ KUBECONFIG_WITH_SENTINEL % ?}"
fi
FINAL_VALIDATION_MARKER = " $COURSE_ROOT /.final-validation-owned"
FINAL_DEPLOYMENT_UID = "$(
sed -n '1s/^deployment_uid=//p' " $FINAL_VALIDATION_MARKER " 2> /dev/null
)"
FINAL_STATEFULSET_UID = "$(
sed -n '2s/^statefulset_uid=//p' " $FINAL_VALIDATION_MARKER " 2> /dev/null
)"
if ! printf '%s\n' " $FINAL_DEPLOYMENT_UID " | \
grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || \
! printf '%s\n' " $FINAL_STATEFULSET_UID " | \
grep -Eq '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || \
! {
printf 'deployment_uid=%s\n' " $FINAL_DEPLOYMENT_UID "
printf 'statefulset_uid=%s\n' " $FINAL_STATEFULSET_UID "
} | cmp -s - " $FINAL_VALIDATION_MARKER " ; then
echo '停止:最终业务验收账本缺失或格式无效' >&2
exit 1
fi
for LEDGER in \
" $COURSE_ROOT /.taskboard-pv-owned" \
" $COURSE_ROOT /.kind-cluster-owned" \
" $COURSE_ROOT /.kind-create-attempt" \
" $COURSE_ROOT /.taskboard-standalone-owned" \
" $COURSE_ROOT /.taskboard-standalone-attempt" \
" $COURSE_ROOT /.taskboard-1.0.0-owned" \
" $COURSE_ROOT /.taskboard-2.0.0-owned" \
" $COURSE_ROOT /.buildx-builder-owned" \
" $COURSE_ROOT /.buildx-builder-attempt" \
" $COURSE_ROOT /.buildkit-driver-image-preexisting" \
" $COURSE_ROOT /.kind-node-image-preexisting" \
" $COURSE_ROOT /.kind-network-preexisting"
do
if [ -e " $LEDGER " ]; then
printf '停止:尚有未完成的资源账本 %s\n' " $LEDGER " >&2
exit 1
fi
done
rm -f -- " $FINAL_VALIDATION_MARKER "
COURSE_BIN = " $COURSE_ROOT /bin"
PATH_SOURCE = " $PATH "
PATH_RESULT = ''
PATH_RESULT_FIELDS = 0
while : ; do
case " $PATH_SOURCE " in
* : * )
PATH_ENTRY = "${ PATH_SOURCE %%:* }"
PATH_SOURCE = "${ PATH_SOURCE #*: }"
PATH_MORE = 'yes'
;;
*)
PATH_ENTRY = " $PATH_SOURCE "
PATH_MORE = 'no'
;;
esac
if [ " $PATH_ENTRY " != " $COURSE_BIN " ]; then
if [ " $PATH_RESULT_FIELDS " -eq 0 ]; then
PATH_RESULT = " $PATH_ENTRY "
else
PATH_RESULT = " $PATH_RESULT : $PATH_ENTRY "
fi
PATH_RESULT_FIELDS = $(( PATH_RESULT_FIELDS + 1 ))
fi
[ " $PATH_MORE " = 'no' ] && break
done
PATH = " $PATH_RESULT "
export PATH
PATH_CHECK_SOURCE = " $PATH "
while : ; do
case " $PATH_CHECK_SOURCE " in
* : * )
PATH_CHECK_ENTRY = "${ PATH_CHECK_SOURCE %%:* }"
PATH_CHECK_SOURCE = "${ PATH_CHECK_SOURCE #*: }"
PATH_CHECK_MORE = 'yes'
;;
*)
PATH_CHECK_ENTRY = " $PATH_CHECK_SOURCE "
PATH_CHECK_MORE = 'no'
;;
esac
if [ " $PATH_CHECK_ENTRY " = " $COURSE_BIN " ]; then
echo '停止:PATH 仍包含课程 bin 精确分量' >&2
exit 1
fi
[ " $PATH_CHECK_MORE " = 'no' ] && break
done
cd " $HOME_PHYSICAL "
rm -rf -- " $COURSE_ROOT "
if [ -e " $COURSE_ROOT " ]; then
echo '停止:课程目录删除后仍存在' >&2
exit 1
fi
if [ " $KUBECONFIG_PREVIOUS_STATE " = 'set' ]; then
KUBECONFIG = " $KUBECONFIG_RESTORE_VALUE "
export KUBECONFIG
else
unset KUBECONFIG
fi
printf 'course_root=absent path_entry=removed kubeconfigRestored=%s\n' \
" $KUBECONFIG_PREVIOUS_STATE "
unset -f read_file_mode_and_uid 2> /dev/null || :
unset -f validate_kubeconfig_env_marker 2> /dev/null || :
unset -f record_preexisting 2> /dev/null || :
unset -f is_full_container_id 2> /dev/null || :
unset -f write_cluster_ledger 2> /dev/null || :
unset -f read_cluster_ledger 2> /dev/null || :
unset -f read_runtime_state 2> /dev/null || :
unset -f validate_expected_node 2> /dev/null || :
unset -f remove_recorded_residual 2> /dev/null || :
unset -f is_container_id 2> /dev/null || :
unset -f is_image_id 2> /dev/null || :
unset -f write_builder_ledger 2> /dev/null || :
unset -f read_builder_ledger 2> /dev/null || :
unset -f read_builder_state 2> /dev/null || :
unset -f validate_builder_facts 2> /dev/null || :
unset -f record_buildkit_baseline 2> /dev/null || :
unset -f read_standalone_state 2> /dev/null || :
unset -f read_standalone_marker 2> /dev/null || :
unset -f validate_standalone_facts 2> /dev/null || :
unset -f capture_standalone_attempt 2> /dev/null || :
unset -f cleanup_blocked_client 2> /dev/null || :
unset -f read_cluster_cleanup_ledger 2> /dev/null || :
unset -f read_cluster_cleanup_state 2> /dev/null || :
unset -f validate_cleanup_node 2> /dev/null || :
unset -f remove_owned_tag 2> /dev/null || :
unset -f is_builder_container_id 2> /dev/null || :
unset -f is_builder_image_id 2> /dev/null || :
unset -f read_builder_cleanup_ledger 2> /dev/null || :
unset -f read_builder_cleanup_state 2> /dev/null || :
unset -f read_buildkit_ledger_identity 2> /dev/null || :
for COURSE_FUNCTION in \
read_file_mode_and_uid validate_kubeconfig_env_marker \
record_preexisting is_full_container_id write_cluster_ledger \
read_cluster_ledger read_runtime_state validate_expected_node \
remove_recorded_residual is_container_id is_image_id \
write_builder_ledger read_builder_ledger read_builder_state \
validate_builder_facts record_buildkit_baseline \
read_standalone_state read_standalone_marker validate_standalone_facts \
capture_standalone_attempt cleanup_blocked_client \
read_cluster_cleanup_ledger read_cluster_cleanup_state \
validate_cleanup_node remove_owned_tag is_builder_container_id \
is_builder_image_id read_builder_cleanup_ledger \
read_builder_cleanup_state read_buildkit_ledger_identity
do
if typeset -f " $COURSE_FUNCTION " > /dev/null 2 >&1 ; then
printf '停止:课程函数未取消:%s\n' " $COURSE_FUNCTION " >&2
exit 1
fi
done
unset COURSE_FUNCTION
for COURSE_VARIABLE in \
COURSE_ROOT COURSE_BIN COURSE_MARKER COURSE_CONTEXT COURSE_NODE_ID \
COURSE_ROOT_CREATED EXPECTED_COURSE_ROOT HOME_PHYSICAL \
COURSE_ROOT_PHYSICAL EXPECTED_PHYSICAL KUBECTL_ARCH KIND_ARCH \
KIND_ASSET KIND_NODE_IMAGE NODE_BASELINE NETWORK_BASELINE \
CLUSTER_NAME CLUSTER_LABEL NODE_NAME CLUSTER_MARKER CLUSTER_OWNED \
CREATE_ATTEMPT ATTEMPT_MARKER CLUSTER_ALREADY_OWNED HAS_OWNED HAS_ATTEMPT \
HAS_CLUSTER_MARKER HAS_ATTEMPT_MARKER OWNED_NODE_ID ATTEMPT_NODE_ID \
KIND_HAS_CLUSTER KIND_CLUSTERS LABEL_IDS LABEL_COUNT NAME_IDS NAME_COUNT \
LABEL_NODE_IDS LABEL_NODE_COUNT NAME_NODE_IDS NAME_NODE_COUNT NODE_FACTS \
EXPECTED_NODE_FACTS CURRENT_NODE_ID AFTER_BOOT_ID CREATE_RC \
KIND_DELETE_OUTPUT KIND_DELETE_RC RESIDUAL_DELETE_OUTPUT \
RESIDUAL_DELETE_RC RESIDUAL_VERIFY RCL_MARKER RCL_ALLOW_PENDING \
RCL_NODE_ID RRS_RC WCL_MARKER WCL_NODE_ID RRR_NODE_ID RRR_OUTPUT \
RRR_RC RRR_VERIFY RP_MARKER RP_KIND RP_VALUE RP_OUTPUT RP_RC \
NODE_IMAGE_PREEXISTING KIND_NETWORK_PREEXISTING \
TASKBOARD_IMAGE TASKBOARD_OWNED REUSE_TASKBOARD_IMAGE IMAGE_MARKER \
TASKBOARD_INSPECT_RC BUILT_IMAGE_ID RECORDED_IMAGE_ID CURRENT_IMAGE_ID \
IMAGE_INSPECT IMAGE_INSPECT_OUTPUT IMAGE_INSPECT_RC IMAGE_EXISTS \
BUILDER_NAME BUILDER_NODE BUILDER_CONTAINER BUILDER_CONTAINER_ID \
BUILDER_VOLUME BUILDX_VERSION BUILDX_CREATE_HELP BUILDX_BUILD_HELP BUILDX_RC \
BUILDER_OWNED BUILDER_ATTEMPT BUILDKIT_IMAGE BUILDKIT_BASELINE \
BUILDKIT_BASELINE_OUTPUT BUILDKIT_PREEXISTING BUILDKIT_IMAGE_ID \
BUILDKIT_IMAGE_RC BUILDKIT_IMAGE_USERS BUILDKIT_IMAGE_VERIFY \
BUILDKIT_RM_OUTPUT BUILDKIT_ID_VERIFY DRIVER_IMAGE_STATE \
BUILDER_INSPECT BUILDER_EXISTS BUILDER_CONTAINER_IDS \
BUILDER_CONTAINER_COUNT BUILDER_CONTAINER_FACTS BUILDER_FACTS \
BUILDER_VOLUME_CREATED BUILDER_VOLUME_EXISTS BUILDER_VOLUME_STATE \
BUILDER_CREATE_OUTPUT BUILDER_CREATE_RC BUILDER_BOOT_OUTPUT \
BUILDER_BOOT_RC BUILDER_RM_OUTPUT BUILDER_RM_RC BUILDER_STATE \
BUILDER_STATE_RC CURRENT_BUILDER_CONTAINER_ID CURRENT_BUILDKIT_IMAGE_ID \
CURRENT_VOLUME_CREATED EXPECTED_BUILDER_FACTS REUSE_BUILDER \
HAS_BUILDER_OWNED HAS_BUILDER_ATTEMPT OWNED_BUILDER_CONTAINER_ID \
OWNED_BUILDER_VOLUME_CREATED OWNED_BUILDKIT_IMAGE_ID \
ATTEMPT_BUILDER_CONTAINER_ID ATTEMPT_BUILDER_VOLUME_CREATED \
ATTEMPT_BUILDKIT_IMAGE_ID WBL_FILE WBL_CONTAINER_ID WBL_VOLUME_CREATED \
WBL_IMAGE_ID RBL_FILE RBL_ALLOW_PENDING RBL_CONTAINER_ID \
RBL_VOLUME_CREATED RBL_IMAGE_ID RBS_RC VBF_CONTAINER_ID \
VBF_VOLUME_CREATED VBF_IMAGE_ID VBF_RC RBB_RC \
CLEANUP_OWNED_CONTAINER_ID CLEANUP_OWNED_VOLUME_CREATED \
CLEANUP_OWNED_IMAGE_ID CLEANUP_ATTEMPT_CONTAINER_ID \
CLEANUP_ATTEMPT_VOLUME_CREATED CLEANUP_ATTEMPT_IMAGE_ID \
AUTHORIZED_BUILDER_ID AUTHORIZED_VOLUME_CREATED \
AUTHORIZED_BUILDKIT_IMAGE_ID CLEANUP_BUILDER_INSPECT \
CLEANUP_BUILDER_EXISTS CLEANUP_BUILDER_IDS CLEANUP_BUILDER_COUNT \
CLEANUP_BUILDER_FACTS CLEANUP_BUILDER_RC \
CLEANUP_BUILDER_VOLUME_CREATED CLEANUP_BUILDER_VOLUME_EXISTS \
EXPECTED_CLEANUP_BUILDER_FACTS RBCL_FILE RBCL_ALLOW_PENDING \
RBCL_CONTAINER_ID RBCL_VOLUME_CREATED RBCL_IMAGE_ID RBCS_RC \
RBLI_FILE RBLI_ALLOW_PENDING RBLI_CONTAINER_ID RBLI_VOLUME_CREATED \
RBLI_IMAGE_ID RECORDED_BUILDKIT_IMAGE_ID \
STANDALONE_NAME STANDALONE_IMAGE STANDALONE_MARKER STANDALONE_ATTEMPT \
REUSE_STANDALONE HAS_STANDALONE_OWNED HAS_STANDALONE_ATTEMPT \
RECORDED_STANDALONE_ID OWNED_STANDALONE_ID ATTEMPT_STANDALONE_ID \
STANDALONE_ID STANDALONE_IDS STANDALONE_COUNT STANDALONE_FACTS \
STANDALONE_RC STANDALONE_REMOVE_OUTPUT STANDALONE_START_OUTPUT \
STANDALONE_VERIFY PORT_USERS PORT_RC CSA_IDS CSA_FACTS \
RSM_FILE RSM_ALLOW_PENDING RSM_ID RSS_RC VSF_ID VSF_RC \
COURSE_PV_NAME PV_MARKER PV_CLAIM PV_NAME PV_VERIFY PV_VERIFY_RC PV_GONE \
ACTUAL_CONTEXT CONTEXT_RC BASE_PHASE BASE_READY BASE_RESTARTS BASE_UID \
CONFIG_READY CURRENT_UIDS ENDPOINT_COUNT ENDPOINT_READY \
ENDPOINT_ADDRESSES ENDPOINT_READY_STATES NEW_UID NEW_VOLUME \
NOT_READY_CONDITION NOT_READY_PHASE NOT_READY_RESTARTS NOT_READY_UID \
OLD_POD OLD_UID OLD_VOLUME POD READY_COUNT RECOVERED_PHASE \
RECOVERED_READY RECOVERED_RESTARTS RECOVERED_UID REFERENCE TASKBOARD_POD \
DOCKER_CODENAME DOCKER_DISTRO IMAGE_TAG IMAGE_OWNERSHIP_MARKER \
OWNED_IMAGE_ID NODE_IMAGE_ID NODE_IMAGE_USERS NODE_IMAGE_VERIFY \
NETWORK_CONTAINERS NETWORK_VERIFY COURSE_NODE_CONTAINERS \
CLEANUP_KIND_CLUSTERS CLEANUP_KIND_HAS_CLUSTER CLEANUP_LABEL_IDS \
CLEANUP_LABEL_COUNT CLEANUP_NAME_IDS CLEANUP_NAME_COUNT \
CLEANUP_NODE_ID CLEANUP_NODE_FACTS EXPECTED_CLEANUP_FACTS \
OWNED_CLEANUP_NODE_ID ATTEMPT_CLEANUP_NODE_ID AUTHORIZED_NODE_ID \
RCCL_FILE RCCL_ALLOW_PENDING RCCL_NODE_ID RCCS_RC VCN_RC VEN_RC \
NS_OUTPUT NS_RC NS_VERIFY NS_VERIFY_RC ROLLOUT_OUTPUT ROLLOUT_RC \
SERVICE_PROBE_OUTPUT SERVICE_PROBE_RC ALLOWED_INFO ALLOWED_INFO_RC \
ALLOWED_TASKS ALLOWED_TASKS_RC BLOCKED_HTTP_OUTPUT BLOCKED_HTTP_RC \
BLOCKED_REDIS_OUTPUT BLOCKED_REDIS_RC DNS_OUTPUT DNS_RC \
FIRST_RESULT FIRST_TASKS SECOND_RESULT SECOND_TASKS RESULT PROBE_RC \
HEALTH_OUTPUT HEALTH_RC INFO_OUTPUT INFO_RC INFO_ASSERTION \
TASKS_OUTPUT TASKS_RC \
TASKS_RESPONSE TASK_COUNT CURRENT_DEPLOYMENT_UID CURRENT_STATEFULSET_UID \
VALIDATED_DEPLOYMENT_UID VALIDATED_STATEFULSET_UID DEPLOYMENT_UID \
STATEFULSET_UID DEPLOYMENT_ASSERTION STATEFULSET_ASSERTION PVC_ASSERTION \
JOB_ASSERTION HPA_ASSERTION ASSERTION_RC PSA_APPLY_OUTPUT PSA_APPLY_RC \
PSA_GET_OUTPUT PSA_GET_RC INIT_AUTOMOUNT INIT_POD INIT_VOLUMES \
JOB_AUTOMOUNT REDIS_AUTOMOUNT REQUIRED_TOOLS REQUIRED_OPTION \
REQUIRE_REDIS_READY \
SAMPLE_COUNT LAST_METRICS_OUTPUT METRICS_ARGS METRICS_CONTAINER \
METRICS_READY UID_RC ID PREVIOUS_UMASK ADDRESSES PATH_SOURCE PATH_RESULT \
PATH_RESULT_FIELDS PATH_ENTRY PATH_MORE PATH_CHECK_SOURCE \
PATH_CHECK_ENTRY PATH_CHECK_MORE KUBECONFIG_ENV_MARKER \
KUBECONFIG_MARKER_MODE KUBECONFIG_MARKER_UID KUBECONFIG_PREVIOUS_STATE \
KUBECONFIG_PREVIOUS_BYTES KUBECONFIG_HEADER_BYTES \
KUBECONFIG_MARKER_TOTAL_BYTES KUBECONFIG_ACTUAL_HEADER \
KUBECONFIG_EXPECTED_HEADER KUBECONFIG_WITH_SENTINEL \
KUBECONFIG_RESTORE_VALUE RFM_FILE RFM_MODE RFM_UID VKEM_FILE \
VKEM_STATE VKEM_VALUE_BYTES VKEM_HEADER_BYTES VKEM_TOTAL_BYTES \
VKEM_ACTUAL_HEADER VKEM_EXPECTED_HEADER FINAL_VALIDATION_MARKER \
FINAL_DEPLOYMENT_UID FINAL_STATEFULSET_UID LEDGER attempt i tool
do
unset " $COURSE_VARIABLE "
if typeset -p " $COURSE_VARIABLE " > /dev/null 2 >&1 ; then
printf '停止:课程变量未取消:%s\n' " $COURSE_VARIABLE " >&2
exit 1
fi
done
unset COURSE_VARIABLE
if typeset -p COURSE_FUNCTION > /dev/null 2 >&1 || \
typeset -p COURSE_VARIABLE > /dev/null 2>&1 ; then
echo '停止:shell 清理的循环变量仍存在' >&2
exit 1
fi
echo 'shell_state=course_functions_and_variables_removed'