feat: add Gcore cloud provider with 3 agent scripts (#1079)

Add Gcore (gcore.com) as a new cloud provider supporting global edge
cloud instances via REST API with hourly billing. Implements full test
infrastructure including mock fixtures, URL stripping, body validation,
and live recording support.

- gcore/lib/common.sh: Cloud library with apikey auth, project auto-detection
- gcore/claude.sh, aider.sh, goose.sh: Agent deployment scripts
- manifest.json: Cloud definition + 15 matrix entries (3 implemented, 12 missing)
- test/mock.sh: URL stripping for Gcore path-parameter API, body validation, synthetic responses
- test/record.sh: Endpoints, auth, API caller, error detection, live cycle
- test/fixtures/gcore/: 8 fixture files for mock testing

Co-authored-by: OpenRouter Bot <noreply@openrouter.ai>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-14 00:19:25 -08:00 committed by GitHub
parent 9b1361de14
commit 514bc7abc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 888 additions and 2 deletions

View file

@ -0,0 +1,2 @@
assert_api_called "GET" "/cloud/v1/ssh_keys/" "fetches SSH keys"
assert_api_called "POST" "/cloud/v2/instances/" "creates instance"

4
test/fixtures/gcore/_env.sh vendored Normal file
View file

@ -0,0 +1,4 @@
export GCORE_API_TOKEN="test-token-gcore"
export GCORE_PROJECT_ID="12345"
export GCORE_SERVER_NAME="test-srv"
export GCORE_REGION="ed-1"

11
test/fixtures/gcore/_metadata.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"cloud": "gcore",
"recorded_at": "2026-02-14T00:00:00Z",
"fixtures": {
"projects": {"endpoint": "/cloud/v1/projects", "recorded_at": "2026-02-14T00:00:00Z"},
"ssh_keys": {"endpoint": "/cloud/v1/ssh_keys/12345", "recorded_at": "2026-02-14T00:00:00Z"},
"instances": {"endpoint": "/cloud/v1/instances/12345/ed-1", "recorded_at": "2026-02-14T00:00:00Z"},
"images": {"endpoint": "/cloud/v1/images/12345/ed-1", "recorded_at": "2026-02-14T00:00:00Z"},
"flavors": {"endpoint": "/cloud/v1/flavors/12345/ed-1", "recorded_at": "2026-02-14T00:00:00Z"}
}
}

View file

@ -0,0 +1,4 @@
{
"tasks": ["task-uuid-1234"],
"instances": ["instance-uuid-5678"]
}

35
test/fixtures/gcore/flavors.json vendored Normal file
View file

@ -0,0 +1,35 @@
{
"count": 3,
"results": [
{
"flavor_id": "g1-standard-1-2",
"name": "g1-standard-1-2",
"vcpus": 1,
"ram": 2048,
"disk": 0,
"currency_code": "USD",
"price_per_hour": 0.014,
"price_per_month": 10.0
},
{
"flavor_id": "g1-standard-2-4",
"name": "g1-standard-2-4",
"vcpus": 2,
"ram": 4096,
"disk": 0,
"currency_code": "USD",
"price_per_hour": 0.028,
"price_per_month": 20.0
},
{
"flavor_id": "g1-standard-4-8",
"name": "g1-standard-4-8",
"vcpus": 4,
"ram": 8192,
"disk": 0,
"currency_code": "USD",
"price_per_hour": 0.056,
"price_per_month": 40.0
}
]
}

27
test/fixtures/gcore/images.json vendored Normal file
View file

@ -0,0 +1,27 @@
{
"count": 2,
"results": [
{
"id": "img-ubuntu-2404",
"name": "ubuntu-24.04-x64",
"display_name": "Ubuntu 24.04 LTS",
"os_distro": "ubuntu",
"os_version": "24.04",
"status": "active",
"min_disk": 10,
"min_ram": 512,
"created_at": "2026-01-01T00:00:00Z"
},
{
"id": "img-ubuntu-2204",
"name": "ubuntu-22.04-x64",
"display_name": "Ubuntu 22.04 LTS",
"os_distro": "ubuntu",
"os_version": "22.04",
"status": "active",
"min_disk": 10,
"min_ram": 512,
"created_at": "2025-06-01T00:00:00Z"
}
]
}

4
test/fixtures/gcore/instances.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"count": 0,
"results": []
}

