Monorepo for Tangled
tangled.org
1package knotserver
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "syscall"
8
9 "github.com/urfave/cli/v3"
10 "tangled.org/core/hook"
11 "tangled.org/core/knotserver/db"
12 "tangled.org/core/knotserver/sandbox"
13 "tangled.org/core/log"
14)
15
16// MigrateIsolationCommand returns the CLI command for migrate-isolation.
17func MigrateIsolationCommand() *cli.Command {
18 return &cli.Command{
19 Name: "migrate-isolation",
20 Usage: "chown existing repos to their owner virtual UIDs and refresh hooks (run once before enabling --secure-mode)",
21 Action: RunMigrateIsolation,
22 Flags: []cli.Flag{
23 &cli.StringFlag{
24 Name: "git-dir",
25 Usage: "base directory for git repos",
26 Value: "/home/git",
27 },
28 &cli.StringFlag{
29 Name: "db",
30 Usage: "path to knotserver SQLite database",
31 Value: "knotserver.db",
32 },
33 &cli.StringFlag{
34 Name: "internal-api",
35 Usage: "internal API address for hook configuration",
36 Value: "127.0.0.1:5444",
37 },
38 &cli.BoolFlag{
39 Name: "force",
40 Usage: "re-run chown/chmod even on already-migrated repos",
41 },
42 },
43 }
44}
45
46// RunMigrateIsolation iterates over all repos in the DB, assigns virtual UIDs
47// to their owners, chowns the repo trees recursively, and records isolated_at.
48func RunMigrateIsolation(ctx context.Context, cmd *cli.Command) error {
49 logger := log.FromContext(ctx)
50 logger = log.SubLogger(logger, "migrate-isolation")
51
52 gitDir := cmd.String("git-dir")
53 dbPath := cmd.String("db")
54 internalApi := cmd.String("internal-api")
55
56 serviceGid, err := sandbox.ServiceGid(gitDir)
57 if err != nil {
58 return fmt.Errorf("resolve service gid: %w", err)
59 }
60
61 hookCfg := hook.Config(
62 hook.WithScanPath(gitDir),
63 hook.WithInternalApi(internalApi),
64 )
65
66 d, err := db.Setup(ctx, dbPath)
67 if err != nil {
68 return fmt.Errorf("failed to open db: %w", err)
69 }
70
71 repos, err := d.AllReposForMigration(cmd.Bool("force"))
72 if err != nil {
73 return fmt.Errorf("failed to list repos: %w", err)
74 }
75
76 if len(repos) == 0 {
77 logger.Info("no repos need migration")
78 return nil
79 }
80
81 logger.Info("starting isolation migration", "total", len(repos))
82
83 var migrated, skipped, failed int
84
85 for _, repo := range repos {
86 repoPath, _, _, err := d.ResolveRepoDIDOnDisk(gitDir, repo.RepoDID)
87 if err != nil {
88 logger.Error("repo not on disk, skipping",
89 "repo_did", repo.RepoDID, "error", err)
90 skipped++
91 continue
92 }
93
94 ownerUID, err := d.GetOrAssignOwnerUID(repo.OwnerDID)
95 if err != nil {
96 logger.Error("failed to get/assign UID",
97 "repo_did", repo.RepoDID, "owner_did", repo.OwnerDID, "error", err)
98 failed++
99 continue
100 }
101
102 // regenerate hooks first so the chown walk preserves their 0755 mode
103 // via the executable-bit check in ChownRepoTree.
104 if err := hook.SetupRepo(hookCfg, repoPath); err != nil {
105 logger.Error("failed to set up hooks",
106 "repo_did", repo.RepoDID, "path", repoPath, "error", err)
107 failed++
108 continue
109 }
110
111 if err := sandbox.ChmodRepoTree(repoPath); err != nil {
112 logger.Error("chmod failed",
113 "repo_did", repo.RepoDID, "path", repoPath, "error", err)
114 failed++
115 continue
116 }
117
118 if err := sandbox.ChownRepoTree(repoPath, int(ownerUID), int(serviceGid)); err != nil {
119 // EPERM: process lacks cap_chown; log and continue rather than
120 // aborting so all failures are reported.
121 if isEPERM(err) {
122 logger.Error("chown failed: process lacks cap_chown "+
123 "(run as root or grant cap_chown to the binary)",
124 "repo_did", repo.RepoDID, "path", repoPath, "uid", ownerUID)
125 } else {
126 logger.Error("chown failed",
127 "repo_did", repo.RepoDID, "path", repoPath, "uid", ownerUID, "error", err)
128 }
129 failed++
130 continue
131 }
132
133 if err := d.MarkRepoIsolated(repo.RepoDID); err != nil {
134 logger.Error("failed to record isolated_at",
135 "repo_did", repo.RepoDID, "error", err)
136 failed++
137 continue
138 }
139
140 logger.Info("migrated repo",
141 "repo_did", repo.RepoDID, "owner_did", repo.OwnerDID, "uid", ownerUID, "path", repoPath)
142 migrated++
143 }
144
145 logger.Info("migration complete",
146 "migrated", migrated, "skipped", skipped, "failed", failed, "total", len(repos))
147
148 if failed > 0 {
149 return fmt.Errorf("%d repo(s) failed to migrate", failed)
150 }
151 return nil
152}
153
154func isEPERM(err error) bool {
155 if pathErr, ok := err.(*os.PathError); ok {
156 return pathErr.Err == syscall.EPERM
157 }
158 return false
159}