CLIログ設計のシンプル化とsudo実行時のファイル所有権問題の解消

basecamp/once

CLIコマンドはすべてstderrに、TUIのみファイルログに切り替える設計へ変更し、sudo実行時にログファイルがrootオーナーで作成される問題を解消した。

背景

これまでのログ設計では、RootCommandPersistentPreRunE / 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)
    })
}),

ToLogFiledefer で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.gologging/log.go が個別に os.MkdirAll + os.Chown を呼んでいた重複実装が解消された。

fsutil.OpenFileos.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_TRUNC0o600 パーミッションが既定値として設定される。application_backup.goos.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実行時のファイル所有権問題をアプリケーション全体で一貫して解決する基盤となっている。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
7f1c6d25

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という3部構成が明確に守られています。各セクションの役割も適切です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きのシンタックスハイライト(```go:path/to/file.go)やPR番号のリンク記法([#11](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Go言語、Cobra、ファイルシステムのパーミッションなど、専門知識を持つエンジニアを対象とした適切な技術レベルで記述されています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクション、各パラグラフが「総論→各論」の構成になっており、トピックセンテンスが段落の冒頭に配置されているため、非常に読みやすい構造です。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロックは、提供されたDiff情報と完全に一致しています。変更前後のコード比較も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Cobraのライフサイクルフック(PersistentPreRunE)、クロージャ、UID/GIDなど、技術用語が正確かつ適切な文脈で使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

ログ初期化責務の変更や、`fsutil.OpenFile`による所有権継承の仕組みなど、技術的な変更点に関する説明はDiffの内容と整合しており、論理的で正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(ログ設計の変更理由、sudo実行時の問題点など)は、PRのDescriptionやDiff内のコード変更によって裏付けられており、ハルシネーションは検出されませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#11)やコード内で使用されているパーミッション値(0o644など)が正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRの主題である「ログ設定の簡素化」と、その重要な副作用である「所有権問題の解消」を的確に要約しており、PR内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に含まれないバージョン情報やリリース予定など、外部知識の追記は見られませんでした。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

変更内容を客観的な事実として記述しており、PR情報と矛盾するような時間表現(「既に」「まもなく」など)の誤用はありません。