Skip to content

feat(datacommons-admin) Add CLI for schema migrations - #219

Open
juliawu wants to merge 11 commits into
datacommonsorg:mainfrom
juliawu:migration-cli
Open

feat(datacommons-admin) Add CLI for schema migrations#219
juliawu wants to merge 11 commits into
datacommonsorg:mainfrom
juliawu:migration-cli

Conversation

@juliawu

@juliawu juliawu commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR adds the datacommons admin migrate-db command and integrates automated schema migrations into datacommons admin init-db.

With this change, DCP admins can update their databases to the latest schema by running:

datacommons admin migrate-db

Any databases intialized via init-db will automatically have all available migrations applied.

Key Changes

Added Locking in IngestionHelperClient (ingestion_helper_client.py):

  • Added acquire_lock(): sends POST /database/lock/acquire.
  • Added release_lock(): sends POST /database/lock/release.

Admin CLI Migration Integration (admin_cli.py):

  • datacommons admin migrate-db:
    • Auto-discovers Spanner details and Ingestion Helper URL from Terraform outputs.
    • Checks for pending migrations and exits cleanly if already up-to-date.
    • Displays pending migrations, a safety warning recommending a database backup, and prompts for confirmation defaulting to No ([y/N]).
    • Supports -y / --yes (auto_approve) flag for non-interactive / CI/CD automation.
  • datacommons admin init-db:
    • Automatically applies schema migrations (auto_approve=True) following database initialization and before seeding.

Added datacommons-db dependency to datacommons-admin.

Testing Strategy

Added new unit tests to tests/test_admin_cli.py

Manually verified workflow by creating a new DCP instance to test the init-db part, including:

  • Initial init-db call runs migrations automatically
  • Re-running init-db results in no-op from migration standpoint

Manually verified workflow by testing the migrate-db on an existing DCP instance, including:

  • Running migrate-db on an existing instance does prompt before proceeding
  • Confirming does apply the migrations as expected
  • Re-running migrate-db on an up-to-date instance results in no-op from migration standpoint
  • Attempting to apply migrations while an ingestion job is running results in an error and message to wait for ingestions to complete first.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces database schema migration capabilities to the datacommons-admin CLI by adding a new migrate-db command and integrating automatic migrations into the init-db command. It utilizes a distributed lock mechanism via the ingestion helper service to prevent concurrent migrations. The review feedback highlights a potential lock leak vulnerability in _apply_migrations if an exception occurs during lock acquisition, and recommends supporting service account impersonation for SpannerClient by passing the service account email to the migration runner.

Comment on lines +556 to +568
def _create_migration_runner(
project_id: str, instance_id: str, database_id: str
) -> MigrationRunner:
"""Initializes a SpannerClient and returns a MigrationRunner instance."""
try:
spanner_client = SpannerClient(
project_id=project_id,
instance_id=instance_id,
database_id=database_id,
)
return MigrationRunner(spanner_client=spanner_client)
except Exception as e:
raise click.ClickException(f"Failed to initialize migration runner: {e}") from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In environments where the user or runner relies on service account impersonation to access Spanner, initializing SpannerClient with default credentials will result in permission denied errors. To ensure Spanner operations run with the correct permissions, we should construct and pass impersonated credentials to SpannerClient when service_account_email is provided.

def _create_migration_runner(
    project_id: str,
    instance_id: str,
    database_id: str,
    service_account_email: str | None = None,
) -> MigrationRunner:
    """Initializes a SpannerClient and returns a MigrationRunner instance."""
    try:
        credentials = None
        if service_account_email:
            import google.auth
            from google.auth import impersonated_credentials
            base_credentials, _ = google.auth.default()
            credentials = impersonated_credentials.Credentials(
                source_credentials=base_credentials,
                target_principal=service_account_email,
                target_scopes=["https://www.googleapis.com/auth/cloud-platform"],
            )
        spanner_client = SpannerClient(
            project_id=project_id,
            instance_id=instance_id,
            database_id=database_id,
            credentials=credentials,
        )
        return MigrationRunner(spanner_client=spanner_client)
    except Exception as e:
        raise click.ClickException(f"Failed to initialize migration runner: {e}") from e

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remind me how's the auth for spanner writes is granted? It's through the CLI service account?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it's using ADC credentials acquired via gcloud auth application-default login.

Would we ever expect users to run migrations in a CI/CD automation and not manually by CLI? Given that it's performing potentially irreversible operations on the database, I assumed we'd be requiring users to migrate manually via CLI, so I haven't configured any service accounts.

Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py
Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py Outdated
@juliawu
juliawu marked this pull request as ready for review August 18, 2026 23:50
@juliawu
juliawu requested a review from gmechali August 18, 2026 23:50

@gmechali gmechali left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you Julia!

Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py
Comment on lines +556 to +568
def _create_migration_runner(
project_id: str, instance_id: str, database_id: str
) -> MigrationRunner:
"""Initializes a SpannerClient and returns a MigrationRunner instance."""
try:
spanner_client = SpannerClient(
project_id=project_id,
instance_id=instance_id,
database_id=database_id,
)
return MigrationRunner(spanner_client=spanner_client)
except Exception as e:
raise click.ClickException(f"Failed to initialize migration runner: {e}") from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remind me how's the auth for spanner writes is granted? It's through the CLI service account?

Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py Outdated
Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py Outdated
Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py Outdated
Comment thread packages/datacommons-admin/datacommons_admin/admin_cli.py Outdated
"""Apply pending schema migrations to the Spanner database."""
click.secho("Datacommons Admin Migrate-DB", fg="cyan", bold=True)
client, project_id, instance_id, database_id = _setup_ingestion_client()
_run_migrations(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think migrate_db belongs in admin_cli, but nearly all things above (essentially _run_migrations and all other helpers) need to move out.

Admin_cli should be an orchestrator file, it's already racing to 1,000 lines.
The ingestion_helper_client I think sets the right pattern here. Perhaps we can have a migration_runner_client, or something similar.

In datacommosn_db, I believe you created a new subdir for it. How about reusing the same pattern in datacommons_admin?

assert json.loads(called_payload["argument"]) == expected_arg


@pytest.fixture

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit of a similar comment as above, I think organizing the test file as well will be helpful. It's growing very quickly. Curious to hear your thoughts on how we can improve test file structure a bit, and lay the foundation for future proliferation of tests, while keeping this organized :)

Comment thread packages/datacommons-db/datacommons_db/clients/spanner_client.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants