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 }