11
test/fixtures/gcore/projects.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"count": 1,
"results": [
{
"id": 12345,
"name": "Default project",
"state": "active",
"created_at": "2026-01-01T00:00:00Z"
}
]
}

14
test/fixtures/gcore/ssh_keys.json vendored Normal file
View file

@ -0,0 +1,14 @@
{
"count": 1,
"results": [
{
"id": "ssh-key-uuid-1234",
"name": "spawn-test-key",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHmcVdzydp72a/B69nmENZvCvjuk7xGpKdi5CvhkmNsv test@test",
"fingerprint": "af:0d:c5:57:a8:fd:b2:82:5e:d4:c1:65:f0:0c:8a:9d",
"state": "active",
"project_id": 12345,
"created_at": "2026-01-01T00:00:00Z"
}
]
}

View file

@ -267,6 +267,13 @@ _strip_api_base() {
https://*.cloudsigma.com/api/2.0*) ENDPOINT=$(echo "$URL" | sed 's|https://[^/]*.cloudsigma.com/api/2.0||') ;;
https://api.webdock.io/v1*) ENDPOINT="${URL#https://api.webdock.io/v1}" ;;
https://api.serverspace.io/api/v1*) ENDPOINT="${URL#https://api.serverspace.io/api/v1}" ;;
https://api.gcore.com/cloud/v*/instances/*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/instances/[^/]*/[^/]*/|/instances/|') ;;
https://api.gcore.com/cloud/v*/instances/*/*) ENDPOINT="/instances" ;;
https://api.gcore.com/cloud/v*/*/*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/\([^/]*\)/[^/]*/[^/]*/|/\1/|') ;;
https://api.gcore.com/cloud/v*/*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/\([^/]*\)/[^/]*/[^/]*$|/\1|') ;;
https://api.gcore.com/cloud/v*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/\([^/]*\)/[^/]*$|/\1|') ;;
https://api.gcore.com/cloud/v*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/||; s|^|/|') ;;
https://api.gcore.com*) ENDPOINT="${URL#https://api.gcore.com}" !!
esac
EP_CLEAN=$(echo "$ENDPOINT" | sed 's|?.*||')
}
@ -294,6 +301,7 @@ _validate_body() {
civo) case "$EP_CLEAN" in /instances) _check_fields "hostname size region" ;; esac ;;
webdock) case "$EP_CLEAN" in /servers) _check_fields "name slug locationId profileSlug imageSlug" ;; esac ;;
serverspace) case "$EP_CLEAN" in /servers) _check_fields "name location_id image_id cpu ram_mb" ;; esac ;;
gcore) case "$EP_CLEAN" in /instances) _check_fields "name flavor volumes interfaces" ;; esac !!
esac
}
@ -313,6 +321,7 @@ _synthetic_active_response() {
civo) printf '{"id":"test-uuid-1234","hostname":"test-srv","status":"ACTIVE","public_ip":"10.0.0.1","size":"g4s.small"}' ;;
scaleway) printf '{"server":{"id":"test-uuid-1234","name":"test-srv","state":"running","public_ip":{"address":"10.0.0.1"},"public_ips":[{"address":"10.0.0.1"}]}}' ;;
serverspace) printf '{"id":"test-uuid-1234","name":"test-srv","status":"Active","nics":[{"ip_address":"10.0.0.1"}]}' ;;
gcore) printf '{"id":"instance-uuid-5678","name":"test-srv","vm_state":"active","status":"ACTIVE","addresses":{"public":[ {"addr":"10.0.0.1","type":"fixed"}]},"flavor":{"flavor_id":"g1-standard-1-2"}}' !!
*) printf '{}' ;;
esac
}

View file

