Files
chenhg5-cc-connect/daemon/manager.go
cg33 1c92a03fdc feat(daemon): add CC_LOG_MAX_BACKUPS env var support (#1260)
* feat(daemon): add CC_LOG_MAX_BACKUPS env var support (#1222)

PR #1243 only addressed CC_LOG_MAX_SIZE while leaving the backup
count hard-wired to one (.log.1). That still loses any post-mortem
context older than one rotation, which is the same class of failure
users reported on #1222. This change adds the matching knob so the
post-mortem trail is configurable, with the same flag > env >
default priority used for size.

- daemon: add ParseLogBackups(s) (>=1, no unit suffix, error echoes
  input) and DefaultLogMaxBackups = 3.
- daemon: extend RotatingWriter with maxBackups; rotateLocked walks
  the chain (delete .N, shift .(N-1) -> .N .. .1 -> .2, rename
  active -> .1, reopen) and a public Rotate() hook for tests/SIGHUP.
- daemon: Config/Meta gain LogMaxBackups; Resolve() defaults to 3.
- cmd/cc-connect: resolveLogMaxBackups + preScanLogMaxBackupsFlag +
  --log-max-backups flag; startup log now reports max_backups and
  its source. The rotating-writer setup happens before flag.Parse so
  the pre-scan keeps the flag effective there too.
- daemon/launchd.go, daemon/systemd.go, daemon/windows.go: thread
  CC_LOG_MAX_BACKUPS through the service templates so a fresh
  install picks it up.
- tests: TestParseLogBackups (19 subtests + error-echo), three new
  RotatingWriter tests (chain, disabled, fallback), four resolver
  tests + pre-scan tests in cmd/cc-connect. TestIssue1222_BackupRetention
  pins the new env-var behaviour as the regression test for the
  follow-up to #1222.

* fix(daemon): silence errcheck on logrotate_test.go defer Close

QA review (run 27109765660) flagged defer w.Close() in the 4
backup-related tests added by #1260. Wrap each in
defer func() { _ = w.Close() }() so errcheck is satisfied without
changing test semantics (temp-dir cleanup is best-effort).

Verified locally:
- golangci-lint --new-from-rev origin/main ./daemon/... -> 0 issues
- go test -count=1 -tags no_web ./daemon/ ./cmd/cc-connect/ -> ok

---------

Co-authored-by: cc-connect dev-claudecode <dev-claudecode@cc-connect.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 09:59:09 +08:00

273 lines
7.1 KiB
Go

package daemon
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"time"
"github.com/BurntSushi/toml"
)
const (
DefaultLogMaxSize = 10 * 1024 * 1024 // 10 MB
DefaultLogMaxBackups = 3 // active + .1 + .2 + .3
ServiceName = "cc-connect"
)
type Config struct {
BinaryPath string
WorkDir string
LogFile string
LogMaxSize int64
LogMaxBackups int
EnvPATH string // capture user's PATH so agents are accessible
EnvExtra map[string]string // selected environment variables needed by the service runtime
// NoCaptureSecrets, when true, restricts the install-time env capture
// to proxy-related variables only and skips both the config.toml ${ENV}
// placeholder scan and any extension discoverers registered via
// RegisterEnvDiscoverer. Operators who'd rather inject secrets via
// keychain / `secret-tool` / EnvironmentFile= set this to keep token
// values out of the service manager files on disk.
NoCaptureSecrets bool
}
type Status struct {
Installed bool
Running bool
PID int
Platform string // "systemd", "launchd", "schtasks"
}
type Manager interface {
Install(cfg Config) error
Uninstall() error
Start() error
Stop() error
Restart() error
Status() (*Status, error)
Platform() string
}
// NewManager returns a platform-specific daemon manager.
func NewManager() (Manager, error) {
return newPlatformManager()
}
func DefaultLogFile() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cc-connect", "logs", "cc-connect.log")
}
func DefaultDataDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cc-connect")
}
// ── Metadata ────────────────────────────────────────────────
// Stored at ~/.cc-connect/daemon.json so that `logs`, `status`,
// etc. can locate the log file without parsing service definitions.
type Meta struct {
LogFile string `json:"log_file"`
LogMaxSize int64 `json:"log_max_size"`
LogMaxBackups int `json:"log_max_backups"`
WorkDir string `json:"work_dir"`
BinaryPath string `json:"binary_path"`
InstalledAt string `json:"installed_at"`
}
func metaPath() string {
return filepath.Join(DefaultDataDir(), "daemon.json")
}
func SaveMeta(m *Meta) error {
if err := os.MkdirAll(filepath.Dir(metaPath()), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(metaPath(), data, 0644)
}
func LoadMeta() (*Meta, error) {
data, err := os.ReadFile(metaPath())
if err != nil {
return nil, err
}
var m Meta
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
func RemoveMeta() {
os.Remove(metaPath())
}
func NowISO() string {
return time.Now().Format(time.RFC3339)
}
func Resolve(cfg *Config) error {
if cfg.BinaryPath == "" {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot detect binary path: %w", err)
}
real, err := filepath.EvalSymlinks(exe)
if err == nil {
exe = real
}
cfg.BinaryPath = exe
}
if cfg.WorkDir == "" {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("cannot detect working directory: %w", err)
}
cfg.WorkDir = wd
}
if cfg.LogFile == "" {
cfg.LogFile = DefaultLogFile()
}
if cfg.LogMaxSize <= 0 {
cfg.LogMaxSize = DefaultLogMaxSize
}
if cfg.LogMaxBackups < 1 {
cfg.LogMaxBackups = DefaultLogMaxBackups
}
if cfg.EnvPATH == "" {
cfg.EnvPATH = os.Getenv("PATH")
}
if len(cfg.EnvExtra) == 0 {
cfg.EnvExtra = captureDaemonEnv(cfg.NoCaptureSecrets)
if !cfg.NoCaptureSecrets {
captureConfigEnvPlaceholders(filepath.Join(cfg.WorkDir, "config.toml"), cfg.EnvExtra)
}
}
return nil
}
// captureDaemonEnv builds the EnvExtra map baked into the installed
// service file. Proxy-related vars are always captured. When
// noCaptureSecrets is false, every registered EnvDiscoverer is also
// invoked and its (envName -> value) pairs are merged in.
//
// Discoverer errors are logged but never fail the install — the
// daemon's job is to install the service; plugins surface their own
// per-feature warnings at runtime.
func captureDaemonEnv(noCaptureSecrets bool) map[string]string {
env := make(map[string]string)
proxyKeys := []string{
"http_proxy", "https_proxy", "no_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"all_proxy", "ALL_PROXY",
}
for _, key := range proxyKeys {
if value := os.Getenv(key); value != "" {
env[key] = value
}
}
if noCaptureSecrets {
return env
}
for i, d := range snapshotEnvDiscoverers() {
extra, err := d()
if err != nil {
slog.Warn("daemon: env discoverer reported warnings",
"index", i, "err", err)
}
for k, v := range extra {
if !isValidEnvName(k) {
slog.Warn("daemon: dropping invalid env name from discoverer",
"index", i, "key", k)
continue
}
if v == "" {
continue
}
env[k] = v
}
}
return env
}
var configEnvPlaceholderPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// captureConfigEnvPlaceholders scans configPath for ${ENV_NAME} placeholders
// and, for each one set in the current process environment, copies it into
// env. cc-connect resolves these placeholders at startup using os.ExpandEnv;
// if the daemon's service file doesn't carry the values, the started daemon
// process will see empty strings and fail to authenticate to any platform.
//
// Errors are logged and swallowed: a broken or missing config.toml must not
// abort `daemon install`. Empty / unset env names are skipped silently.
func captureConfigEnvPlaceholders(configPath string, env map[string]string) {
if strings.TrimSpace(configPath) == "" || env == nil {
return
}
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("daemon: config env placeholder discovery failed",
"path", configPath, "err", err)
}
return
}
var raw map[string]any
if err := toml.Unmarshal(data, &raw); err != nil {
slog.Warn("daemon: config env placeholder discovery failed",
"path", configPath, "err", err)
return
}
captureConfigEnvPlaceholdersInValue(reflect.ValueOf(raw), env)
}
func captureConfigEnvPlaceholdersInValue(v reflect.Value, env map[string]string) {
if !v.IsValid() {
return
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
if !v.IsNil() {
captureConfigEnvPlaceholdersInValue(v.Elem(), env)
}
case reflect.String:
captureConfigEnvPlaceholdersInString(v.String(), env)
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
captureConfigEnvPlaceholdersInValue(v.Index(i), env)
}
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
captureConfigEnvPlaceholdersInValue(iter.Value(), env)
}
}
}
func captureConfigEnvPlaceholdersInString(s string, env map[string]string) {
matches := configEnvPlaceholderPattern.FindAllStringSubmatch(s, -1)
for _, match := range matches {
if len(match) != 2 {
continue
}
name := match[1]
if v, ok := os.LookupEnv(name); ok && v != "" {
env[name] = v
}
}
}