Skip to content

feat(cli): support remote GCS state fetching - #174

Open
dwnoble wants to merge 2 commits into
datacommonsorg:mainfrom
dwnoble:cli-remote-state
Open

feat(cli): support remote GCS state fetching#174
dwnoble wants to merge 2 commits into
datacommonsorg:mainfrom
dwnoble:cli-remote-state

Conversation

@dwnoble

@dwnoble dwnoble commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Adds support for fetching Terraform state outputs directly from GCS, allowing DCP admin CLI commands to be executed from anywhere outside of the local initialized Terraform folder.

Key Changes:

  • Added global options under the admin command group:
    • --project-id: GCP project ID used to locate the state bucket.
    • --instance-name: DCP instance name used to locate the state bucket.
    • --tf-state-location: Direct GCS URI pointing to the Terraform state file.
  • Context-Aware Remote State Fetching:
    • Updated get_terraform_output to check Click context parameters. If specified, it fetches the state JSON from GCS and extracts values directly. If not, it falls back to local terraform output -json.
  • Colorized Error Monkeypatch:
    • Custom styled Click exceptions to colorize all CLI error outputs to stderr in bold red.

Usage Examples:

Execute configuration tasks from outside your deployment directory:

# Example A: Query ingestion configuration using project ID and instance name
datacommons admin \
  --project-id datcom-website-dev \
  --instance-name dan-ns \
  ingest show-config

# Example B: Initialize database using a direct state location
datacommons admin \
  --tf-state-location gs://tf-state-dan-ns-datcom-website-dev/terraform/state/dan-ns/default.tfstate \
  init-db

(Note: Chained off 'deprecate-namespace' branch/PR)

@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 transitions the configuration from using 'namespace' to 'instance_name' across Terraform files and the admin CLI, while maintaining backward compatibility. It also adds support for fetching Terraform outputs directly from GCS remote state files. The review feedback highlights several opportunities to make the remote state retrieval more robust, such as properly handling whitespace-only state locations, ensuring the parsed JSON state is a dictionary, and passing the project ID to the GCS client, along with adding corresponding assertions in the unit tests.

Comment on lines +43 to +46
if tf_state_location:
gcs_uri = tf_state_location.strip()
if not gcs_uri.endswith(".tfstate"):
gcs_uri = gcs_uri.rstrip("/") + "/default.tfstate"

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.

medium

If tf_state_location is a whitespace-only string (e.g., " "), bool(tf_state_location) evaluates to True, which bypasses the elif project_id and instance_name fallback. It then strips to an empty string and appends /default.tfstate, resulting in an invalid GCS URI error. We should strip the string first and check if it is non-empty to allow falling back to the project_id and instance_name logic if they are provided.

Suggested change
if tf_state_location:
gcs_uri = tf_state_location.strip()
if not gcs_uri.endswith(".tfstate"):
gcs_uri = gcs_uri.rstrip("/") + "/default.tfstate"
tf_state_location_stripped = tf_state_location.strip() if tf_state_location else ""
if tf_state_location_stripped:
gcs_uri = tf_state_location_stripped
if not gcs_uri.endswith(".tfstate"):
gcs_uri = gcs_uri.rstrip("/") + "/default.tfstate"

Comment on lines +62 to +64
try:
client = storage.Client()
except Exception as 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.

medium

When initializing the storage.Client, it is highly recommended to pass the project_id if it is available. This ensures that the client is explicitly bound to the target GCP project for quota and billing purposes, rather than relying on the ambient default project of the active gcloud credentials.

Suggested change
try:
client = storage.Client()
except Exception as e:
try:
client = storage.Client(project=project_id) if project_id else storage.Client()
except Exception as e:

Comment on lines +85 to +90
try:
state_data = json.loads(content)
except json.JSONDecodeError:
raise click.ClickException(f"Failed to parse Terraform state at '{gcs_uri}' as valid JSON.")

outputs = state_data.get("outputs", {})

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.

medium

If the downloaded GCS file is valid JSON but not a dictionary (e.g., a JSON list or primitive), calling state_data.get("outputs", {}) will raise an AttributeError. We should defensively verify that state_data is a dictionary.

        try:
            state_data = json.loads(content)
        except json.JSONDecodeError:
            raise click.ClickException(f"Failed to parse Terraform state at '{gcs_uri}' as valid JSON.")

        if not isinstance(state_data, dict):
            raise click.ClickException(f"Invalid Terraform state format at '{gcs_uri}': expected a JSON object.")

        outputs = state_data.get("outputs", {})

if key not in outputs:
from pathlib import Path

location_desc = tf_state_location or f"GCP project: '{project_id}' / instance: '{instance_name}'" if use_remote else f"'{Path.cwd()}'"

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.

