package app import ( "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 pipelineCampaignConfig struct { PipelinePath string CampaignPath string Pipeline *config.PipelineConfig Campaign *config.CampaignConfig } var downloadObjectToTempFn = storage.DownloadObjectToTemp type commandConfig struct { Config *config.Config cleanup func() error } func (c *commandConfig) Close() error { if c == nil || c.cleanup == nil { return nil } cleanup := c.cleanup c.cleanup = nil return cleanup() } func retainedCommandConfig(cfg *config.Config) *commandConfig { return &commandConfig{Config: cfg} } func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (loaded *commandConfig, err error) { var cleanup func() error defer func() { if err == nil || cleanup == nil { return } if cleanupErr := cleanup(); cleanupErr != nil { err = errors.Join(err, cleanupErr) } }() base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag) if err != nil { return nil, err } if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" { cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts) if err != nil { return nil, err } return retainedCommandConfig(cfg), nil } discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths) if err != nil { return nil, err } if discoveredSession.Path != "" { cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts) if err != nil { return nil, err } return retainedCommandConfig(cfg), nil } sessionID := strings.TrimSpace(sessionOpts.SessionID) if sessionID == "" { return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires a session_id") } rootPrefix := "" if base.Pipeline.Storage.S3 != nil { rootPrefix = base.Pipeline.Storage.S3.RootPrefix } sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID) remoteKey := artifacts.S3SessionConfigKey(sessionPrefix) partialCfg := &config.Config{ Pipeline: base.Pipeline, Campaign: base.Campaign, PipelinePath: base.PipelinePath, CampaignPath: base.CampaignPath, } store, err := newCommandObjectStore(ctx, partialCfg, nil) if err != nil { return nil, missingSessionConfigError(discoveredSession.Searched, fmt.Sprintf("remote session %q unavailable: %v", remoteKey, err)) } sessionInfo, err := findRemoteSessionConfig(ctx, store, sessionPrefix, remoteKey) if err != nil { return nil, missingSessionConfigError(discoveredSession.Searched, err.Error()) } sessionTempPath, err := downloadObjectToTempFn(ctx, store, remoteKey, "narratio-session-*.yml") if err != nil { return nil, missingSessionConfigError(discoveredSession.Searched, fmt.Sprintf("remote session %q download failed: %v", remoteKey, err)) } cleanup = func() error { if err := fileops.RemoveAllUnderRoot(filepath.Dir(sessionTempPath), sessionTempPath); err != nil { return fmt.Errorf("remove downloaded remote session config: %w", err) } return nil } sessionBytes, err := os.ReadFile(sessionTempPath) if err != nil { return nil, fmt.Errorf("read downloaded remote session config: %w", err) } sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(base.Pipeline)+"/"+remoteKey, sessionBytes, sessionOpts) if err != nil { return nil, err } cfg, err := config.Resolve( base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, sessionTempPath, sessionCfg, config.SessionSource{ Source: "session_config.s3", LocalPath: sessionTempPath, S3Bucket: s3BucketName(base.Pipeline), S3Key: remoteKey, S3Size: sessionInfo.Size, S3ETag: sessionInfo.ETag, }, ) if err != nil { return nil, err } loaded = &commandConfig{Config: cfg, cleanup: cleanup} cleanup = nil return loaded, nil } func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) { resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag) if err != nil { return nil, err } pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath) if err != nil { return nil, err } resolvedCampaignPath, err := resolveCampaignConfigPath(pipelineCfg, campaignFlag, campaignFileFlag) if err != nil { return nil, err } campaignCfg, err := config.LoadCampaign(resolvedCampaignPath) if err != nil { return nil, err } if selectedID := strings.TrimSpace(campaignFlag); selectedID != "" && strings.TrimSpace(campaignFileFlag) == "" { if got := config.CampaignID(campaignCfg); got != selectedID { return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID) } } return &pipelineCampaignConfig{ PipelinePath: resolvedPipelinePath, CampaignPath: resolvedCampaignPath, Pipeline: pipelineCfg, Campaign: campaignCfg, }, nil } func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) { objects, err := store.List(ctx, sessionPrefix) if err != nil { return storage.ObjectInfo{}, fmt.Errorf("remote session %q list failed: %w", remoteKey, err) } for _, obj := range objects { if obj.Key == remoteKey { return obj, nil } } return storage.ObjectInfo{}, fmt.Errorf("remote session %q not found", remoteKey) } func s3BucketName(cfg *config.PipelineConfig) string { if cfg == nil || cfg.Storage.S3 == nil { return "" } return strings.TrimSpace(cfg.Storage.S3.Bucket) }