package app import ( "context" "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" ) type effectiveLocks struct { Static []config.PublishLockRule Remote []config.PublishLockRule All []config.PublishLockRule Key string } 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, error) { key, err := remoteLocksKey(cfg) if err != nil { return nil, "", err } exists, err := store.Exists(ctx, key) if err != nil { return nil, key, fmt.Errorf("check remote locks %q: %w", key, err) } if !exists { return &config.PublishLockStore{}, key, nil } tmp, err := storage.DownloadObjectToTemp(ctx, store, key, "narratio-locks-*.yml") if err != nil { return nil, key, fmt.Errorf("download remote locks %q: %w", key, err) } defer func() { _ = os.Remove(tmp) }() data, err := os.ReadFile(tmp) if err != nil { return nil, key, fmt.Errorf("read remote locks %q: %w", key, err) } 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, 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 uploadRemoteLockStore(ctx context.Context, store storage.ObjectStore, key string, lockStore *config.PublishLockStore) error { data, err := config.MarshalPublishLockStore(lockStore) if err != nil { return err } tmp, err := os.CreateTemp("", "narratio-locks-upload-*.yml") if err != nil { return fmt.Errorf("create lock store temp file: %w", err) } tmpPath := tmp.Name() defer func() { _ = os.Remove(tmpPath) }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("write lock store temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close lock store temp file: %w", err) } if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil { return fmt.Errorf("upload remote locks %q: %w", key, err) } return nil } 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 := os.MkdirAll(filepath.Dir(cleaned), 0o755); err != nil { return fmt.Errorf("create output directory: %w", err) } return os.WriteFile(cleaned, data, 0o644) }