179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
|
|
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, body, err := store.Read(ctx, key)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return &config.PublishLockStore{}, key, "", nil
|
|
}
|
|
return nil, key, "", fmt.Errorf("read remote locks %q: %w", key, err)
|
|
}
|
|
defer body.Close()
|
|
if strings.TrimSpace(info.ETag) == "" {
|
|
return nil, key, "", fmt.Errorf("read remote locks %q: object has no generation", key)
|
|
}
|
|
data, err := io.ReadAll(body)
|
|
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, 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)
|
|
}
|