gea-program 0.17.0__tar.gz → 0.18.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gea_program-0.17.0/gea_program.egg-info → gea_program-0.18.0}/PKG-INFO +10 -2
- {gea_program-0.17.0 → gea_program-0.18.0}/README.md +9 -1
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/__init__.py +1 -1
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/__main__.py +28 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/acceptance_tests.py +154 -0
- gea_program-0.18.0/gea/backup.py +262 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/doctor.py +16 -1
- gea_program-0.18.0/gea/help/backup.md +11 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/helplib.py +1 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/service.py +60 -1
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/web/app.html +12 -0
- {gea_program-0.17.0 → gea_program-0.18.0/gea_program.egg-info}/PKG-INFO +10 -2
- {gea_program-0.17.0 → gea_program-0.18.0}/gea_program.egg-info/SOURCES.txt +2 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/pyproject.toml +1 -1
- {gea_program-0.17.0 → gea_program-0.18.0}/LICENSE +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/BENCH_TEST_PROTOCOL.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/accuracy_statement.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/alarm_engine.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/bench.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/blind_harness.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/acex_lomonosov_age_depth_model.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/acex_lomonosov_age_depth_model.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/agassiz77_canada_temperature.csv +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/agassiz77_canada_temperature.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/barbados_110_consolidation.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/barbados_110_consolidation.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/bengal_u1452_grain_size.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/bengal_u1452_grain_size.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/blake_164_methane_isotopes.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/blake_164_methane_isotopes.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/chicxulub_m0077a_pwave_velocity.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/chicxulub_m0077a_pwave_velocity.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/collingwood_1_28_ks_complete.las +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/collingwood_1_28_ks_complete.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/costa_rica_odp_friction_envelope.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/costa_rica_odp_friction_envelope.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/dead_sea_5017_debrite_xrf_ms.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/dead_sea_5017_debrite_xrf_ms.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/dsdp_504b_physical_properties.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/dsdp_504b_physical_properties.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/dsdp_504b_sound_velocity.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/dsdp_504b_sound_velocity.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/elgygytgyn_5011_turbidites.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/elgygytgyn_5011_turbidites.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/epica_domec_co2_800kyr.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/epica_domec_co2_800kyr.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/fram_909_organic_petrography.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/fram_909_organic_petrography.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/gbr_325_coral_uth_ages.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/gbr_325_coral_uth_ages.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/gisp2_greenland_temperature.csv +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/gisp2_greenland_temperature.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/gom_308_t2p_insitu.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/gom_308_t2p_insitu.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/guaymas_385_dom_d13c.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/guaymas_385_dom_d13c.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/hikurangi_u1520_friction_insitu.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/hikurangi_u1520_friction_insitu.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/hydrate_ridge_204_ncr_hydrate.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/hydrate_ridge_204_ncr_hydrate.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/iodp_u1324_pore_pressure.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/iodp_u1324_pore_pressure.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/jfast_c0019_slow_slip_events.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/jfast_c0019_slow_slip_events.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/kennetcook_2_p129_excerpt.las +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/kennetcook_2_p129_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_bhgm_density.dat +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_bhgm_density.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_complog_6020_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_complog_6020_excerpt.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_hlog246_temperature.dat +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_hlog246_temperature.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_rockmech_compress.dat +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_rockmech_compress.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_tvd_0_9080_excerpt.dat +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_hb_tvd_0_9080_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_vb_rockmech_compress.dat +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_vb_rockmech_compress.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_vb_vlog251_temperature.dat +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ktb_vb_vlog251_temperature.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/l06_06_nl_survey.csv +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/l06_06_nl_survey.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/l07_01_nl_excerpt.las +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/l07_01_nl_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/mariana_1200_serpentinite_geochem.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/mariana_1200_serpentinite_geochem.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/med_160_sapropels.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/med_160_sapropels.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/nankai_megasplay_shear_strength.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/nankai_megasplay_shear_strength.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1027b_thermal_conductivity.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1027b_thermal_conductivity.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1027c_cork_temperature.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1027c_cork_temperature.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1165b_thermal_conductivity.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1165b_thermal_conductivity.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1274a_mantle_peridotite_mad.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_1274a_mantle_peridotite_mad.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_504b_dike_elastic_moduli.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_504b_dike_elastic_moduli.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_504b_leg137_borehole_fluids.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_504b_leg137_borehole_fluids.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_735b_gabbro_elastic_moduli.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/odp_735b_gabbro_elastic_moduli.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/peru_201_sulfate_reduction.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/peru_201_sulfate_reduction.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/scorpio_e1_sa_excerpt.las +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/scorpio_e1_sa_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/sumatra_362_cohesion.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/sumatra_362_cohesion.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/university_6_17_no1_tx_excerpt.las +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/university_6_17_no1_tx_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ursa_308_xrd_mineralogy.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/ursa_308_xrd_mineralogy.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/volve_15_9_19_sr_excerpt.las +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/volve_15_9_19_sr_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/volve_15_9_19a_core_excerpt.csv +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/volve_15_9_19a_core_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/volve_f12_f14_production_excerpt.csv +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/volve_f12_f14_production_excerpt.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/walvis_208_petm_carbonate.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/walvis_208_petm_carbonate.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/woodlark_1109_rock_eval.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/catalog/woodlark_1109_rock_eval.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/certificates.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/cli.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/client_reports.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/config_versioning.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/conformance.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/correlation.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/dashboard.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/deviation.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/downhole_engine.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/drift_monitor.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/earth_model.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/etp.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/etp12_schemas.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/example_register_map.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/fat_sat.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/files.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/follower.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/forward_model.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/gamma.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/gauge_aging.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/gauge_specs.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/geodesy.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/gravity_reference.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/TESTER_GUIDE.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/alarms.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/audit-update.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/conformance.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/data-in.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/doctor.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/drift.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/etp.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/files.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/instruments.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/notifications.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/osdu.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/patches.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/ppdm.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/quakeml.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/quality.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/seedlink.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/seismic.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/site.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/sites.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/sra.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/start.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/transients.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/upkeep.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/vibration.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/help/well-tests.md +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/housekeeping.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/inverse_engine.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/jobs.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/live_ports.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/loadtest.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/matplotlib_demo.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/modbus.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/model_card.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/mqtt_port.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/notify.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/opcua_port.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/operator_app.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/osdu.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/patches.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/permits.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/ports.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/ppdm.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/profile_catalog.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/project.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/qt6_downhole_app.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/quakeml.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reconciler.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reference/IU_ANMO_10_BHZ_response.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reference/IU_ANMO_10_BHZ_response.xml +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reference/libmseed_steim.provenance.json +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reference/libmseed_steim1_4096.mseed +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reference/libmseed_steim2_4096.mseed +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/reference/libmseed_steim2_512.mseed +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/rock_inventory.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/sample_record.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/sample_well_profile.csv +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/sbom.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seedlink.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/segy.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_array.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_assoc.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_detect.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_film.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_harmonic.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_qc.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_response.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_signature.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_track.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_uncertainty.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/seismic_unlisted.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/sensor_swap.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/service_life.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/shell.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/shut_in.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/sla_report.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/sra.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/store_forward.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/strata_join.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/supervisor.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/survey_cmd.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/survey_view.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/telemetry.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/tool_library.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/transient.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/vibration.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/well_assembler.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/well_test_validation.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/wits0.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/witsml.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea/workspace.py +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea_program.egg-info/dependency_links.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea_program.egg-info/entry_points.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea_program.egg-info/requires.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/gea_program.egg-info/top_level.txt +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/setup.cfg +0 -0
- {gea_program-0.17.0 → gea_program-0.18.0}/tests/test_acceptance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gea-program
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: GEA-Program: downhole gauge monitoring - canonical measurement records, quality rules, drift evaluation with a re-fit change log, well-test validation, alarm management, model cards, store-and-forward, monthly SLA measurement, FAT/SAT protocols and a dashboard, generated from live objects in the client's reporting outline.
|
|
5
5
|
Author: Daniel T. Murphy
|
|
6
6
|
License: MPL-2.0
|
|
@@ -125,7 +125,7 @@ gea dashboard --catalog-well volve_f12_f14_production_excerpt:15/9-F-12:10000 --
|
|
|
125
125
|
gea client-report --report accuracy --out client_report
|
|
126
126
|
gea model-cards --out model_cards
|
|
127
127
|
gea sbom --out sbom
|
|
128
|
-
gea accept # the product gate (
|
|
128
|
+
gea accept # the product gate (342 checks)
|
|
129
129
|
gea help drift # the help library, by the job (16 pages; the same text is on every dashboard page)
|
|
130
130
|
gea guide # the click-by-click tester guide (docs/TESTER_GUIDE.md)
|
|
131
131
|
gea gui # the desktop window (pip install "gea-program[desktop]")
|
|
@@ -722,6 +722,14 @@ headers on every response, and `gea housekeeping --workspace C:\site --apply`
|
|
|
722
722
|
than truncating them and prunes finished job folders and old recordings. `gea
|
|
723
723
|
loadtest --patches 16 --seconds 60 --with-service` tells you whether the site
|
|
724
724
|
machine carries the patches it will be given, with the page still answering.
|
|
725
|
+
`gea backup --workspace C:\site --out D:\gea-backups --keep 14` copies the
|
|
726
|
+
whole site into one dated archive with a manifest of every file's hash, onto
|
|
727
|
+
another disk or a share; schedule it daily from Audit / Update, verify an
|
|
728
|
+
archive with `--verify`, and restore one onto an empty folder with
|
|
729
|
+
`--restore ... --to` and open it once before the first well is live - the
|
|
730
|
+
records are the product, and a backup nobody has restored is a hope. The
|
|
731
|
+
doctor warns when there has never been a backup or the last is more than a
|
|
732
|
+
day old. `gea help backup`.
|
|
725
733
|
|
|
726
734
|
## The standalone install kit
|
|
727
735
|
|
|
@@ -86,7 +86,7 @@ gea dashboard --catalog-well volve_f12_f14_production_excerpt:15/9-F-12:10000 --
|
|
|
86
86
|
gea client-report --report accuracy --out client_report
|
|
87
87
|
gea model-cards --out model_cards
|
|
88
88
|
gea sbom --out sbom
|
|
89
|
-
gea accept # the product gate (
|
|
89
|
+
gea accept # the product gate (342 checks)
|
|
90
90
|
gea help drift # the help library, by the job (16 pages; the same text is on every dashboard page)
|
|
91
91
|
gea guide # the click-by-click tester guide (docs/TESTER_GUIDE.md)
|
|
92
92
|
gea gui # the desktop window (pip install "gea-program[desktop]")
|
|
@@ -683,6 +683,14 @@ headers on every response, and `gea housekeeping --workspace C:\site --apply`
|
|
|
683
683
|
than truncating them and prunes finished job folders and old recordings. `gea
|
|
684
684
|
loadtest --patches 16 --seconds 60 --with-service` tells you whether the site
|
|
685
685
|
machine carries the patches it will be given, with the page still answering.
|
|
686
|
+
`gea backup --workspace C:\site --out D:\gea-backups --keep 14` copies the
|
|
687
|
+
whole site into one dated archive with a manifest of every file's hash, onto
|
|
688
|
+
another disk or a share; schedule it daily from Audit / Update, verify an
|
|
689
|
+
archive with `--verify`, and restore one onto an empty folder with
|
|
690
|
+
`--restore ... --to` and open it once before the first well is live - the
|
|
691
|
+
records are the product, and a backup nobody has restored is a hope. The
|
|
692
|
+
doctor warns when there has never been a backup or the last is more than a
|
|
693
|
+
day old. `gea help backup`.
|
|
686
694
|
|
|
687
695
|
## The standalone install kit
|
|
688
696
|
|
|
@@ -411,6 +411,16 @@ def main(argv=None) -> int:
|
|
|
411
411
|
p_es.add_argument("--frames", type=int, default=600)
|
|
412
412
|
p_es.add_argument("--interval", type=float, default=1.0, help="seconds between samples")
|
|
413
413
|
p_es.add_argument("--seed", type=int, default=1)
|
|
414
|
+
p_bk = sub.add_parser("backup", help="the site copied off the machine: one dated archive with a manifest; --verify an archive; --restore one onto an empty folder; --status")
|
|
415
|
+
p_bk.add_argument("--workspace", type=str, default=None, help="the site folder (for a backup, or --status)")
|
|
416
|
+
p_bk.add_argument("--out", type=str, default=None, help="the folder the archive goes to - another disk or a share, never inside the workspace")
|
|
417
|
+
p_bk.add_argument("--keep", type=int, default=None, help="keep only the newest N archives of this site in --out")
|
|
418
|
+
p_bk.add_argument("--label", type=str, default="", help="a note carried in the manifest (e.g. 'before the gauge swap')")
|
|
419
|
+
p_bk.add_argument("--verify", type=str, default=None, help="check this archive against its manifest and its .sha256")
|
|
420
|
+
p_bk.add_argument("--restore", type=str, default=None, help="restore this archive ...")
|
|
421
|
+
p_bk.add_argument("--to", type=str, default=None, help="... onto this empty folder")
|
|
422
|
+
p_bk.add_argument("--status", action="store_true", help="when the last backup of --workspace was taken, and whether that is too long ago")
|
|
423
|
+
p_bk.add_argument("--actor", type=str, default="cli")
|
|
414
424
|
p_sl = sub.add_parser("seedlink", help="the seismic leg's live port: a SeedLink (3 / 4.0) station's records as it writes them, byte for byte into day files; --selftest against the simulator")
|
|
415
425
|
p_sl.add_argument("--config", type=str, default=None, help="the SeedLink config JSON (host, port, streams)")
|
|
416
426
|
p_sl.add_argument("--write-example-config", type=str, default=None, help="write an example config here and exit")
|
|
@@ -2017,6 +2027,24 @@ def main(argv=None) -> int:
|
|
|
2017
2027
|
stop.set()
|
|
2018
2028
|
print("wits0-sim: stopped")
|
|
2019
2029
|
return 0
|
|
2030
|
+
elif a.cmd == "backup":
|
|
2031
|
+
from . import backup as _B
|
|
2032
|
+
try:
|
|
2033
|
+
if a.verify:
|
|
2034
|
+
r = _B.verify(a.verify); print(_B.report_text(r, "verify")); return 0 if r["status"] == "OK" else 1
|
|
2035
|
+
if a.restore:
|
|
2036
|
+
if not a.to:
|
|
2037
|
+
raise SystemExit("backup --restore needs --to <empty folder>")
|
|
2038
|
+
print(_B.report_text(_B.restore(a.restore, a.to, a.actor), "restore")); return 0
|
|
2039
|
+
if not a.workspace:
|
|
2040
|
+
raise SystemExit("backup needs --workspace <site folder> (with --out <another disk>), or --verify / --restore an archive")
|
|
2041
|
+
if a.status:
|
|
2042
|
+
r = _B.status(a.workspace); print(_B.report_text(r, "status")); return 0 if not (r["never"] or r["stale"]) else 1
|
|
2043
|
+
if not a.out:
|
|
2044
|
+
raise SystemExit("backup needs --out <a folder on another disk or a share>")
|
|
2045
|
+
print(_B.report_text(_B.make(a.workspace, a.out, a.actor, keep=a.keep, label=a.label), "make")); return 0
|
|
2046
|
+
except (ValueError, OSError) as e:
|
|
2047
|
+
raise SystemExit(f"backup: {e}")
|
|
2020
2048
|
elif a.cmd == "seedlink":
|
|
2021
2049
|
import json as _json
|
|
2022
2050
|
from . import seedlink as _L
|
|
@@ -5719,6 +5719,159 @@ def section_bk_seedlink(tmp: str) -> None:
|
|
|
5719
5719
|
"names the simulator to start; the help page says a gap is counted, never bridged"
|
|
5720
5720
|
+ (f" [failed: {', '.join(failed)}; row={json.dumps({k: row.get(k) for k in ('status', 'protocol', 'records', 'latency_p95_s', 'last_error')})}]" if failed else ""))
|
|
5721
5721
|
|
|
5722
|
+
|
|
5723
|
+
def section_bl_backup(tmp: str) -> None:
|
|
5724
|
+
"""Section BL - the site copied off the machine: the archive and its manifest, verify, a tampered archive named,
|
|
5725
|
+
restore onto an empty folder and the workspace that comes back, the refusals, the doctor's line, the page's
|
|
5726
|
+
card, the job and the daily schedule."""
|
|
5727
|
+
import http.cookiejar
|
|
5728
|
+
import subprocess as _sp
|
|
5729
|
+
import urllib.request
|
|
5730
|
+
import urllib.error
|
|
5731
|
+
import zipfile
|
|
5732
|
+
from . import backup as B
|
|
5733
|
+
from . import doctor as D
|
|
5734
|
+
from . import helplib as H
|
|
5735
|
+
from .telemetry import TelemetryRecorder, TelemetryConfig
|
|
5736
|
+
from .downhole_engine import DownholeEngine, SimulatorConfig
|
|
5737
|
+
from .workspace import Workspace
|
|
5738
|
+
from .service import Service, Users
|
|
5739
|
+
pkg = Path(__file__).parent
|
|
5740
|
+
d = Path(tmp, "bl"); d.mkdir()
|
|
5741
|
+
hist = str(d / "hist.csv")
|
|
5742
|
+
TelemetryRecorder(engine=DownholeEngine(SimulatorConfig()), config=TelemetryConfig(duration_hours=0.5, seed=5)).run().export_csv(hist)
|
|
5743
|
+
wsp = str(d / "ws"); ws = Workspace.create(wsp, "Pad 3", actor="tester")
|
|
5744
|
+
w = ws.add_well_file(hist, display="SWD 1", actor="tester")
|
|
5745
|
+
users = Users(str(Path(wsp, "users.json"))); users.add("op", "operator-pass-1", "operator"); users.add("adm", "admin-pass-1", "admin")
|
|
5746
|
+
(Path(wsp) / "jobs" / "run-0001").mkdir(parents=True); (Path(wsp) / "jobs" / "run-0001" / "log.txt").write_text("a finished run")
|
|
5747
|
+
(Path(wsp) / "jobs" / "_uploads").mkdir(); (Path(wsp) / "jobs" / "_uploads" / "x.bin").write_bytes(b"scratch")
|
|
5748
|
+
(Path(wsp) / "jobs" / "schedule.json").write_text(json.dumps({"entries": []}))
|
|
5749
|
+
|
|
5750
|
+
# -- make, verify, status, restore; the refusals; the pruning ---------------------------------------------------
|
|
5751
|
+
never = B.status(wsp)
|
|
5752
|
+
out = str(d / "off-disk")
|
|
5753
|
+
r1 = B.make(wsp, out, "tester", keep=2, label="first")
|
|
5754
|
+
v1 = B.verify(r1["path"])
|
|
5755
|
+
side1 = os.path.isfile(r1["path"] + ".sha256") # before the later --keep prunes this first archive and its hash file
|
|
5756
|
+
st1 = B.status(wsp)
|
|
5757
|
+
with zipfile.ZipFile(r1["path"]) as z:
|
|
5758
|
+
names = z.namelist(); man = json.loads(z.read("MANIFEST.json"))
|
|
5759
|
+
inside_refused = None
|
|
5760
|
+
try:
|
|
5761
|
+
B.make(wsp, os.path.join(wsp, "records", "bk"), "tester")
|
|
5762
|
+
except ValueError as e:
|
|
5763
|
+
inside_refused = str(e)
|
|
5764
|
+
rr = B.restore(r1["path"], str(d / "restored"), "tester")
|
|
5765
|
+
ws2 = Workspace(str(d / "restored"))
|
|
5766
|
+
nonempty_refused = None
|
|
5767
|
+
try:
|
|
5768
|
+
B.restore(r1["path"], str(d / "restored"), "tester")
|
|
5769
|
+
except ValueError as e:
|
|
5770
|
+
nonempty_refused = str(e)
|
|
5771
|
+
# tamper: rewrite one member in a copy; verify names it and restore refuses it
|
|
5772
|
+
bad = str(d / "tampered.zip")
|
|
5773
|
+
with zipfile.ZipFile(r1["path"]) as zin, zipfile.ZipFile(bad, "w") as zout:
|
|
5774
|
+
for info in zin.infolist():
|
|
5775
|
+
data = zin.read(info.filename)
|
|
5776
|
+
if info.filename == "workspace.json":
|
|
5777
|
+
data = data.replace(b"Pad 3", b"Pad 4")
|
|
5778
|
+
zout.writestr(info, data)
|
|
5779
|
+
vbad = B.verify(bad)
|
|
5780
|
+
tampered_refused = None
|
|
5781
|
+
try:
|
|
5782
|
+
B.restore(bad, str(d / "restored2"), "tester")
|
|
5783
|
+
except ValueError as e:
|
|
5784
|
+
tampered_refused = str(e)
|
|
5785
|
+
r2 = B.make(wsp, out, "tester", keep=2); r3 = B.make(wsp, out, "tester", keep=2)
|
|
5786
|
+
left = sorted(n for n in os.listdir(out) if n.endswith(".zip"))
|
|
5787
|
+
rec_files = [n for n in names if n.startswith(f"wells/{w['id']}/")]
|
|
5788
|
+
c1 = {
|
|
5789
|
+
"never_then_current": never["never"] and never["stale"] and st1["never"] is False and st1["stale"] is False and st1["present"] is True and st1["count"] == 1,
|
|
5790
|
+
"manifest": r1["n_files"] == man["n_files"] and man["protocol"] == "gea.backup/1" and man["site"] == "Pad 3" and man["wells"] == [w["id"]] and man["users_json"] is True,
|
|
5791
|
+
"members": "users.json" in names and "records/audit.jsonl" in names and "workspace.json" in names and "jobs/schedule.json" in names and bool(rec_files)
|
|
5792
|
+
and not any(n.startswith("jobs/run-") or "_uploads" in n for n in names),
|
|
5793
|
+
"verify_ok": v1["status"] == "OK" and v1["n_files"] == r1["n_files"] and v1["archive_sha256_matches"] is True and side1,
|
|
5794
|
+
"inside_refused": bool(inside_refused) and "inside the workspace" in inside_refused,
|
|
5795
|
+
"restored_site": rr["site"] == "Pad 3" and rr["wells"] == [w["id"]] and rr["users_json"] is True and ws2.manifest["name"] == "Pad 3" and ws2.wells()[0]["id"] == w["id"],
|
|
5796
|
+
"restored_note_audit": os.path.isfile(d / "restored" / "restored_from.json") and any(a["action"] == "backup.restore" for a in ws2.audit_log()),
|
|
5797
|
+
"restored_accounts": Users(str(d / "restored" / "users.json")).check("op", "operator-pass-1") is not None,
|
|
5798
|
+
"nonempty_refused": bool(nonempty_refused) and "empty folder" in nonempty_refused,
|
|
5799
|
+
"tampered": vbad["status"] == "FAIL" and vbad["mismatched"] == ["workspace.json"] and bool(tampered_refused) and "does not verify" in tampered_refused,
|
|
5800
|
+
"pruned": len(left) == 2 and os.path.basename(r3["path"]) in left and os.path.basename(r1["path"]) not in left and r3["pruned"] == [os.path.basename(r1["path"])],
|
|
5801
|
+
"audited": any(a["action"] == "backup.make" for a in ws.audit_log()),
|
|
5802
|
+
}
|
|
5803
|
+
failed1 = [k for k, v in c1.items() if not v]
|
|
5804
|
+
ok(not failed1,
|
|
5805
|
+
"BL1 the site copied off the machine: one dated archive carrying every well's source and records, users.json, the audit log, the manifest "
|
|
5806
|
+
"and the schedule - never the job run folders or the upload scratch - with MANIFEST.json (site, version, every file's SHA-256) inside and the "
|
|
5807
|
+
"archive's own hash beside it; verify reads OK; status turns from NEVER to current; a folder inside the workspace is refused; the archive "
|
|
5808
|
+
"restored onto an empty folder opens as the same site with the same well, the same accounts and a restored_from note, and a second restore "
|
|
5809
|
+
"onto it is refused; a tampered archive fails verify naming the file and is refused by restore; --keep prunes the oldest; both in the audit"
|
|
5810
|
+
+ (f" [failed: {', '.join(failed1)}]" if failed1 else ""))
|
|
5811
|
+
|
|
5812
|
+
# -- the doctor, the service, the page, the CLI ------------------------------------------------------------------
|
|
5813
|
+
wsp2 = str(d / "ws2"); ws3 = Workspace.create(wsp2, "Pad 3", actor="tester")
|
|
5814
|
+
users3 = Users(str(Path(wsp2, "users.json"))); users3.add("adm", "admin-pass-1", "admin"); users3.add("op", "operator-pass-1", "operator")
|
|
5815
|
+
f_never = [x for x in D.check_workspace(wsp2, port=0) if "backup" in x["what"]]
|
|
5816
|
+
svc = Service(wsp2, port=0, scheduler=False).start()
|
|
5817
|
+
base = svc.url.rstrip("/")
|
|
5818
|
+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
|
5819
|
+
|
|
5820
|
+
def call(method, path, body=None):
|
|
5821
|
+
req = urllib.request.Request(base + path, method=method, data=(json.dumps(body).encode() if body is not None else None))
|
|
5822
|
+
req.add_header("Content-Type", "application/json"); req.add_header("X-GEA-Action", "1")
|
|
5823
|
+
try:
|
|
5824
|
+
with opener.open(req, timeout=120) as rr_:
|
|
5825
|
+
return rr_.status, json.loads(rr_.read())
|
|
5826
|
+
except urllib.error.HTTPError as e:
|
|
5827
|
+
return e.code, json.loads(e.read())
|
|
5828
|
+
try:
|
|
5829
|
+
call("POST", "/api/login", {"name": "op", "password": "operator-pass-1"})
|
|
5830
|
+
st_op = call("GET", "/api/backup")[0]
|
|
5831
|
+
call("POST", "/api/login", {"name": "adm", "password": "admin-pass-1"})
|
|
5832
|
+
st_v0, v0 = call("GET", "/api/backup")
|
|
5833
|
+
st_run0 = call("POST", "/api/backup/run", {})[0]
|
|
5834
|
+
st_in = call("POST", "/api/backup/config", {"out_dir": os.path.join(wsp2, "records", "bk")})[0]
|
|
5835
|
+
st_bad_t = call("POST", "/api/backup/config", {"out_dir": str(d / "off2"), "daily_at": "2:30"})[0]
|
|
5836
|
+
st_cfg, cfg = call("POST", "/api/backup/config", {"out_dir": str(d / "off2"), "keep": 7, "daily_at": "02:30"})
|
|
5837
|
+
st_run, job = call("POST", "/api/backup/run", {})
|
|
5838
|
+
done = svc.app.runner.wait(job["id"], 120)
|
|
5839
|
+
st_v1, v1s = call("GET", "/api/backup")
|
|
5840
|
+
st_sch, sch = call("POST", "/api/backup/schedule", {})
|
|
5841
|
+
st_v2, v2s = call("GET", "/api/backup")
|
|
5842
|
+
finally:
|
|
5843
|
+
svc.stop()
|
|
5844
|
+
f_after = [x for x in D.check_workspace(wsp2, port=0) if "backup" in x["what"]]
|
|
5845
|
+
page_src = (pkg / "web" / "app.html").read_text(encoding="utf-8")
|
|
5846
|
+
help_md = (pkg / "help" / "backup.md").read_text(encoding="utf-8")
|
|
5847
|
+
r_st0 = _sp.run([sys.executable, "-m", "gea", "backup", "--workspace", str(d / "ws3-none"), "--status"], capture_output=True, text=True)
|
|
5848
|
+
r_st = _sp.run([sys.executable, "-m", "gea", "backup", "--workspace", wsp2, "--status"], capture_output=True, text=True)
|
|
5849
|
+
r_v = _sp.run([sys.executable, "-m", "gea", "backup", "--verify", v1s["status"]["last"]["path"]], capture_output=True, text=True)
|
|
5850
|
+
r_vbad = _sp.run([sys.executable, "-m", "gea", "backup", "--verify", bad], capture_output=True, text=True)
|
|
5851
|
+
r_rs = _sp.run([sys.executable, "-m", "gea", "backup", "--restore", v1s["status"]["last"]["path"], "--to", str(d / "restored3")], capture_output=True, text=True)
|
|
5852
|
+
c2 = {
|
|
5853
|
+
"doctor_never": len(f_never) == 1 and f_never[0]["level"] == "warn" and "no backup has ever been taken" in f_never[0]["what"] and "gea backup" in f_never[0]["fix"],
|
|
5854
|
+
"api_admin_only_and_empty": st_op == 403 and st_v0 == 200 and v0["status"]["never"] is True and v0["schedule"] is None and v0["config"]["out_dir"] == "",
|
|
5855
|
+
"config_refusals": st_run0 == 400 and st_in == 400 and st_bad_t == 400 and st_cfg == 200 and cfg["keep"] == 7,
|
|
5856
|
+
"job_ran": st_run == 200 and done["status"] == "DONE" and st_v1 == 200 and v1s["status"]["never"] is False and v1s["status"]["present"] is True
|
|
5857
|
+
and os.path.isfile(v1s["status"]["last"]["path"]) and v1s["status"]["last"]["actor"] == "adm",
|
|
5858
|
+
"scheduled": st_sch == 200 and sch["name"] == "backup-daily" and sch["daily_at"] == "02:30" and sch["args"][0] == "backup" and "--keep" in sch["args"]
|
|
5859
|
+
and v2s["schedule"] is not None and v2s["schedule"]["name"] == "backup-daily",
|
|
5860
|
+
"doctor_after": len(f_after) == 1 and f_after[0]["level"] == "ok" and "last backup" in f_after[0]["what"],
|
|
5861
|
+
"page": "The backup" in page_src and "/api/backup/run" in page_src and "/api/backup/schedule" in page_src,
|
|
5862
|
+
"cli_status": r_st0.returncode == 1 and "NEVER" in r_st0.stdout and r_st.returncode == 0 and "current" in r_st.stdout,
|
|
5863
|
+
"cli_verify": r_v.returncode == 0 and "OK" in r_v.stdout and r_vbad.returncode == 1 and "workspace.json" in r_vbad.stdout,
|
|
5864
|
+
"cli_restore": r_rs.returncode == 0 and "users.json present" in r_rs.stdout,
|
|
5865
|
+
"help": any(t["topic"] == "backup" for t in H.topics()) and not H.check("backup") and "never restored" in help_md.split("will not call a measurement")[1],
|
|
5866
|
+
}
|
|
5867
|
+
failed2 = [k for k, v in c2.items() if not v]
|
|
5868
|
+
ok(not failed2,
|
|
5869
|
+
"BL2 the doctor warns NEVER until a backup exists and says when the last one was afterwards; the Audit / Update page's backup card (admin only) "
|
|
5870
|
+
"refuses a folder inside the workspace and a malformed hour, saves the folder and the keep count, runs the backup as a job that the status then "
|
|
5871
|
+
"shows with its actor, and schedules it daily as the `backup-daily` entry; `gea backup --status` exits 1 on NEVER and 0 when current, "
|
|
5872
|
+
"`--verify` says OK or names the tampered file, `--restore` brings the accounts back; the help page says a backup never restored is a hope"
|
|
5873
|
+
+ (f" [failed: {', '.join(failed2)}; r_st0={r_st0.stdout[-120:]!r} {r_st0.stderr[-120:]!r}]" if failed2 else ""))
|
|
5874
|
+
|
|
5722
5875
|
def main() -> int:
|
|
5723
5876
|
print("GEA-Program - ACCEPTANCE SUITE (the product gate)")
|
|
5724
5877
|
# a handle a job subprocess still holds at the end must not turn a finished gate into a traceback on Windows:
|
|
@@ -5785,6 +5938,7 @@ def main() -> int:
|
|
|
5785
5938
|
section_bi_conformance(tmp)
|
|
5786
5939
|
section_bj_etp(tmp)
|
|
5787
5940
|
section_bk_seedlink(tmp)
|
|
5941
|
+
section_bl_backup(tmp)
|
|
5788
5942
|
if _FAILS:
|
|
5789
5943
|
print(f"[ACCEPTANCE] {len(_FAILS)} FAILURES ({_PASS} passed):")
|
|
5790
5944
|
for f in _FAILS:
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""backup - the site, copied off the machine, and proven to come back.
|
|
2
|
+
|
|
3
|
+
The records are the product. Nothing in housekeeping, the workspace or the service copied the site anywhere;
|
|
4
|
+
the doctor told a person to restore users.json "from a backup" that no command wrote. This module writes it.
|
|
5
|
+
|
|
6
|
+
`make` zips the workspace - every well's source and records, the seismic stations with their source and live
|
|
7
|
+
day files, the configuration store, the monitor, the reports, users.json, the audit log, the patches, the
|
|
8
|
+
schedule - into one dated archive in a folder that should be on another disk or a share, with a manifest
|
|
9
|
+
(program version, site name, every file's SHA-256 and size) and the manifest's own hash beside it. The job
|
|
10
|
+
folders of finished runs and the upload scratch are left out: they are reproducible, and they are what grows.
|
|
11
|
+
`verify` opens an archive and checks every file against its manifest. `restore` extracts one into an empty
|
|
12
|
+
folder, verifies, opens it as a workspace and says what came back - a backup nobody has restored is a hope.
|
|
13
|
+
`status` says when the last backup was taken and whether that is too long ago; the doctor prints it.
|
|
14
|
+
|
|
15
|
+
What it will not do: back up into the workspace itself (refused - a lost disk loses both); restore over a
|
|
16
|
+
folder that has anything in it; call an archive good whose manifest does not match its files.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import time
|
|
26
|
+
import zipfile
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
STALE_AFTER_H = 26.0 # a daily backup that is more than a day and change old
|
|
31
|
+
EXCLUDE_TOP = ('jobs',) # taken selectively: schedule.json only
|
|
32
|
+
EXCLUDE_NAMES = ('_uploads', '__pycache__')
|
|
33
|
+
LOG_NAME = 'backups.jsonl' # records/backups.jsonl: one line per backup taken from this workspace
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _utc() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sha256(path: str) -> str:
|
|
41
|
+
h = hashlib.sha256()
|
|
42
|
+
with open(path, 'rb') as f:
|
|
43
|
+
for chunk in iter(lambda: f.read(1 << 20), b''):
|
|
44
|
+
h.update(chunk)
|
|
45
|
+
return h.hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sha256_bytes(b: bytes) -> str:
|
|
49
|
+
return hashlib.sha256(b).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _inside(child: str, parent: str) -> bool:
|
|
53
|
+
c, p = os.path.realpath(child), os.path.realpath(parent)
|
|
54
|
+
return c == p or c.startswith(p + os.sep)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def site_files(workspace_path: str) -> List[str]:
|
|
58
|
+
"""The relative paths a backup carries, sorted: everything but the job run folders and the upload scratch."""
|
|
59
|
+
out: List[str] = []
|
|
60
|
+
for root, dirs, files in os.walk(workspace_path):
|
|
61
|
+
rel_root = os.path.relpath(root, workspace_path)
|
|
62
|
+
top = rel_root.split(os.sep)[0] if rel_root != '.' else ''
|
|
63
|
+
dirs[:] = sorted(d for d in dirs if d not in EXCLUDE_NAMES and not (rel_root == '.' and d in EXCLUDE_TOP))
|
|
64
|
+
for name in sorted(files):
|
|
65
|
+
if name.endswith(('.tmp', '.lock')) or name in EXCLUDE_NAMES:
|
|
66
|
+
continue
|
|
67
|
+
rel = os.path.normpath(os.path.join(rel_root, name)) if rel_root != '.' else name
|
|
68
|
+
out.append(rel.replace(os.sep, '/'))
|
|
69
|
+
# the schedule is the one thing under jobs/ worth carrying: it is what a person set, not what a run left
|
|
70
|
+
sched = os.path.join(workspace_path, 'jobs', 'schedule.json')
|
|
71
|
+
if os.path.isfile(sched):
|
|
72
|
+
out.append('jobs/schedule.json')
|
|
73
|
+
return sorted(set(out))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make(workspace_path: str, out_dir: str, actor: str = 'backup', keep: Optional[int] = None, label: str = '') -> dict:
|
|
77
|
+
"""Write `gea-site-<slug>-<UTC>.zip` into out_dir with MANIFEST.json inside and <zip>.sha256 beside it; prune to
|
|
78
|
+
the newest `keep` archives of this site when asked; log the backup in records/backups.jsonl and the audit."""
|
|
79
|
+
from . import __version__
|
|
80
|
+
from .workspace import Workspace
|
|
81
|
+
ws = Workspace(workspace_path)
|
|
82
|
+
if _inside(out_dir, ws.path):
|
|
83
|
+
raise ValueError(f'the backup folder {out_dir} is inside the workspace - a lost disk would lose both; use another disk or a share')
|
|
84
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
85
|
+
slug = ''.join(c if c.isalnum() or c in '-_' else '-' for c in ws.manifest['name']).strip('-') or 'site'
|
|
86
|
+
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
|
87
|
+
name = f'gea-site-{slug}-{stamp}.zip'
|
|
88
|
+
k = 2
|
|
89
|
+
while os.path.exists(os.path.join(out_dir, name)): # two in one second: the second is numbered, never overwrites
|
|
90
|
+
name = f'gea-site-{slug}-{stamp}-{k}.zip'; k += 1
|
|
91
|
+
path = os.path.join(out_dir, name)
|
|
92
|
+
files = site_files(ws.path)
|
|
93
|
+
entries: Dict[str, dict] = {}
|
|
94
|
+
total = 0
|
|
95
|
+
t0 = time.time()
|
|
96
|
+
with zipfile.ZipFile(path + '.part', 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as z:
|
|
97
|
+
for rel in files:
|
|
98
|
+
src = os.path.join(ws.path, *rel.split('/'))
|
|
99
|
+
try:
|
|
100
|
+
data = open(src, 'rb').read()
|
|
101
|
+
except OSError as e: # a file that vanished between the walk and the read (a rotated log)
|
|
102
|
+
entries[rel] = {'skipped': str(e)}
|
|
103
|
+
continue
|
|
104
|
+
z.writestr(zipfile.ZipInfo.from_file(src, arcname=rel), data)
|
|
105
|
+
entries[rel] = {'sha256': _sha256_bytes(data), 'bytes': len(data)}
|
|
106
|
+
total += len(data)
|
|
107
|
+
manifest = {'protocol': 'gea.backup/1', 'program_version': __version__, 'site': ws.manifest['name'], 'workspace': ws.path,
|
|
108
|
+
'generated_utc': _utc(), 'actor': actor, 'label': label, 'files': entries,
|
|
109
|
+
'n_files': sum(1 for v in entries.values() if 'sha256' in v), 'bytes': total,
|
|
110
|
+
'excluded': ['jobs/<run folders>', 'jobs/_uploads', '*.tmp', '*.lock'],
|
|
111
|
+
'wells': list(ws.manifest.get('wells', [])), 'seismic': list(ws.manifest.get('seismic', [])),
|
|
112
|
+
'users_json': 'users.json' in entries}
|
|
113
|
+
z.writestr('MANIFEST.json', json.dumps(manifest, indent=1))
|
|
114
|
+
os.replace(path + '.part', path)
|
|
115
|
+
digest = _sha256(path)
|
|
116
|
+
with open(path + '.sha256', 'w', encoding='utf-8') as f:
|
|
117
|
+
f.write(f'{digest} {name}\n')
|
|
118
|
+
pruned = []
|
|
119
|
+
if keep is not None and keep > 0:
|
|
120
|
+
def _order(n: str): # by the stamp, then the same-second number: a lexical sort puts '-2' before '.zip'
|
|
121
|
+
m = re.match(rf'gea-site-{re.escape(slug)}-(\d{{8}}T\d{{6}}Z)(?:-(\d+))?\.zip$', n)
|
|
122
|
+
return (m.group(1), int(m.group(2) or 1)) if m else ('', 0)
|
|
123
|
+
mine = sorted((n for n in os.listdir(out_dir) if n.startswith(f'gea-site-{slug}-') and n.endswith('.zip')), key=_order)
|
|
124
|
+
for old in mine[:-keep]:
|
|
125
|
+
for p in (os.path.join(out_dir, old), os.path.join(out_dir, old + '.sha256')):
|
|
126
|
+
if os.path.isfile(p):
|
|
127
|
+
os.remove(p)
|
|
128
|
+
pruned.append(old)
|
|
129
|
+
rec = {'utc': manifest['generated_utc'], 'path': path, 'sha256': digest, 'bytes_archive': os.path.getsize(path), 'bytes_files': total,
|
|
130
|
+
'n_files': manifest['n_files'], 'seconds': round(time.time() - t0, 2), 'pruned': pruned, 'actor': actor, 'program_version': __version__}
|
|
131
|
+
with open(os.path.join(ws.dir('records'), LOG_NAME), 'a', encoding='utf-8') as f:
|
|
132
|
+
f.write(json.dumps(rec) + '\n')
|
|
133
|
+
ws.audit(actor, 'backup.make', {'path': path, 'n_files': manifest['n_files'], 'bytes': total, 'sha256': digest, 'pruned': pruned})
|
|
134
|
+
return rec
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def verify(zip_path: str) -> dict:
|
|
138
|
+
"""Every file in the archive against MANIFEST.json, the archive against its .sha256 when that is beside it."""
|
|
139
|
+
out = {'path': zip_path, 'status': 'OK', 'n_files': 0, 'mismatched': [], 'missing': [], 'extra': [], 'archive_sha256': None, 'archive_sha256_matches': None,
|
|
140
|
+
'manifest': None}
|
|
141
|
+
if not os.path.isfile(zip_path):
|
|
142
|
+
out.update({'status': 'FAIL', 'error': 'no such file'})
|
|
143
|
+
return out
|
|
144
|
+
try:
|
|
145
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
146
|
+
bad = z.testzip()
|
|
147
|
+
if bad is not None:
|
|
148
|
+
out.update({'status': 'FAIL', 'error': f'corrupt member {bad}'})
|
|
149
|
+
return out
|
|
150
|
+
if 'MANIFEST.json' not in z.namelist():
|
|
151
|
+
out.update({'status': 'FAIL', 'error': 'no MANIFEST.json - not a gea backup'})
|
|
152
|
+
return out
|
|
153
|
+
man = json.loads(z.read('MANIFEST.json'))
|
|
154
|
+
out['manifest'] = {k: man.get(k) for k in ('protocol', 'program_version', 'site', 'generated_utc', 'n_files', 'bytes', 'wells', 'seismic', 'users_json')}
|
|
155
|
+
names = set(n for n in z.namelist() if n != 'MANIFEST.json' and not n.endswith('/'))
|
|
156
|
+
for rel, meta in man['files'].items():
|
|
157
|
+
if 'sha256' not in meta:
|
|
158
|
+
continue
|
|
159
|
+
if rel not in names:
|
|
160
|
+
out['missing'].append(rel)
|
|
161
|
+
continue
|
|
162
|
+
if _sha256_bytes(z.read(rel)) != meta['sha256']:
|
|
163
|
+
out['mismatched'].append(rel)
|
|
164
|
+
out['n_files'] += 1
|
|
165
|
+
out['extra'] = sorted(names - set(man['files']))
|
|
166
|
+
except (zipfile.BadZipFile, KeyError, ValueError) as e:
|
|
167
|
+
out.update({'status': 'FAIL', 'error': f'unreadable archive: {e}'})
|
|
168
|
+
return out
|
|
169
|
+
side = zip_path + '.sha256'
|
|
170
|
+
out['archive_sha256'] = _sha256(zip_path)
|
|
171
|
+
if os.path.isfile(side):
|
|
172
|
+
with open(side, encoding='utf-8') as f:
|
|
173
|
+
out['archive_sha256_matches'] = f.read().split()[0] == out['archive_sha256']
|
|
174
|
+
if out['mismatched'] or out['missing'] or out['extra'] or out['archive_sha256_matches'] is False:
|
|
175
|
+
out['status'] = 'FAIL'
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def restore(zip_path: str, target: str, actor: str = 'restore') -> dict:
|
|
180
|
+
"""Into an empty (or absent) folder only; verified first; opened as a workspace afterwards and described."""
|
|
181
|
+
from .workspace import Workspace
|
|
182
|
+
v = verify(zip_path)
|
|
183
|
+
if v['status'] != 'OK':
|
|
184
|
+
raise ValueError(f"the archive does not verify: {v.get('error') or (v['mismatched'] + v['missing'] + v['extra'])}")
|
|
185
|
+
if os.path.exists(target) and (not os.path.isdir(target) or os.listdir(target)):
|
|
186
|
+
raise ValueError(f'the restore target must be an empty folder: {target}')
|
|
187
|
+
os.makedirs(target, exist_ok=True)
|
|
188
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
189
|
+
for info in z.infolist():
|
|
190
|
+
if info.filename == 'MANIFEST.json' or info.filename.endswith('/'):
|
|
191
|
+
continue
|
|
192
|
+
rel = os.path.normpath(info.filename)
|
|
193
|
+
if rel.startswith('..') or os.path.isabs(rel):
|
|
194
|
+
raise ValueError(f'refusing a path outside the target: {info.filename}')
|
|
195
|
+
dst = os.path.join(target, rel)
|
|
196
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
197
|
+
with z.open(info) as src, open(dst, 'wb') as out:
|
|
198
|
+
shutil.copyfileobj(src, out)
|
|
199
|
+
man = json.loads(z.read('MANIFEST.json'))
|
|
200
|
+
back = [rel for rel, meta in man['files'].items() if 'sha256' in meta and _sha256(os.path.join(target, *rel.split('/'))) != meta['sha256']]
|
|
201
|
+
if back:
|
|
202
|
+
raise ValueError(f'{len(back)} file(s) differ after extraction: {back[:5]}')
|
|
203
|
+
ws = Workspace(target)
|
|
204
|
+
note = {'protocol': 'gea.restore/1', 'restored_utc': _utc(), 'from': zip_path, 'archive_sha256': v['archive_sha256'], 'backup_generated_utc': man['generated_utc'],
|
|
205
|
+
'backup_program_version': man['program_version'], 'actor': actor}
|
|
206
|
+
with open(os.path.join(target, 'restored_from.json'), 'w', encoding='utf-8') as f:
|
|
207
|
+
json.dump(note, f, indent=1)
|
|
208
|
+
ws.audit(actor, 'backup.restore', {'from': zip_path, 'archive_sha256': v['archive_sha256'], 'n_files': v['n_files']})
|
|
209
|
+
s = ws.summary()
|
|
210
|
+
return {'target': target, 'site': ws.manifest['name'], 'n_files': v['n_files'], 'wells': list(ws.manifest.get('wells', [])), 'seismic': list(ws.manifest.get('seismic', [])),
|
|
211
|
+
'users_json': os.path.isfile(os.path.join(target, 'users.json')), 'backup_generated_utc': man['generated_utc'], 'summary': s}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def status(workspace_path: str, stale_after_h: float = STALE_AFTER_H) -> dict:
|
|
215
|
+
"""The last backup logged from this workspace, its age, whether it is still where the log says, and the verdict."""
|
|
216
|
+
p = os.path.join(workspace_path, 'records', LOG_NAME)
|
|
217
|
+
out = {'last': None, 'age_h': None, 'stale': True, 'never': True, 'present': None, 'count': 0, 'stale_after_h': stale_after_h}
|
|
218
|
+
if not os.path.isfile(p):
|
|
219
|
+
return out
|
|
220
|
+
last = None
|
|
221
|
+
n = 0
|
|
222
|
+
with open(p, encoding='utf-8') as f:
|
|
223
|
+
for line in f:
|
|
224
|
+
if line.strip():
|
|
225
|
+
try:
|
|
226
|
+
last = json.loads(line); n += 1
|
|
227
|
+
except ValueError:
|
|
228
|
+
continue
|
|
229
|
+
out['count'] = n
|
|
230
|
+
if last is None:
|
|
231
|
+
return out
|
|
232
|
+
t = datetime.strptime(last['utc'], '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
|
|
233
|
+
age_h = (datetime.now(timezone.utc) - t).total_seconds() / 3600.0
|
|
234
|
+
out.update({'last': last, 'age_h': round(age_h, 2), 'stale': age_h > stale_after_h, 'never': False, 'present': os.path.isfile(last['path'])})
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def report_text(r: dict, kind: str) -> str:
|
|
239
|
+
if kind == 'make':
|
|
240
|
+
return (f"backup: {r['path']}\n {r['n_files']} files, {r['bytes_files'] / 1048576:.1f} MB of site in a {r['bytes_archive'] / 1048576:.1f} MB archive, "
|
|
241
|
+
f"sha256 {r['sha256'][:16]}..., {r['seconds']} s" + (f"\n pruned: {', '.join(r['pruned'])}" if r['pruned'] else '')
|
|
242
|
+
+ "\n verify it once with --verify, and restore it once onto an empty folder before trusting it")
|
|
243
|
+
if kind == 'verify':
|
|
244
|
+
m = r.get('manifest') or {}
|
|
245
|
+
head = f"verify: {r['path']} - {r['status']}"
|
|
246
|
+
if r.get('error'):
|
|
247
|
+
return head + f"\n {r['error']}"
|
|
248
|
+
return (head + f"\n site {m.get('site')!r}, taken {m.get('generated_utc')} by gea-program {m.get('program_version')}: {r['n_files']} files checked"
|
|
249
|
+
+ (f", archive hash {'matches' if r['archive_sha256_matches'] else 'DOES NOT MATCH'} its .sha256" if r['archive_sha256_matches'] is not None else ', no .sha256 beside it')
|
|
250
|
+
+ (f"\n mismatched: {r['mismatched']}" if r['mismatched'] else '') + (f"\n missing: {r['missing']}" if r['missing'] else '')
|
|
251
|
+
+ (f"\n extra: {r['extra']}" if r['extra'] else ''))
|
|
252
|
+
if kind == 'restore':
|
|
253
|
+
return (f"restored: {r['target']}\n site {r['site']!r} from the backup of {r['backup_generated_utc']}: {r['n_files']} files, "
|
|
254
|
+
f"{len(r['wells'])} well(s) {r['wells']}, {len(r['seismic'])} station(s), users.json {'present' if r['users_json'] else 'ABSENT'}"
|
|
255
|
+
"\n open it: gea serve --workspace <that folder> --port 8766, and look before you trust the copy")
|
|
256
|
+
if kind == 'status':
|
|
257
|
+
if r['never']:
|
|
258
|
+
return 'backup: NEVER - no backup has been taken from this workspace (gea backup --workspace ... --out <another disk>)'
|
|
259
|
+
l = r['last']
|
|
260
|
+
return (f"backup: last {l['utc']} ({r['age_h']} h ago) -> {l['path']} ({'present' if r['present'] else 'NOT FOUND where the log says'}); "
|
|
261
|
+
f"{r['count']} on record; {'STALE' if r['stale'] else 'current'} (stale after {r['stale_after_h']:g} h)")
|
|
262
|
+
return json.dumps(r, indent=1)
|
|
@@ -225,8 +225,23 @@ def check_workspace(path: str, host: str = '127.0.0.1', port: int = 8765) -> Lis
|
|
|
225
225
|
try:
|
|
226
226
|
n_users = len(json.load(open(users, encoding='utf-8')).get('users', []))
|
|
227
227
|
except (OSError, json.JSONDecodeError):
|
|
228
|
-
f.append(_finding('block', 'users.json is not valid JSON', 'restore it from a backup, or move it aside to start with a fresh administrator'))
|
|
228
|
+
f.append(_finding('block', 'users.json is not valid JSON', 'restore it from a backup (gea backup --restore <archive> --to <empty folder>), or move it aside to start with a fresh administrator'))
|
|
229
229
|
f.append(_finding('info', f'{n_users} account(s)' + ('' if n_users else ' - the first visit to the page creates the administrator')))
|
|
230
|
+
try:
|
|
231
|
+
from . import backup as BK
|
|
232
|
+
bs = BK.status(ws.path)
|
|
233
|
+
if bs['never']:
|
|
234
|
+
f.append(_finding('warn', 'no backup has ever been taken from this workspace - a lost disk is a lost month, and the records are the product',
|
|
235
|
+
'gea backup --workspace <this> --out <a folder on another disk or a share>; then schedule it daily on the Audit / Update page'))
|
|
236
|
+
elif bs['stale']:
|
|
237
|
+
f.append(_finding('warn', f"the last backup is {bs['age_h']:.0f} h old ({bs['last']['utc']}); daily means under {bs['stale_after_h']:g} h",
|
|
238
|
+
'run the backup, and check the daily schedule entry is enabled'))
|
|
239
|
+
elif bs['present'] is False:
|
|
240
|
+
f.append(_finding('warn', f"the last backup ({bs['last']['utc']}) is not where the log says: {bs['last']['path']}", 'the backup disk or share is not mounted, or the file was moved'))
|
|
241
|
+
else:
|
|
242
|
+
f.append(_finding('ok', f"last backup {bs['last']['utc']} ({bs['age_h']:.1f} h ago), present at {bs['last']['path']}"))
|
|
243
|
+
except Exception as e:
|
|
244
|
+
f.append(_finding('warn', f'backup status could not be read: {e}'))
|
|
230
245
|
try:
|
|
231
246
|
from .patches import PatchStore
|
|
232
247
|
ps = PatchStore(ws).list()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# The backup
|
|
2
|
+
|
|
3
|
+
> The records are the product. A lost disk is a lost month unless the site is copied off the machine every day - and a copy nobody has restored is a hope, not a backup. This page is the command that copies, the one that checks, and the one that brings it back.
|
|
4
|
+
|
|
5
|
+
**The command**: `gea backup --workspace C:\site --out D:\gea-backups --keep 14` writes one dated archive, `gea-site-<name>-<UTC>.zip`, into a folder that must be on another disk or a share (a folder inside the workspace is refused). On the dashboard, Audit / Update -> The backup: set the folder, Back up now (a job), Schedule daily (a schedule entry, `backup-daily`, at the hour given, UTC). `gea backup --verify <archive>` checks every file in it against its manifest and the archive against its `.sha256`. `gea backup --restore <archive> --to C:\site-restored` extracts onto an empty folder, verifies every file again, opens the result as a workspace and says what came back; then `gea serve --workspace C:\site-restored --port 8766` and look. `gea backup --workspace C:\site --status` prints when the last one was taken; the doctor prints the same line and warns when there has never been one or the last is more than 26 hours old.
|
|
6
|
+
|
|
7
|
+
**What it writes**: the whole site - every well's source files and records, every seismic station's source and live day files, `config/`, `monitor/`, `reports/`, `users.json`, `records/` (the audit log, the notifications, the patches, the certificates, the swaps), `workspace.json` and `jobs/schedule.json` - with `MANIFEST.json` inside (the program version, the site name, the time, every file's SHA-256 and size, the wells and stations by id) and `<archive>.sha256` beside it. Left out: the job run folders and the upload scratch, which are reproducible and are what grows. Each backup is logged in `records/backups.jsonl` and in the audit log (`backup.make`); a restore writes `restored_from.json` in the restored folder and `backup.restore` in its audit log. `--keep N` prunes the older archives of this site in that folder.
|
|
8
|
+
|
|
9
|
+
**The number to check**: on the card and in the doctor, `current` with the hours since the last backup, or `NEVER` / `STALE`; on `--verify`, `OK` with the file count and "archive hash matches"; on `--restore`, the well and station counts and `users.json present`.
|
|
10
|
+
|
|
11
|
+
**What this page will not call a measurement**: a backup that was never restored - run the restore once onto an empty folder and open the page. An archive whose manifest does not match its files - `--verify` says FAIL and names them, and `--restore` refuses it. A copy on the same disk - the folder inside the workspace is refused; a second folder on the same drive is not refused, and is not a backup either. The job run folders - they are not in the archive, by design.
|
|
@@ -46,6 +46,7 @@ INDEX: List[tuple] = [
|
|
|
46
46
|
('conformance', 'Conformity, in the standards\' words', 'certificates against ISO/IEC 17025 7.8 and the bias against its class under a named ILAC-G8 decision rule'),
|
|
47
47
|
('etp', 'WITSML 2.x over ETP', 'the live port on Energistics ETP v1.2: subscribe to the store\'s channels and it pushes'),
|
|
48
48
|
('seedlink', 'Live stations (SeedLink)', 'the seismic leg\'s live port: a station\'s records as it writes them, resumed by sequence number, folded into its record list'),
|
|
49
|
+
('backup', 'The backup', 'the site copied off the machine, verified, and restored once before it is trusted'),
|
|
49
50
|
('doctor', 'Which code is running', 'gea doctor and the serve start-up gate'),
|
|
50
51
|
('audit-update', 'Audit / Update', 'the whole audit log with filters, the program against PyPI, every report against its source'),
|
|
51
52
|
('upkeep', 'Upkeep', 'housekeeping, sessions, the TLS proxy, the load test'),
|