Files

156 lines
5.1 KiB
Go

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
}