Skip to content

Commit b0929cf

Browse files
jbleschHoeze
andauthored
feat(grz-cli, grz-db): require & record QC workflow version; log grzctl runtime version (closes #532) (#561)
## Summary Require a QC workflow version for new `populate-qc` runs (CLI flag or env var), persist `qc_workflow_version` on Detailed QC results, and record the `grzctl` dependency runtime versions on submission state transitions for auditability. ## What changed - CLI - `populate-qc` now requires `--qc-workflow-version` and also reads `GRZCTL_QC_WORKFLOW_VERSION`. See [packages/grzctl/src/grzctl/commands/db/cli.py](packages/grzctl/src/grzctl/commands/db/cli.py). - Model / DB - Added `qc_workflow_version` to `DetailedQCResult` (nullable column for backward compatibility). - Alembic migration added to create the column. - Tests - Updated and added tests in [packages/grzctl/tests/cli/test_db.py](packages/grzctl/tests/cli/test_db.py): - Updated existing `test_populate_qc` to pass `--qc-workflow-version`. - Added tests for flag usage, env var usage, and missing-version failure. - Version logging - Cache and store `grzctl` dependy runtime versions with submission state logs so historical logs are traceable to the runtime that wrote them. ## DB / upgrade Run DB migrations before using the new CLI: ```bash # Using the CLI helper grzctl db upgrade # or directly with alembic (repo-specific) alembic upgrade head ``` The migration adds a nullable qc_workflow_version column so existing rows remain NULL. Closes #532 --------- Co-authored-by: Florian R. Hölzlwimmer <Hoeze@users.noreply.github.com>
1 parent 9e83ad3 commit b0929cf

9 files changed

Lines changed: 483 additions & 28 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""add grzctl_version and qc_workflow_version columns
2+
3+
Revision ID: c5f6a8b9d2e1
4+
Revises: 834bd50b8734
5+
Create Date: 2026-05-06 00:00:00.000000
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision: str = "c5f6a8b9d2e1"
14+
down_revision: str | Sequence[str] | None = "834bd50b8734"
15+
branch_labels: str | Sequence[str] | None = None
16+
depends_on: str | Sequence[str] | None = None
17+
18+
19+
def upgrade() -> None:
20+
# Add grzctl_versions to submission_states
21+
op.add_column(
22+
"submission_states",
23+
sa.Column("grzctl_versions", sa.JSON(), nullable=True),
24+
)
25+
26+
# Add qc_workflow_version to detailed_qc_results
27+
op.add_column(
28+
"detailed_qc_results",
29+
sa.Column("qc_workflow_version", sa.String(length=64), nullable=True),
30+
)
31+
32+
33+
def downgrade() -> None:
34+
"""Downgrade schema."""
35+
raise RuntimeError("Downgrades not supported.")

packages/grz-db/src/grz_db/models/submission/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ class SubmissionStateLogBase(SQLModel):
257257

258258
state: SubmissionStateEnum
259259
data: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
260+
grzctl_versions: dict[str, str] | None = Field(
261+
default=None,
262+
description="grzctl versions that created this state log (nullable for backward compatibility with old state logs)",
263+
sa_column=Column(JSON),
264+
)
260265
timestamp: datetime.datetime = Field(
261266
default_factory=lambda: datetime.datetime.now(datetime.UTC),
262267
sa_column=Column(DateTime(timezone=True), nullable=False),
@@ -465,6 +470,11 @@ class DetailedQCResult(SQLModel, table=True):
465470
targeted_regions_above_min_coverage: float
466471
targeted_regions_above_min_coverage_passed_qc: bool
467472
targeted_regions_above_min_coverage_percent_deviation: float
473+
qc_workflow_version: str | None = Field(
474+
default=None,
475+
sa_column=Column(sa.String(length=64), nullable=True),
476+
description="QC workflow version (nullable for backward compatibility with old results)",
477+
)
468478

469479
model_config = ConfigDict( # type: ignore
470480
populate_by_name=True,
@@ -737,6 +747,7 @@ def update_submission_state(
737747
submission_id: str,
738748
state: SubmissionStateEnum,
739749
data: dict | None = None,
750+
grzctl_versions: dict[str, str] | None = None,
740751
) -> SubmissionStateLog:
741752
"""
742753
Updates a submission's state to the specified state.
@@ -745,6 +756,7 @@ def update_submission_state(
745756
submission_id: Submission ID of the submission to update.
746757
state: New state of the submission.
747758
data: Optional data to attach to the update.
759+
grzctl_versions: Optional dictionary of grzctl dependency versions.
748760
749761
Returns:
750762
An instance of SubmissionStateLog.
@@ -757,7 +769,11 @@ def update_submission_state(
757769
raise ValueError("No author defined")
758770

759771
state_log_payload = SubmissionStateLogPayload(
760-
submission_id=submission_id, author_name=self._author.name, state=state, data=data
772+
submission_id=submission_id,
773+
author_name=self._author.name,
774+
state=state,
775+
data=data,
776+
grzctl_versions=grzctl_versions,
761777
)
762778
signature = state_log_payload.sign(self._author.private_key())
763779

packages/grzctl/src/grzctl/__init__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,23 @@
22
GRZ Control CLI for GRZ administrators.
33
"""
44

5-
__version__ = "1.4.0"
5+
from importlib.metadata import PackageNotFoundError, version
6+
7+
__version__ = "1.4.0" # This version is managed by release-please
8+
9+
10+
def get_versions() -> dict[str, str | None]:
11+
def get_version(package_name: str) -> str | None:
12+
try:
13+
return version(package_name)
14+
except PackageNotFoundError:
15+
return None
16+
17+
return {
18+
"grzctl": get_version("grzctl"),
19+
"grz-cli": get_version("grz-cli"),
20+
"grz-common": get_version("grz-common"),
21+
"grz-db": get_version("grz-db"),
22+
"grz-pydantic-models": get_version("grz-pydantic-models"),
23+
"grz-check": get_version("grz-check"),
24+
}

packages/grzctl/src/grzctl/cli.py

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@
33
"""
44

55
import logging.config
6-
import shutil
7-
import subprocess
8-
from importlib.metadata import version
96
from pathlib import Path
10-
from textwrap import dedent
117

128
import click
139
import grz_common.cli as grzcli
@@ -16,6 +12,7 @@
1612
from grz_common.cli.dump_config import dump_config
1713
from grz_common.logging import setup_cli_logging
1814

15+
from . import get_versions
1916
from .commands.archive import archive
2017
from .commands.clean import clean
2118
from .commands.consent import consent
@@ -45,26 +42,16 @@ def build_cli():
4542
"""
4643
Factory for building the CLI application.
4744
"""
45+
versions = get_versions()
4846

4947
@click.group(
5048
cls=OrderedGroup,
5149
help="GRZ Control CLI for GRZ administrators.",
5250
)
5351
@click.version_option(
54-
version=version("grzctl"),
52+
version=versions["grzctl"],
5553
prog_name="grzctl",
56-
message=dedent(f"""\
57-
%(prog)s v%(version)s
58-
grz-cli v{version("grz-cli")}
59-
grz-common v{version("grz-common")}
60-
grz-db v{version("grz-db")}
61-
grz-pydantic-models v{version("grz-pydantic-models")}
62-
""")
63-
+ (
64-
subprocess.run(["grz-check", "--version"], capture_output=True, text=True).stdout.strip() # noqa: S607
65-
if shutil.which("grz-check") is not None
66-
else ""
67-
),
54+
message="\n".join(f"{k} {'v' + v if v else 'unknown'}" for k, v in versions.items()),
6855
)
6956
@click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
7057
@click.option(

packages/grzctl/src/grzctl/commands/db/cli.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from pydantic import Field
5959
from tqdm.auto import tqdm
6060

61+
from ... import get_versions
6162
from ...models.config import DbConfig, ListConfig
6263
from .. import limit
6364
from . import SignatureStatus, _verify_signature
@@ -503,7 +504,12 @@ def update(ctx: click.Context, submission_id: str, state_str: str, data_json: st
503504
console_err.print(f"[yellow]Not modifying state of errored submission '{submission_id}'.[/yellow]")
504505
ctx.exit()
505506

506-
new_state_log = db_service.update_submission_state(submission_id, state_enum, parsed_data)
507+
new_state_log = db_service.update_submission_state(
508+
submission_id,
509+
state_enum,
510+
parsed_data,
511+
grzctl_versions={k: (v if v is not None else "unknown") for k, v in get_versions().items()},
512+
)
507513
console_err.print(
508514
f"[green]Submission '{submission_id}' updated to state '{new_state_log.state.value}'. Log ID: {new_state_log.id}[/green]"
509515
)
@@ -757,13 +763,20 @@ class QCReportRow(StrictBaseModel):
757763
@submission.command()
758764
@click.argument("submission_id", type=str)
759765
@click.argument("report_csv_path", metavar="path/to/report.csv", type=grzcli.FILE_R_E)
766+
@click.option(
767+
"--qc-workflow-version",
768+
type=str,
769+
required=True,
770+
envvar="GRZCTL_QC_WORKFLOW_VERSION",
771+
help="QC workflow version to store with detailed QC results. Can also be provided via GRZCTL_QC_WORKFLOW_VERSION.",
772+
)
760773
@click.option(
761774
"--confirm/--no-confirm",
762775
default=True,
763776
help="Whether to confirm changes before committing to database. (Default: confirm)",
764777
)
765778
@click.pass_context
766-
def populate_qc(ctx: click.Context, submission_id: str, report_csv_path: str, confirm: bool):
779+
def populate_qc(ctx: click.Context, submission_id: str, report_csv_path: str, qc_workflow_version: str, confirm: bool):
767780
"""Populate the submission database from a detailed QC pipeline report."""
768781
db = ctx.obj["db_url"]
769782
db_service = get_submission_db_instance(db, author=ctx.obj["author"])
@@ -800,6 +813,7 @@ def populate_qc(ctx: click.Context, submission_id: str, report_csv_path: str, co
800813
targeted_regions_above_min_coverage_passed_qc=report.targeted_regions_above_min_coverage_qc_status
801814
== QCStatus.PASS,
802815
targeted_regions_above_min_coverage_percent_deviation=report.targeted_regions_above_min_coverage_deviation,
816+
qc_workflow_version=qc_workflow_version,
803817
)
804818
)
805819
table = rich.table.Table(
@@ -900,7 +914,9 @@ def show(ctx: click.Context, submission_id: str, output_json: bool):
900914
signature_status, verifying_key_comment = _verify_signature(
901915
ctx.obj["public_keys"], state_log.author_name, state_log
902916
)
903-
state_dict = state_log.model_dump(mode="json", include={"id", "timestamp", "state", "data"})
917+
state_dict = state_log.model_dump(
918+
mode="json", include={"id", "timestamp", "state", "data", "grzctl_versions"}
919+
)
904920
state_dict["data_steward"] = state_log.author_name
905921
state_dict["data_steward_signature"] = signature_status
906922
state_dict["signature_key_comment"] = verifying_key_comment
@@ -936,11 +952,12 @@ def show(ctx: click.Context, submission_id: str, output_json: bool):
936952

937953
renderables: list[rich.console.RenderableType] = [rich.padding.Padding(attribute_table, (1, 0))]
938954
if submission.states:
939-
state_table = rich.table.Table(title="State History")
955+
state_table = rich.table.Table(title="State History", show_header=True)
940956
state_table.add_column("Log ID", style="dim", width=12)
941957
state_table.add_column("Timestamp (UTC)", style="yellow")
942958
state_table.add_column("State", style="green")
943959
state_table.add_column("Data", style="cyan", overflow="ellipsis")
960+
state_table.add_column("Dependency Versions", style="blue")
944961
state_table.add_column("Data Steward", style="magenta")
945962
state_table.add_column("Signature Status")
946963

@@ -960,6 +977,7 @@ def show(ctx: click.Context, submission_id: str, output_json: bool):
960977
state_log.timestamp.isoformat(),
961978
state_str,
962979
data_str,
980+
json.dumps(state_log.grzctl_versions) if state_log.grzctl_versions else _TEXT_MISSING,
963981
data_steward_str,
964982
signature_status_str,
965983
)

packages/grzctl/src/grzctl/commands/db/sync.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from grz_db.models.author import Author
99
from grz_db.models.submission import SubmissionDb, SubmissionStateEnum
1010

11+
from ... import get_versions
12+
1113
log = logging.getLogger(__name__)
1214

1315

@@ -84,4 +86,8 @@ def _determine_target_state(s3_state: InboxSubmissionState) -> SubmissionStateEn
8486

8587

8688
def _update_state(db: SubmissionDb, submission_id: str, state: SubmissionStateEnum, author: Author):
87-
db.update_submission_state(submission_id, state)
89+
db.update_submission_state(
90+
submission_id,
91+
state,
92+
grzctl_versions={k: (v if v is not None else "unknown") for k, v in get_versions().items()},
93+
)

packages/grzctl/src/grzctl/dbcontext.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from grz_db.models.submission import SubmissionDb, SubmissionStateEnum
99
from pydantic import ValidationError
1010

11+
from . import get_versions
1112
from .commands.db.cli import get_submission_db_instance
1213
from .models.config import DbConfig
1314

@@ -79,6 +80,12 @@ def __init__(
7980
self.enabled = enabled
8081
self.db: SubmissionDb | None = None
8182

83+
@cached_property
84+
def grzctl_versions(self) -> dict[str, str]:
85+
"""Get version information."""
86+
versions = get_versions()
87+
return {k: v or "unknown" for k, v in versions.items()}
88+
8289
@cached_property
8390
def expected_prior_states(self) -> set[SubmissionStateEnum | None]:
8491
# determine expected prior state based on order of enums
@@ -106,7 +113,7 @@ def __enter__(self):
106113
self._check_prerequisites()
107114

108115
log.debug(f"Updating submission {self.submission_id} state to {self.start_state.name}")
109-
self.db.update_submission_state(self.submission_id, self.start_state)
116+
self.db.update_submission_state(self.submission_id, self.start_state, grzctl_versions=self.grzctl_versions)
110117

111118
except SubmissionNotFoundError:
112119
raise
@@ -127,7 +134,9 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException
127134
error_state = SubmissionStateEnum.ERROR
128135
log.error(f"Operation failed for {self.submission_id}. Updating DB to {error_state.name}.")
129136
try:
130-
self.db.update_submission_state(self.submission_id, error_state, data={"error": error_message})
137+
self.db.update_submission_state(
138+
self.submission_id, error_state, data={"error": error_message}, grzctl_versions=self.grzctl_versions
139+
)
131140
except Exception as db_exc:
132141
log.error(f"Failed to write error state to DB: {db_exc}")
133142

@@ -136,7 +145,9 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException
136145
else:
137146
log.info(f"Operation successful. Updating DB to {self.end_state.name}.")
138147
try:
139-
self.db.update_submission_state(self.submission_id, self.end_state)
148+
self.db.update_submission_state(
149+
self.submission_id, self.end_state, grzctl_versions=self.grzctl_versions
150+
)
140151
except Exception as db_exc:
141152
log.error(f"Failed to write success state to DB: {db_exc}")
142153

0 commit comments

Comments
 (0)