GCP Hello World¶
A complete, runnable example: a Debian VM on GCP that serves Hello World! on port 8000, verified by an HttpTest.
Source: examples/gcp-hello/
What it creates¶
| GCP resource | Name | Purpose |
|---|---|---|
google_compute_instance |
hello-world |
e2-micro Debian VM, us-central1-a |
google_compute_firewall |
allow-http-8000 |
Opens TCP 8000 from anywhere |
The VM's startup script installs a tiny Python HTTP server as a systemd service:
from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Hello World!\n")
HTTPServer(("", 8000), Handler).serve_forever()
File layout¶
examples/gcp-hello/
├── config.py ← ProjectConfig (stack definition)
├── infra/
│ ├── main.tf ← Terraform: VM, firewall, outputs
│ └── terraform.tfvars.j2 ← Jinja2 template for .tfvars
└── pyproject.toml ← path dependency on parent small-config
config.py¶
The example uses with Project(...) and puts the smoke test on TerraformComponent(..., tests=[...]) with a SelfOutputRef so the check stays next to the infra stack (see Tests on components).
from small_config import HttpTest, Project, SelfOutputRef, TerraformComponent, active_env, sc
default_env = "dev"
project_id = "omega-buckeye-451005-b6"
ssh_pub_key = sc.secret("ssh_pub_key")
with Project("gcp-hello", active_env()) as PROJECT:
TerraformComponent(
name="infra",
vars={
"project_id": project_id,
"ssh_pub_key": ssh_pub_key,
},
tests=[
HttpTest(
name="hello http",
url=SelfOutputRef("ip", transform=lambda ip: f"http://{ip}:8000"),
expect_status=200,
expect_body_contains="Hello World",
),
],
)
Equivalent options: project-level HttpTest in the Project block, with TerraformComponent(...) as infra: infra.tests = [...], or HttpTest(..., component=infra, url=infra.output("ip", ...)).
infra/terraform.tfvars.j2¶
This Jinja2 template is rendered to .dist/infra/terraform.tfvars before Terraform runs. The {{ ... }} variables come from vars= in TerraformComponent:
The ssh_pub_key value comes from SC_SECRET_SSH_PUB_KEY — it's resolved at deploy time and never stored in plain text in config.py.
infra/main.tf¶
Key parts:
variable "project_id" {}
variable "ssh_pub_key" {}
resource "google_compute_instance" "hello_world" {
name = "hello-world"
machine_type = "e2-micro"
...
metadata = {
ssh-keys = "debian:${var.ssh_pub_key}"
startup-script = local.startup_script
}
}
output "ip" {
value = google_compute_instance.hello_world.network_interface[0].access_config[0].nat_ip
}
The ip output is what infra.output("ip") reads back into HttpTest.
Running the example¶
Prerequisites¶
- Python 3.14+ and uv
terraformCLI installedgcloudauthenticated:gcloud auth application-default login- Access to a GCP project
Setup¶
Edit project_id in config.py to match your GCP project.
Dry run (no infrastructure created)¶
This renders infra/terraform.tfvars.j2 → .dist/infra/terraform.tfvars and shows the Terraform commands that would run.
Deploy + test¶
Expected output:
[sc] component 'infra' (single)
[sc] run: terraform init
[sc] run: terraform apply -auto-approve
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
ip = "34.123.1.164"
...
Tests
┏━━━━━━━━━━━━┳━━━━━━━━┓
┃ Name ┃ Result ┃
┡━━━━━━━━━━━━╇━━━━━━━━┩
│ hello http │ PASS │
└────────────┴────────┘
The VM takes ~25 seconds to create; the HTTP server may need another 10–20 seconds to start. HttpTest retries up to 6 times (5-second delay each) to handle this.
Read the IP later¶
Tear down¶
Verify in GCP:
gcloud compute instances list --project=<your-project> --filter="name=hello-world"
# Listed 0 items.
Extending the example¶
Add a second environment:
def project(self, env: str) -> Project:
project_id = "my-prod-project" if env == "production" else "my-dev-project"
...
State goes to sc.state.production.json, separate from sc.state.dev.json.
Add an Ansible configuration step:
from small_config import AnsibleComponent
configure = AnsibleComponent(
name="configure",
path="./ansible", # required here: directory name differs from name="configure"
vars={"host": infra.output("ip")},
depends_on=[infra],
)
return Project(..., components=[infra, configure], ...)
If your playbook directory were named configure/, you could omit path (defaults to ./configure).
Add another smoke test: