// Command oews-national-sync expands the BLS OEWS wage baseline to ALL detailed SOC occupations at the US // NATIONAL level (M5 price to win Phase 1). The existing rates sync (cmd/rates-sync) writes the Galapagos // HI and NM state rows; this job writes a national US row for every detailed OEWS occupation so any // requirement whose title resolves to a SOC gets at least the national wage band even outside HI and NM. // // It downloads the BLS oe.occupation flat file (with the required descriptive User Agent that names a // contact email, since BLS returns 403 to a default User Agent), keeps the detailed SOCs (display level // 3, roughly 800), builds the two national series per SOC (OEUN...04 annual mean, OEUN...13 annual // median), pulls the latest annual value in batches of 50 through the shared BLS client, and upserts one // row per (occupation_code, area='US', metric) into the global public bls_wages catalog. It is idempotent: // a second run updates values and inserts nothing new. Roughly 800 SOCs times 2 datatypes is about 1600 // series, about 32 batched API requests, well inside the BLS 500 request daily limit. // // The national rows carry area='US', area_name='United States', and an empty role (national data has no // Galapagos role); the HI and NM Galapagos state rows the rates sync writes are complementary and untouched. // // Env: BLS_API_KEY (server side only, never logged) and MM_MIGRATOR_DATABASE_URL (or MM_DATABASE_URL, the // migrator DSN, since bls_wages is a global catalog the app role may only read). Optional: BLS_API_BASE // (override the API endpoint), BLS_OCCUPATION_URL (override the flat file URL), BLS_USER_AGENT (override // the descriptive User Agent). Build and run FIPS validated: CGO_ENABLED=0 GOFIPS140=v1.0.0 go build, run // with GODEBUG=fips140=on. package main import ( "context" "crypto/fips140" "crypto/pbkdf2" "crypto/sha256" "errors" "log" "net/http" "os" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/platform/bls" "github.com/missionworkforce/missionmatch/internal/platform/database" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // defaultUserAgent is the descriptive User Agent naming a contact email, required by the BLS download. const defaultUserAgent = "missionMATCH wage sync (joseph.w.anady@gmail.com)" // batchPause is a small courtesy sleep between BLS API batches so the sync is a good API citizen. const batchPause = 500 * time.Millisecond func main() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() // FIPS self test: derive one PBKDF2 HMAC SHA 256 key under the active crypto module and fail closed if // it errors, so a build that dropped the validated module is caught at boot, the same in build FIPS gate // the api and rates sync binaries use. if err := fipsSelfTest(); err != nil { log.Fatalf("oews-national-sync: FIPS self test (PBKDF2 HMAC SHA 256) failed: %v", err) } log.Printf("oews-national-sync: fips_enabled=%v module=%s", fips140.Enabled(), fips140.Version()) key := strings.TrimSpace(os.Getenv("BLS_API_KEY")) dsn := firstNonEmpty(os.Getenv("MM_MIGRATOR_DATABASE_URL"), os.Getenv("MM_DATABASE_URL")) if key == "" { log.Fatal("oews-national-sync: BLS_API_KEY not set (server side env only)") } if dsn == "" { log.Fatal("oews-national-sync: set MM_MIGRATOR_DATABASE_URL (or MM_DATABASE_URL)") } userAgent := firstNonEmpty(os.Getenv("BLS_USER_AGENT"), defaultUserAgent) occURL := firstNonEmpty(os.Getenv("BLS_OCCUPATION_URL"), bls.OccupationListURL) pool, err := pgxpool.New(ctx, dsn) if err != nil { log.Fatalf("oews-national-sync: db connect: %v", err) } defer pool.Close() if err := pool.Ping(ctx); err != nil { log.Fatalf("oews-national-sync: db ping: %v", err) } // bls_wages is a global catalog the application role may only read, so this writer needs the BYPASSRLS // migrator DSN; assert it now for a legible failure instead of a mid run permission denied. if err := database.AssertMigratorRole(ctx, pool); err != nil { log.Fatalf("oews-national-sync: %v", err) } q := dbgen.New(pool) // Phase 1: download and parse the detailed OEWS occupations (display level 3). occs, err := bls.FetchDetailedOccupations(ctx, &http.Client{Timeout: 90 * time.Second}, occURL, userAgent) if err != nil { log.Fatalf("oews-national-sync: download occupation list: %v", err) } if len(occs) == 0 { log.Fatal("oews-national-sync: the occupation list held no detailed SOCs; refusing to proceed") } log.Printf("oews-national-sync: detailed OEWS occupations found=%d", len(occs)) // Phase 2: build the two national series per SOC and index them by series id so the fetched value maps // back to its occupation and metric. byID := make(map[string]bls.SeriesSpec, len(occs)*len(bls.Metrics)) ids := make([]string, 0, len(occs)*len(bls.Metrics)) for _, o := range occs { for _, spec := range bls.NationalSeries(o.SOC, o.Title) { byID[spec.SeriesID] = spec ids = append(ids, spec.SeriesID) } } log.Printf("oews-national-sync: national series to query=%d (about %d batched requests)", len(ids), (len(ids)+49)/50) // Phase 3: fetch the latest annual value per series, batched 50 per request with a courtesy pause, then // upsert. FetchLatest already batches at 50; wrap it per batch so a courtesy pause sits between batches // and a No Data series is simply absent (the client skips an empty data series). series, err := fetchBatched(ctx, bls.New(key, os.Getenv("BLS_API_BASE")), ids) if err != nil { if errors.Is(err, bls.ErrThrottled) { log.Printf("oews-national-sync: BLS daily threshold reached, stopping run gracefully") return } log.Fatalf("oews-national-sync: fetch: %v", err) } log.Printf("oews-national-sync: series returned with data=%d", len(series)) // Phase 4: upsert one national row per (occupation_code, area='US', metric). A series with No Data is // absent from the results, so it is skipped without failing the run. upserted := 0 skippedNoValue := 0 for _, s := range series { spec, ok := byID[s.ID] if !ok { continue } var val *decimal.Decimal if v, err := decimal.NewFromString(strings.TrimSpace(s.Value)); err == nil { val = &v } else { skippedNoValue++ continue // an unparseable value is not written; the honest floor holds for that occupation } raw := []byte(`{"series_id":"` + s.ID + `","year":"` + s.Year + `","value":"` + s.Value + `"}`) if err := q.UpsertBlsWageByOcc(ctx, dbgen.UpsertBlsWageByOccParams{ SeriesID: s.ID, OccupationCode: spec.OccCode, OccupationTitle: spec.OccTitle, Role: spec.Role, Area: spec.Area, AreaName: spec.AreaName, Metric: spec.Metric, PeriodYear: s.Year, Value: val, Raw: raw, }); err != nil { log.Printf("oews-national-sync: upsert %s: %v", s.ID, err) continue } upserted++ } total := int64(-1) if meta, err := q.WagesSyncMeta(ctx); err == nil { total = meta.Total } log.Printf("oews-national-sync: DONE, occupations=%d series_queried=%d rows_upserted=%d skipped_no_value=%d; store holds %d wage series", len(occs), len(ids), upserted, skippedNoValue, total) } // fetchBatched pulls the latest data point for each series in batches of 50, pausing briefly between // batches so the sync is a good API citizen. A No Data series is simply absent from a batch's results. func fetchBatched(ctx context.Context, client *bls.Client, ids []string) ([]bls.Series, error) { const batch = 50 var out []bls.Series for start := 0; start < len(ids); start += batch { end := start + batch if end > len(ids) { end = len(ids) } got, err := client.FetchLatest(ctx, ids[start:end]) if err != nil { return out, err } out = append(out, got...) if end < len(ids) { select { case <-ctx.Done(): return out, ctx.Err() case <-time.After(batchPause): } } } return out, nil } // fipsSelfTest derives one PBKDF2 HMAC SHA 256 key under the active crypto module. It fails closed if the // module is absent or errors, so a non FIPS build is caught at boot. func fipsSelfTest() error { _, err := pbkdf2.Key(sha256.New, "oews-national-sync-self-test", []byte("oews-national-sync-salt"), 600000, 32) return err } func firstNonEmpty(vals ...string) string { for _, v := range vals { if s := strings.TrimSpace(v); s != "" { return s } } return "" }