Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,10 @@
// updateContent runs inside the container after repo content is updated
// with new commits. We use it to build new content so users' builds are
// mostly incremental.
"updateContentCommand": ".devcontainer/update_content.sh"
"updateContentCommand": ".devcontainer/update_content.sh",
// postStartCommand runs inside the container every time it starts, so on
// every `devpod up`. We use it to (re-)fetch the shared workstation
// secrets from Secret Manager on each start; it is a no-op off GCP
// (e.g. Codespaces).
"postStartCommand": ".devcontainer/devpod/fetch-workstation-secrets.sh || true"
}
76 changes: 76 additions & 0 deletions .devcontainer/devpod/fetch-workstation-secrets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/bash
#
# Fetches the shared workstation secrets from Google Secret Manager and
# writes them as `export` lines into a profile snippet that every shell
# sources, so they appear as environment variables in every terminal on
# the workstation.
#
# This runs automatically on every `devpod up` (via `postStartCommand` in
# `devcontainer.json`) on DevPod GCP workstations. It is a no-op anywhere
# without a GCE metadata server / service account, e.g. Codespaces or
# local Docker. It is safe to re-run by hand to pick up secret changes
# mid-session, after which you open a new shell:
#
# .devcontainer/devpod/fetch-workstation-secrets.sh
#
# A secret is injected iff it carries the label `workstation-env=true`,
# and the secret's NAME becomes the environment variable name, so name
# secrets as valid shell identifiers (e.g. `OPENAI_API_KEY`). See the
# "Workstation secrets" section of `.devcontainer/README.md` for the
# `gcloud` commands to add/list/remove secrets and the one-time setup.

# Intentionally NOT `set -e`: a failure to fetch must never break
# workstation creation or a shell — secrets are best-effort.
set -uo pipefail

OUT=/etc/profile.d/10-workstation-secrets.sh
META="http://metadata.google.internal/computeMetadata/v1"
MFLAVOR=(-H "Metadata-Flavor: Google")

log() { echo "fetch-workstation-secrets: $*" >&2; }

# Bail quietly unless we're on a GCE VM (the metadata server answers).
if ! curl -sf -m 5 "${MFLAVOR[@]}" "$META/instance/name" >/dev/null 2>&1; then
log "no GCE metadata server; skipping (expected off-GCP)."
exit 0
fi

PROJECT="$(curl -sf -m 5 "${MFLAVOR[@]}" "$META/project/project-id" 2>/dev/null || true)"
TOKEN="$(curl -sf -m 5 "${MFLAVOR[@]}" \
"$META/instance/service-accounts/default/token" 2>/dev/null \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])' 2>/dev/null || true)"
if [ -z "${PROJECT:-}" ] || [ -z "${TOKEN:-}" ]; then
log "no service-account token (is a service account attached?); skipping."
exit 0
fi

# Do all the Secret Manager REST work in Python (stdlib only); it prints
# ready-to-source `export` lines. PROJECT/TOKEN come from the env.
script_dir="$(cd "$(dirname "$0")" && pwd)"
body="$(PROJECT="$PROJECT" TOKEN="$TOKEN" \
python3 "$script_dir/fetch_workstation_secrets.py")"
if [ $? -ne 0 ]; then
log "Secret Manager fetch failed; leaving existing secrets in place."
exit 0
fi

# Write atomically as a profile snippet readable only by us and root.
tmp="$(mktemp)"
{
echo "# Generated by .devcontainer/devpod/fetch-workstation-secrets.sh."
echo "# Secrets in project '$PROJECT' labelled workstation-env=true. Do not edit."
echo "$body"
} >"$tmp"
sudo install -m 0640 -o root -g "$(id -gn)" "$tmp" "$OUT"
rm -f "$tmp"

# `/etc/profile.d` is sourced by login shells; make sure interactive
# bash and zsh shells (e.g. VS Code terminals) pick it up too.
source_line=". $OUT # workstation secrets"
for rc in /etc/bash.bashrc /etc/zsh/zshrc; do
if [ -f "$rc" ] && ! sudo grep -qF "$OUT" "$rc"; then
echo "[ -r $OUT ] && $source_line" | sudo tee -a "$rc" >/dev/null
fi
done

log "wrote $(grep -c '^export ' "$OUT" 2>/dev/null || echo 0) secret(s) to $OUT"
72 changes: 72 additions & 0 deletions .devcontainer/devpod/fetch_workstation_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Fetches workstation secrets from Google Secret Manager.

