CLIログ設計のシンプル化とsudo実行時のファイル所有権問題の解消
CLIコマンドはすべてstderrに、TUIのみファイルログに切り替える設計へ変更し、sudo実行時にログファイルがrootオーナーで作成される問題を解消した。
背景
これまでのログ設計では、RootCommand の PersistentPreRunE / PersistentPostRun フックでファイルログを初期化・クローズしていた。この設計ではすべてのサブコマンドがファイルへ書き込む動作が既定となるため、TUIを持たない background_run コマンドは PersistentPreRunE をオーバーライドして SetupStderr() を呼び直す例外処理を設けていた。
また、sudo 経由での実行時、ファイルやディレクトリの作成がroot権限で行われるため、生成されるログファイルやバックアップファイルのオーナーがrootになり、後続の通常ユーザー操作で権限エラーが発生しうる問題があった。
技術的な変更
ログ初期化の移動とスコープの明確化
ロギングの初期化責務が整理され、「CLIはstderrのみ」「ファイルログはTUI実行時のみ」という境界が明確になった。
cmd/once/main.go のエントリポイントで logging.SetupStderr() を無条件に呼び出すことで、すべてのサブコマンドの既定ログ先がstderrになった。
func main() {
logging.SetupStderr()
if err := command.NewRootCommand().Execute(); err != nil {
os.Exit(1)
}
}
TUIを起動する RootCommand.RunE では、新設された logging.ToLogFile 関数にクロージャを渡す形に変わった。ファイルへの切り替えと復元がこの関数内に閉じており、closeLogger フィールドや PersistentPostRun フックが不要になった。
変更前:
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
closeLogger, err := logging.SetupFile()
if err != nil {
return fmt.Errorf("setting up logging: %w", err)
}
r.closeLogger = closeLogger
return nil
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
if r.closeLogger != nil {
r.closeLogger()
}
},
RunE: WithNamespace(func(ctx context.Context, ns *docker.Namespace, cmd *cobra.Command, args []string) error {
return ui.Run(ns, r.installImageRef)
}),
変更後:
RunE: WithNamespace(func(ctx context.Context, ns *docker.Namespace, cmd *cobra.Command, args []string) error {
return logging.ToLogFile(func() error {
return ui.Run(ns, r.installImageRef)
})
}),
ToLogFile は defer でstderrへの復元とファイルクローズを保証し、ログディレクトリの特定やファイルオープンに失敗した場合は警告を出しつつ fn() をそのまま実行するフォールバック動作を持つ。
func ToLogFile(fn func() error) error {
dir, err := stateDir()
if err != nil {
slog.Warn("Could not determine log directory", "error", err)
return fn()
}
path := filepath.Join(dir, "once.log")
file, err := fsutil.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
slog.Warn("Could not open log file", "error", err)
return fn()
}
defer func() {
SetupStderr()
file.Close()
}()
slog.SetDefault(slog.New(slog.NewTextHandler(file, nil)))
return fn()
}
fsutilパッケージの新設によるファイル所有権管理の集約
所有権の設定ロジックが internal/fsutil パッケージに集約され、application_backup.go や logging/log.go が個別に os.MkdirAll + os.Chown を呼んでいた重複実装が解消された。
fsutil.OpenFile は os.OpenFile の上位互換として設計されており、以下の動作を追加する:
-
親ディレクトリの自動作成:
os.MkdirAllで不足するディレクトリ階層を作成する -
所有権の継承: 既存の最近傍祖先ディレクトリのUID/GIDを
findOwnershipで特定し、新規作成したディレクトリ群とファイル自身に適用する
func OpenFile(path string, flag int, perm os.FileMode) (*os.File, error) {
dir := filepath.Dir(path)
uid, gid, err := findOwnership(dir)
if err != nil {
return nil, fmt.Errorf("determining ownership for %s: %w", dir, err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating directory %s: %w", dir, err)
}
if err := chownNewDirs(dir, uid, gid); err != nil {
return nil, fmt.Errorf("setting directory ownership for %s: %w", dir, err)
}
file, err := os.OpenFile(path, flag, perm)
if err != nil {
return nil, err
}
_ = os.Chown(path, uid, gid)
return file, nil
}
ファイル作成の一般ユースケース向けに CreateFile も提供されており、O_RDWR|O_CREATE|O_TRUNC と 0o600 パーミッションが既定値として設定される。application_backup.go の os.Create + os.Chown の呼び出しはこの関数に置き換えられた。
Chown の失敗は _ で無視されており、所有権設定の失敗がファイルオープン自体を妨げない設計になっている点も注目される。
設計判断
ファイルログのスコープをTUI実行に限定するという設計判断が、アーキテクチャ全体を単純化している。
従来の設計は「既定でファイルログ、例外のみstderr」だったが、今回は「既定でstderr、TUIのみファイルへ一時切り替え」に逆転した。これにより、background_run のオーバーライドという例外ケースが消滅し、RootCommand から closeLogger フィールドと2つのCobraライフサイクルフックが削除された。ToLogFile はクロージャを受け取る設計によりロガーの切り替えと復元をひとつの関数に閉じ込め、呼び出し側が後処理を意識する必要をなくしている。
fsutil.OpenFile の設計では、所有権の継承元として「ファイルではなく既存の最近傍祖先ディレクトリ」を参照する。これはsudo実行時に /var/lib/once/logs/ のような既存ディレクトリが一般ユーザー所有であれば、その配下に作成されるログファイルも同じオーナーを引き継ぐという動作を実現する。
まとめ
ログ初期化の責務をエントリポイントへ移動し、ファイルログをTUI実行スコープに限定することで、例外処理とCobraフックによる複雑さを排除した。同時に fsutil パッケージへの所有権管理の集約が、sudo実行時のファイル所有権問題をアプリケーション全体で一貫して解決する基盤となっている。