tenseleyflow/shithub / 6a7809d

Browse files

S34: admin CLI — bootstrap-admin, run-job, recompute

Authored by espadonne
SHA
6a7809d8192739da2acc7fd7adb9da76f01f2ff4
Parents
59e9bbb
Tree
aafa78d

1 changed file

StatusFile+-
M cmd/shithubd/admin.go 160 0
cmd/shithubd/admin.gomodified
@@ -4,6 +4,7 @@ package main
44
 
55
 import (
66
 	"context"
7
+	"encoding/json"
78
 	"errors"
89
 	"fmt"
910
 	"os"
@@ -12,12 +13,14 @@ import (
1213
 	"github.com/jackc/pgx/v5/pgtype"
1314
 	"github.com/spf13/cobra"
1415
 
16
+	admindb "github.com/tenseleyFlow/shithub/internal/admin/sqlc"
1517
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1618
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
1719
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
1820
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1921
 	"github.com/tenseleyFlow/shithub/internal/infra/db"
2022
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
23
+	"github.com/tenseleyFlow/shithub/internal/worker"
2124
 )
2225
 
2326
 var adminCmd = &cobra.Command{
@@ -206,7 +209,164 @@ typically after manual identity verification through a support channel.`,
206209
 	},
207210
 }
208211
 
212
+// adminBootstrapAdminCmd is the chicken-and-egg solver for first
213
+// install: there's no /admin/users/{id} toggle reachable until at
214
+// least one user already has is_site_admin=true. Operators run this
215
+// once on a fresh deploy. Subsequent admin grants happen through the
216
+// /admin UI under an existing admin's session.
217
+var adminBootstrapAdminCmd = &cobra.Command{
218
+	Use:   "bootstrap-admin <username>",
219
+	Short: "Set users.is_site_admin=true on a single user (first-install bootstrap)",
220
+	Long: `Flips the site-admin flag on a user account so the /admin surface
221
+is reachable. Designed for the first-install bootstrap: once any admin
222
+exists, future grants happen in /admin/users/{id}.
223
+
224
+Writes an audit row attributed to actor_id=0 (CLI) so the bootstrap
225
+remains traceable.`,
226
+	Args: cobra.ExactArgs(1),
227
+	RunE: func(cmd *cobra.Command, args []string) error {
228
+		username := args[0]
229
+		cfg, err := config.Load(nil)
230
+		if err != nil {
231
+			return err
232
+		}
233
+		if cfg.DB.URL == "" {
234
+			return errors.New("admin bootstrap-admin: DB not configured (set SHITHUB_DATABASE_URL)")
235
+		}
236
+		ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
237
+		defer cancel()
238
+		pool, err := db.Open(ctx, db.Config{
239
+			URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
240
+			ConnectTimeout: cfg.DB.ConnectTimeout,
241
+		})
242
+		if err != nil {
243
+			return fmt.Errorf("db open: %w", err)
244
+		}
245
+		defer pool.Close()
246
+		uq := usersdb.New()
247
+		user, err := uq.GetUserByUsername(ctx, pool, username)
248
+		if err != nil {
249
+			return fmt.Errorf("user %q not found", username)
250
+		}
251
+		aq := admindb.New()
252
+		if err := aq.SetUserSiteAdmin(ctx, pool, admindb.SetUserSiteAdminParams{
253
+			ID: user.ID, IsSiteAdmin: true,
254
+		}); err != nil {
255
+			return fmt.Errorf("set site admin: %w", err)
256
+		}
257
+		recorder := audit.NewRecorder()
258
+		_ = recorder.Record(ctx, pool, 0,
259
+			audit.ActionAdminSiteAdminGranted, audit.TargetUser, user.ID,
260
+			map[string]any{"via": "cli", "bootstrap": true})
261
+		_, _ = fmt.Fprintf(cmd.OutOrStdout(),
262
+			"bootstrap-admin: %s now has is_site_admin=true\n", user.Username)
263
+		return nil
264
+	},
265
+}
266
+
267
+// adminRunJobCmd enqueues an arbitrary worker job. Break-glass for
268
+// operators when the normal trigger path is broken or the job needs a
269
+// one-off invocation.
270
+var adminRunJobCmd = &cobra.Command{
271
+	Use:   "run-job <kind> [payload-json]",
272
+	Short: "Enqueue a worker job by kind + JSON payload",
273
+	Long: `Inserts a row into the jobs queue with the given kind and payload.
274
+Payload defaults to {} when omitted. The worker pool picks the job up
275
+on its next claim cycle (within IdlePoll seconds).`,
276
+	Args: cobra.RangeArgs(1, 2),
277
+	RunE: func(cmd *cobra.Command, args []string) error {
278
+		kind := args[0]
279
+		payloadJSON := []byte("{}")
280
+		if len(args) == 2 {
281
+			payloadJSON = []byte(args[1])
282
+		}
283
+		var payload map[string]any
284
+		if err := json.Unmarshal(payloadJSON, &payload); err != nil {
285
+			return fmt.Errorf("payload not valid JSON: %w", err)
286
+		}
287
+		cfg, err := config.Load(nil)
288
+		if err != nil {
289
+			return err
290
+		}
291
+		if cfg.DB.URL == "" {
292
+			return errors.New("admin run-job: DB not configured")
293
+		}
294
+		ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
295
+		defer cancel()
296
+		pool, err := db.Open(ctx, db.Config{
297
+			URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
298
+			ConnectTimeout: cfg.DB.ConnectTimeout,
299
+		})
300
+		if err != nil {
301
+			return fmt.Errorf("db open: %w", err)
302
+		}
303
+		defer pool.Close()
304
+		id, err := worker.Enqueue(ctx, pool, worker.Kind(kind), payload, worker.EnqueueOptions{})
305
+		if err != nil {
306
+			return fmt.Errorf("enqueue: %w", err)
307
+		}
308
+		_, _ = fmt.Fprintf(cmd.OutOrStdout(), "run-job: enqueued #%d (kind=%s)\n", id, kind)
309
+		return nil
310
+	},
311
+}
312
+
313
+// adminRecomputeCmd triggers a counter reconciler. Useful when a
314
+// star_count / fork_count drifts from the source-of-truth row count.
315
+// Currently supports `star_count` and `fork_count`; new metrics get
316
+// new branches here.
317
+var adminRecomputeCmd = &cobra.Command{
318
+	Use:   "recompute <metric>",
319
+	Short: "Recompute a denormalized counter (star_count, fork_count)",
320
+	Args:  cobra.ExactArgs(1),
321
+	RunE: func(cmd *cobra.Command, args []string) error {
322
+		metric := args[0]
323
+		cfg, err := config.Load(nil)
324
+		if err != nil {
325
+			return err
326
+		}
327
+		if cfg.DB.URL == "" {
328
+			return errors.New("admin recompute: DB not configured")
329
+		}
330
+		ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
331
+		defer cancel()
332
+		pool, err := db.Open(ctx, db.Config{
333
+			URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
334
+			ConnectTimeout: cfg.DB.ConnectTimeout,
335
+		})
336
+		if err != nil {
337
+			return fmt.Errorf("db open: %w", err)
338
+		}
339
+		defer pool.Close()
340
+		var sql string
341
+		switch metric {
342
+		case "star_count":
343
+			sql = `UPDATE repos r
344
+			       SET star_count = COALESCE(s.n, 0)
345
+			       FROM (SELECT repo_id, COUNT(*)::bigint AS n FROM stars GROUP BY repo_id) s
346
+			       WHERE r.id = s.repo_id`
347
+		case "fork_count":
348
+			sql = `UPDATE repos r
349
+			       SET fork_count = COALESCE(f.n, 0)
350
+			       FROM (SELECT fork_of_repo_id AS rid, COUNT(*)::bigint AS n
351
+			             FROM repos WHERE fork_of_repo_id IS NOT NULL GROUP BY fork_of_repo_id) f
352
+			       WHERE r.id = f.rid`
353
+		default:
354
+			return fmt.Errorf("unknown metric %q (want star_count | fork_count)", metric)
355
+		}
356
+		tag, err := pool.Exec(ctx, sql)
357
+		if err != nil {
358
+			return fmt.Errorf("recompute: %w", err)
359
+		}
360
+		_, _ = fmt.Fprintf(cmd.OutOrStdout(),
361
+			"recompute: %s — %d rows updated\n", metric, tag.RowsAffected())
362
+		return nil
363
+	},
364
+}
365
+
209366
 func init() {
210367
 	adminCmd.AddCommand(adminResetPasswordCmd)
211368
 	adminCmd.AddCommand(adminClear2FACmd)
369
+	adminCmd.AddCommand(adminBootstrapAdminCmd)
370
+	adminCmd.AddCommand(adminRunJobCmd)
371
+	adminCmd.AddCommand(adminRecomputeCmd)
212372
 }