diff --git a/Model/bin/createApolloReleasePackage b/Model/bin/createApolloReleasePackage new file mode 100755 index 0000000000..8226d8a38b --- /dev/null +++ b/Model/bin/createApolloReleasePackage @@ -0,0 +1,249 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +# Added only when GUS_HOME is set: unconditional interpolation warns at COMPILE +# time, before any option is looked at, so a new user's first output would be +# Perl noise instead of the usage text. +use lib (defined $ENV{GUS_HOME} && length $ENV{GUS_HOME} + ? "$ENV{GUS_HOME}/lib/perl" : ()); + +use File::Path qw(make_path); + +# Wiring only. Every rule with a wrong answer worth catching lives in +# ApolloRelease::Cli or the module that owns it, because a Perl script cannot be +# `use`d by a test without running its main() -- the tool this replaces kept its +# thresholds and rename detection in a script nothing ever exercised. +# +# The step order below is the whole safety story: nothing is read before it can +# be validated, nothing is written before a human decision is settled. + +my $CLI = 'ApiCommonModel::Model::ApolloRelease::Cli'; + +# Parsed before anything loads from GUS_HOME: --help and a missing option must +# not require credentials, a GUS_HOME or a database. +my $opt = eval { _requireCli(); $CLI->parseOptions(@ARGV) }; +_fail($@) unless $opt; + +if ($opt->{phase} eq 'help') { + print $CLI->usage(); + exit 0; +} + +# Reported as one clean line rather than a Perl die with file and line: these +# messages are read by a release engineer, not by whoever wrote the module. +my $status = eval { main($opt) }; +_fail($@) unless defined $status; +exit $status; + +sub main { + my ($opt) = @_; + + # --- 2. preflight -------------------------------------------------------- + # + # Run in BOTH phases: --report is the rehearsal for --generate, so a report + # that succeeds and is then followed by a --generate dying at startup wastes + # the curation-team round trip the two-phase split exists to protect. + $CLI->assertEnvironment(\%ENV, !$opt->{apollo_roster}); + $CLI->assertPreviousRelease($opt->{previous_release}); + + my $outDir = $CLI->outputDir($opt); + + my %generateOpts = ( + outDir => "$outDir", + base => $opt->{base_url}, + project => $opt->{project}, + build => $opt->{build}, + wsDir => $opt->{ws_dir}, + gusHome => $ENV{GUS_HOME}, + ); + $CLI->assertGenerateConfig(\%generateOpts); + + _generate()->assertToolsAvailable(); + + # --- 3. inputs, all read-only ------------------------------------------- + my $portal = _portal()->loadFromCommand($opt->{project}); + + # BEFORE assertPortalSane, deliberately. Portal.pm collects its warnings + # rather than writing to stderr, so surfacing them is this script's job -- and + # held until after the sanity gates they were discarded on exactly the runs + # that needed them. A gate that dies must not swallow the evidence for its + # own message. + _printWarnings('portal', [_portal()->warnings()]); + + $CLI->assertPortalSane($portal); + + my ($live, $apolloSource) = _loadApollo($opt); + $CLI->assertApolloSane($live, $apolloSource); + + my $overlayPath = "$ENV{GUS_HOME}/data/ApiCommonModel/Model/apollo/roster-overlay.txt"; + my $overlay = _overlay()->parseFile($overlayPath); + + # --- 5. renames ---------------------------------------------------------- + my $resolved = $CLI->resolveRenames($portal, $live, { + previous_release => $opt->{previous_release}, + ws_dir => $opt->{ws_dir}, + project => $opt->{project}, + build => $opt->{build}, + }); + + # Before the gate below for the same reason: a declined merge explains why an + # organism became a prune candidate, and so why a bucket came up empty. + _printWarnings('renames', $resolved->{warnings}); + _printRenameProvenance($resolved); + + # --- 6. reconcile -------------------------------------------------------- + my $result = _reconcile()->reconcile($portal, $live, $overlay, $resolved->{renames}); + $CLI->assertUpdateBucketSane($result, $opt->{force}); + + print "Apollo roster read from $apolloSource\n"; + print "roster overlay read from $overlayPath\n\n"; + + my $report = _report()->render($result, {build => $opt->{build}, + environment => $opt->{environment}}); + print $report; + + # After the report and independent of the decision gate: an approved annotated + # prune raises no pending decision, so it is the case that runs unread. + my $atStake = $CLI->annotatedPruneWarning($result); + print "\nAT STAKE: $atStake" if $atStake; + + # --- --report stops here, having changed nothing ------------------------- + if ($opt->{phase} eq 'report') { + print "\n--report changed nothing. Re-run with --generate to build the package\n" + . "into $outDir once the decisions above are recorded in the roster overlay.\n"; + return 0; + } + + # --- 7. the gate --------------------------------------------------------- + $CLI->assertGenerationAllowed($result, $opt->{force}); + + # --- 8. generate --------------------------------------------------------- + my $roster = $CLI->generationRoster($result, $opt->{organisms}); + + # Before make_path below, deliberately: a refused run must not leave a + # half-created release directory for the next person to find. + $CLI->assertRosterNonEmpty($roster); + + if (@{$opt->{organisms}}) { + print "\nPARTIAL PACKAGE: --organism narrowed generation to " + . scalar(@$roster) . " organism(s).\n" + . "The report above covers the whole roster; the command files below cover\n" + . "only what was generated.\n"; + } + + my $commandDir = "$outDir/updateCommands"; + make_path("$outDir/data", "$outDir/twoBit", $commandDir); + + print "\ngenerating " . scalar(@$roster) . " organism(s) into $outDir\n"; + + my $generated = _generate()->generateAll($roster, sub { + my ($organism) = @_; + return _generate()->generateOrganism($organism, \%generateOpts); + }); + + _printWarnings('generate', [_generate()->warnings()]); + + # From the NARROWED result so the commands describe what was actually built: + # full-roster commands beside a partial package would repoint an Apollo + # organism at a directory nobody generated. + my $written = _commands()->writeCommandFiles( + $CLI->narrowResult($result, $opt->{organisms}), $commandDir, + build => $opt->{build}); + + _write("$outDir/report.txt", $report); + _write("$outDir/report.tsv", _report()->renderTsv($result)); + + printf("\n%d succeeded, %d failed\n", + scalar @{$generated->{succeeded}}, scalar @{$generated->{failed}}); + print " $written->{curl}\n $written->{groovy}\n"; + print " $outDir/report.txt\n $outDir/report.tsv\n"; + + return 0 unless @{$generated->{failed}}; + + # Printed in full: the run is hours long and the organisms are independent, so + # failures accumulate rather than stopping it. Exiting zero here is how a + # package ships with holes in it. + print STDERR "\nFAILED organisms:\n"; + print STDERR " $_: $generated->{errors}{$_}\n" for @{$generated->{failed}}; + + return 1; +} + +# --- helpers --- + +sub _loadApollo { + my ($opt) = @_; + + # A saved findAllOrganisms response goes through the same normalise() seam, so + # an offline rehearsal exercises every rule the live path does but the fetch. + return (_apollo()->loadFromFile($opt->{apollo_roster}), $opt->{apollo_roster}) + if $opt->{apollo_roster}; + + # Labelled from Apollo::apiUrl, never a second copy of the same default: the + # report must name the host actually read. + return (_apollo()->loadFromApi(), _apollo()->apiUrl()); +} + +sub _printWarnings { + my ($label, $warnings) = @_; + + return unless $warnings && @$warnings; + + print "\n" . scalar(@$warnings) . " $label warning(s):\n"; + print " $_\n" for @$warnings; + print "\n"; +} + +# A rename is the one action that touches curated annotations, so say which +# mechanism decided it: "the database says so" and "the sequences match" carry +# very different weight. +sub _printRenameProvenance { + my ($resolved) = @_; + + my @from = sort keys %{$resolved->{renames}}; + return unless @from || @{$resolved->{unresolved}}; + + print "\nrename resolution\n"; + printf(" %-24s -> %-24s (%s)\n", + $_, $resolved->{renames}{$_}, $resolved->{mechanism}{$_}) for @from; + print " unresolved orphan(s), left as prune candidates: " + . join(', ', @{$resolved->{unresolved}}) . "\n" + if @{$resolved->{unresolved}}; + print "\n"; +} + +sub _write { + my ($path, $content) = @_; + open(my $fh, '>:raw', $path) or die "Cannot write $path: $!\n"; + print $fh $content; + close $fh or die "Cannot close $path: $!\n"; + return 1; +} + +sub _fail { + my ($error) = @_; + chomp(my $message = $error || 'failed for no stated reason'); + print STDERR "createApolloReleasePackage: $message\n"; + exit 2; +} + +# Lazy and by name so --help and an option error never touch GUS_HOME. Each +# wrapper returns the class name, so callers read as normal method calls. +sub _requireCli { _load('Cli') } +sub _portal { _load('Portal') } +sub _apollo { _load('Apollo') } +sub _overlay { _load('Overlay') } +sub _reconcile { _load('Reconcile') } +sub _report { _load('Report') } +sub _generate { _load('Generate') } +sub _commands { _load('Commands') } + +sub _load { + my ($name) = @_; + my $class = "ApiCommonModel::Model::ApolloRelease::$name"; + eval "require $class; 1" or die $@; + return $class; +} + diff --git a/Model/bin/jbrowseOrganismList b/Model/bin/jbrowseOrganismList index 37d39e764a..35ab103d8c 100644 --- a/Model/bin/jbrowseOrganismList +++ b/Model/bin/jbrowseOrganismList @@ -8,10 +8,11 @@ use Data::Dumper; my ($projectName) = @ARGV; -my $jbrowseUtil = ApiCommonModel::Model::JBrowseUtil->new({projectName => $projectName, organismAbbrev => $organismAbbrev}); +my $jbrowseUtil = ApiCommonModel::Model::JBrowseUtil->new({projectName => $projectName}); my $dbh = $jbrowseUtil->getDbh(); my $sql = "select distinct o.public_abbrev as organism_abbrev + , o.abbrev as internal_abbrev , o.name_for_filenames , o.strain_abbrev , o.IS_REFERENCE_STRAIN @@ -33,7 +34,7 @@ my $historySql = "select h.build_number, o.public_abbrev, h.genome_source, h.gen where h.dataset_presenter_id = dd.dataset_presenter_id and dd.name like '%primary_genome_RSRC' and h.annotation_version is not null - and o.taxon_id = nt.taxon_id"; + and dd.taxon_id = o.taxon_id"; my $sh = $dbh->prepare($sql); @@ -60,11 +61,11 @@ print encode_json($result); sub addHistoryToOrganism { my ($h, $orgs) = @_; - my $publicAbbrev = $h->{PUBLIC_ABBREV}; + my $publicAbbrev = $h->{public_abbrev}; foreach(@$orgs) { - if($_->{ORGANISM_ABBREV} eq $publicAbbrev) { - push @{$_->{HISTORY}}, $h; + if($_->{organism_abbrev} eq $publicAbbrev) { + push @{$_->{history}}, $h; return; } } diff --git a/Model/data/apollo/roster-overlay.txt b/Model/data/apollo/roster-overlay.txt new file mode 100644 index 0000000000..10c5214ac6 --- /dev/null +++ b/Model/data/apollo/roster-overlay.txt @@ -0,0 +1,43 @@ +# Roster overlay for the Apollo release package. +# +# The roster is seeded from LIVE PROD APOLLO. This file records the human +# decisions layered on top of it. Every line needs a reason: this file is the +# only written record of why an organism is or is not in Apollo. +# +# add # who approved, when, why +# remove # who approved, when, why + +# --- Host genomes ------------------------------------------------------- +# Apollo curates pathogens. The previous script excluded these implicitly, +# via an @databases array that omitted HostDB and SchistoDB. On the UniDB +# portal that filter does not exist, so the exclusion has to be stated. +# +# All 11 seeded 2026-08-21 to reproduce b68 behaviour; each line names its +# organism rather than repeating that provenance. + +remove hsapREF # host genome: Homo sapiens +remove mmusC57BL6J # host genome: Mus musculus C57BL/6J +remove rnorBNNHsdMcwi # host genome: Rattus norvegicus BN/NHsdMcwi +remove btauHereford # host genome: Bos taurus Hereford +remove clupfamiliarisSID07034 # host genome: Canis lupus familiaris +remove ggalbGalGal1 # host genome: Gallus gallus bGalGal1 +remove cpor2N # host genome: Cavia porcellus 2N +remove mfasREF # host genome: Macaca fascicularis +remove mmulAG07107 # host genome: Macaca mulatta AG07107 +remove mmyomMyoMyo1 # host genome: Myotis myotis mMyoMyo1 +remove dmeliso-1 # host genome: Drosophila melanogaster iso-1 + +# --- Model fungi -------------------------------------------------------- +# In FungiDB, qualify on the criteria, never been in Apollo. Seeded to match +# existing behaviour, but nobody has consciously decided this -- raise with the +# curation team. + +remove scerS288C # model organism, never in Apollo; decision unconfirmed +remove spom972h # model organism, never in Apollo; decision unconfirmed + +# --- Deliberately NOT listed here --------------------------------------- +# hcapNAm1 (Histoplasma mississippiense NAm1) is a FungiDB pathogen that +# qualifies and is absent from Apollo. It is left in the add-candidate bucket +# for the curation team rather than suppressed here. Model/t/overlay.t pins +# that it appears in neither direction, so adding it here without revisiting +# this comment fails the build. diff --git a/Model/lib/perl/ApolloRelease/Absolutize.pm b/Model/lib/perl/ApolloRelease/Absolutize.pm new file mode 100644 index 0000000000..1094b311ae --- /dev/null +++ b/Model/lib/perl/ApolloRelease/Absolutize.pm @@ -0,0 +1,79 @@ +package ApiCommonModel::Model::ApolloRelease::Absolutize; + +use strict; +use warnings; + +# Apollo embeds JBrowse and reads its config off disk, so every URL there must +# be absolute; the jbrowse* scripts emit site-relative "/a/..." because they +# normally serve a website. A text pass rather than a typed transformation +# because many of these live inside free text -- HTML blobs, menuTemplate URLs, +# JavaScript bodies -- that a typed accessor cannot reach, and because the typed +# apollo code path upstream is still unimplemented. +# +# Deliberately IO-FREE: strings in, strings out, the caller owns the files. +# That is what lets all of it be tested with no fixtures. Do not add a +# rewriteFile() convenience -- it pulls disk coupling into every test. + +# rewrite() and assertNoRelative() MUST share this pattern. The assertion is +# rewrite's post-condition, so it has to validate exactly what rewrite changes; +# tightening one definition of "relative" without the other either leaves URLs +# unverified or dies on a URL no rewrite could remove, silently either way. +# +# A NEGATIVE lookbehind so that a "/a/" at the very start of the string matches +# too -- a positive one has nothing to match there and misses it. Any word or +# path character before "/a/" means continuation, not site root: +# "$projectUrl/a/service" already carries a base, and prefixing it again would +# yield "$projectUrlhttps://...". Protocol-relative "//a/" is left alone for +# the same reason. +my $RELATIVE = qr{(? $shown ? " (first $shown shown)" : "") . ":\n" + . join('', map { " $_\n" } @found); +} + +1; diff --git a/Model/lib/perl/ApolloRelease/Apollo.pm b/Model/lib/perl/ApolloRelease/Apollo.pm new file mode 100644 index 0000000000..9c1e3321a1 --- /dev/null +++ b/Model/lib/perl/ApolloRelease/Apollo.pm @@ -0,0 +1,151 @@ +package ApiCommonModel::Model::ApolloRelease::Apollo; + +use strict; +use warnings; + +use JSON; +use LWP::UserAgent; +use HTTP::Request::Common qw(POST); + +# Reads the live Apollo organism roster: the SEED for the release, and the only +# record of what curators actually decided. Organisms are keyed by the abbrev +# parsed out of `directory`, never commonName -- `directory` is machine-written +# by our own update commands, commonName is editable in the Apollo GUI. + +# The one place the API base is decided. The CLI reports which source the +# roster came from, and a second copy of this default there let the report +# attribute the roster to a host it never read. +sub apiUrl { + return $ENV{APOLLO_API_URL} || 'https://apollo-api.veupathdb.org'; +} + +sub loadFromApi { + my ($class) = @_; + + my $url = $class->apiUrl(); + my $user = $ENV{APOLLO_API_USER} or die "APOLLO_API_USER is not set\n"; + my $pass = $ENV{APOLLO_API_PASS} or die "APOLLO_API_PASS is not set\n"; + + my $agent = LWP::UserAgent->new(timeout => 900); + my $response = $agent->request( + POST "$url/organism/findAllOrganisms", + Content_Type => 'form-data', + Content => [username => $user, password => $pass], + ); + + die "Apollo API request failed: " . $response->status_line . "\n" + unless $response->is_success; + + my $decoded = $class->_decodeRoster($response->content, $url); + + # An empty roster makes every organism look new, so the commands would try to + # re-add the entire set. + die "Apollo returned no organisms from $url.\n" + . "Check APOLLO_API_USER/PASS, and check that you are running this on a\n" + . "Penn host -- the API is IP-restricted.\n" + unless @$decoded; + + return $class->normalise($decoded); +} + +sub loadFromFile { + my ($class, $path) = @_; + + open(my $fh, '<', $path) or die "Cannot read $path: $!"; + local $/; + my $json = <$fh>; + close $fh; + + return $class->normalise($class->_decodeRoster($json, $path)); +} + +# A 200 carrying an HTML login page or a truncated body would otherwise die with +# a bare "malformed JSON string" from inside the JSON module, naming neither the +# source nor the cause. An unauthenticated request served a login page with a +# 200 has already happened here. +sub _decodeRoster { + my ($class, $body, $source) = @_; + + my $decoded = eval { decode_json($body) }; + + die "Apollo returned a non-JSON body from $source.\n" + . "This is usually an HTML login or error page served with a 200 status:\n" + . "check APOLLO_API_USER/PASS and network access to the API.\n" + unless $decoded; + + die "Apollo response from $source was not a list of organisms.\n" + unless ref $decoded eq 'ARRAY'; + + return $decoded; +} + +# The seam: takes an already-decoded document, so the normalisation rules run +# with no fixture, HTTP call or credentials. Both loaders wrap it. +sub normalise { + my ($class, $decoded) = @_; + + my %byAbbrev; + + foreach my $raw (@$decoded) { + my $directory = $raw->{directory} || ''; + + # Whitespace and trailing slashes in one pass, so a mixture of both is + # fully removed. A single stray trailing byte is not cosmetic: the tainted + # abbrev matches no portal organism, so reconciliation reports the real + # genome as an add and the tainted one as a prune -- the add-plus-prune case + # this project exists to prevent. + $directory =~ s{^\s+}{}; + $directory =~ s{[\s/]+$}{}; + + my ($abbrev) = $directory =~ m{([^/]+)$}; + + unless (defined $abbrev && length $abbrev) { + warn "Apollo organism id $raw->{id} has an unparseable directory '$raw->{directory}'; skipping\n"; + next; + } + + # Interior junk cannot be trimmed without inventing an identity, so validate + # the shape instead. Skipping is the safe failure: an organism absent from + # this hash can only become an approval-gated add downstream, never a prune, + # since prune requires presence in Apollo. + unless ($abbrev =~ m{\A[A-Za-z0-9_.-]+\z}) { + warn "Apollo organism id $raw->{id} has a malformed abbrev '$abbrev' " + . "from directory '$raw->{directory}'; skipping\n"; + next; + } + + # Corruption, not a shape we can normalise: overwriting drops one from the + # roster, and the portal diff then generates commands for whichever came + # last -- including discarding annotations belonging to the other. + if (exists $byAbbrev{$abbrev}) { + die "Apollo has two organisms with directory '$raw->{directory}' " + . "(ids $byAbbrev{$abbrev}{id} and $raw->{id}).\n" + . "Resolve this in Apollo before generating release commands.\n"; + } + + $byAbbrev{$abbrev} = { + abbrev => $abbrev, + id => $raw->{id}, + common_name => $raw->{commonName}, + directory => $raw->{directory}, + blatdb => $raw->{blatdb}, + annotation_count => $raw->{annotationCount} || 0, + public_mode => $raw->{publicMode} ? 1 : 0, + }; + } + + return \%byAbbrev; +} + +# Apollo names an organism " []". Compare only +# the part before the bracket. +sub commonNameDisagrees { + my ($class, $apolloOrganism, $portalName) = @_; + + my $live = $apolloOrganism->{common_name} || ''; + $live =~ s{\s*\[[^\]]*\]\s*$}{}; + + return ($live eq $portalName) ? 0 : 1; +} + +1; diff --git a/Model/lib/perl/ApolloRelease/Cli.pm b/Model/lib/perl/ApolloRelease/Cli.pm new file mode 100644 index 0000000000..27a3e51a56 --- /dev/null +++ b/Model/lib/perl/ApolloRelease/Cli.pm @@ -0,0 +1,557 @@ +package ApiCommonModel::Model::ApolloRelease::Cli; + +use strict; +use warnings; + +use Getopt::Long qw(GetOptionsFromArray); + +use ApiCommonModel::Model::ApolloRelease::Rename; +use ApiCommonModel::Model::ApolloRelease::Report; + +# Everything in createApolloReleasePackage decidable without a database, a +# subprocess or a filesystem. A Perl script cannot be `use`d by a test without +# running its main(), so logic left in Model/bin/ is never exercised until a +# release engineer runs it against prod -- which is how the tool this replaces +# came to report success on an empty release. The script is thin wiring; every +# rule with a wrong answer worth catching is a class method here. +# +# RENAME RESOLUTION lives here, not in Rename.pm, which answers one question: +# does this .fai describe the same assembly as that one. Resolving a rename is +# a two-mechanism policy whose first mechanism needs no file I/O, and putting it +# there would give that module a second reason to change. + +# Sanity floor for the portal organism count. +# +# An organism set only ever grows -- retirement is losing the reference/annotated +# flags, not leaving the list -- so a large drop means the query, the model or +# the project name is wrong. +# +# The floor sits just above the size of the live Apollo roster, because every +# Apollo organism absent from the portal becomes a prune candidate: below that +# the tool would propose unpublishing curated genomes on the strength of a broken +# query. It is far enough below the real portal count that curation churn or a +# component database reload cannot trip it. +use constant PORTAL_FLOOR => 500; + +my $RENAME = 'ApiCommonModel::Model::ApolloRelease::Rename'; +my $REPORT = 'ApiCommonModel::Model::ApolloRelease::Report'; + +my @ENVIRONMENTS = qw(qa prod); + +# --- Options --- + +sub usage { + return <<'USAGE'; +createApolloReleasePackage --build N (--report | --generate) [options] + +Builds the JBrowse configuration and sequence data that Apollo, the genome +curation platform, serves for a VEuPathDB release -- and the command files a +human then runs against Apollo. It never calls a mutating Apollo endpoint. + +Phases (exactly one, they cost differently): + --report minutes. Portal + Apollo + overlay + rename resolution + + reconciliation, printed. Changes nothing on disk. This is + what goes to the curation team. + --generate hours. Everything --report does, then builds the package for + the approved roster and writes the update command files. + +Options: + --build N release build number (required) + --environment qa|prod which roster to write (default prod) + --project NAME WDK model name (default UniDB) + --out-dir DIR package root (default $HOME/apolloConfigs) + --base-url URL absolutization base (default https://veupathdb.org) + --webservices-dir DIR webServices tree holding the genomes + (default /var/www/Common/apiSiteFilesMirror/webServices) + --previous-release DIR the previous release's directory, i.e. the one + containing data/. Enables the assembly-identity + fallback for renames the database cannot explain. + --organism ABBREV narrow GENERATION to this organism; repeatable. + Reconciliation always runs over everything, so the + report and the safety invariants are unaffected. + --apollo-roster FILE read the Apollo roster from a saved + findAllOrganisms response instead of the API. For + offline rehearsal; a real release uses the API. + --force proceed past the pending-decision gate and past an + empty update bucket. Neither is overridden lightly. + --help this text + +Output: /release-// + data// twoBit/.2bit updateCommands/ report.txt report.tsv + +Environment: GUS_HOME, and APOLLO_API_USER / APOLLO_API_PASS unless +--apollo-roster is given. No password is ever read from the source. +USAGE +} + +# Dies with a plain message on any bad combination. Reads nothing but @argv and +# $ENV{HOME}: --help and a missing option must be answerable with no +# credentials, no GUS_HOME and no database. +sub parseOptions { + my ($class, @argv) = @_; + + my %opt = ( + environment => 'prod', + project => 'UniDB', + out_dir => ($ENV{HOME} || '.') . '/apolloConfigs', + base_url => 'https://veupathdb.org', + ws_dir => '/var/www/Common/apiSiteFilesMirror/webServices', + organisms => [], + ); + + my ($report, $generate, $help); + + my $parser = Getopt::Long::Parser->new(config => ['no_auto_abbrev', 'no_ignore_case']); + + # GetOptionsFromArray warns to stderr and returns false; turn that into the + # same death as every other bad option so a caller sees one message. + my $problem; + local $SIG{__WARN__} = sub { $problem ||= $_[0] }; + + $parser->getoptionsfromarray( + \@argv, + 'report' => \$report, + 'generate' => \$generate, + 'help' => \$help, + 'build=s' => \$opt{build}, + 'environment=s' => \$opt{environment}, + 'project=s' => \$opt{project}, + 'out-dir=s' => \$opt{out_dir}, + 'base-url=s' => \$opt{base_url}, + 'webservices-dir=s' => \$opt{ws_dir}, + 'previous-release=s' => \$opt{previous_release}, + 'apollo-roster=s' => \$opt{apollo_roster}, + 'organism=s' => $opt{organisms}, + 'force' => \$opt{force}, + ) or do { chomp(my $m = $problem || 'bad options'); die "$m\n" }; + + die "unexpected argument(s): @argv\n" if @argv; + + # --help short-circuits every other rule: the one invocation that must work on + # a machine where nothing is configured. + if ($help) { + $opt{phase} = 'help'; + return \%opt; + } + + die "exactly one of --report or --generate is required (they cost differently:\n" + . "--report takes minutes and changes nothing; --generate takes hours)\n" + unless ($report ? 1 : 0) + ($generate ? 1 : 0) == 1; + + $opt{phase} = $report ? 'report' : 'generate'; + + die "--build N is required (the release build number)\n" + unless defined $opt{build} && length $opt{build}; + die "--build must be a positive integer, got '$opt{build}'\n" + unless $opt{build} =~ /^[1-9][0-9]*$/; + $opt{build} += 0; + + die "--environment must be one of: @ENVIRONMENTS (got '$opt{environment}')\n" + unless grep { $_ eq $opt{environment} } @ENVIRONMENTS; + + # Accepting --organism on --report would produce a report that looks filtered + # and is not, which is worse than refusing it. + die "--organism narrows generation only and has no effect with --report;\n" + . "the reconciliation always runs over every organism.\n" + if @{$opt{organisms}} && $opt{phase} eq 'report'; + + foreach my $key (qw(project base_url ws_dir out_dir)) { + die "--" . ($key =~ s/_/-/gr) . " cannot be empty\n" + unless defined $opt{$key} && length $opt{$key}; + } + + return \%opt; +} + +sub outputDir { + my ($class, $opt) = @_; + return "$opt->{out_dir}/release-$opt->{build}/$opt->{environment}"; +} + +# --- Preflight -- everything checkable before any real work --- + +# $env is passed in rather than read from %ENV so this is testable, and so one +# place decides which variables a run needs. +sub assertEnvironment { + my ($class, $env, $needApolloCredentials) = @_; + + my @required = ('GUS_HOME'); + push @required, qw(APOLLO_API_USER APOLLO_API_PASS) if $needApolloCredentials; + + foreach my $name (@required) { + die "$name is not set.\n" + . "Source the site's etc/setenv, and export the Apollo API credentials\n" + . "(APOLLO_API_USER / APOLLO_API_PASS) -- they are never stored in the repo.\n" + unless defined $env->{$name} && length $env->{$name}; + } + + return 1; +} + +sub assertPreviousRelease { + my ($class, $dir) = @_; + + return 1 unless defined $dir && length $dir; + + die "--previous-release $dir does not exist\n" unless -e $dir; + die "--previous-release $dir is not a directory\n" unless -d $dir; + die "--previous-release $dir is not readable\n" unless -r $dir; + + return 1; +} + +# The keys Generate::generateOrganism reads out of its %$opts. Both failure +# modes are silent-ish: `wsdir` for `wsDir` fails deep into a run, and a missing +# `gusHome` never fails at all -- it degrades to running "/bin/