Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

knotserver: add migrate-isolation command

author
Anirudh Oppiliappan
committer
Tangled
date (Jun 12, 2026, 12:22 PM +0300) commit 2b410653 parent eb83c202 change-id mwxzqxrr
+162
+3
cmd/knot/main.go
··· 10 10 "tangled.org/core/hook" 11 11 "tangled.org/core/keyfetch" 12 12 "tangled.org/core/knotserver" 13 + "tangled.org/core/knotserver/sandbox/sandboxexec" 13 14 tlog "tangled.org/core/log" 14 15 ) 15 16 ··· 22 23 knotserver.Command(), 23 24 keyfetch.Command(), 24 25 hook.Command(), 26 + knotserver.MigrateIsolationCommand(), 27 + sandboxexec.Command(), 25 28 }, 26 29 } 27 30
+159
knotserver/migrate_isolation.go
··· 1 + package knotserver 2 + 3 + import ( 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. 17 + func 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. 48 + func 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 + 154 + func isEPERM(err error) bool { 155 + if pathErr, ok := err.(*os.PathError); ok { 156 + return pathErr.Err == syscall.EPERM 157 + } 158 + return false 159 + }