156 lines
5.1 KiB
Go
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
|
|
}
|