1// Tool to generate fake accounts, content, and interactions.
2// Intended for development and benchmarking. Similar to 'stress' and could
3// merge at some point.
4
5package main
6
7import (
8 "context"
9 "encoding/json"
10 "fmt"
11 "os"
12 "runtime"
13
14 comatproto "github.com/bluesky-social/indigo/api/atproto"
15 "github.com/bluesky-social/indigo/fakedata"
16 "github.com/bluesky-social/indigo/util/cliutil"
17
18 _ "github.com/joho/godotenv/autoload"
19 _ "go.uber.org/automaxprocs"
20
21 "github.com/carlmjohnson/versioninfo"
22 "github.com/urfave/cli/v2"
23 "golang.org/x/sync/errgroup"
24)
25
26func main() {
27 run(os.Args)
28}
29
30func run(args []string) {
31
32 app := cli.App{
33 Name: "fakermaker",
34 Usage: "bluesky fake account/content generator",
35 Version: versioninfo.Short(),
36 }
37
38 app.Flags = []cli.Flag{
39 &cli.StringFlag{
40 Name: "pds-host",
41 Usage: "method, hostname, and port of PDS instance",
42 Value: "http://localhost:4849",
43 EnvVars: []string{"ATP_PDS_HOST"},
44 },
45 &cli.StringFlag{
46 Name: "admin-password",
47 Usage: "admin authentication password for PDS",
48 Required: true,
49 EnvVars: []string{"ATP_AUTH_ADMIN_PASSWORD"},
50 },
51 &cli.IntFlag{
52 Name: "jobs",
53 Aliases: []string{"j"},
54 Usage: "number of parallel threads to use",
55 Value: runtime.NumCPU(),
56 },
57 }
58 app.Commands = []*cli.Command{
59 &cli.Command{
60 Name: "gen-accounts",
61 Usage: "create accounts (DID, handle, profile)",
62 Action: genAccounts,
63 Flags: []cli.Flag{
64 &cli.IntFlag{
65 Name: "count",
66 Aliases: []string{"n"},
67 Usage: "total number of accounts to create",
68 Value: 100,
69 },
70 &cli.IntFlag{
71 Name: "count-celebrities",
72 Usage: "number of accounts as 'celebrities' (many followers)",
73 Value: 10,
74 },
75 &cli.StringFlag{
76 Name: "domain-suffix",
77 Usage: "domain to register handle under",
78 Value: "test",
79 },
80 &cli.BoolFlag{
81 Name: "use-invite-code",
82 Usage: "create and use an invite code",
83 Value: false,
84 },
85 },
86 },
87 &cli.Command{
88 Name: "gen-profiles",
89 Usage: "creates profile records for accounts",
90 Action: genProfiles,
91 Flags: []cli.Flag{
92 &cli.StringFlag{
93 Name: "catalog",
94 Usage: "file path of account catalog JSON file",
95 Value: "data/fakermaker/accounts.json",
96 },
97 &cli.BoolFlag{
98 Name: "no-avatars",
99 Usage: "disable avatar image generation",
100 Value: false,
101 },
102 &cli.BoolFlag{
103 Name: "no-banners",
104 Usage: "disable profile banner image generation",
105 Value: false,
106 },
107 },
108 },
109 &cli.Command{
110 Name: "gen-graph",
111 Usage: "creates social graph (follows and mutes)",
112 Action: genGraph,
113 Flags: []cli.Flag{
114 &cli.StringFlag{
115 Name: "catalog",
116 Usage: "file path of account catalog JSON file",
117 Value: "data/fakermaker/accounts.json",
118 },
119 &cli.IntFlag{
120 Name: "max-follows",
121 Usage: "create up to this many follows for each account",
122 Value: 90,
123 },
124 &cli.IntFlag{
125 Name: "max-mutes",
126 Usage: "create up to this many mutes (blocks) for each account",
127 Value: 25,
128 },
129 },
130 },
131 &cli.Command{
132 Name: "gen-posts",
133 Usage: "creates posts for accounts",
134 Action: genPosts,
135 Flags: []cli.Flag{
136 &cli.StringFlag{
137 Name: "catalog",
138 Usage: "file path of account catalog JSON file",
139 Value: "data/fakermaker/accounts.json",
140 },
141 &cli.IntFlag{
142 Name: "max-posts",
143 Usage: "create up to this many posts for each account",
144 Value: 10,
145 },
146 &cli.Float64Flag{
147 Name: "frac-image",
148 Usage: "portion of posts to include images",
149 Value: 0.25,
150 },
151 &cli.Float64Flag{
152 Name: "frac-mention",
153 Usage: "of posts created, fraction to include mentions in",
154 Value: 0.50,
155 },
156 },
157 },
158 &cli.Command{
159 Name: "gen-interactions",
160 Usage: "create interactions between accounts",
161 Action: genInteractions,
162 Flags: []cli.Flag{
163 &cli.StringFlag{
164 Name: "catalog",
165 Usage: "file path of account catalog JSON file",
166 Value: "data/fakermaker/accounts.json",
167 },
168 &cli.Float64Flag{
169 Name: "frac-like",
170 Usage: "fraction of posts in timeline to like",
171 Value: 0.20,
172 },
173 &cli.Float64Flag{
174 Name: "frac-repost",
175 Usage: "fraction of posts in timeline to repost",
176 Value: 0.20,
177 },
178 &cli.Float64Flag{
179 Name: "frac-reply",
180 Usage: "fraction of posts in timeline to reply to",
181 Value: 0.20,
182 },
183 },
184 },
185 &cli.Command{
186 Name: "run-browsing",
187 Usage: "creates read-only load on service (notifications, timeline, etc)",
188 Action: runBrowsing,
189 Flags: []cli.Flag{
190 &cli.StringFlag{
191 Name: "catalog",
192 Usage: "file path of account catalog JSON file",
193 Value: "data/fakermaker/accounts.json",
194 },
195 },
196 },
197 }
198 all := fakedata.MeasureIterations("entire command")
199 app.RunAndExitOnError()
200 all(1)
201}
202
203// registers fake accounts with PDS, and spits out JSON-lines to stdout with auth info
204func genAccounts(cctx *cli.Context) error {
205
206 // establish atproto client, with admin token for auth
207 xrpcc, err := cliutil.GetXrpcClient(cctx, false)
208 if err != nil {
209 return err
210 }
211 adminToken := cctx.String("admin-password")
212 if len(adminToken) > 0 {
213 xrpcc.AdminToken = &adminToken
214 }
215
216 countTotal := cctx.Int("count")
217 countCelebrities := cctx.Int("count-celebrities")
218 domainSuffix := cctx.String("domain-suffix")
219 if countCelebrities > countTotal {
220 return fmt.Errorf("more celebrities than total accounts!")
221 }
222 countRegulars := countTotal - countCelebrities
223
224 var inviteCode *string = nil
225 if cctx.Bool("use-invite-code") {
226 resp, err := comatproto.ServerCreateInviteCodes(context.TODO(), xrpcc, &comatproto.ServerCreateInviteCodes_Input{
227 UseCount: int64(countTotal),
228 ForAccounts: nil,
229 CodeCount: 1,
230 })
231 if err != nil {
232 return err
233 }
234 if len(resp.Codes) != 1 || len(resp.Codes[0].Codes) != 1 {
235 return fmt.Errorf("expected a single invite code")
236 }
237 inviteCode = &resp.Codes[0].Codes[0]
238 }
239
240 // call helper to do actual creation
241 var usr *fakedata.AccountContext
242 var line []byte
243 t1 := fakedata.MeasureIterations("register celebrity accounts")
244 for i := 0; i < countCelebrities; i++ {
245 if usr, err = fakedata.GenAccount(xrpcc, i, "celebrity", domainSuffix, inviteCode); err != nil {
246 return err
247 }
248 // compact single-line JSON by default
249 if line, err = json.Marshal(usr); err != nil {
250 return nil
251 }
252 fmt.Println(string(line))
253 }
254 t1(countCelebrities)
255
256 t2 := fakedata.MeasureIterations("register regular accounts")
257 for i := 0; i < countRegulars; i++ {
258 if usr, err = fakedata.GenAccount(xrpcc, i, "regular", domainSuffix, inviteCode); err != nil {
259 return err
260 }
261 // compact single-line JSON by default
262 if line, err = json.Marshal(usr); err != nil {
263 return nil
264 }
265 fmt.Println(string(line))
266 }
267 t2(countRegulars)
268 return nil
269}
270
271func genProfiles(cctx *cli.Context) error {
272 catalog, err := fakedata.ReadAccountCatalog(cctx.String("catalog"))
273 if err != nil {
274 return err
275 }
276
277 pdsHost := cctx.String("pds-host")
278 genAvatar := !cctx.Bool("no-avatars")
279 genBanner := !cctx.Bool("no-banners")
280 jobs := cctx.Int("jobs")
281
282 accChan := make(chan fakedata.AccountContext, len(catalog.Celebs)+len(catalog.Regulars))
283 eg := new(errgroup.Group)
284 for i := 0; i < jobs; i++ {
285 eg.Go(func() error {
286 for acc := range accChan {
287 xrpcc, err := fakedata.AccountXrpcClient(pdsHost, &acc)
288 if err != nil {
289 return err
290 }
291 if err = fakedata.GenProfile(xrpcc, &acc, genAvatar, genBanner); err != nil {
292 return err
293 }
294 }
295 return nil
296 })
297 }
298
299 for _, acc := range append(catalog.Celebs, catalog.Regulars...) {
300 accChan <- acc
301 }
302 close(accChan)
303 return eg.Wait()
304}
305
306func genGraph(cctx *cli.Context) error {
307 catalog, err := fakedata.ReadAccountCatalog(cctx.String("catalog"))
308 if err != nil {
309 return err
310 }
311
312 pdsHost := cctx.String("pds-host")
313 maxFollows := cctx.Int("max-follows")
314 maxMutes := cctx.Int("max-mutes")
315 jobs := cctx.Int("jobs")
316
317 accChan := make(chan fakedata.AccountContext, len(catalog.Celebs)+len(catalog.Regulars))
318 eg := new(errgroup.Group)
319 for i := 0; i < jobs; i++ {
320 eg.Go(func() error {
321 for acc := range accChan {
322 xrpcc, err := fakedata.AccountXrpcClient(pdsHost, &acc)
323 if err != nil {
324 return err
325 }
326 if err = fakedata.GenFollowsAndMutes(xrpcc, catalog, &acc, maxFollows, maxMutes); err != nil {
327 return err
328 }
329 }
330 return nil
331 })
332 }
333
334 for _, acc := range append(catalog.Celebs, catalog.Regulars...) {
335 accChan <- acc
336 }
337 close(accChan)
338 return eg.Wait()
339}
340
341func genPosts(cctx *cli.Context) error {
342 catalog, err := fakedata.ReadAccountCatalog(cctx.String("catalog"))
343 if err != nil {
344 return err
345 }
346
347 pdsHost := cctx.String("pds-host")
348 maxPosts := cctx.Int("max-posts")
349 fracImage := cctx.Float64("frac-image")
350 fracMention := cctx.Float64("frac-mention")
351 jobs := cctx.Int("jobs")
352
353 accChan := make(chan fakedata.AccountContext, len(catalog.Celebs)+len(catalog.Regulars))
354 eg := new(errgroup.Group)
355 for i := 0; i < jobs; i++ {
356 eg.Go(func() error {
357 for acc := range accChan {
358 xrpcc, err := fakedata.AccountXrpcClient(pdsHost, &acc)
359 if err != nil {
360 return err
361 }
362 if err = fakedata.GenPosts(xrpcc, catalog, &acc, maxPosts, fracImage, fracMention); err != nil {
363 return err
364 }
365 }
366 return nil
367 })
368 }
369
370 for _, acc := range append(catalog.Celebs, catalog.Regulars...) {
371 accChan <- acc
372 }
373 close(accChan)
374 return eg.Wait()
375}
376
377func genInteractions(cctx *cli.Context) error {
378 catalog, err := fakedata.ReadAccountCatalog(cctx.String("catalog"))
379 if err != nil {
380 return err
381 }
382
383 pdsHost := cctx.String("pds-host")
384 fracLike := cctx.Float64("frac-like")
385 fracRepost := cctx.Float64("frac-repost")
386 fracReply := cctx.Float64("frac-reply")
387 jobs := cctx.Int("jobs")
388
389 accChan := make(chan fakedata.AccountContext, len(catalog.Celebs)+len(catalog.Regulars))
390 eg := new(errgroup.Group)
391 for i := 0; i < jobs; i++ {
392 eg.Go(func() error {
393 for acc := range accChan {
394 xrpcc, err := fakedata.AccountXrpcClient(pdsHost, &acc)
395 if err != nil {
396 return err
397 }
398 t1 := fakedata.MeasureIterations("all interactions")
399 if err := fakedata.GenLikesRepostsReplies(xrpcc, &acc, fracLike, fracRepost, fracReply); err != nil {
400 return err
401 }
402 t1(1)
403 }
404 return nil
405 })
406 }
407
408 for _, acc := range append(catalog.Celebs, catalog.Regulars...) {
409 accChan <- acc
410 }
411 close(accChan)
412 return eg.Wait()
413}
414
415func runBrowsing(cctx *cli.Context) error {
416 catalog, err := fakedata.ReadAccountCatalog(cctx.String("catalog"))
417 if err != nil {
418 return err
419 }
420
421 pdsHost := cctx.String("pds-host")
422 jobs := cctx.Int("jobs")
423
424 accChan := make(chan fakedata.AccountContext, len(catalog.Celebs)+len(catalog.Regulars))
425 eg := new(errgroup.Group)
426 for i := 0; i < jobs; i++ {
427 eg.Go(func() error {
428 for acc := range accChan {
429 xrpcc, err := fakedata.AccountXrpcClient(pdsHost, &acc)
430 if err != nil {
431 return err
432 }
433 if err := fakedata.BrowseAccount(xrpcc, &acc); err != nil {
434 return err
435 }
436 }
437 return nil
438 })
439 }
440
441 for _, acc := range append(catalog.Celebs, catalog.Regulars...) {
442 accChan <- acc
443 }
444 close(accChan)
445 return eg.Wait()
446}