daemon: agent-driven workspace images — repo→image registry (GET/PUT/DELETE /api/repos), spawn resolves custom image, self-healing lvmh-ops control container with docker.sock; 132 tests, e2e 87/87

This commit is contained in:
Raphael Westphal
2026-08-18 23:54:09 +02:00
parent 8fcaeb7c03
commit 7287b831e4
15 changed files with 1216 additions and 38 deletions
+102 -12
View File
@@ -36,6 +36,7 @@ type recordedCreate struct {
Env []string `json:"Env"`
Labels map[string]string `json:"Labels"`
Cmd []string `json:"Cmd"`
WorkingDir string `json:"WorkingDir"`
HostConfig struct {
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
@@ -46,12 +47,16 @@ type recordedCreate struct {
type fakeDocker struct {
mu sync.Mutex
calls []dockerCall
images int // entries served by GET /images/json
volume map[string]bool // existing volumes
nextID int
create []recordedCreate
archives []int // sizes of accepted CopyToContainer tar streams
calls []dockerCall
images int // legacy: >0 serves the default worker image
imageTags map[string]bool // when set, /images/json serves exactly these
volume map[string]bool // existing volumes
nextID int
create []recordedCreate
archives []int // sizes of accepted CopyToContainer tar streams
containers map[string]string // name-or-id → container id
running map[string]bool // container id → running
failBuild bool
failBuildHTTP bool
@@ -70,7 +75,23 @@ type fakeDocker struct {
}
func newFakeDocker() *fakeDocker {
return &fakeDocker{images: 1, volume: map[string]bool{}}
return &fakeDocker{images: 1, volume: map[string]bool{},
containers: map[string]string{}, running: map[string]bool{}}
}
// containerID resolves a name-or-id path token to the tracked container id.
func (f *fakeDocker) containerID(token string) (string, bool) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.containers[token]
return id, ok
}
// isRunning reports the running flag of a tracked container id.
func (f *fakeDocker) isRunning(id string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.running[id]
}
func (f *fakeDocker) server(t *testing.T) *httptest.Server {
@@ -82,6 +103,43 @@ func (f *fakeDocker) server(t *testing.T) *httptest.Server {
// record appends a call; the /vX.Y version prefix negotiated by the SDK is
// stripped so assertions are version-agnostic.
// imageListJSON builds the GET /images/json entry list (joined with commas)
// honoring the reference filter. Legacy mode (imageTags nil): images>0 means
// exactly the default worker image is present.
func (f *fakeDocker) imageListJSON(filtersJSON string) string {
matches := func(ref string) bool {
if f.imageTags == nil {
return f.images > 0 && ref == imageRefWorker
}
return f.imageTags[ref]
}
var refs []string
if filtersJSON != "" {
var flt struct {
Reference map[string]bool `json:"reference"`
}
if err := json.Unmarshal([]byte(filtersJSON), &flt); err != nil {
return ""
}
for ref, want := range flt.Reference {
if want {
refs = append(refs, ref)
}
}
} else if f.imageTags != nil {
for ref := range f.imageTags {
refs = append(refs, ref)
}
}
entries := make([]string, 0, len(refs))
for _, ref := range refs {
if matches(ref) {
entries = append(entries, fmt.Sprintf(`{"Id":"sha256:abc","RepoTags":[%q]}`, ref))
}
}
return strings.Join(entries, ",")
}
func (f *fakeDocker) record(r *http.Request, body string) dockerCall {
path := r.URL.Path
if strings.HasPrefix(path, "/v") {
@@ -171,11 +229,7 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Api-Version", "1.44")
w.WriteHeader(http.StatusOK)
case call.Method == http.MethodGet && call.Path == "/images/json":
list := "[]"
if f.images > 0 {
list = `[{"Id":"sha256:abc","RepoTags":["lvmh-worker:latest"]}]`
}
writeJSONNow(w, http.StatusOK, list)
writeJSONNow(w, http.StatusOK, "["+f.imageListJSON(r.URL.Query().Get("filters"))+"]")
case call.Method == http.MethodPost && call.Path == "/build":
if f.failBuildHTTP {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"build endpoint broken"}`)
@@ -238,19 +292,47 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.nextID++
id := fmt.Sprintf("cid-%d", f.nextID)
f.create = append(f.create, rc)
f.containers[id] = id
if rc.Name != "" {
f.containers[rc.Name] = id
}
f.running[id] = false
f.mu.Unlock()
writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Id":%q,"Warnings":null}`, id))
case call.Method == http.MethodGet && strings.HasPrefix(call.Path, "/containers/") && strings.HasSuffix(call.Path, "/json"):
token := strings.TrimSuffix(strings.TrimPrefix(call.Path, "/containers/"), "/json")
f.mu.Lock()
id, ok := f.containers[token]
running := ok && f.running[id]
f.mu.Unlock()
if !ok {
writeJSONNow(w, http.StatusNotFound, fmt.Sprintf(`{"message":"No such container: %s"}`, token))
return
}
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Id":%q,"State":{"Running":%t}}`, id, running))
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/start"):
if f.failStart {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"start failed"}`)
return
}
token := strings.TrimSuffix(strings.TrimPrefix(call.Path, "/containers/"), "/start")
f.mu.Lock()
if id, ok := f.containers[token]; ok {
f.running[id] = true
}
f.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/stop"):
if f.failStop {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"stop failed"}`)
return
}
token := strings.TrimSuffix(strings.TrimPrefix(call.Path, "/containers/"), "/stop")
f.mu.Lock()
if id, ok := f.containers[token]; ok {
f.running[id] = false
}
f.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/wait"):
if f.failWait {
@@ -277,6 +359,14 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/containers/"):
token := strings.TrimPrefix(call.Path, "/containers/")
f.mu.Lock()
if id, ok := f.containers[token]; ok {
delete(f.containers, token)
delete(f.containers, id)
delete(f.running, id)
}
f.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
f.mu.Lock()