package e2e import ( "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // wantRunning are the supervisord programs that must be RUNNING once the // container is up (build/supervisord.conf). postfix-reload is deliberately // excluded: autostart=false, its healthy resting state is STOPPED. var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"} // checkSupervisorProcesses shells into the container directly (not through the // panel's /status page) so it works before an administrator account even // exists — this is the first thing the harness checks after `docker compose // up` (plan C.4). Programs take a moment to leave STARTING right after the // container starts, so this polls rather than checking once. func checkSupervisorProcesses(s *stack) error { var lastErr error err := waitFor("all supervised programs to reach their steady state", 20*time.Second, 500*time.Millisecond, func() (bool, error) { out, execErr := s.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status") // supervisorctl exits non-zero when any program is not RUNNING, which // is expected for postfix-reload — parse the output regardless of exit // status. states := parseSupervisorStatus(out) if len(states) == 0 { lastErr = fmt.Errorf("supervisorctl status produced nothing to parse: %v\n%s", execErr, out) return false, lastErr } for _, name := range wantRunning { state, ok := states[name] if !ok { lastErr = fmt.Errorf("program %q not reported by supervisorctl:\n%s", name, out) return false, lastErr } if state != "RUNNING" { lastErr = fmt.Errorf("program %q is %s, want RUNNING:\n%s", name, state, out) return false, lastErr } } if state, ok := states["postfix-reload"]; ok && state != "STOPPED" { lastErr = fmt.Errorf("program postfix-reload is %s, want STOPPED (autostart=false):\n%s", state, out) return false, lastErr } return true, nil }) if err != nil { logs := s.logs("selfpost") // #region agent log ps, _ := s.compose("ps", "-a", "--format", "json") inspectOut, _ := exec.Command("docker", "ps", "-a", "--filter", "name=selfpost-e2e", "--format", "{{.Names}} {{.Status}} {{.ID}}").CombinedOutput() dataPath := filepath.Join(s.stageDir, "data") dataMode := "" if fi, e := os.Stat(dataPath); e == nil { dataMode = fmt.Sprintf("%04o", fi.Mode().Perm()) } entrypointSnippet, _ := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", "selfpost:e2e", "-c", "grep -n 'chmod 755 /data\\|SELFPOST_HOSTNAME is not set' /usr/local/bin/entrypoint.sh | head -20").CombinedOutput() agentDebugLog("H4", "process_check.go:fail", "compose selfpost not ready", map[string]any{ "waitErr": err.Error(), "logsLen": len(logs), "logsHead": truncateForDebug(logs, 4000), "composePs": truncateForDebug(string(ps), 2000), "dockerPs": strings.TrimSpace(string(inspectOut)), "hostDataMode": dataMode, "entrypointSnippets": strings.TrimSpace(string(entrypointSnippet)), }) agentDebugLog("H5", "process_check.go:fail", "image entrypoint markers", map[string]any{ "snippet": strings.TrimSpace(string(entrypointSnippet)), }) // #endregion return fmt.Errorf("%w\n==== selfpost logs ====\n%s\n==== docker ps ====\n%s\n==== entrypoint markers ====\n%s", err, logs, inspectOut, entrypointSnippet) } return nil } func parseSupervisorStatus(out string) map[string]string { states := make(map[string]string) for _, line := range strings.Split(out, "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } states[fields[0]] = fields[1] } return states }