porting all github actions from bluesky-social/indigo to tangled CI
at main 8.1 kB view raw
1package main 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "log/slog" 9 "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/api/agnostic" 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/bluesky-social/indigo/xrpc" 16 17 "github.com/urfave/cli/v2" 18) 19 20var cmdAccountMigrate = &cli.Command{ 21 Name: "migrate", 22 Usage: "move account to a new PDS. requires full auth.", 23 Flags: []cli.Flag{ 24 &cli.StringFlag{ 25 Name: "pds-host", 26 Usage: "URL of the new PDS to create account on", 27 Required: true, 28 EnvVars: []string{"ATP_PDS_HOST"}, 29 }, 30 &cli.StringFlag{ 31 Name: "new-handle", 32 Required: true, 33 Usage: "handle on new PDS", 34 EnvVars: []string{"NEW_ACCOUNT_HANDLE"}, 35 }, 36 &cli.StringFlag{ 37 Name: "new-password", 38 Required: true, 39 Usage: "password on new PDS", 40 EnvVars: []string{"NEW_ACCOUNT_PASSWORD"}, 41 }, 42 &cli.StringFlag{ 43 Name: "plc-token", 44 Required: true, 45 Usage: "token from old PDS authorizing token signature", 46 EnvVars: []string{"PLC_SIGN_TOKEN"}, 47 }, 48 &cli.StringFlag{ 49 Name: "invite-code", 50 Usage: "invite code for account signup", 51 }, 52 &cli.StringFlag{ 53 Name: "new-email", 54 Usage: "email address for new account", 55 }, 56 }, 57 Action: runAccountMigrate, 58} 59 60func runAccountMigrate(cctx *cli.Context) error { 61 // NOTE: this could check rev / commit before and after and ensure last-minute content additions get lost 62 ctx := context.Background() 63 64 oldClient, err := loadAuthClient(ctx) 65 if err == ErrNoAuthSession { 66 return fmt.Errorf("auth required, but not logged in") 67 } else if err != nil { 68 return err 69 } 70 did := oldClient.Auth.Did 71 72 newHostURL := cctx.String("pds-host") 73 if !strings.Contains(newHostURL, "://") { 74 return fmt.Errorf("PDS host is not a url: %s", newHostURL) 75 } 76 newHandle := cctx.String("new-handle") 77 _, err = syntax.ParseHandle(newHandle) 78 if err != nil { 79 return err 80 } 81 newPassword := cctx.String("new-password") 82 plcToken := cctx.String("plc-token") 83 inviteCode := cctx.String("invite-code") 84 newEmail := cctx.String("new-email") 85 86 newClient := xrpc.Client{ 87 Host: newHostURL, 88 UserAgent: userAgent(), 89 } 90 91 // connect to new host to discover service DID 92 newHostDesc, err := comatproto.ServerDescribeServer(ctx, &newClient) 93 if err != nil { 94 return fmt.Errorf("failed connecting to new host: %w", err) 95 } 96 newHostDID, err := syntax.ParseDID(newHostDesc.Did) 97 if err != nil { 98 return err 99 } 100 slog.Info("new host", "serviceDID", newHostDID, "url", newHostURL) 101 102 // 1. Create New Account 103 slog.Info("creating account on new host", "handle", newHandle, "host", newHostURL) 104 105 // get service auth token from old host 106 // args: (ctx, client, aud string, exp int64, lxm string) 107 expTimestamp := time.Now().Unix() + 60 108 createAuthResp, err := comatproto.ServerGetServiceAuth(ctx, oldClient, newHostDID.String(), expTimestamp, "com.atproto.server.createAccount") 109 if err != nil { 110 return fmt.Errorf("failed getting service auth token from old host: %w", err) 111 } 112 113 // then create the new account 114 createParams := comatproto.ServerCreateAccount_Input{ 115 Did: &did, 116 Handle: newHandle, 117 Password: &newPassword, 118 } 119 if newEmail != "" { 120 createParams.Email = &newEmail 121 } 122 if inviteCode != "" { 123 createParams.InviteCode = &inviteCode 124 } 125 126 // use service auth for access token, temporarily 127 newClient.Auth = &xrpc.AuthInfo{ 128 Did: did, 129 Handle: newHandle, 130 AccessJwt: createAuthResp.Token, 131 RefreshJwt: createAuthResp.Token, 132 } 133 createAccountResp, err := comatproto.ServerCreateAccount(ctx, &newClient, &createParams) 134 if err != nil { 135 return fmt.Errorf("failed creating new account: %w", err) 136 } 137 138 if createAccountResp.Did != did { 139 return fmt.Errorf("new account DID not a match: %s != %s", createAccountResp.Did, did) 140 } 141 newClient.Auth.AccessJwt = createAccountResp.AccessJwt 142 newClient.Auth.RefreshJwt = createAccountResp.RefreshJwt 143 144 // login client on the new host 145 sess, err := comatproto.ServerCreateSession(ctx, &newClient, &comatproto.ServerCreateSession_Input{ 146 Identifier: did, 147 Password: newPassword, 148 }) 149 if err != nil { 150 return fmt.Errorf("failed login to newly created account on new host: %w", err) 151 } 152 newClient.Auth = &xrpc.AuthInfo{ 153 Did: did, 154 AccessJwt: sess.AccessJwt, 155 RefreshJwt: sess.RefreshJwt, 156 } 157 158 // 2. Migrate Data 159 slog.Info("migrating repo") 160 repoBytes, err := comatproto.SyncGetRepo(ctx, oldClient, did, "") 161 if err != nil { 162 return fmt.Errorf("failed exporting repo: %w", err) 163 } 164 err = comatproto.RepoImportRepo(ctx, &newClient, bytes.NewReader(repoBytes)) 165 if err != nil { 166 return fmt.Errorf("failed importing repo: %w", err) 167 } 168 169 slog.Info("migrating preferences") 170 // TODO: service proxy header for AppView? 171 prefResp, err := agnostic.ActorGetPreferences(ctx, oldClient) 172 if err != nil { 173 return fmt.Errorf("failed fetching old preferences: %w", err) 174 } 175 err = agnostic.ActorPutPreferences(ctx, &newClient, &agnostic.ActorPutPreferences_Input{ 176 Preferences: prefResp.Preferences, 177 }) 178 if err != nil { 179 return fmt.Errorf("failed importing preferences: %w", err) 180 } 181 182 slog.Info("migrating blobs") 183 blobCursor := "" 184 for { 185 listResp, err := comatproto.SyncListBlobs(ctx, oldClient, blobCursor, did, 100, "") 186 if err != nil { 187 return fmt.Errorf("failed listing blobs: %w", err) 188 } 189 for _, blobCID := range listResp.Cids { 190 blobBytes, err := comatproto.SyncGetBlob(ctx, oldClient, blobCID, did) 191 if err != nil { 192 slog.Warn("failed downloading blob", "cid", blobCID, "err", err) 193 continue 194 } 195 _, err = comatproto.RepoUploadBlob(ctx, &newClient, bytes.NewReader(blobBytes)) 196 if err != nil { 197 slog.Warn("failed uploading blob", "cid", blobCID, "err", err, "size", len(blobBytes)) 198 } 199 slog.Info("transferred blob", "cid", blobCID, "size", len(blobBytes)) 200 } 201 if listResp.Cursor == nil || *listResp.Cursor == "" { 202 break 203 } 204 blobCursor = *listResp.Cursor 205 } 206 207 // display migration status 208 // NOTE: this could check between the old PDS and new PDS, polling in a loop showing progress until all records have been indexed 209 statusResp, err := comatproto.ServerCheckAccountStatus(ctx, &newClient) 210 if err != nil { 211 return fmt.Errorf("failed checking account status: %w", err) 212 } 213 slog.Info("account migration status", "status", statusResp) 214 215 // 3. Migrate Identity 216 // NOTE: to work with did:web or non-PDS-managed did:plc, need to do manual migraiton process 217 slog.Info("updating identity to new host") 218 219 credsResp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, &newClient) 220 if err != nil { 221 return fmt.Errorf("failed fetching new credentials: %w", err) 222 } 223 credsBytes, err := json.Marshal(credsResp) 224 if err != nil { 225 return nil 226 } 227 228 var unsignedOp agnostic.IdentitySignPlcOperation_Input 229 if err = json.Unmarshal(credsBytes, &unsignedOp); err != nil { 230 return fmt.Errorf("failed parsing PLC op: %w", err) 231 } 232 unsignedOp.Token = &plcToken 233 234 // NOTE: could add additional sanity checks here that any extra rotation keys were retained, and that old alsoKnownAs and service entries are retained? The stakes aren't super high for the later, as PLC has the full history. PLC and the new PDS already implement some basic sanity checks. 235 236 signedPlcOpResp, err := agnostic.IdentitySignPlcOperation(ctx, oldClient, &unsignedOp) 237 if err != nil { 238 return fmt.Errorf("failed requesting PLC operation signature: %w", err) 239 } 240 241 err = agnostic.IdentitySubmitPlcOperation(ctx, &newClient, &agnostic.IdentitySubmitPlcOperation_Input{ 242 Operation: signedPlcOpResp.Operation, 243 }) 244 if err != nil { 245 return fmt.Errorf("failed submitting PLC operation: %w", err) 246 } 247 248 // 4. Finalize Migration 249 slog.Info("activating new account") 250 251 err = comatproto.ServerActivateAccount(ctx, &newClient) 252 if err != nil { 253 return fmt.Errorf("failed activating new host: %w", err) 254 } 255 err = comatproto.ServerDeactivateAccount(ctx, oldClient, &comatproto.ServerDeactivateAccount_Input{}) 256 if err != nil { 257 return fmt.Errorf("failed deactivating old host: %w", err) 258 } 259 260 slog.Info("account migration completed") 261 return nil 262}