medium

The inline conditional expression combined with or has tricky operator precedence (since if-else has lower precedence than or in Python, it evaluates as (tf_state_location or ...) if use_remote else ...). While correct, this is highly prone to readability issues and future bugs. Splitting this into a clear if/else block is much more maintainable.

        if use_remote:
            location_desc = tf_state_location or f"GCP project: '{project_id}' / instance: '{instance_name}'"
        else:
            location_desc = f"'{Path.cwd()}'"

Comment on lines +624 to +627
assert result.exit_code == 0
assert "VAL=gcs-resolved-val" in result.output
mock_client_inst.bucket.assert_called_once_with("tf-state-mock-instance-mock-project")
mock_bucket.blob.assert_called_once_with("terraform/state/mock-instance/default.tfstate")

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.

medium

Assert that the storage.Client is initialized with the correct project_id to ensure the client is explicitly bound to the target GCP project.

Suggested change
assert result.exit_code == 0
assert "VAL=gcs-resolved-val" in result.output
mock_client_inst.bucket.assert_called_once_with("tf-state-mock-instance-mock-project")
mock_bucket.blob.assert_called_once_with("terraform/state/mock-instance/default.tfstate")
assert result.exit_code == 0
assert "VAL=gcs-resolved-val" in result.output
mock_storage_client.assert_called_once_with(project="mock-project")
mock_client_inst.bucket.assert_called_once_with("tf-state-mock-instance-mock-project")
mock_bucket.blob.assert_called_once_with("terraform/state/mock-instance/default.tfstate")

Comment on lines +630 to +632
mock_client_inst.reset_mock()
mock_bucket.reset_mock()
result = runner.invoke(

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.

medium

Reset mock_storage_client before the second test case to prevent assertions from being affected by the first test case.

Suggested change
mock_client_inst.reset_mock()
mock_bucket.reset_mock()
result = runner.invoke(
mock_storage_client.reset_mock()
mock_client_inst.reset_mock()
mock_bucket.reset_mock()
result = runner.invoke(

Comment on lines +639 to +642
assert result.exit_code == 0
assert "VAL=gcs-resolved-val" in result.output
mock_client_inst.bucket.assert_called_once_with("custom-bucket")
mock_bucket.blob.assert_called_once_with("custom-prefix/state.tfstate")

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.

medium

Assert that the storage.Client is initialized without arguments when only tf-state-location is provided.

Suggested change
assert result.exit_code == 0
assert "VAL=gcs-resolved-val" in result.output
mock_client_inst.bucket.assert_called_once_with("custom-bucket")
mock_bucket.blob.assert_called_once_with("custom-prefix/state.tfstate")
assert result.exit_code == 0
assert "VAL=gcs-resolved-val" in result.output
mock_storage_client.assert_called_once_with()
mock_client_inst.bucket.assert_called_once_with("custom-bucket")
mock_bucket.blob.assert_called_once_with("custom-prefix/state.tfstate")

@dwnoble
dwnoble requested a review from gmechali July 17, 2026 11:52
tfvars_content = tfvars_example
tfvars_content = tfvars_content.replace('"$$PROJECT_ID$$"', f'"{project_id}"')
tfvars_content = tfvars_content.replace('"$$NAMESPACE$$"', f'"{namespace}"')
tfvars_content = tfvars_content.replace('"$$NAMESPACE$$"', f'"{instance_name}"')

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.

Any reason to leave the NAMESPACE here?

"""Initialize Terraform scaffolding for Data Commons administration/infrastructure."""
click.secho("Data Commons Admin Init", fg="cyan", bold=True)

# For backward compatibility, fallback to namespace if instance_name is empty

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.

I think we rip the bandaid off tbh. Or we ll carry this over far longer than we need.
No one is really using this yet, if so, they can continue using previous version of the CLI.

project_id: str | None,
instance_name: str | None,
) -> dict:
from google.cloud import storage

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.

nit: move imports to top of file?


if use_remote:
outputs = _get_outputs_from_gcs(tf_state_location, project_id, instance_name)
else:

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 we move this elsewhere and put it in a helper for validation to use the CLI (or at least this function)?

There's no point in fetching the terraform state and doig all that logic above if it's gonna fail here.

blob = bucket.blob(blob_name)

try:
content = blob.download_as_text()

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.

one criticism from Jorge is that he doesn't want us to bring in the terraform state locally as it opens up the possibility of someone running a tf command they shouldnt.

For this specific scenario, we can just read the file from GCS and parse the output without copying the data over in the local terraform state right?

@dwnoble
dwnoble requested a review from clincoln8 July 17, 2026 18:21
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