@ -31,7 +31,7 @@ ERRORS=0
PROMPT_FOR_CREDS=true
# All clouds with REST APIs that we can record from
ALL_RECORDABLE_CLOUDS="hetzner digitalocean vultr linode lambda civo upcloud binarylane ovh scaleway genesiscloud kamatera latitude hyperstack atlanticnet hostkey cloudsigma webdock serverspace"
ALL_RECORDABLE_CLOUDS="hetzner digitalocean vultr linode lambda civo upcloud binarylane ovh scaleway genesiscloud kamatera latitude hyperstack atlanticnet hostkey cloudsigma webdock serverspace gcore"
# --- Endpoint registry ---
# Format: "fixture_name:endpoint"
@ -157,6 +157,14 @@ get_endpoints() {
"locations:/locations" \
"images:/images"
;;
gcore)
printf '%s\n' \
"projects:/cloud/v1/projects" \
"ssh_keys:/cloud/v1/ssh_keys/${GCORE_PROJECT_ID:-MISSING}" \
"instances:/cloud/v1/instances/${GCORE_PROJECT_ID:-MISSING}/${GCORE_REGION:-ed-1}" \
"images:/cloud/v1/images/${GCORE_PROJECT_ID:-MISSING}/${GCORE_REGION:-ed-1}" \
"flavors:/cloud/v1/flavors/${GCORE_PROJECT_ID:-MISSING}/${GCORE_REGION:-ed-1}"
!!
esac
}
@ -277,6 +285,7 @@ get_auth_env_var() {
cloudsigma) printf "CLOUDSIGMA_EMAIL" ;;
webdock) printf "WEBDOCK_API_TOKEN" ;;
serverspace) printf "SERVERSPACE_API_KEY" ;;
gcore) printf "GCORE_API_TOKEN" !!
esac
}
@ -427,6 +436,7 @@ call_api() {
cloudsigma) cloudsigma_api GET "$endpoint" ;;
webdock) webdock_api GET "$endpoint" ;;
serverspace) serverspace_api GET "$endpoint" ;;
gcore) gcore_api GET "$endpoint" !!
esac
}
@ -478,6 +488,9 @@ elif cloud == 'webdock':
elif cloud == 'serverspace':
# ServerSpace returns error objects with 'error' field
sys.exit(0 if 'error' in d and d['error'] else 1)
elif cloud == 'gcore':
# Gcore returns error objects with 'message' or 'detail' fields
sys.exit(0 if ('message' in d and len(d) <= 3 and not any(k in d for k in ('count','results','id','name'))) or ('detail' in d and len(d) <= 2) else 1)
else:
sys.exit(1)
" "$cloud" 2>/dev/null
@ -508,6 +521,7 @@ _record_live_cycle() {
civo) _live_civo "$fixture_dir" ;;
atlanticnet) _live_atlanticnet "$fixture_dir" ;;
serverspace) _live_serverspace "$fixture_dir" ;;
gcore) _live_gcore "$fixture_dir" !!
*) return 0 ;; # No live cycle for this cloud yet
esac
}
@ -869,6 +883,58 @@ _live_serverspace() {
return 0
}
_live_gcore_body() {
local fixture_dir="$1"
local name="spawn-record-$(date +%s)"
local region="${GCORE_REGION:-ed-1}"
local project_id="${GCORE_PROJECT_ID:-}"
printf '%b\n' " ${CYAN}live${NC} Creating test instance '${name}' (g1-standard-1-2, ${region})..." >&2
local ssh_keys_response
ssh_keys_response=$(gcore_api GET "/cloud/v1/ssh_keys/${project_id}")
local ssh_key_name
ssh_key_name=$(echo "$ssh_keys_response" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
keys = d.get('results', [])
print(keys[0]['name'] if keys else '')
" 2>/dev/null) || ssh_key_name=""
local image_id
image_id=$(echo "$(gcore_api GET "/cloud/v1/images/${project_id}/${region}")" | python3 -c "
import json, sys
d = json.loads(sys.stdin.read())
for img in d.get('results', []):
if 'ubuntu' in img.get('name', '').lower() and '24' in img.get('os_version', ''):
print(img['id']); break
else:
imgs = d.get('results', [])
if imgs: print(imgs[0]['id'])
" 2>/dev/null) || image_id=""
python3 -c "
import json, sys
body = {
'name': sys.argv[1],
'flavor': 'g1-standard-1-2',
'volumes': [{'source': 'image', 'image_id': sys.argv[3], 'size': 20, 'boot_index': 0}],
'interfaces': [{'type': 'external'}]
}
if sys.argv[2]:
body['keypair_name'] = sys.argv[2]
print(json.dumps(body))
" "$name" "$ssh_key_name" "$image_id"
}
_live_gcore() {
local project_id="${GCORE_PROJECT_ID:-}"
local region="${GCORE_REGION:-ed-1}"
_live_create_delete_cycle "$1" gcore_api \
"/cloud/v2/instances/${project_id}/${region}" \
"/cloud/v1/instances/${project_id}/${region}/{id}" \
"d.get('instances',[''])[0]" _live_gcore_body 5
}
# --- Record one cloud ---
# Check credentials and prompt if needed; returns 1 to skip this cloud
_record_ensure_credentials() {