Monorepo for Tangled tangled.org
6

Configure Feed

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

1package spindle 2 3import ( 4 "context" 5 _ "embed" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "log/slog" 10 "maps" 11 "net/http" 12 "path/filepath" 13 "sync" 14 "time" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 "github.com/go-chi/chi/v5" 19 "github.com/go-git/go-git/v5/plumbing/object" 20 "github.com/hashicorp/go-version" 21 "tangled.org/core/api/tangled" 22 "tangled.org/core/eventconsumer" 23 "tangled.org/core/eventconsumer/cursor" 24 "tangled.org/core/eventstream" 25 "tangled.org/core/idresolver" 26 "tangled.org/core/jetstream" 27 kgit "tangled.org/core/knotserver/git" 28 "tangled.org/core/log" 29 "tangled.org/core/notifier" 30 "tangled.org/core/rbac" 31 "tangled.org/core/spindle/config" 32 "tangled.org/core/spindle/db" 33 "tangled.org/core/spindle/engine" 34 "tangled.org/core/spindle/engines/dummy" 35 "tangled.org/core/spindle/engines/microvm" 36 "tangled.org/core/spindle/engines/nixery" 37 "tangled.org/core/spindle/git" 38 "tangled.org/core/spindle/models" 39 "tangled.org/core/spindle/queue" 40 "tangled.org/core/spindle/secrets" 41 "tangled.org/core/spindle/xrpc" 42 "tangled.org/core/tid" 43 "tangled.org/core/workflow" 44 "tangled.org/core/xrpc/serviceauth" 45) 46 47//go:embed motd 48var defaultMotd []byte 49 50const ( 51 rbacDomain = "thisserver" 52) 53 54type Spindle struct { 55 jc *jetstream.JetstreamClient 56 tap *Tap 57 embedTap *embeddedTap 58 db *db.DB 59 e *rbac.Enforcer 60 l *slog.Logger 61 n *notifier.Notifier 62 engs map[string]models.Engine 63 jq *queue.Queue 64 cfg *config.Config 65 ks *eventconsumer.Consumer 66 res *idresolver.Resolver 67 vault secrets.Manager 68 motd []byte 69 motdMu sync.RWMutex 70 rootCtx context.Context 71} 72 73// New creates a new Spindle server with the provided configuration and engines. 74func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]models.Engine) (*Spindle, error) { 75 logger := log.FromContext(ctx) 76 77 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 78 if err != nil { 79 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 80 } 81 e.E.EnableAutoSave(true) 82 83 n := notifier.New() 84 85 var vault secrets.Manager 86 switch cfg.Server.Secrets.Provider { 87 case "openbao": 88 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 89 return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 90 } 91 vault, err = secrets.NewOpenBaoManager( 92 cfg.Server.Secrets.OpenBao.ProxyAddr, 93 logger, 94 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 95 ) 96 if err != nil { 97 return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 98 } 99 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 100 case "sqlite", "": 101 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 102 if err != nil { 103 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 104 } 105 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 106 default: 107 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 108 } 109 110 if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil { 111 return nil, fmt.Errorf("failed to run startup migrations: %w", err) 112 } 113 114 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 115 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 116 117 collections := []string{ 118 tangled.SpindleMemberNSID, 119 tangled.RepoNSID, 120 tangled.RepoCollaboratorNSID, 121 } 122 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 123 if err != nil { 124 return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 125 } 126 jc.AddDid(cfg.Server.Owner) 127 128 // Check if the spindle knows about any Dids; 129 dids, err := d.GetAllDids() 130 if err != nil { 131 return nil, fmt.Errorf("failed to get all dids: %w", err) 132 } 133 for _, d := range dids { 134 jc.AddDid(d) 135 } 136 137 knownRepos, err := d.AllRepos() 138 if err != nil { 139 return nil, fmt.Errorf("failed to get known repos: %w", err) 140 } 141 for _, r := range knownRepos { 142 if r.Owner != "" { 143 jc.AddDid(r.Owner.String()) 144 } 145 } 146 147 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 148 149 spindle := &Spindle{ 150 jc: jc, 151 e: e, 152 db: d, 153 l: logger, 154 n: &n, 155 engs: engines, 156 jq: jq, 157 cfg: cfg, 158 res: resolver, 159 vault: vault, 160 motd: defaultMotd, 161 rootCtx: ctx, 162 } 163 164 err = e.AddSpindle(rbacDomain) 165 if err != nil { 166 return nil, fmt.Errorf("failed to set rbac domain: %w", err) 167 } 168 err = spindle.configureOwner() 169 if err != nil { 170 return nil, err 171 } 172 logger.Info("owner set", "did", cfg.Server.Owner) 173 174 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 175 if err != nil { 176 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 177 } 178 179 err = jc.StartJetstream(ctx, spindle.ingest()) 180 if err != nil { 181 return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 182 } 183 184 // spindle listen to knot stream for sh.tangled.git.refUpdate 185 // which will sync the local workflow files in spindle and enqueues the 186 // pipeline job for on-push workflows 187 ccfg := eventconsumer.NewConsumerConfig() 188 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 189 ccfg.ProcessFunc = spindle.processKnotStream 190 ccfg.CursorStore = cursorStore 191 if cfg.Server.Dev { 192 ccfg.RetryInterval = 5 * time.Second 193 ccfg.MaxRetryInterval = 10 * time.Second 194 } else { 195 ccfg.RetryInterval = 1 * time.Minute 196 ccfg.MaxRetryInterval = 10 * time.Minute 197 } 198 knownKnots, err := d.Knots() 199 if err != nil { 200 return nil, err 201 } 202 for _, knot := range knownKnots { 203 logger.Info("adding source start", "knot", knot) 204 src := eventconsumer.NewKnotSource(knot) 205 eventconsumer.MigrateLegacyCursor(cursorStore, src) 206 ccfg.Sources[src] = struct{}{} 207 } 208 spindle.ks = eventconsumer.NewConsumer(*ccfg) 209 210 if cfg.Server.Tap.Embed { 211 pw, err := randomAdminPassword() 212 if err != nil { 213 return nil, err 214 } 215 cfg.Server.Tap.AdminPassword = pw 216 logger.Info("embedded tap: using random admin password") 217 } 218 spindle.tap = NewTapClient(spindle) 219 220 return spindle, nil 221} 222 223// DB returns the database instance. 224func (s *Spindle) DB() *db.DB { 225 return s.db 226} 227 228// Queue returns the job queue instance. 229func (s *Spindle) Queue() *queue.Queue { 230 return s.jq 231} 232 233// Engines returns the map of available engines. 234func (s *Spindle) Engines() map[string]models.Engine { 235 return s.engs 236} 237 238// Vault returns the secrets manager instance. 239func (s *Spindle) Vault() secrets.Manager { 240 return s.vault 241} 242 243// Notifier returns the notifier instance. 244func (s *Spindle) Notifier() *notifier.Notifier { 245 return s.n 246} 247 248// Enforcer returns the RBAC enforcer instance. 249func (s *Spindle) Enforcer() *rbac.Enforcer { 250 return s.e 251} 252 253// SetMotdContent sets custom MOTD content, replacing the embedded default. 254func (s *Spindle) SetMotdContent(content []byte) { 255 s.motdMu.Lock() 256 defer s.motdMu.Unlock() 257 s.motd = content 258} 259 260// GetMotdContent returns the current MOTD content. 261func (s *Spindle) GetMotdContent() []byte { 262 s.motdMu.RLock() 263 defer s.motdMu.RUnlock() 264 return s.motd 265} 266 267// Start starts the Spindle server (blocking). 268func (s *Spindle) Start(ctx context.Context) error { 269 // starts a job queue runner in the background 270 s.jq.Start() 271 defer s.jq.Stop() 272 273 // Stop vault token renewal if it implements Stopper 274 if stopper, ok := s.vault.(secrets.Stopper); ok { 275 defer stopper.Stop() 276 } 277 278 tapCtx, tapCancel := context.WithCancel(ctx) 279 280 if s.cfg.Server.Tap.Embed { 281 emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap")) 282 if err != nil { 283 tapCancel() 284 return fmt.Errorf("starting embedded tap: %w", err) 285 } 286 s.embedTap = emb 287 defer func() { 288 tapCancel() 289 s.embedTap.Shutdown() 290 }() 291 292 go s.watchTapDrain(tapCtx, tapCancel) 293 } else { 294 defer tapCancel() 295 } 296 297 go func() { 298 s.l.Info("starting knot event consumer") 299 s.ks.Start(ctx) 300 }() 301 302 s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url) 303 s.tap.Start(tapCtx) 304 305 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 306 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 307} 308 309func (s *Spindle) declareTapInterest(ctx context.Context) { 310 repos, err := s.db.AllRepos() 311 if err != nil { 312 s.l.Warn("tap declare: failed to load known repos", "err", err) 313 return 314 } 315 seen := make(map[syntax.DID]struct{}, len(repos)) 316 dids := make([]syntax.DID, 0, len(repos)) 317 for _, r := range repos { 318 if r.Owner == "" { 319 continue 320 } 321 if _, ok := seen[r.Owner]; ok { 322 continue 323 } 324 seen[r.Owner] = struct{}{} 325 dids = append(dids, r.Owner) 326 } 327 if err := s.tap.AddOwnerDIDs(ctx, dids); err != nil { 328 s.l.Warn("tap declare: AddRepos rejected", "count", len(dids), "err", err) 329 return 330 } 331 s.l.Info("tap declare: known owner DIDs registered", "count", len(dids)) 332} 333 334func Run(ctx context.Context) error { 335 cfg, err := config.Load(ctx) 336 if err != nil { 337 return fmt.Errorf("failed to load config: %w", err) 338 } 339 340 if err := ensureGitVersion(); err != nil { 341 return fmt.Errorf("ensuring git version: %w", err) 342 } 343 344 d, err := db.Make(ctx, cfg.Server.DBPath) 345 if err != nil { 346 return fmt.Errorf("failed to setup db: %w", err) 347 } 348 349 nixeryEng, err := nixery.New(ctx, cfg) 350 if err != nil { 351 return err 352 } 353 354 microvmEng, err := microvm.New(ctx, cfg, d) 355 if err != nil { 356 return err 357 } 358 359 s, err := New(ctx, cfg, d, map[string]models.Engine{ 360 "nixery": nixeryEng, 361 "microvm": microvmEng, 362 "dummy": dummy.New(log.FromContext(ctx)), 363 }) 364 if err != nil { 365 return err 366 } 367 368 return s.Start(ctx) 369} 370 371func (s *Spindle) Router() http.Handler { 372 mux := chi.NewRouter() 373 374 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 375 w.Write(s.GetMotdContent()) 376 }) 377 mux.HandleFunc("/events", s.Events) 378 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 379 380 mux.Mount("/xrpc", s.XrpcRouter()) 381 return mux 382} 383 384func (s *Spindle) XrpcRouter() http.Handler { 385 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res.Directory(), s.cfg.Server.Did().String()) 386 387 l := log.SubLogger(s.l, "xrpc") 388 389 x := xrpc.Xrpc{ 390 Logger: l, 391 Db: s.db, 392 Enforcer: s.e, 393 Engines: s.engs, 394 Config: s.cfg, 395 Resolver: s.res, 396 Vault: s.vault, 397 Notifier: s.Notifier(), 398 ServiceAuth: serviceAuth, 399 } 400 401 return x.Router() 402} 403 404func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventstream.Event) error { 405 l := log.FromContext(ctx).With("handler", "processKnotStream") 406 l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 407 if msg.Nsid == tangled.GitRefUpdateNSID { 408 event := tangled.GitRefUpdate{} 409 if err := json.Unmarshal(msg.EventJson, &event); err != nil { 410 l.Error("error unmarshalling", "err", err) 411 return err 412 } 413 l = l.With("repo", event.Repo, "ref", event.Ref, "newSha", event.NewSha) 414 l.Debug("debug") 415 416 repoDid := syntax.DID(event.Repo) 417 repo, err := s.db.GetRepoByDid(repoDid) 418 if err != nil { 419 return fmt.Errorf("unknown repoDid %s: %w", repoDid, err) 420 } 421 422 if src.Host != repo.Knot { 423 return fmt.Errorf("repo knot does not match event source: %s != %s", src.Host, repo.Knot) 424 } 425 426 // NOTE: we are blindly trusting the knot that it will return only repos it own 427 repoCloneUri := s.newRepoCloneUrl(src.Key(), repoDid) 428 repoPath := s.newRepoPath(repoDid) 429 if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 430 return fmt.Errorf("sync git repo: %w", err) 431 } 432 l.Info("synced git repo") 433 434 scheme := "https" 435 if s.cfg.Server.Dev { 436 scheme = "http" 437 } 438 client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, repo.Knot)} 439 440 // HACK: fetch current default branch 441 // TODO: this should be included in refUpdate event 442 defaultBranch, _ := func(repo syntax.DID) (string, error) { 443 defaultBranchOut, err := tangled.RepoGetDefaultBranch(ctx, client, repo.String()) 444 if err != nil { 445 return "", err 446 } 447 return defaultBranchOut.Name, nil 448 }(repoDid) 449 450 compiler := workflow.Compiler{ 451 ChangedFiles: event.ChangedFiles, 452 Trigger: tangled.Pipeline_TriggerMetadata{ 453 Kind: string(workflow.TriggerKindPush), 454 Push: &tangled.Pipeline_PushTriggerData{ 455 Ref: event.Ref, 456 OldSha: event.OldSha, 457 NewSha: event.NewSha, 458 }, 459 Repo: &tangled.Pipeline_TriggerRepo{ 460 Did: repo.Owner.String(), 461 Knot: repo.Knot, 462 Repo: (*string)(&repo.Rkey), 463 RepoDid: (*string)(&repoDid), 464 DefaultBranch: defaultBranch, 465 }, 466 }, 467 } 468 469 // load workflow definitions from rev (without spindle context) 470 rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 471 if err != nil { 472 return fmt.Errorf("loading pipeline: %w", err) 473 } 474 if len(rawPipeline) == 0 { 475 l.Info("no workflow definition find for the repo. skipping the event") 476 return nil 477 } 478 tpl := compiler.Compile(compiler.Parse(rawPipeline)) 479 // TODO: pass compile error to workflow log 480 for _, w := range compiler.Diagnostics.Errors { 481 l.Error(w.String()) 482 } 483 for _, w := range compiler.Diagnostics.Warnings { 484 l.Warn(w.String()) 485 } 486 if len(tpl.Workflows) == 0 { 487 l.Info("no workflow matching trigger 'push'. skipping the event") 488 return nil 489 } 490 491 pipelineId := models.PipelineId{ 492 Knot: tpl.TriggerMetadata.Repo.Knot, 493 Rkey: tid.TID(), 494 } 495 if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 496 l.Error("failed to create pipeline event", "err", err) 497 return nil 498 } 499 err = s.processPipeline(ctx, repoDid, tpl, pipelineId) 500 if err != nil { 501 return err 502 } 503 } 504 505 return nil 506} 507 508func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 509 if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 510 return nil, fmt.Errorf("syncing git repo: %w", err) 511 } 512 gr, err := kgit.Open(repoPath, rev) 513 if err != nil { 514 return nil, fmt.Errorf("opening git repo: %w", err) 515 } 516 517 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 518 if errors.Is(err, object.ErrDirectoryNotFound) { 519 // return empty RawPipeline when directory doesn't exist 520 return nil, nil 521 } else if err != nil { 522 return nil, fmt.Errorf("loading file tree: %w", err) 523 } 524 525 var rawPipeline workflow.RawPipeline 526 for _, e := range workflowDir { 527 if !e.IsFile() { 528 continue 529 } 530 531 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 532 contents, err := gr.RawContent(fpath) 533 if err != nil { 534 return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 535 } 536 537 rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 538 Name: e.Name, 539 Contents: contents, 540 }) 541 } 542 543 return rawPipeline, nil 544} 545 546func (s *Spindle) processPipeline(ctx context.Context, repoDid syntax.DID, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 547 // Build pipeline environment variables once for all workflows 548 pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId) 549 550 // filter & init workflows 551 workflows := make(map[models.Engine][]models.Workflow) 552 for _, w := range tpl.Workflows { 553 if w == nil { 554 continue 555 } 556 if _, ok := s.engs[w.Engine]; !ok { 557 err := s.db.StatusFailed(models.WorkflowId{ 558 PipelineId: pipelineId, 559 Name: w.Name, 560 }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 561 if err != nil { 562 return fmt.Errorf("db.StatusFailed: %w", err) 563 } 564 565 continue 566 } 567 568 eng := s.engs[w.Engine] 569 570 if _, ok := workflows[eng]; !ok { 571 workflows[eng] = []models.Workflow{} 572 } 573 574 ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 575 if err != nil { 576 err = s.db.StatusFailed(models.WorkflowId{ 577 PipelineId: pipelineId, 578 Name: w.Name, 579 }, fmt.Sprintf("init workflow: %s", err), -1, s.n) 580 if err != nil { 581 return fmt.Errorf("db.StatusFailed: %w", err) 582 } 583 584 continue 585 } 586 587 // inject TANGLED_* env vars after InitWorkflow 588 // This prevents user-defined env vars from overriding them 589 if ewf.Environment == nil { 590 ewf.Environment = make(map[string]string) 591 } 592 maps.Copy(ewf.Environment, pipelineEnv) 593 594 workflows[eng] = append(workflows[eng], *ewf) 595 } 596 597 // enqueue pipeline 598 ok := s.jq.Enqueue(repoDid, queue.Job{ 599 Run: func() error { 600 engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 601 RepoDid: repoDid, 602 Workflows: workflows, 603 }, pipelineId) 604 return nil 605 }, 606 OnFail: func(jobError error) { 607 s.l.Error("pipeline run failed", "error", jobError) 608 }, 609 }) 610 if !ok { 611 return fmt.Errorf("failed to enqueue pipeline: queue is full") 612 } 613 s.l.Info("pipeline enqueued successfully", "id", pipelineId) 614 615 // after successful enqueue, emit StatusPending for all workflows 616 for _, ewfs := range workflows { 617 for _, ewf := range ewfs { 618 err := s.db.StatusPending(models.WorkflowId{ 619 PipelineId: pipelineId, 620 Name: ewf.Name, 621 }, s.n) 622 if err != nil { 623 return fmt.Errorf("db.StatusPending: %w", err) 624 } 625 } 626 } 627 return nil 628} 629 630// newRepoPath creates a path to store repository by its did and rkey. 631// The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey 632func (s *Spindle) newRepoPath(repo syntax.DID) string { 633 return filepath.Join(s.cfg.Server.RepoDir, repo.String()) 634} 635 636func (s *Spindle) newRepoCloneUrl(knot string, did syntax.DID) string { 637 scheme := "https://" 638 if s.cfg.Server.Dev { 639 scheme = "http://" 640 } 641 return fmt.Sprintf("%s%s/%s", scheme, knot, did) 642} 643 644const RequiredVersion = "2.49.0" 645 646func ensureGitVersion() error { 647 v, err := git.Version() 648 if err != nil { 649 return fmt.Errorf("fetching git version: %w", err) 650 } 651 if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 652 return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 653 } 654 return nil 655} 656 657func (s *Spindle) resolvePipelineRepoDid(repo *tangled.Pipeline_TriggerRepo) (syntax.DID, error) { 658 if repo.RepoDid == nil || *repo.RepoDid == "" { 659 return "", fmt.Errorf("pipeline trigger missing repoDid") 660 } 661 repoDid, err := syntax.ParseDID(*repo.RepoDid) 662 if err != nil { 663 return "", fmt.Errorf("parse repoDid %s: %w", *repo.RepoDid, err) 664 } 665 if _, err := s.db.GetRepoByDid(repoDid); err != nil { 666 return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err) 667 } 668 return repoDid, nil 669} 670 671func (s *Spindle) configureOwner() error { 672 cfgOwner := s.cfg.Server.Owner 673 674 existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 675 if err != nil { 676 return err 677 } 678 679 switch len(existing) { 680 case 0: 681 // no owner configured, continue 682 case 1: 683 // find existing owner 684 existingOwner := existing[0] 685 686 // no ownership change, this is okay 687 if existingOwner == s.cfg.Server.Owner { 688 break 689 } 690 691 // remove existing owner 692 err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 693 if err != nil { 694 return nil 695 } 696 default: 697 return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 698 } 699 700 return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 701}