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:
+155
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
// ops.go — the lvmh-ops control container: an agent pi with docker access
|
||||
// (docker.sock bind) that prepares repos, builds per-repo worker images and
|
||||
// registers them via /api/repos. Kept alive by StartOpsLoop.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
)
|
||||
|
||||
// Ops container wiring constants.
|
||||
const (
|
||||
opsImageRef string = "lvmh-ops:latest"
|
||||
opsContainerName string = "lvmh-ops"
|
||||
labelOps string = "lvmh.ops"
|
||||
opsSessionID string = "lvmh-ops-control"
|
||||
envControlDockerfile string = "LVMH_CONTROL_DOCKERFILE"
|
||||
defaultControlDockerfile string = "/app/build/docker/control.Dockerfile"
|
||||
opsRecheckInterval time.Duration = 30 * time.Second
|
||||
|
||||
volumeOpsWork string = "lvmh-ops-work"
|
||||
opsMount string = "/ops"
|
||||
opsAPIBase string = "http://lvmh:8686"
|
||||
dockerSock string = "/var/run/docker.sock"
|
||||
)
|
||||
|
||||
// EnsureOps guarantees the ops control container is running: a running
|
||||
// container is a no-op, a stopped one is started, a missing one is built
|
||||
// (image) + created + started. Errors are returned, never fatal — the ops
|
||||
// loop logs them and retries on the next tick (docker may be down at boot).
|
||||
func (s *Spawner) EnsureOps(ctx context.Context) error {
|
||||
inspect, err := s.cli.ContainerInspect(ctx, opsContainerName)
|
||||
if err == nil {
|
||||
if inspect.State != nil && inspect.State.Running {
|
||||
return nil
|
||||
}
|
||||
return s.cli.ContainerStart(ctx, opsContainerName, container.StartOptions{})
|
||||
}
|
||||
if !cerrdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("inspect %s: %w", opsContainerName, err)
|
||||
}
|
||||
if err := s.ensureOpsImage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createOps(ctx)
|
||||
}
|
||||
|
||||
// ensureOpsImage builds the ops image when missing, from the control
|
||||
// Dockerfile (build context derived like the worker's: dir above the
|
||||
// dockerfile's dir).
|
||||
func (s *Spawner) ensureOpsImage(ctx context.Context) error {
|
||||
exists, err := s.imageRefExists(ctx, opsImageRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(s.controlDockerfile); err != nil {
|
||||
return fmt.Errorf("control Dockerfile missing at %s: %w", s.controlDockerfile, err)
|
||||
}
|
||||
return s.buildImage(ctx, filepath.Dir(filepath.Dir(s.controlDockerfile)), s.controlDockerfile, opsImageRef)
|
||||
}
|
||||
|
||||
// createOps creates and starts the ops container with its own workspace
|
||||
// volume, the shared session/pi caches, and the docker socket. Never
|
||||
// auto-removed: the loop restarts it in place.
|
||||
func (s *Spawner) createOps(ctx context.Context) error {
|
||||
for _, vol := range []string{volumeOpsWork, volumeSessions, volumePiCache} {
|
||||
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: vol}); err != nil {
|
||||
return fmt.Errorf("volume %s: %w", vol, err)
|
||||
}
|
||||
}
|
||||
env := []string{
|
||||
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
||||
envToken + "=" + daemonToken,
|
||||
"LVMH_URL=" + s.containerURL,
|
||||
envLVMHSessionID + "=" + opsSessionID,
|
||||
envLVMHAgent + "=1",
|
||||
"LVMH_API=" + opsAPIBase,
|
||||
}
|
||||
if pat, ok, _ := s.store.GetSetting(settingGitLabToken); ok && pat != "" {
|
||||
env = append(env, "LVMH_GITEA_TOKEN="+pat)
|
||||
}
|
||||
cfg := &container.Config{
|
||||
Image: opsImageRef,
|
||||
Env: env,
|
||||
WorkingDir: opsMount,
|
||||
Labels: map[string]string{labelOps: "true", labelSession: opsSessionID},
|
||||
}
|
||||
hostCfg := &container.HostConfig{
|
||||
Binds: []string{
|
||||
volumeOpsWork + ":" + opsMount,
|
||||
volumeSessions + ":" + sessionsMount,
|
||||
volumePiCache + ":" + cacheMount,
|
||||
dockerSock + ":" + dockerSock,
|
||||
},
|
||||
NetworkMode: container.NetworkMode(s.network),
|
||||
AutoRemove: false,
|
||||
Init: &[]bool{true}[0],
|
||||
}
|
||||
if _, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, opsContainerName); err != nil {
|
||||
return fmt.Errorf("docker create: %w", err)
|
||||
}
|
||||
if err := s.cli.ContainerStart(ctx, opsContainerName, container.StartOptions{}); err != nil {
|
||||
s.removeContainer(context.Background(), opsContainerName)
|
||||
return fmt.Errorf("docker start: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOpsLoop keeps the ops container alive, re-checking every
|
||||
// opsRecheckInterval. Ensure failures are logged, never fatal.
|
||||
func (s *Spawner) StartOpsLoop(ctx context.Context) {
|
||||
go func() {
|
||||
if err := s.EnsureOps(ctx); err != nil {
|
||||
log.Printf("ops: ensure: %v", err)
|
||||
}
|
||||
ticker := time.NewTicker(opsRecheckInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.EnsureOps(ctx); err != nil {
|
||||
log.Printf("ops: ensure: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RemoveOps stops and removes the ops container by NAME (it has no
|
||||
// containers-table row, so RemoveSession cannot handle it). The ops loop
|
||||
// recreates it on the next tick.
|
||||
func (s *Spawner) RemoveOps(_ context.Context) error {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Duration(stopTimeoutSeconds)*time.Second)
|
||||
if err := s.cli.ContainerStop(stopCtx, opsContainerName, container.StopOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
cancel()
|
||||
return fmt.Errorf("docker stop: %w", err)
|
||||
}
|
||||
cancel()
|
||||
s.removeContainer(context.Background(), opsContainerName)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user