Reads PROJECT and TOKEN (a service-account access token) from the
environment and prints a ready-to-source `export NAME='value'` line for
every secret labelled `workstation-env=true`.
"""

import base64
import json
import os
import re
import sys
import urllib.error
import urllib.request
from typing import Any

_API = "https://secretmanager.googleapis.com/v1"
_VALID_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def _get(url: str, token: str) -> Any:
request = urllib.request.Request(
url,
headers={"Authorization": "Bearer " + token},
)
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)


def main() -> int:
project = os.environ["PROJECT"]
token = os.environ["TOKEN"]

try:
listing = _get(
f"{_API}/projects/{project}/secrets"
"?filter=labels.workstation-env%3Dtrue&pageSize=500",
token,
)
except urllib.error.URLError as error:
print(f"# listing secrets failed: {error}", file=sys.stderr)
return 1

lines = []
for secret in listing.get("secrets", []):
name = secret["name"].rsplit("/", 1)[1]
if not _VALID_NAME.match(name):
print(
f"# skipped '{name}': not a valid env var name",
file=sys.stderr,
)
continue
try:
version = _get(
f"{_API}/projects/{project}/secrets/{name}"
"/versions/latest:access",
token,
)
value = base64.b64decode(version["payload"]["data"]).decode()
except Exception as error: # noqa: BLE001 (skip unreadable secret)
print(f"# skipped '{name}': {error}", file=sys.stderr)
continue
# Single-quote the value, escaping any embedded single quotes.
escaped = value.replace("'", "'\\''")
lines.append(f"export {name}='{escaped}'")

print("\n".join(lines))
return 0


if __name__ == "__main__":
sys.exit(main())
224 changes: 224 additions & 0 deletions .devcontainer/devpod/gcloud-poweroff.provider.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Patched copy of the DevPod gcloud provider (v0.0.12). The ONLY change
# from upstream is `agent.exec.shutdown` (more details below). When bumping
# the provider version, re-sync this file from the upstream release and
# re-apply that one change:
# https://github.com/loft-sh/devpod-provider-gcloud/releases
name: gcloud
version: v0.0.12
description: |-
DevPod on Google Cloud
icon: https://devpod.sh/assets/gcp.svg
optionGroups:
- options:
- DISK_SIZE
- DISK_IMAGE
- MACHINE_TYPE
name: "GCloud options"
- options:
- AGENT_PATH
- INACTIVITY_TIMEOUT
- INJECT_DOCKER_CREDENTIALS
- INJECT_GIT_CREDENTIALS
name: "Agent options"
options:
PROJECT:
description: The project id to use.
required: true
command: gcloud config list --quiet --verbosity=error --format "value(core.project)" 2>/dev/null || true
ZONE:
description: The google cloud zone to create the VM in. E.g. europe-west1-d
required: true
command: |-
GCLOUD_ZONE=$(gcloud config list --quiet --verbosity=error --format "value(compute.zone)" 2>/dev/null || true)
if [ -z "$GCLOUD_ZONE" ]; then
echo "europe-west2-b"
else
echo $GCLOUD_ZONE
fi
suggestions:
- asia-east1-a
- asia-east1-b
- asia-east1-c
- asia-east2-a
- asia-east2-b
- asia-east2-c
- asia-northeast1-a
- asia-northeast1-c
- asia-northeast2-b
- asia-northeast3-b
- asia-south1-a
- asia-south1-b
- asia-southeast1-a
- europe-north1-a
- europe-north1-b
- europe-north1-c
- europe-west1-b
- europe-west1-c
- europe-west1-d
- europe-west2-a
- europe-west2-b
- europe-west2-c
- europe-west3-a
- europe-west3-b
- europe-west3-c
- europe-west4-a
- europe-west4-b
- europe-west4-c
- europe-west9-a
- europe-west9-b
- europe-west9-c
- me-central1-a
- me-central1-b
- me-central1-c
- me-west1-a
- me-west1-b
- me-west1-c
- northamerica-northeast1-a
- northamerica-northeast1-b
- northamerica-northeast1-c
- southamerica-east1-a
- southamerica-east1-b
- southamerica-east1-c
- southamerica-west1-a
- southamerica-west1-b
- southamerica-west1-c
- us-central1-a
- us-central1-b
- us-central1-f
- us-east1-b
- us-east1-c
- us-east1-d
- us-east4-a
- us-east4-b
- us-east4-c
- us-south1-a
- us-south1-b
- us-south1-c
- us-west1-a
- us-west1-b
- us-west1-c
- us-west2-a
- us-west2-b
- us-west2-c
- us-west4-a
- us-west4-b
- us-west4-c
NETWORK:
description: The network id to use.
SUBNETWORK:
description: The subnetwork id to use.
TAG:
description: A tag to attach to the instance.
default: "devpod"
DISK_SIZE:
description: The disk size to use (GB).
default: "40"
DISK_IMAGE:
description: The disk image to use.
default: projects/cos-cloud/global/images/cos-101-17162-127-5
SERVICE_ACCOUNT:
description: A service account to attach
default: ""
PUBLIC_IP_ENABLED:
description: Use a public ip to access the instance
default: "true"
MACHINE_TYPE:
description: The machine type to use.
default: c2-standard-4
suggestions:
- f1-micro
- e2-small
- e2-medium
- n2-standard-2
- n2-standard-4
- n2-standard-8
- n2-standard-16
- n2-highcpu-8
- n2-highcpu-16
- c2-standard-4
- c2-standard-8
- c2-standard-16
- c2-standard-30
- g2-standard-4
- g2-standard-8
- g2-standard-12
- g2-standard-16
- a2-highgpu-1g
- a2-highgpu-2g
INACTIVITY_TIMEOUT:
description: If defined, will automatically stop the VM after the inactivity period.
default: 5m
INJECT_GIT_CREDENTIALS:
description: "If DevPod should inject git credentials into the remote host."
default: "true"
INJECT_DOCKER_CREDENTIALS:
description: "If DevPod should inject docker credentials into the remote host."
default: "true"
AGENT_PATH:
description: The path where to inject the DevPod agent to.
default: /var/lib/toolbox/devpod
GCLOUD_PROVIDER_TOKEN:
local: true
hidden: true
cache: 5m
description: "The Google Cloud auth token to use"
command: |-
${GCLOUD_PROVIDER} token
agent:
path: ${AGENT_PATH}
inactivityTimeout: ${INACTIVITY_TIMEOUT}
injectGitCredentials: ${INJECT_GIT_CREDENTIALS}
injectDockerCredentials: ${INJECT_DOCKER_CREDENTIALS}
binaries:
GCLOUD_PROVIDER:
- os: linux
arch: amd64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-linux-amd64
checksum: ebc38f3ce8f74f1ea4d79f7ff7de2c6fafb7cceb252422b106013c5ceca402bd
- os: linux
arch: arm64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-linux-arm64
checksum: b333374e0f97c514e4ce7c3433a7f1fd6aa46922ca18856d470203145ffc2f0f
exec:
# PATCHED. The stock provider runs `${GCLOUD_PROVIDER} stop --raw`,
# which calls the GCP stop API with a token minted at `up` time. That
# token expires after ~1 hour, so an inactivity timeout longer than
# ~1 h fires its stop after the token is dead — and the provider's
# `rawStop` swallows the resulting auth error, so the VM never shuts
# down.
#
# Our replacement is a guest-side `poweroff` which needs no token;
# GCP turns a guest shutdown into TERMINATED (compute billing stops).
# The daemon runs as root, so no sudo is needed.
shutdown: |-
/sbin/poweroff
binaries:
GCLOUD_PROVIDER:
- os: linux
arch: amd64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-linux-amd64
checksum: ebc38f3ce8f74f1ea4d79f7ff7de2c6fafb7cceb252422b106013c5ceca402bd
- os: linux
arch: arm64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-linux-arm64
checksum: b333374e0f97c514e4ce7c3433a7f1fd6aa46922ca18856d470203145ffc2f0f
- os: darwin
arch: amd64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-darwin-amd64
checksum: 37e7a73ebb1be6961695320d54fcb142c021774ff7f5b339a2dec5bbbc317e54
- os: darwin
arch: arm64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-darwin-arm64
checksum: 0eb3862c7e5a07a71a6decfe499320664a57925dda2817d1c4a59e4d598f4f82
- os: windows
arch: amd64
path: https://github.com/loft-sh/devpod-provider-gcloud/releases/download/v0.0.12/devpod-provider-gcloud-windows-amd64.exe
checksum: c5140711e5a5bac0219a9efd35c8690eb0fbfdc7b6f28e2afe2c05ebf0a17eaa
exec:
init: ${GCLOUD_PROVIDER} init
command: ${GCLOUD_PROVIDER} command
create: ${GCLOUD_PROVIDER} create
delete: ${GCLOUD_PROVIDER} delete
start: ${GCLOUD_PROVIDER} start
stop: ${GCLOUD_PROVIDER} stop
status: ${GCLOUD_PROVIDER} status
Loading
Loading