package app import ( "context" "flag" "fmt" "io" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" ) // Locks dispatches publish lock list and mutation helpers. func Locks(ctx context.Context, args []string, out io.Writer) error { if len(args) > 0 && !strings.HasPrefix(args[0], "-") { switch args[0] { case "add": return LocksAdd(ctx, args[1:], out) case "remove": return LocksRemove(ctx, args[1:], out) default: return fmt.Errorf("locks: unknown subcommand %q", args[0]) } } return LocksList(ctx, args, out) } // LocksList lists effective publish locks. func LocksList(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("locks", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags addCommonConfigFlags(fs, &flags) if err := parseSessionAwareFlags("locks", fs, args, &flags.sessionID); err != nil { return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks: session_id is required") } cfg, _, locks, _, err := loadHelperContext(ctx, flags, true) if err != nil { return fmt.Errorf("locks: %w", err) } writeLocks(out, cfg, locks) return nil } // LocksAdd adds or updates one remote lock. func LocksAdd(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("locks add", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags var reason string var force bool addCommonConfigFlags(fs, &flags) fs.StringVar(&reason, "reason", "", "lock reason") fs.BoolVar(&force, "force", false, "update existing remote lock") source, err := parseSessionIDAndOnePositionalArg("locks add", "source id", fs, args, &flags.sessionID) if err != nil { return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks add: session_id is required") } cfg, store, locks, _, err := loadHelperContext(ctx, flags, true) if err != nil { return fmt.Errorf("locks add: %w", err) } if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks add"); err != nil { return fmt.Errorf("locks add: %w", err) } if _, ok := lockSourceSet(locks.Static)[source]; ok { return fmt.Errorf("locks add: source %q is locked by pipeline config and cannot be modified remotely", source) } remoteSet := lockSourceSet(locks.Remote) if _, exists := remoteSet[source]; exists && !force { return fmt.Errorf("locks add: remote lock for %q already exists; pass --force to update", source) } remoteSet[source] = config.PublishLockRule{Source: source, Reason: strings.TrimSpace(reason)} remoteLocks := lockMapValues(remoteSet) if _, err := config.ValidatePublishLockRules(remoteLocks, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks"); err != nil { return fmt.Errorf("locks add: %w", err) } if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.PublishLockStore{Locks: remoteLocks}); err != nil { return fmt.Errorf("locks add: %w", err) } _, err = fmt.Fprintf(out, "narratio session locks add: locked %s\n", source) return err } // LocksRemove removes one remote lock. func LocksRemove(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("locks remove", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags addCommonConfigFlags(fs, &flags) source, err := parseSessionIDAndOnePositionalArg("locks remove", "source id", fs, args, &flags.sessionID) if err != nil { return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks remove: session_id is required") } cfg, store, locks, _, err := loadHelperContext(ctx, flags, true) if err != nil { return fmt.Errorf("locks remove: %w", err) } if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks remove"); err != nil { return fmt.Errorf("locks remove: %w", err) } remoteSet := lockSourceSet(locks.Remote) if _, ok := remoteSet[source]; !ok { if _, static := lockSourceSet(locks.Static)[source]; static { return fmt.Errorf("locks remove: source %q is locked by pipeline config and cannot be unlocked remotely", source) } return fmt.Errorf("locks remove: remote lock for %q does not exist", source) } delete(remoteSet, source) remoteLocks := lockMapValues(remoteSet) if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.PublishLockStore{Locks: remoteLocks}); err != nil { return fmt.Errorf("locks remove: %w", err) } _, err = fmt.Fprintf(out, "narratio session locks remove: unlocked %s\n", source) return err } func writeLocks(out io.Writer, cfg *config.Config, locks *effectiveLocks) { if locks == nil || len(locks.All) == 0 { fmt.Fprintln(out, "Publish locks: none") return } fmt.Fprintln(out, "Publish locks:") published := map[string]config.PublishOutputRule{} if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Publish != nil { for _, rule := range cfg.Pipeline.Publish.Outputs { published[strings.TrimSpace(rule.Source)] = rule } } staticSet := lockSourceSet(locks.Static) for _, lock := range locks.All { origin := "remote" if _, ok := staticSet[lock.Source]; ok { origin = "pipeline" } promo := "not-published" if _, ok := published[lock.Source]; ok { promo = "published" } reason := strings.TrimSpace(lock.Reason) if reason == "" { reason = "(no reason)" } fmt.Fprintf(out, "- %s origin=%s %s reason=%s\n", lock.Source, origin, promo, reason) } } func lockMapValues(in map[string]config.PublishLockRule) []config.PublishLockRule { keys := make([]string, 0, len(in)) for key := range in { keys = append(keys, key) } sort.Strings(keys) out := make([]config.PublishLockRule, 0, len(keys)) for _, key := range keys { item := in[key] item.Source = key item.Reason = strings.TrimSpace(item.Reason) out = append(out, item) } return out }