package app import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) type effectiveLocks struct { Static []config.PublishLockRule Remote []config.PublishLockRule All []config.PublishLockRule Key string } const ( remoteLockMutationAttempts = 4 // MaxRemoteLockStoreBytes bounds the mutable remote publish-lock document. MaxRemoteLockStoreBytes int64 = 1 << 20 ) func remoteLocksKey(cfg *config.Config) (string, error) { if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { return "", fmt.Errorf("resolved config is required") } if cfg.Pipeline.Storage.S3 == nil { return "", fmt.Errorf("pipeline.storage.s3 configuration is required") } sessionPrefix := artifacts.S3SessionPrefix( cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID, ) return artifacts.S3SessionLocksKey(sessionPrefix), nil } func loadRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*config.PublishLockStore, string, string, error) { key, err := remoteLocksKey(cfg) if err != nil { return nil, "", "", err } if store == nil { return nil, key, "", fmt.Errorf("remote lock store is required") } info, data, err := storage.ReadObjectBounded(ctx, store, key, MaxRemoteLockStoreBytes) if err != nil { if errors.Is(err, os.ErrNotExist) { return &config.PublishLockStore{}, key, "", nil } var limitErr *storage.ReadLimitError if errors.As(err, &limitErr) { return nil, key, "", fmt.Errorf("read remote lock control object %q with %d-byte limit: %w", key, MaxRemoteLockStoreBytes, err) } return nil, key, "", fmt.Errorf("read remote locks %q: %w", key, err) } if strings.TrimSpace(info.ETag) == "" { return nil, key, "", fmt.Errorf("read remote locks %q: object has no generation", key) } lockStore, err := config.LoadPublishLockStoreBytes("s3://"+s3BucketName(cfg.Pipeline)+"/"+key, data, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius) if err != nil { return nil, key, "", err } return lockStore, key, info.ETag, nil } func loadEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*effectiveLocks, error) { staticLocks := staticPublishLocks(cfg) if store == nil { return &effectiveLocks{ Static: staticLocks, All: append([]config.PublishLockRule(nil), staticLocks...), }, nil } lockStore, key, _, err := loadRemoteLockStore(ctx, cfg, store) if err != nil { return nil, err } remoteLocks := append([]config.PublishLockRule(nil), lockStore.Locks...) return &effectiveLocks{ Static: staticLocks, Remote: remoteLocks, All: config.MergePublishLockRules(staticLocks, remoteLocks), Key: key, }, nil } func staticPublishLocks(cfg *config.Config) []config.PublishLockRule { if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil { return nil } return append([]config.PublishLockRule(nil), cfg.Pipeline.Publish.Locks...) } func applyEffectiveLocks(cfg *config.Config, locks []config.PublishLockRule) { if cfg == nil || cfg.Pipeline == nil { return } if cfg.Pipeline.Publish == nil { cfg.Pipeline.Publish = &config.PublishConfig{} } cfg.Pipeline.Publish.Locks = append([]config.PublishLockRule(nil), locks...) } func mutateRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.ObjectStore, mutate func(*config.PublishLockStore) error) error { for attempt := 0; attempt < remoteLockMutationAttempts; attempt++ { if err := ctx.Err(); err != nil { return err } lockStore, key, generation, err := loadRemoteLockStore(ctx, cfg, store) if err != nil { return err } if err := mutate(lockStore); err != nil { return err } data, err := config.MarshalPublishLockStore(lockStore) if err != nil { return err } condition := storage.WriteCondition{MatchETag: generation} if generation == "" { condition = storage.WriteCondition{RequireAbsent: true} } _, err = store.UploadConditional(ctx, bytes.NewReader(data), key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}, condition) if err == nil { return nil } if !errors.Is(err, storage.ErrConditionNotMet) { return fmt.Errorf("upload remote locks %q: %w", key, err) } } if err := ctx.Err(); err != nil { return err } return fmt.Errorf("update remote locks: concurrent updates prevented a conditional write after %d attempts", remoteLockMutationAttempts) } func lockSourceSet(locks []config.PublishLockRule) map[string]config.PublishLockRule { out := make(map[string]config.PublishLockRule, len(locks)) for _, lock := range locks { source := strings.TrimSpace(lock.Source) if source == "" { continue } lock.Source = source lock.Reason = strings.TrimSpace(lock.Reason) out[source] = lock } return out } func writeLocalFile(path string, data []byte, force bool) error { cleaned := filepath.Clean(strings.TrimSpace(path)) if cleaned == "" || cleaned == "." { return fmt.Errorf("output path is required") } if !force { if _, err := os.Stat(cleaned); err == nil { return fmt.Errorf("output file %q already exists; pass --force to overwrite", cleaned) } else if err != nil && !os.IsNotExist(err) { return fmt.Errorf("check output file %q: %w", cleaned, err) } } if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(cleaned)); err != nil { return fmt.Errorf("create output directory: %w", err) } if err := os.WriteFile(cleaned, data, fileops.WorkspaceFileMode); err != nil { return err } return os.Chmod(cleaned, fileops.WorkspaceFileMode) }