WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Docker 沙箱后端可行性 PoC
|
||||
|
||||
配套文档:[Docker 沙箱后端重做调研](../../sandbox-docker-backend.md)。
|
||||
|
||||
这个程序直接打 Docker Engine API,逐项验证「Docker 能不能承载 WeKnora
|
||||
`RemoteSandboxClient` 契约 + E2B 那套 Snapshot 工作流」。它不是产品代码,也不参与主模块构建
|
||||
(自带 `go.mod`),只作为调研结论的可复现证据。
|
||||
|
||||
每一项都打印 `PASS/FAIL` 和实际观测值。带 `(GAP)` 的步骤断言的是「Docker 做不到什么」,
|
||||
它们同样以 PASS 结束——PASS 表示差距被复现了,不是表示能力具备。
|
||||
|
||||
## 跑法
|
||||
|
||||
需要一个可达的 Docker daemon(尊重 `DOCKER_HOST`),能拉 `python:3.11-slim`:
|
||||
|
||||
```bash
|
||||
cd docs/poc/docker-sandbox
|
||||
go run . # daemon 在本机 unix socket 上时可能需要 sudo -E
|
||||
```
|
||||
|
||||
程序会自建一个带 uid 1000 `user` 账号的模板镜像(对齐 E2B 模板约定),跑完自行删除它创建的容器。
|
||||
留下的 `weknora-poc/*` 镜像用 `docker image rm` 清理。
|
||||
|
||||
## 覆盖的内容
|
||||
|
||||
- 生命周期:Create / Connect(换客户端重连)/ List(label 过滤)/ Delete / Pause / Stop+Start
|
||||
- 执行:user、workdir、env、stdin、stdout+stderr 分离、退出码、超时
|
||||
- 文件面:archive API 读写与 stat、exec 实现的 mkdir/ls/rm
|
||||
- 会话语义:`pip install` 与写文件跨 exec 存活
|
||||
- Snapshot:commit 成镜像、从快照启新沙箱、v1→v2 增量、按 label 列举、层数与体积
|
||||
- 差距复现:客户端取消不杀进程、CapDrop ALL 后 root 越不过权限位、快照不含内存态、
|
||||
daemon 无空闲 TTL、kill `docker run` 会留下还在跑的容器
|
||||
@@ -0,0 +1,29 @@
|
||||
module github.com/Tencent/WeKnora/docs/poc/docker-sandbox
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
@@ -0,0 +1,612 @@
|
||||
// PoC for docs/sandbox-docker-backend.md: can the Docker Engine API back the
|
||||
// semantics WeKnora's RemoteSandboxClient contract requires (session-persistent
|
||||
// sandbox, exec, filesystem, metadata, lifecycle), plus the snapshot workflow
|
||||
// planned for E2B?
|
||||
//
|
||||
// It drives a real daemon and prints PASS/FAIL plus the observed evidence for
|
||||
// every operation, including the ones Docker cannot honour ("GAP" steps, which
|
||||
// assert the gap rather than a capability).
|
||||
//
|
||||
// Run with a reachable daemon (DOCKER_HOST honoured):
|
||||
//
|
||||
// cd docs/poc/docker-sandbox && go run .
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
const (
|
||||
baseImage = "python:3.11-slim"
|
||||
execUser = "user"
|
||||
)
|
||||
|
||||
var failures int
|
||||
|
||||
func step(name string, err error, detail string) {
|
||||
if err != nil {
|
||||
failures++
|
||||
fmt.Printf("FAIL %-48s %v\n", name, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("PASS %-48s %s\n", name, detail)
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
cli, err := client.New(client.WithHostFromEnv(), client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
fmt.Println("cannot reach docker:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
ping, err := cli.Ping(ctx, client.PingOptions{})
|
||||
step("Health: ping control plane", err, fmt.Sprintf("api=%s", ping.APIVersion))
|
||||
|
||||
tmplImage := "weknora-poc/template:v2"
|
||||
err = buildTemplateImage(ctx, cli, tmplImage)
|
||||
step("Template: base image with uid-1000 user", err, tmplImage)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sessionID := fmt.Sprintf("poc-session-%d", time.Now().Unix())
|
||||
labels := map[string]string{
|
||||
"weknora.managed": "true",
|
||||
"weknora.tenant": "1",
|
||||
"weknora.session": sessionID,
|
||||
"weknora.config": "cfg-poc",
|
||||
"weknora.createdAt": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// --- Create -------------------------------------------------------------
|
||||
id, err := createSandbox(ctx, cli, tmplImage, labels)
|
||||
step("Create: container as session sandbox", err, short(id))
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer remove(cli, id)
|
||||
|
||||
// --- Connect / Get ------------------------------------------------------
|
||||
fresh, err := client.New(client.WithHostFromEnv(), client.WithAPIVersionNegotiation())
|
||||
if err == nil {
|
||||
defer fresh.Close()
|
||||
var insp client.ContainerInspectResult
|
||||
insp, err = fresh.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
|
||||
if err == nil && insp.Container.Config.Labels["weknora.session"] != sessionID {
|
||||
err = errors.New("metadata labels not preserved")
|
||||
}
|
||||
step("Connect+Metadata: re-attach from a new client", err,
|
||||
fmt.Sprintf("state=%s session=%s", insp.Container.State.Status,
|
||||
insp.Container.Config.Labels["weknora.session"]))
|
||||
}
|
||||
|
||||
// --- List by metadata ---------------------------------------------------
|
||||
listed, err := cli.ContainerList(ctx, client.ContainerListOptions{
|
||||
All: true,
|
||||
Filters: client.Filters{}.Add("label", "weknora.session="+sessionID),
|
||||
})
|
||||
if err == nil && len(listed.Items) != 1 {
|
||||
err = fmt.Errorf("expected 1 container, got %d", len(listed.Items))
|
||||
}
|
||||
step("List: server-side metadata filter", err, fmt.Sprintf("matched=%d", len(listed.Items)))
|
||||
|
||||
// --- Exec: user / workdir / env / stdin / demux / exit code -------------
|
||||
res, err := execIn(ctx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", `cat; echo "user=$(id -un) cwd=$(pwd) env=$GREET"; echo oops >&2; exit 7`},
|
||||
user: execUser,
|
||||
workDir: "/workspace",
|
||||
env: []string{"GREET=hello"},
|
||||
stdin: "stdin-payload\n",
|
||||
})
|
||||
if err == nil {
|
||||
switch {
|
||||
case res.exitCode != 7:
|
||||
err = fmt.Errorf("exit code %d, want 7", res.exitCode)
|
||||
case !strings.Contains(res.stdout, "user=user cwd=/workspace env=hello"):
|
||||
err = fmt.Errorf("stdout mismatch: %q", res.stdout)
|
||||
case !strings.Contains(res.stdout, "stdin-payload"):
|
||||
err = fmt.Errorf("stdin not delivered: %q", res.stdout)
|
||||
case !strings.Contains(res.stderr, "oops"):
|
||||
err = fmt.Errorf("stderr not separated: %q", res.stderr)
|
||||
}
|
||||
}
|
||||
step("Exec: user/workdir/env/stdin/demux/exit", err,
|
||||
fmt.Sprintf("exit=%d stdout=%q stderr=%q", res.exitCode, oneline(res.stdout), oneline(res.stderr)))
|
||||
|
||||
// --- Exec cancellation: does a client-side cancel kill the process? -----
|
||||
cancelCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
_, execErr := execIn(cancelCtx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", "sleep 30"},
|
||||
})
|
||||
cancel()
|
||||
time.Sleep(1 * time.Second)
|
||||
probe, _ := execIn(ctx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", "ps -eo args= | grep -c '^sleep 30$' || true"},
|
||||
})
|
||||
step("Exec cancel: client cancel does NOT kill process (GAP)", nil,
|
||||
fmt.Sprintf("clientErr=%v surviving 'sleep' procs=%s", execErr != nil, oneline(probe.stdout)))
|
||||
|
||||
// --- Timeout enforced inside the container ------------------------------
|
||||
start := time.Now()
|
||||
killRes, err := execIn(ctx, cli, id, execRequest{
|
||||
cmd: []string{"timeout", "-s", "KILL", "2", "bash", "-lc", "sleep 30"},
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if err == nil && killRes.exitCode != 137 {
|
||||
err = fmt.Errorf("exit code %d, want 137 (SIGKILL)", killRes.exitCode)
|
||||
}
|
||||
step("Timeout: enforce via in-container timeout(1)", err,
|
||||
fmt.Sprintf("exit=%d after=%s", killRes.exitCode, elapsed.Round(100*time.Millisecond)))
|
||||
|
||||
// --- Filesystem: write / read / stat / mkdir / list / remove ------------
|
||||
err = writeFile(ctx, cli, id, "/workspace/input/attachment.txt", []byte("attachment payload\n"))
|
||||
step("WriteFile: PUT /containers/{id}/archive", err, "/workspace/input/attachment.txt")
|
||||
|
||||
content, err := readFile(ctx, cli, id, "/workspace/input/attachment.txt")
|
||||
if err == nil && string(content) != "attachment payload\n" {
|
||||
err = fmt.Errorf("content mismatch: %q", content)
|
||||
}
|
||||
step("ReadFile: GET /containers/{id}/archive", err, fmt.Sprintf("%d bytes", len(content)))
|
||||
|
||||
stat, err := cli.ContainerStatPath(ctx, id, client.ContainerStatPathOptions{
|
||||
Path: "/workspace/input/attachment.txt",
|
||||
})
|
||||
step("Stat: HEAD /containers/{id}/archive", err,
|
||||
fmt.Sprintf("size=%d mode=%v mtime=%s", stat.Stat.Size, stat.Stat.Mode.Perm(),
|
||||
stat.Stat.Mtime.Format(time.RFC3339)))
|
||||
|
||||
rootMk, _ := execIn(ctx, cli, id, execRequest{cmd: []string{"mkdir", "-p", "/workspace/output/rootdir"}})
|
||||
step("CapDrop ALL: root cannot bypass mode bits (GAP)", nil,
|
||||
fmt.Sprintf("mkdir as root exit=%d stderr=%q", rootMk.exitCode, oneline(rootMk.stderr)))
|
||||
|
||||
_, err = mustExec(ctx, cli, id, execRequest{
|
||||
cmd: []string{"mkdir", "-p", "/workspace/output/nested"},
|
||||
user: execUser,
|
||||
})
|
||||
step("MakeDir: exec mkdir -p as sandbox user", err, "/workspace/output/nested")
|
||||
|
||||
if _, err = mustExec(ctx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", "echo body > /workspace/output/nested/report.txt"},
|
||||
user: execUser,
|
||||
}); err != nil {
|
||||
step("ListDir: seed a file to list", err, "")
|
||||
}
|
||||
lsRes, err := mustExec(ctx, cli, id, execRequest{
|
||||
cmd: []string{"find", "/workspace/output", "-mindepth", "1", "-printf", `%y\t%s\t%T@\t%p\n`},
|
||||
user: execUser,
|
||||
})
|
||||
if err == nil && !strings.Contains(lsRes.stdout, "report.txt") {
|
||||
err = fmt.Errorf("listing missing report.txt: %q", lsRes.stdout)
|
||||
}
|
||||
step("ListDir: exec find (no native API)", err,
|
||||
fmt.Sprintf("exit=%d out=%q err=%q", lsRes.exitCode, oneline(lsRes.stdout), oneline(lsRes.stderr)))
|
||||
|
||||
_, err = mustExec(ctx, cli, id, execRequest{
|
||||
cmd: []string{"rm", "-rf", "/workspace/input/attachment.txt"},
|
||||
user: execUser,
|
||||
})
|
||||
step("Remove: exec rm (no native API)", err, "removed")
|
||||
|
||||
// --- Session-scoped state persists across executions --------------------
|
||||
installRes, err := execIn(ctx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", "pip install --quiet --no-cache-dir --user cowsay==6.1 && echo 1 > /workspace/output/counter.txt"},
|
||||
user: execUser,
|
||||
})
|
||||
if err == nil && installRes.exitCode != 0 {
|
||||
err = fmt.Errorf("install failed: %s %s", oneline(installRes.stdout), oneline(installRes.stderr))
|
||||
}
|
||||
if err == nil {
|
||||
var second execResult
|
||||
second, err = execIn(ctx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", `python -c 'import cowsay; print("cowsay ok")' && cat /workspace/output/counter.txt`},
|
||||
user: execUser,
|
||||
})
|
||||
if err == nil && (!strings.Contains(second.stdout, "cowsay ok") || !strings.Contains(second.stdout, "1")) {
|
||||
err = fmt.Errorf("state lost between executions: %q %q", second.stdout, second.stderr)
|
||||
}
|
||||
}
|
||||
step("Session state: pip install survives next exec", err, "package + file both visible")
|
||||
|
||||
// --- Pause / Unpause (memory kept resident) -----------------------------
|
||||
_, err = cli.ContainerPause(ctx, id, client.ContainerPauseOptions{})
|
||||
if err == nil {
|
||||
insp, _ := cli.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
|
||||
if insp.Container.State.Status != "paused" {
|
||||
err = fmt.Errorf("state=%s", insp.Container.State.Status)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
_, err = cli.ContainerUnpause(ctx, id, client.ContainerUnpauseOptions{})
|
||||
}
|
||||
if err == nil {
|
||||
var after execResult
|
||||
after, err = execIn(ctx, cli, id, execRequest{cmd: []string{"cat", "/workspace/output/counter.txt"}})
|
||||
if err == nil && !strings.Contains(after.stdout, "1") {
|
||||
err = fmt.Errorf("state lost after unpause: %q", after.stdout)
|
||||
}
|
||||
}
|
||||
step("Pause/Unpause: cgroup freezer round-trip", err,
|
||||
"RAM + processes retained, host memory still occupied")
|
||||
|
||||
// --- Stop / Start (filesystem kept, processes lost) ---------------------
|
||||
_, _ = execIn(ctx, cli, id, execRequest{cmd: []string{"bash", "-lc", "nohup sleep 900 >/dev/null 2>&1 & echo started"}})
|
||||
timeoutSec := 5
|
||||
_, err = cli.ContainerStop(ctx, id, client.ContainerStopOptions{Timeout: &timeoutSec})
|
||||
if err == nil {
|
||||
_, err = cli.ContainerStart(ctx, id, client.ContainerStartOptions{})
|
||||
}
|
||||
var afterRestart execResult
|
||||
if err == nil {
|
||||
afterRestart, err = execIn(ctx, cli, id, execRequest{
|
||||
cmd: []string{"bash", "-lc", "cat /workspace/output/counter.txt; ps -eo args= | grep -c '^sleep 900$' || true"},
|
||||
})
|
||||
if err == nil && !strings.Contains(afterRestart.stdout, "1") {
|
||||
err = fmt.Errorf("filesystem lost after restart: %q", afterRestart.stdout)
|
||||
}
|
||||
}
|
||||
step("Stop/Start: filesystem survives, processes do not", err,
|
||||
fmt.Sprintf("counter+sleep-count=%s", oneline(afterRestart.stdout)))
|
||||
|
||||
// --- Snapshot workflow --------------------------------------------------
|
||||
mgmtID, err := createSandbox(ctx, cli, tmplImage,
|
||||
map[string]string{"weknora.managed": "true", "weknora.role": "snapshot-builder"})
|
||||
step("Snapshot: create workspace management sandbox", err, short(mgmtID))
|
||||
if err != nil {
|
||||
report()
|
||||
return
|
||||
}
|
||||
defer remove(cli, mgmtID)
|
||||
|
||||
skillRes, err := execIn(ctx, cli, mgmtID, execRequest{
|
||||
cmd: []string{"bash", "-lc", "pip install --quiet --no-cache-dir requests==2.32.3 && mkdir -p /opt/skills/pdf && echo 'skill v1' > /opt/skills/pdf/SKILL.md"},
|
||||
})
|
||||
if err == nil && skillRes.exitCode != 0 {
|
||||
err = fmt.Errorf("skill install failed: %s", oneline(skillRes.stderr))
|
||||
}
|
||||
step("Snapshot: install a skill into that sandbox", err, "requests==2.32.3 + /opt/skills/pdf")
|
||||
|
||||
snapV1 := "weknora-poc/snapshot:v1"
|
||||
commitRes, err := cli.ContainerCommit(ctx, mgmtID, client.ContainerCommitOptions{
|
||||
Reference: snapV1,
|
||||
Comment: "weknora snapshot v1",
|
||||
Changes: []string{`LABEL weknora.snapshot.version=1`, `LABEL weknora.snapshot.tenant=1`},
|
||||
})
|
||||
step("Snapshot: commit container -> image", err, short(commitRes.ID))
|
||||
|
||||
snapSessionID, err := createSandbox(ctx, cli, snapV1,
|
||||
map[string]string{"weknora.managed": "true", "weknora.session": sessionID + "-from-snapshot"})
|
||||
step("Snapshot: session sandbox boots from snapshot", err, short(snapSessionID))
|
||||
if err == nil {
|
||||
defer remove(cli, snapSessionID)
|
||||
var check execResult
|
||||
check, err = execIn(ctx, cli, snapSessionID, execRequest{
|
||||
cmd: []string{"bash", "-lc", `python -c 'import requests; print(requests.__version__)' && cat /opt/skills/pdf/SKILL.md`},
|
||||
})
|
||||
if err == nil && (!strings.Contains(check.stdout, "2.32.3") || !strings.Contains(check.stdout, "skill v1")) {
|
||||
err = fmt.Errorf("snapshot content missing: %q %q", check.stdout, check.stderr)
|
||||
}
|
||||
step("Snapshot: installed skill present in new sandbox", err, oneline(check.stdout))
|
||||
}
|
||||
|
||||
// Incremental snapshot update: install a second skill on top of v1.
|
||||
upgradeID, err := createSandbox(ctx, cli, snapV1, map[string]string{"weknora.role": "snapshot-builder"})
|
||||
if err == nil {
|
||||
defer remove(cli, upgradeID)
|
||||
_, err = execIn(ctx, cli, upgradeID, execRequest{
|
||||
cmd: []string{"bash", "-lc", "mkdir -p /opt/skills/chart && echo 'skill v2' > /opt/skills/chart/SKILL.md"},
|
||||
})
|
||||
}
|
||||
snapV2 := "weknora-poc/snapshot:v2"
|
||||
if err == nil {
|
||||
_, err = cli.ContainerCommit(ctx, upgradeID, client.ContainerCommitOptions{
|
||||
Reference: snapV2,
|
||||
Changes: []string{`LABEL weknora.snapshot.version=2`},
|
||||
})
|
||||
}
|
||||
step("Snapshot: incremental update v1 -> v2", err, snapV2)
|
||||
|
||||
imgs, err := cli.ImageList(ctx, client.ImageListOptions{
|
||||
All: true,
|
||||
Filters: client.Filters{}.Add("label", "weknora.snapshot.version"),
|
||||
})
|
||||
step("Snapshot: list snapshots by label", err, fmt.Sprintf("count=%d", len(imgs.Items)))
|
||||
|
||||
insp, err := cli.ImageInspect(ctx, snapV2)
|
||||
if err == nil {
|
||||
step("Snapshot: layer/size accounting", nil,
|
||||
fmt.Sprintf("layers=%d size=%.1fMB (image layer hard limit: 127)",
|
||||
len(insp.RootFS.Layers), float64(insp.Size)/1e6))
|
||||
} else {
|
||||
step("Snapshot: layer/size accounting", err, "")
|
||||
}
|
||||
|
||||
// Does a snapshot preserve running processes / RAM? (E2B pause does.)
|
||||
_, _ = execIn(ctx, cli, mgmtID, execRequest{cmd: []string{"bash", "-lc", "nohup sleep 600 >/dev/null 2>&1 & echo started"}})
|
||||
procSnap := "weknora-poc/snapshot:proc"
|
||||
_, err = cli.ContainerCommit(ctx, mgmtID, client.ContainerCommitOptions{Reference: procSnap})
|
||||
if err == nil {
|
||||
var procID string
|
||||
procID, err = createSandbox(ctx, cli, procSnap, map[string]string{"weknora.role": "proc-check"})
|
||||
if err == nil {
|
||||
defer remove(cli, procID)
|
||||
var out execResult
|
||||
out, err = execIn(ctx, cli, procID, execRequest{cmd: []string{"bash", "-lc", "ps -eo args= | grep -c '^sleep 600$' || true"}})
|
||||
step("Snapshot: running processes NOT captured (GAP)", err,
|
||||
fmt.Sprintf("'sleep' procs in restored sandbox=%s", oneline(out.stdout)))
|
||||
}
|
||||
}
|
||||
|
||||
insp2, err := cli.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
|
||||
step("Idle TTL: daemon has none, only timestamps (GAP)", err,
|
||||
fmt.Sprintf("startedAt=%s -> WeKnora must sweep", insp2.Container.State.StartedAt))
|
||||
|
||||
checkCLIOrphan(ctx)
|
||||
|
||||
report()
|
||||
}
|
||||
|
||||
// checkCLIOrphan reproduces what today's CLI-based DockerSandbox does on
|
||||
// timeout: exec.CommandContext kills the `docker run --rm` client process. The
|
||||
// workload lives in the daemon, so killing the client does not stop it — the
|
||||
// timeout is reported to the caller while the container keeps burning CPU.
|
||||
func checkCLIOrphan(ctx context.Context) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
step("CLI orphan: skipped (docker CLI not on PATH)", nil, "")
|
||||
return
|
||||
}
|
||||
name := fmt.Sprintf("weknora-poc-orphan-%d", time.Now().UnixNano())
|
||||
runCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(runCtx, "docker", "run", "--rm", "--name", name,
|
||||
baseImage, "sleep", "120")
|
||||
cliErr := cmd.Run()
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
out, err := exec.CommandContext(ctx, "docker", "ps", "--filter", "name="+name,
|
||||
"--format", "{{.State}} {{.Status}}").Output()
|
||||
state := oneline(string(out))
|
||||
_ = exec.CommandContext(ctx, "docker", "rm", "-f", name).Run()
|
||||
if state == "" {
|
||||
state = "gone"
|
||||
}
|
||||
step("CLI orphan: killing `docker run` leaves it running (GAP)", err,
|
||||
fmt.Sprintf("cliErr=%v container=%s", cliErr != nil, state))
|
||||
}
|
||||
|
||||
func report() {
|
||||
fmt.Println()
|
||||
if failures == 0 {
|
||||
fmt.Println("RESULT: all checks passed")
|
||||
return
|
||||
}
|
||||
fmt.Printf("RESULT: %d check(s) failed\n", failures)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------------
|
||||
|
||||
func remove(cli *client.Client, id string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_, _ = cli.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true})
|
||||
}
|
||||
|
||||
func buildTemplateImage(ctx context.Context, cli *client.Client, tag string) error {
|
||||
if _, err := cli.ImageInspect(ctx, tag); err == nil {
|
||||
return nil
|
||||
}
|
||||
dockerfile := `FROM ` + baseImage + `
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends procps findutils && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd -m -u 1000 user && mkdir -p /workspace/input /workspace/output && chown -R user:user /workspace
|
||||
WORKDIR /workspace
|
||||
`
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "Dockerfile", Mode: 0o644, Size: int64(len(dockerfile))}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write([]byte(dockerfile)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := cli.ImageBuild(ctx, &buf, client.ImageBuildOptions{Tags: []string{tag}, Remove: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func createSandbox(ctx context.Context, cli *client.Client, img string, labels map[string]string) (string, error) {
|
||||
pids := int64(256)
|
||||
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||
Image: img,
|
||||
Config: &container.Config{
|
||||
Cmd: []string{"sleep", "infinity"},
|
||||
WorkingDir: "/workspace",
|
||||
Labels: labels,
|
||||
},
|
||||
HostConfig: &container.HostConfig{
|
||||
Resources: container.Resources{
|
||||
Memory: 1 << 30,
|
||||
MemorySwap: 1 << 30,
|
||||
NanoCPUs: 1_000_000_000,
|
||||
PidsLimit: &pids,
|
||||
},
|
||||
CapDrop: []string{"ALL"},
|
||||
SecurityOpt: []string{"no-new-privileges"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := cli.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
|
||||
return created.ID, err
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
type execRequest struct {
|
||||
cmd []string
|
||||
user string
|
||||
workDir string
|
||||
env []string
|
||||
stdin string
|
||||
}
|
||||
|
||||
type execResult struct {
|
||||
stdout string
|
||||
stderr string
|
||||
exitCode int
|
||||
}
|
||||
|
||||
func execIn(ctx context.Context, cli *client.Client, id string, req execRequest) (execResult, error) {
|
||||
created, err := cli.ExecCreate(ctx, id, client.ExecCreateOptions{
|
||||
Cmd: req.cmd,
|
||||
User: req.user,
|
||||
WorkingDir: req.workDir,
|
||||
Env: req.env,
|
||||
AttachStdin: req.stdin != "",
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return execResult{}, err
|
||||
}
|
||||
attached, err := cli.ExecAttach(ctx, created.ID, client.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return execResult{}, err
|
||||
}
|
||||
defer attached.Close()
|
||||
|
||||
if req.stdin != "" {
|
||||
if _, err := attached.Conn.Write([]byte(req.stdin)); err != nil {
|
||||
return execResult{}, err
|
||||
}
|
||||
if cw, ok := attached.Conn.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, copyErr := stdcopy.StdCopy(&stdout, &stderr, attached.Reader)
|
||||
copyDone <- copyErr
|
||||
}()
|
||||
select {
|
||||
case err = <-copyDone:
|
||||
if err != nil {
|
||||
return execResult{}, err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return execResult{stdout: stdout.String(), stderr: stderr.String()}, ctx.Err()
|
||||
}
|
||||
|
||||
inspect, err := cli.ExecInspect(ctx, created.ID, client.ExecInspectOptions{})
|
||||
if err != nil {
|
||||
return execResult{}, err
|
||||
}
|
||||
return execResult{
|
||||
stdout: stdout.String(),
|
||||
stderr: stderr.String(),
|
||||
exitCode: inspect.ExitCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mustExec fails when the command itself exits non-zero, so a silently
|
||||
// failing setup step cannot be mistaken for a passing capability check.
|
||||
func mustExec(ctx context.Context, cli *client.Client, id string, req execRequest) (execResult, error) {
|
||||
res, err := execIn(ctx, cli, id, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if res.exitCode != 0 {
|
||||
return res, fmt.Errorf("exit=%d stderr=%s", res.exitCode, oneline(res.stderr))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func writeFile(ctx context.Context, cli *client.Client, id, dest string, content []byte) error {
|
||||
if _, err := execIn(ctx, cli, id, execRequest{cmd: []string{"mkdir", "-p", dirOf(dest)}}); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
name := dest[strings.LastIndex(dest, "/")+1:]
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: name, Mode: 0o644, Size: int64(len(content)), Uid: 1000, Gid: 1000,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := cli.CopyToContainer(ctx, id, client.CopyToContainerOptions{
|
||||
DestinationPath: dirOf(dest),
|
||||
Content: &buf,
|
||||
CopyUIDGID: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func readFile(ctx context.Context, cli *client.Client, id, src string) ([]byte, error) {
|
||||
res, err := cli.CopyFromContainer(ctx, id, client.CopyFromContainerOptions{SourcePath: src})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Content.Close()
|
||||
tr := tar.NewReader(res.Content)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, errors.New("file not present in archive")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.Typeflag == tar.TypeReg {
|
||||
return io.ReadAll(tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dirOf(p string) string {
|
||||
idx := strings.LastIndex(p, "/")
|
||||
if idx <= 0 {
|
||||
return "/"
|
||||
}
|
||||
return p[:idx]
|
||||
}
|
||||
|
||||
func short(id string) string {
|
||||
if len(id) > 12 {
|
||||
return id[:12]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func oneline(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
Reference in New Issue
Block a user