fix(digitalocean): use canonical DIGITALOCEAN_ACCESS_TOKEN env var (#3099)

Replaces all references to DO_API_TOKEN with DIGITALOCEAN_ACCESS_TOKEN,
matching DigitalOcean's official CLI and API documentation. This includes
TypeScript source, tests, shell scripts, Packer config, CI workflows,
and documentation.

Supersedes #3068 (rebased onto current main).

Agent: pr-maintainer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-29 18:48:56 -07:00 committed by GitHub
parent b9473f25b8
commit 0bd8930c09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 147 additions and 60 deletions

View file

@ -31,7 +31,7 @@ Cloud credentials are stored in `~/.config/spawn/{cloud}.json` (loaded by `sh/sh
For each cloud with a fixture directory, check if its required env vars are set:
- **hetzner**: `HCLOUD_TOKEN`
- **digitalocean**: `DO_API_TOKEN`
- **digitalocean**: `DIGITALOCEAN_ACCESS_TOKEN`
- **aws**: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`
Skip clouds where credentials are missing (log which ones).
@ -53,11 +53,11 @@ curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1
curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1/locations"
```
### DigitalOcean (needs DO_API_TOKEN)
### DigitalOcean (needs DIGITALOCEAN_ACCESS_TOKEN)
```bash
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/account/keys"
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/sizes"
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/regions"
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/account/keys"
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/sizes"
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/regions"
```
For any other cloud directories found, read their TypeScript module in `packages/cli/src/{cloud}/` to discover the API base URL and auth pattern, then call equivalent GET-only endpoints.

View file

@ -71,18 +71,18 @@ jobs:
- name: Generate variables file
run: |
jq -n \
--arg token "$DO_API_TOKEN" \
--arg token "$DIGITALOCEAN_ACCESS_TOKEN" \
--arg agent "$AGENT_NAME" \
--arg tier "$TIER" \
--argjson install "$INSTALL_COMMANDS" \
'{
do_api_token: $token,
digitalocean_access_token: $token,
agent_name: $agent,
cloud_init_tier: $tier,
install_commands: $install
}' > packer/auto.pkrvars.json
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
AGENT_NAME: ${{ matrix.agent }}
TIER: ${{ steps.config.outputs.tier }}
INSTALL_COMMANDS: ${{ steps.config.outputs.install }}
@ -96,7 +96,7 @@ jobs:
if: cancelled()
run: |
# Filter by spawn-packer tag to avoid destroying builder droplets from other workflows
DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/droplets?per_page=200&tag_name=spawn-packer" \
| jq -r '.droplets[].id')
@ -107,28 +107,28 @@ jobs:
for ID in $DROPLET_IDS; do
echo "Destroying orphaned builder droplet: ${ID}"
curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \
curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/droplets/${ID}" || true
done
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
- name: Cleanup old snapshots
if: success()
run: |
PREFIX="spawn-${AGENT_NAME}-"
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/images?private=true&per_page=100" \
| jq -r --arg prefix "$PREFIX" \
'[.images[] | select(.name | startswith($prefix))] | sort_by(.created_at) | reverse | .[1:] | .[].id')
for ID in $SNAPSHOTS; do
echo "Deleting old snapshot: ${ID}"
curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \
curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/images/${ID}" || true
done
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
AGENT_NAME: ${{ matrix.agent }}
- name: Submit to DO Marketplace
@ -162,7 +162,7 @@ jobs:
HTTP_CODE=$(curl -s -o /tmp/mp-response.json -w "%{http_code}" \
-X PATCH \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${DO_API_TOKEN}" \
-H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
-d "$(jq -n \
--arg reason "Nightly rebuild — $(date -u '+%Y-%m-%d')" \
--argjson imageId "$IMG_ID" \
@ -177,6 +177,6 @@ jobs:
exit 1 ;;
esac
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
AGENT_NAME: ${{ matrix.agent }}
MARKETPLACE_APP_IDS: ${{ secrets.MARKETPLACE_APP_IDS }}

View file

@ -206,7 +206,7 @@ export OPENROUTER_API_KEY=sk-or-v1-xxxxx
# Cloud-specific credentials (varies by provider)
# Note: Sprite uses `sprite login` for authentication
export HCLOUD_TOKEN=... # For Hetzner
export DO_API_TOKEN=... # For DigitalOcean
export DIGITALOCEAN_ACCESS_TOKEN=... # For DigitalOcean
# Run non-interactively
spawn claude hetzner
@ -258,7 +258,7 @@ If spawn fails to install, try these steps:
2. **Set credentials via environment variables** before launching:
```powershell
$env:OPENROUTER_API_KEY = "sk-or-v1-xxxxx"
$env:DO_API_TOKEN = "dop_v1_xxxxx" # For DigitalOcean
$env:DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_xxxxx" # For DigitalOcean
$env:HCLOUD_TOKEN = "xxxxx" # For Hetzner
spawn openclaw digitalocean
```

View file

@ -408,7 +408,7 @@
"description": "Cloud servers (account + payment method required)",
"url": "https://www.digitalocean.com/",
"type": "api",
"auth": "DO_API_TOKEN",
"auth": "DIGITALOCEAN_ACCESS_TOKEN",
"provision_method": "POST /v2/droplets with user_data",
"exec_method": "ssh root@IP",
"interactive_method": "ssh -t root@IP",

View file

@ -47,8 +47,8 @@ describe("parseAuthEnvVars", () => {
});
it("should extract env var starting with letter followed by digits", () => {
expect(parseAuthEnvVars("DO_API_TOKEN")).toEqual([
"DO_API_TOKEN",
expect(parseAuthEnvVars("DIGITALOCEAN_ACCESS_TOKEN")).toEqual([
"DIGITALOCEAN_ACCESS_TOKEN",
]);
});
});

View file

@ -259,7 +259,7 @@ describe("digitalocean/getServerIp", () => {
);
const { getServerIp } = await import("../digitalocean/digitalocean");
// Need to set the token state
process.env.DO_API_TOKEN = "test-token";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-token";
// getServerIp calls doApi which uses internal state token - need to set via ensureDoToken
// But doApi will use _state.token. Since we can't easily set _state, we test the 404 path
// by mocking fetch to always return 404

View file

@ -25,9 +25,15 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
let warnSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
// Save and clear DO_API_TOKEN
savedEnv["DO_API_TOKEN"] = process.env.DO_API_TOKEN;
delete process.env.DO_API_TOKEN;
// Save and clear all accepted DigitalOcean token env vars
for (const v of [
"DIGITALOCEAN_ACCESS_TOKEN",
"DIGITALOCEAN_API_TOKEN",
"DO_API_TOKEN",
]) {
savedEnv[v] = process.env[v];
delete process.env[v];
}
// Fail OAuth connectivity check → tryDoOAuth returns null immediately
globalThis.fetch = mock(() => Promise.reject(new Error("Network unreachable")));
@ -73,7 +79,25 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});
it("does NOT show payment warning when DO_API_TOKEN env var is set", async () => {
it("does NOT show payment warning when DIGITALOCEAN_ACCESS_TOKEN env var is set", async () => {
process.env.DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_invalid_env_token";
await expect(ensureDoToken()).rejects.toThrow();
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});
it("does NOT show payment warning when DIGITALOCEAN_API_TOKEN env var is set", async () => {
process.env.DIGITALOCEAN_API_TOKEN = "dop_v1_invalid_env_token";
await expect(ensureDoToken()).rejects.toThrow();
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});
it("does NOT show payment warning when legacy DO_API_TOKEN env var is set", async () => {
process.env.DO_API_TOKEN = "dop_v1_invalid_env_token";
await expect(ensureDoToken()).rejects.toThrow();

View file

@ -68,7 +68,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
price: "test",
url: "https://digitalocean.com",
type: "api",
auth: "DO_API_TOKEN",
auth: "DIGITALOCEAN_ACCESS_TOKEN",
provision_method: "api",
exec_method: "ssh root@IP",
interactive_method: "ssh -t root@IP",
@ -138,6 +138,8 @@ describe("prioritizeCloudsByCredentials", () => {
// Save and clear credential env vars
for (const v of [
"HCLOUD_TOKEN",
"DIGITALOCEAN_ACCESS_TOKEN",
"DIGITALOCEAN_API_TOKEN",
"DO_API_TOKEN",
"UPCLOUD_USERNAME",
"UPCLOUD_PASSWORD",
@ -191,7 +193,7 @@ describe("prioritizeCloudsByCredentials", () => {
it("should move multiple credential clouds to front", () => {
process.env.HCLOUD_TOKEN = "test-token";
process.env.DO_API_TOKEN = "test-do-token";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-do-token";
const manifest = makeManifest();
const clouds = [
"upcloud",
@ -290,7 +292,7 @@ describe("prioritizeCloudsByCredentials", () => {
it("should preserve relative order within each group", () => {
process.env.HCLOUD_TOKEN = "token";
process.env.DO_API_TOKEN = "token";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "token";
const manifest = makeManifest();
// Input order: digitalocean before hetzner (both have creds)
const clouds = [
@ -331,7 +333,7 @@ describe("prioritizeCloudsByCredentials", () => {
it("should count all credential clouds correctly with all set", () => {
process.env.HCLOUD_TOKEN = "t1";
process.env.DO_API_TOKEN = "t2";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "t2";
process.env.UPCLOUD_USERNAME = "u";
process.env.UPCLOUD_PASSWORD = "p";
const manifest = makeManifest();
@ -350,4 +352,30 @@ describe("prioritizeCloudsByCredentials", () => {
expect(result.sortedClouds.slice(3)).toContain("sprite");
expect(result.sortedClouds.slice(3)).toContain("localcloud");
});
it("should recognize legacy DO_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
process.env.DO_API_TOKEN = "legacy-token";
const manifest = makeManifest();
const clouds = [
"digitalocean",
"hetzner",
];
const result = prioritizeCloudsByCredentials(clouds, manifest);
expect(result.credCount).toBe(1);
expect(result.sortedClouds[0]).toBe("digitalocean");
});
it("should recognize DIGITALOCEAN_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
process.env.DIGITALOCEAN_API_TOKEN = "alt-token";
const manifest = makeManifest();
const clouds = [
"digitalocean",
"hetzner",
];
const result = prioritizeCloudsByCredentials(clouds, manifest);
expect(result.credCount).toBe(1);
expect(result.sortedClouds[0]).toBe("digitalocean");
});
});

View file

@ -209,12 +209,12 @@ describe("getScriptFailureGuidance", () => {
it("should show specific env var name and setup hint for default case when authHint is provided", () => {
const savedOR = process.env.OPENROUTER_API_KEY;
const savedDO = process.env.DO_API_TOKEN;
const savedDO = process.env.DIGITALOCEAN_ACCESS_TOKEN;
delete process.env.OPENROUTER_API_KEY;
delete process.env.DO_API_TOKEN;
const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN");
delete process.env.DIGITALOCEAN_ACCESS_TOKEN;
const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DIGITALOCEAN_ACCESS_TOKEN");
const joined = lines.join("\n");
expect(joined).toContain("DO_API_TOKEN");
expect(joined).toContain("DIGITALOCEAN_ACCESS_TOKEN");
expect(joined).toContain("OPENROUTER_API_KEY");
expect(joined).toContain("spawn digitalocean");
expect(joined).toContain("setup");
@ -222,7 +222,7 @@ describe("getScriptFailureGuidance", () => {
process.env.OPENROUTER_API_KEY = savedOR;
}
if (savedDO !== undefined) {
process.env.DO_API_TOKEN = savedDO;
process.env.DIGITALOCEAN_ACCESS_TOKEN = savedDO;
}
});
@ -230,7 +230,7 @@ describe("getScriptFailureGuidance", () => {
const lines = stripped_getScriptFailureGuidance(42, "digitalocean");
const joined = lines.join("\n");
expect(joined).toContain("spawn digitalocean");
expect(joined).not.toContain("DO_API_TOKEN");
expect(joined).not.toContain("DIGITALOCEAN_ACCESS_TOKEN");
});
it("should handle multi-credential auth hint", () => {

View file

@ -58,6 +58,7 @@ export {
getImplementedClouds,
hasCloudCli,
hasCloudCredentials,
isAuthEnvVarSet,
isInteractiveTTY,
levenshtein,
loadManifestWithSpinner,

View file

@ -489,9 +489,26 @@ export function parseAuthEnvVars(auth: string): string[] {
.filter((s) => /^[A-Z][A-Z0-9_]{3,}$/.test(s));
}
/** Legacy env var names accepted as aliases for the canonical names in the manifest */
const AUTH_VAR_ALIASES: Record<string, string[]> = {
DIGITALOCEAN_ACCESS_TOKEN: [
"DIGITALOCEAN_API_TOKEN",
"DO_API_TOKEN",
],
};
/** Check if an auth env var (or one of its legacy aliases) is set */
export function isAuthEnvVarSet(varName: string): boolean {
if (process.env[varName]) {
return true;
}
const aliases = AUTH_VAR_ALIASES[varName];
return !!aliases?.some((a) => !!process.env[a]);
}
/** Format an auth env var line showing whether it's already set or needs to be exported */
function formatAuthVarLine(varName: string, urlHint?: string): string {
if (process.env[varName]) {
if (isAuthEnvVarSet(varName)) {
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
}
const hint = urlHint ? ` ${pc.dim(`# ${urlHint}`)}` : "";
@ -504,12 +521,12 @@ export function hasCloudCredentials(auth: string): boolean {
if (vars.length === 0) {
return false;
}
return vars.every((v) => !!process.env[v]);
return vars.every((v) => isAuthEnvVarSet(v));
}
/** Format a single credential env var as a status line (green if set, red if missing) */
export function formatCredStatusLine(varName: string, urlHint?: string): string {
if (process.env[varName]) {
if (isAuthEnvVarSet(varName)) {
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
}
const suffix = urlHint ? ` ${pc.dim(urlHint)}` : "";
@ -542,7 +559,7 @@ export function collectMissingCredentials(authVars: string[], cloud?: string): s
missing.push("OPENROUTER_API_KEY");
}
for (const v of authVars) {
if (!process.env[v]) {
if (!isAuthEnvVarSet(v)) {
missing.push(v);
}
}

View file

@ -666,14 +666,14 @@ async function tryDoOAuth(): Promise<string | null> {
if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DO_API_TOKEN=dop_v1_...");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (!oauthCode) {
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DO_API_TOKEN=dop_v1_...");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
@ -729,15 +729,22 @@ async function tryDoOAuth(): Promise<string | null> {
/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
export async function ensureDoToken(): Promise<boolean> {
// 1. Env var
if (process.env.DO_API_TOKEN) {
_state.token = process.env.DO_API_TOKEN.trim();
// 1. Env var (DIGITALOCEAN_ACCESS_TOKEN > DIGITALOCEAN_API_TOKEN > DO_API_TOKEN)
const envToken =
process.env.DIGITALOCEAN_ACCESS_TOKEN ?? process.env.DIGITALOCEAN_API_TOKEN ?? process.env.DO_API_TOKEN;
if (envToken) {
const envVarName = process.env.DIGITALOCEAN_ACCESS_TOKEN
? "DIGITALOCEAN_ACCESS_TOKEN"
: process.env.DIGITALOCEAN_API_TOKEN
? "DIGITALOCEAN_API_TOKEN"
: "DO_API_TOKEN";
_state.token = envToken.trim();
if (await testDoToken()) {
logInfo("Using DigitalOcean API token from environment");
await saveTokenToConfig(_state.token);
return false;
}
logWarn("DO_API_TOKEN from environment is invalid");
logWarn(`${envVarName} from environment is invalid`);
_state.token = "";
}
@ -776,7 +783,7 @@ export async function ensureDoToken(): Promise<boolean> {
// 3. Try OAuth browser flow
// Show payment method reminder for first-time users (no saved config, no env token)
if (!saved && !process.env.DO_API_TOKEN) {
if (!saved && !envToken) {
process.stderr.write("\n");
logWarn("DigitalOcean requires a payment method before you can create servers.");
logWarn("If you haven't added one yet, visit: https://cloud.digitalocean.com/account/billing");

View file

@ -7,7 +7,7 @@ packer {
}
}
variable "do_api_token" {
variable "digitalocean_access_token" {
type = string
sensitive = true
}
@ -32,7 +32,7 @@ locals {
}
source "digitalocean" "spawn" {
api_token = var.do_api_token
api_token = var.digitalocean_access_token
image = "ubuntu-24-04-x64"
region = "sfo3"
# 2 GB RAM needed Claude's native installer and zeroclaw's Rust build

View file

@ -62,7 +62,7 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/cursor.sh)
| Variable | Description | Default |
|---|---|---|
| `DO_API_TOKEN` | DigitalOcean API token | — (OAuth if unset) |
| `DIGITALOCEAN_ACCESS_TOKEN` | DigitalOcean API token (also accepts `DIGITALOCEAN_API_TOKEN` or `DO_API_TOKEN`) | — (OAuth if unset) |
| `DO_DROPLET_NAME` | Name for the created droplet | auto-generated |
| `DO_REGION` | Datacenter region (see regions below) | `nyc3` |
| `DO_DROPLET_SIZE` | Droplet size slug (see sizes below) | `s-2vcpu-2gb` |
@ -97,7 +97,7 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/cursor.sh)
```bash
DO_DROPLET_NAME=dev-mk1 \
DO_API_TOKEN=your-token \
DIGITALOCEAN_ACCESS_TOKEN=your-token \
OPENROUTER_API_KEY=sk-or-v1-xxxxx \
bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/claude.sh)
```
@ -107,7 +107,7 @@ Override region and droplet size:
```bash
DO_REGION=fra1 \
DO_DROPLET_SIZE=s-1vcpu-2gb \
DO_API_TOKEN=your-token \
DIGITALOCEAN_ACCESS_TOKEN=your-token \
OPENROUTER_API_KEY=sk-or-v1-xxxxx \
bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/claude.sh)
```

View file

@ -9,7 +9,7 @@
// Required env:
// ANTHROPIC_API_KEY — For the AI driver (Claude Haiku)
// OPENROUTER_API_KEY — Injected into spawn for the agent
// Cloud credentials — HCLOUD_TOKEN, DO_API_TOKEN, AWS_ACCESS_KEY_ID, etc.
// Cloud credentials — HCLOUD_TOKEN, DIGITALOCEAN_ACCESS_TOKEN, AWS_ACCESS_KEY_ID, etc.
//
// Outputs JSON to stdout: { success: boolean, duration: number, transcript: string, uxIssues?: UxIssue[] }
@ -47,7 +47,7 @@ function buildCredentialHints(): string {
const hetzner = process.env.HCLOUD_TOKEN ?? "";
if (hetzner) creds.push(`Hetzner token: ${hetzner}`);
const doToken = process.env.DO_API_TOKEN ?? "";
const doToken = process.env.DIGITALOCEAN_ACCESS_TOKEN ?? process.env.DIGITALOCEAN_API_TOKEN ?? process.env.DO_API_TOKEN ?? "";
if (doToken) creds.push(`DigitalOcean token: ${doToken}`);
const awsKey = process.env.AWS_ACCESS_KEY_ID ?? "";
@ -79,6 +79,8 @@ function redactSecrets(text: string): string {
const secrets = [
process.env.OPENROUTER_API_KEY,
process.env.HCLOUD_TOKEN,
process.env.DIGITALOCEAN_ACCESS_TOKEN,
process.env.DIGITALOCEAN_API_TOKEN,
process.env.DO_API_TOKEN,
process.env.AWS_ACCESS_KEY_ID,
process.env.AWS_SECRET_ACCESS_KEY,

View file

@ -4,11 +4,19 @@
# Implements the standard cloud driver interface (_digitalocean_*) for
# provisioning and managing DigitalOcean droplets in the E2E test suite.
#
# Requires: DO_API_TOKEN, jq, ssh
# Accepts: DIGITALOCEAN_ACCESS_TOKEN, DIGITALOCEAN_API_TOKEN, or DO_API_TOKEN
# API: https://api.digitalocean.com/v2
# SSH user: root
set -eo pipefail
# ── Resolve DigitalOcean token (canonical > alternate > legacy) ───────────
if [ -n "${DIGITALOCEAN_ACCESS_TOKEN:-}" ]; then
DO_API_TOKEN="${DIGITALOCEAN_ACCESS_TOKEN}"
elif [ -n "${DIGITALOCEAN_API_TOKEN:-}" ]; then
DO_API_TOKEN="${DIGITALOCEAN_API_TOKEN}"
fi
export DO_API_TOKEN
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@ -19,7 +27,7 @@ _DO_DEFAULT_REGION="nyc3"
# ---------------------------------------------------------------------------
# _do_curl_auth [curl-args...]
#
# Wrapper around curl that passes the DO_API_TOKEN via a temp config file
# Wrapper around curl that passes the token via a temp config file
# instead of a command-line -H flag. This keeps the token out of `ps` output.
# All arguments are forwarded to curl.
# ---------------------------------------------------------------------------
@ -37,19 +45,19 @@ _do_curl_auth() {
# ---------------------------------------------------------------------------
# _digitalocean_validate_env
#
# Validates that DO_API_TOKEN is set and the DigitalOcean API is reachable
# with valid credentials.
# Validates that a DigitalOcean token is set and the API is reachable.
# Accepts DIGITALOCEAN_ACCESS_TOKEN, DIGITALOCEAN_API_TOKEN, or DO_API_TOKEN.
# Returns 0 on success, 1 on failure.
# ---------------------------------------------------------------------------
_digitalocean_validate_env() {
if [ -z "${DO_API_TOKEN:-}" ]; then
log_err "DO_API_TOKEN is not set"
log_err "DigitalOcean token is not set (set DIGITALOCEAN_ACCESS_TOKEN, DIGITALOCEAN_API_TOKEN, or DO_API_TOKEN)"
return 1
fi
if ! _do_curl_auth -sf \
"${_DO_API}/account" >/dev/null 2>&1; then
log_err "DigitalOcean API authentication failed — check DO_API_TOKEN"
log_err "DigitalOcean API authentication failed — check your token"
return 1
fi