Gap Analysis
Gap analysis finds the AWS and GCP resources that no Terraform state in Stategraph manages, and generates the Terraform configuration to import them. The Stategraph server reads your cloud with the credentials that you give it.
Unmanaged resources can come from:
- Manual console changes
- Scripts or CLI commands
- Other IaC tools
- Forgotten experiments
How it works
Cloud provider inventory
Stategraph
Compares the two.
Gap report
Unmanaged resources.
It compares the inventory with your states.
The gap report lists the resources that no state manages.
Supported providers
| Provider | Inventory source | Status |
|---|---|---|
| AWS | Resource Explorer | Supported |
| GCP | Cloud Asset Inventory | Supported |
| Azure | Resource Graph | Planned |
AWS
Setup requirements
- Resource Explorer on, with at least one index (required)
- An aggregator index, for discovery across regions (recommended)
- A default view (required)
AWS_DEFAULT_REGIONon the server: the region for Resource Explorer queries
Required IAM permissions
To read the inventory:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"resource-explorer-2:Search",
"resource-explorer-2:ListIndexes",
"resource-explorer-2:GetDefaultView"
],
"Resource": "*"
}
]
}
To generate Terraform configurations (optional), add read access, such as ReadOnlyAccess:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"arn:aws:iam::aws:policy/ReadOnlyAccess"
],
"Resource": "*"
}
]
}
Enable Resource Explorer
# Enable Resource Explorer in current region
aws resource-explorer-2 create-index --type LOCAL
# Create aggregator index (recommended for multi-region)
aws resource-explorer-2 update-index-type \
--arn "arn:aws:resource-explorer-2:us-east-1:123456789012:index/..." \
--type AGGREGATOR
# Create default view
aws resource-explorer-2 create-view --view-name default
aws resource-explorer-2 associate-default-view --view-arn "arn:aws:resource-explorer-2:..."
Check configuration
# Set API base (or use --api-base flag)
export STATEGRAPH_API_BASE=https://stategraph.example.com
stategraph tenant gaps config \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=aws
Response:
{
"provider": "aws",
"status": "ready",
"ready_for_gap_analysis": true,
"ready_for_terraform_import": true,
"has_aggregator": true,
"aggregator_region": "us-east-1",
"indexed_regions": ["us-east-1", "us-west-2", "eu-west-1"],
"index_count": 3,
"warnings": []
}
GCP
Setup requirements
- The Cloud Asset API on
- Cloud Asset Inventory access at the project, folder, or organization level
Required IAM permissions
To read the inventory:
# Grant Cloud Asset Viewer role
gcloud projects add-iam-policy-binding PROJECT_ID \
--member='serviceAccount:SA_EMAIL' \
--role='roles/cloudasset.viewer'
To generate Terraform configurations (optional):
# Grant Viewer role (read-only access to all resources)
gcloud projects add-iam-policy-binding PROJECT_ID \
--member='serviceAccount:SA_EMAIL' \
--role='roles/viewer'
Enable Cloud Asset API
gcloud services enable cloudasset.googleapis.com --project=PROJECT_ID
Scope configuration
| Scope | Environment variable | Coverage |
|---|---|---|
| Project | GOOGLE_CLOUD_PROJECT |
One project |
| Folder | GOOGLE_CLOUD_FOLDER |
All projects in the folder |
| Organization | GOOGLE_CLOUD_ORGANIZATION |
Entire organization |
If you set no scope, Stategraph detects it from the Application Default Credentials.
Check configuration
# Set API base (or use --api-base flag)
export STATEGRAPH_API_BASE=https://stategraph.example.com
stategraph tenant gaps config \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp
Response:
{
"provider": "gcp",
"status": "ready",
"ready_for_gap_analysis": true,
"ready_for_terraform_import": false,
"scope_type": "project",
"scope_id": "projects/my-project",
"has_org_access": false,
"warnings": [
{
"code": "NO_VIEWER_PERMISSION",
"message": "Optional: To generate Terraform configurations for unmanaged resources, grant 'roles/viewer' (read-only) to your service account.",
"fix": "gcloud projects add-iam-policy-binding my-project \\\n --member='serviceAccount:123456-compute@developer.gserviceaccount.com' \\\n --role='roles/viewer'"
}
]
}
Supported GCP resource types
The supported types include:
| Service | Resource types |
|---|---|
| Compute Engine | Instances, Disks, Networks, Subnetworks, Firewalls, Load Balancers |
| Cloud Storage | Buckets |
| Cloud SQL | Instances |
| BigQuery | Datasets, Tables |
| IAM | Service Accounts, Roles |
| Cloud Functions | Functions |
| Pub/Sub | Topics, Subscriptions |
| Cloud Run | Services |
| GKE | Clusters, Node Pools |
| Cloud KMS | Key Rings, Crypto Keys |
Run gap analysis
In the console, Gap Analysis is under Inventory. With the CLI:
# Set API base (or use --api-base flag)
export STATEGRAPH_API_BASE=https://stategraph.example.com
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=aws \
--format=json
--format sets the output:
table(default): a table and aSummary: N managed, M unmanagedlinejson: machine-readable output, as in the examples on this pagesimple: one tab-separated row per line, no headers
Gap analysis runs in the background. The first call for a provider starts a scan and returns a running status:
{
"status": "running",
"started_at": "2026-06-26T17:30:00Z"
}
When the scan finishes, run the command again to get the result. A running response has no unmanaged_resources array, so pipe to jq only after that. Later calls use the cache:
{
"summary": {
"total_aws_resources": 512,
"managed_by_stategraph": 460,
"unmanaged": 49,
"phantom_filtered": 3
},
"unmanaged_resources": [
{
"arn": "arn:aws:ec2:us-east-1:123456789012:security-group/sg-0abc1234def567890",
"service": "ec2",
"resource_type": "ec2:security-group",
"region": "us-east-1",
"owning_account_id": "123456789012"
}
],
"fetched_at": 1705312800
}
GCP fields
GCP returns total_cloud_resources in the summary, and asset_name and project_id on each unmanaged resource. The GCP resource type is in resource_type, as for AWS.
Understanding results
| Metric | Description |
|---|---|
total_aws_resources / total_cloud_resources |
All resources in the cloud inventory (AWS / GCP) |
managed_by_stategraph |
Resources that match a Terraform state |
unmanaged |
Resources in no state |
phantom_filtered |
Excluded provider-managed resources (see Phantom resources) |
Phantom resources
Gap analysis excludes provider-managed resources that you cannot import:
| Provider | Excluded resources |
|---|---|
| AWS | Default Athena workgroups and catalogs, service-linked IAM roles, default event buses, default S3 Storage Lens dashboards |
| GCP | Default compute service accounts, default VPC networks, default firewall rules |
Generating Terraform configurations
The server runs OpenTofu (tofu init, then tofu plan -generate-config-out) to generate import blocks and resource configuration. OpenTofu reads each resource with the server's credentials, which need the optional read access in AWS or GCP. In the console, start it from the unmanaged resources on the Gap Analysis page.
Via CLI
List the unmanaged resources with gaps analyze, then generate the configuration with gaps import:
# Set API base (or use --api-base flag)
export STATEGRAPH_API_BASE=https://stategraph.example.com
# Get unmanaged resources
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp --format=json | jq '.unmanaged_resources' > unmanaged.json
# Generate Terraform configuration
stategraph tenant gaps import \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp \
unmanaged.json
Response:
{
"generated_hcl": "resource \"google_compute_instance\" \"imported_orphan_vm\" {\n name = \"orphan-vm\"\n machine_type = \"e2-medium\"\n zone = \"us-central1-a\"\n ...\n}\n",
"import_blocks": "import {\n to = google_compute_instance.imported_orphan_vm\n id = \"projects/my-project/zones/us-central1-a/instances/orphan-vm\"\n}\n",
"provider_hcl": "terraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n version = \"~> 5.0\"\n }\n }\n}\n",
"supported_count": 1,
"unsupported_count": 0,
"unsupported_resources": []
}
Using generated code
- Save the generated HCL to a
.tffile. - Run
tofu planorterraform planto verify. - Run
tofu applyorterraform applyto import.
# Save generated configuration
cat > import.tf << 'EOF'
import {
to = google_compute_instance.imported_orphan_vm
id = "projects/my-project/zones/us-central1-a/instances/orphan-vm"
}
resource "google_compute_instance" "imported_orphan_vm" {
name = "orphan-vm"
machine_type = "e2-medium"
zone = "us-central1-a"
# ... generated attributes
}
EOF
# Verify and import
tofu plan
tofu apply
Caching
Stategraph caches the results, to prevent rate limiting and reduce API calls:
| Setting | Default | Description |
|---|---|---|
| Cache TTL | 3 hours | How long results stay in the cache. GAP_ANALYSIS_CACHE_TTL sets it in seconds (10800). |
| Cache location | /var/cache/stategraph/gap-analysis/ |
Cache directory |
# Set API base (or use --api-base flag)
export STATEGRAPH_API_BASE=https://stategraph.example.com
# Use cache (default, fast)
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp
# Force fresh scan (slower)
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp \
--source=no-cache
--source=no-cache starts a new scan and returns running: run the command again without it to get the result. In the console, Refresh skips the cache. fetched_at is the time of the last fetch from the cloud provider.
Filtering results
By service
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp --format=json | jq '.unmanaged_resources | map(select(.service == "compute"))'
By region
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp --format=json | jq '.unmanaged_resources | map(select(.region == "us-central1-a"))'
By resource type
# AWS (resource_type, e.g. "ec2:instance")
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=aws --format=json | jq '.unmanaged_resources | map(select(.resource_type == "ec2:instance"))'
# GCP (resource_type, e.g. "compute.googleapis.com/Instance")
stategraph tenant gaps analyze \
--tenant=550e8400-e29b-41d4-a716-446655440000 \
--provider=gcp --format=json | jq '.unmanaged_resources | map(select(.resource_type == "compute.googleapis.com/Instance"))'
Troubleshooting
AWS: No indexes found
"warnings": [{"code": "NO_INDEXES", "message": "No Resource Explorer indexes found"}]
Solution:
- Enable Resource Explorer in at least one region:
aws resource-explorer-2 create-index --type LOCAL
AWS: No aggregator
"warnings": [{"code": "NO_AGGREGATOR", "message": "No aggregator index configured"}]
Solution:
- Change one index to the aggregator type, for discovery across regions:
aws resource-explorer-2 update-index-type --arn "INDEX_ARN" --type AGGREGATOR
GCP: Permission denied
"warnings": [{"code": "PERMISSION_DENIED", "message": "Permission denied for scope projects/my-project"}]
Solution:
- Grant the Cloud Asset Viewer role:
gcloud projects add-iam-policy-binding my-project \
--member='serviceAccount:YOUR_SA@my-project.iam.gserviceaccount.com' \
--role='roles/cloudasset.viewer'
GCP: Insufficient OAuth scopes
"warnings": [{"code": "INSUFFICIENT_SCOPES", "message": "VM OAuth scopes are insufficient"}]
Solution:
- Update the VM scopes. The VM must restart:
gcloud compute instances stop INSTANCE_NAME --zone=ZONE
gcloud compute instances set-service-account INSTANCE_NAME \
--zone=ZONE --scopes=cloud-platform
gcloud compute instances start INSTANCE_NAME --zone=ZONE
Terraform import fails with permission denied
Gap analysis reads the Cloud Asset API, but a Terraform import needs read permissions on the resources themselves.
Solution:
- Grant
roles/viewerfor Terraform operations:
gcloud projects add-iam-policy-binding my-project \
--member='serviceAccount:YOUR_SA@my-project.iam.gserviceaccount.com' \
--role='roles/viewer'
Best practices
- Run the configuration check first to verify permissions, then scan weekly or after deployments.
- Classify each gap as intentional or a concern. Bring unmanaged production resources under control, and record why the others stay unmanaged.
- On GCP, use the organization scope for complete visibility.
Credential configuration
AWS credentials
Stategraph uses the standard AWS credential chain:
- Environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) - Shared credentials file (
~/.aws/credentials) - IAM role for Amazon EC2 / ECS task role
- IAM Roles for Service Accounts (IRSA) in EKS
Docker Compose
Option 1. Pass the credentials as environment variables (for development):
services:
server:
image: ghcr.io/stategraph/stategraph-server:latest
environment:
# ... existing config ...
AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
AWS_DEFAULT_REGION: "us-east-1"
# Optional: for temporary credentials
# AWS_SESSION_TOKEN: "your-session-token"
Option 2. Mount your local AWS credentials directory (recommended for development):
services:
server:
image: ghcr.io/stategraph/stategraph-server:latest
environment:
# ... existing config ...
AWS_DEFAULT_REGION: "us-east-1"
# Optional: specify a named profile
# AWS_PROFILE: "my-profile"
volumes:
- ~/.aws:/home/stategraph/.aws:ro
Option 3. Keep the credentials in a .env file that you do not commit to git:
# .env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=us-east-1
services:
server:
image: ghcr.io/stategraph/stategraph-server:latest
env_file:
- .env
Kubernetes
Option 1. A Secret with environment variables:
apiVersion: v1
kind: Secret
metadata:
name: aws-credentials
namespace: stategraph
stringData:
AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: stategraph
namespace: stategraph
spec:
template:
spec:
containers:
- name: stategraph
envFrom:
- secretRef:
name: aws-credentials
env:
- name: AWS_DEFAULT_REGION
value: "us-east-1"
Option 2. IAM Roles for Service Accounts (IRSA), recommended for EKS:
- Create an IAM role with the required permissions and a trust policy for your EKS cluster.
- Annotate the Kubernetes service account:
apiVersion: v1
kind: ServiceAccount
metadata:
name: stategraph
namespace: stategraph
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/stategraph-gap-analysis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: stategraph
namespace: stategraph
spec:
template:
spec:
serviceAccountName: stategraph
containers:
- name: stategraph
env:
- name: AWS_DEFAULT_REGION
value: "us-east-1"
Option 3. EC2 instance profile. On EC2, or with node IAM roles, you need no more configuration. The node IAM role needs the Resource Explorer permissions.
GCP credentials
Stategraph uses the standard GCP credential chain:
GOOGLE_APPLICATION_CREDENTIALSenvironment variable (path to service account JSON)- Application Default Credentials (ADC)
- Compute Engine / GKE metadata server
- Workload Identity (GKE)
Docker Compose
Mount your GCP service account key file and set GOOGLE_APPLICATION_CREDENTIALS to its path:
services:
server:
image: ghcr.io/stategraph/stategraph-server:latest
environment:
# ... existing config ...
GOOGLE_APPLICATION_CREDENTIALS: "/secrets/gcp-sa-key.json"
GOOGLE_CLOUD_PROJECT: "my-project-id"
volumes:
- ./gcp-sa-key.json:/secrets/gcp-sa-key.json:ro
Keep the key out of version control
Add your service account key file to .gitignore.
Kubernetes
Option 1. A Secret with the service account key:
- Create a secret from your service account JSON:
kubectl create secret generic gcp-credentials \
--namespace=stategraph \
--from-file=key.json=./gcp-sa-key.json
- Mount the secret and set the environment variable:
apiVersion: apps/v1
kind: Deployment
metadata:
name: stategraph
namespace: stategraph
spec:
template:
spec:
containers:
- name: stategraph
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: "/secrets/gcp/key.json"
- name: GOOGLE_CLOUD_PROJECT
value: "my-project-id"
volumeMounts:
- name: gcp-credentials
mountPath: /secrets/gcp
readOnly: true
volumes:
- name: gcp-credentials
secret:
secretName: gcp-credentials
Option 2. Workload Identity, recommended for GKE:
- Turn on Workload Identity on your GKE cluster.
- Create a GCP service account with the required permissions.
- Bind the Kubernetes service account to the GCP service account:
gcloud iam service-accounts add-iam-policy-binding \
stategraph-sa@PROJECT_ID.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:PROJECT_ID.svc.id.goog[stategraph/stategraph]"
- Annotate the Kubernetes service account:
apiVersion: v1
kind: ServiceAccount
metadata:
name: stategraph
namespace: stategraph
annotations:
iam.gke.io/gcp-service-account: stategraph-sa@PROJECT_ID.iam.gserviceaccount.com
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: stategraph
namespace: stategraph
spec:
template:
spec:
serviceAccountName: stategraph
containers:
- name: stategraph
env:
- name: GOOGLE_CLOUD_PROJECT
value: "my-project-id"
Option 3. GCE metadata server. On GCE, or on GKE without Workload Identity, Stategraph uses the default service account. It needs the Cloud Asset Viewer permissions.
Multi-cloud configuration
For AWS and GCP at the same time:
Docker Compose:
services:
server:
image: ghcr.io/stategraph/stategraph-server:latest
environment:
# AWS
AWS_DEFAULT_REGION: "us-east-1"
# GCP
GOOGLE_APPLICATION_CREDENTIALS: "/secrets/gcp-sa-key.json"
GOOGLE_CLOUD_PROJECT: "my-project-id"
volumes:
- ~/.aws:/home/stategraph/.aws:ro
- ./gcp-sa-key.json:/secrets/gcp-sa-key.json:ro
Kubernetes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: stategraph
namespace: stategraph
spec:
template:
spec:
serviceAccountName: stategraph # With IRSA and/or Workload Identity
containers:
- name: stategraph
env:
# AWS
- name: AWS_DEFAULT_REGION
value: "us-east-1"
# GCP
- name: GOOGLE_APPLICATION_CREDENTIALS
value: "/secrets/gcp/key.json"
- name: GOOGLE_CLOUD_PROJECT
value: "my-project-id"
volumeMounts:
- name: gcp-credentials
mountPath: /secrets/gcp
readOnly: true
volumes:
- name: gcp-credentials
secret:
secretName: gcp-credentials
Next steps
- Dashboards: custom views of your inventory.
- Query Language: query resources with SQL.
- Environment Variables: full configuration reference.