#!/usr/bin/env bash API_URL="${SCW_API_URL:-https://api.scaleway.com}" CREDENTIALS_FILE="${LAB_CREDENTIALS_FILE:-${HOME}/.config/two-lab/scaleway.env}" OFFER_NAME="${LAB_OFFER:-EM-B212X-SSD}" OS_NAME="${LAB_OS_NAME:-Debian}" OS_VERSION="${LAB_OS_VERSION:-12}" LAB_NAME="${LAB_NAME:-two-lab}" LAB_TAG="${LAB_TAG:-two-lab}" INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-3600}" DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-900}" SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-600}" POLL_INTERVAL="${LAB_POLL_INTERVAL:-20}" HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}" HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}" STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}" SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}" CREATED_ID="" info () { echo "== ${1}" >&2; } warn () { echo "!! ${1}" >&2; } die () { echo "!! ${1}" >&2; exit 1; } usage () { cat >&2 < [arguments] plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création et le prix ; ne crée rien up crée le serveur de lab, attend la fin de son installation et son SSH status liste les serveurs de lab du projet ssh [commande] se connecte au serveur de lab down supprime tous les serveurs de lab du projet et attend leur disparition session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive environnement : SCW_SECRET_KEY et SCW_DEFAULT_PROJECT_ID (requis, à défaut lus dans ${CREDENTIALS_FILE}, fichier en 0600, lignes CLÉ=valeur), SCW_DEFAULT_ZONE (${ZONE}), LAB_OFFER (${OFFER_NAME}), LAB_OS_NAME (${OS_NAME}), LAB_OS_VERSION (${OS_VERSION}), LAB_SSH_KEY (${SSH_KEY} ; à défaut, l'agent SSH) ne jamais lancer sous 'bash -x' : la trace afficherait SCW_SECRET_KEY. EOF exit 2 } load_credentials () { local KEY VALUE if [[ -z "${SCW_SECRET_KEY:-}" || -z "${SCW_DEFAULT_PROJECT_ID:-}" ]] && [[ -f "${CREDENTIALS_FILE}" ]]; then [[ $(ls -l "${CREDENTIALS_FILE}" | cut -c5-10) == "------" ]] \ || die "${CREDENTIALS_FILE} est lisible par d'autres que son propriétaire : chmod 600" while IFS='=' read -r KEY VALUE || [[ -n "${KEY}" ]]; do case "${KEY}" in SCW_SECRET_KEY) [[ -n "${SCW_SECRET_KEY:-}" ]] || SCW_SECRET_KEY="${VALUE}" ;; SCW_DEFAULT_PROJECT_ID) [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]] || SCW_DEFAULT_PROJECT_ID="${VALUE}" ;; SCW_DEFAULT_ZONE) [[ -n "${SCW_DEFAULT_ZONE:-}" ]] || SCW_DEFAULT_ZONE="${VALUE}" ;; esac done < "${CREDENTIALS_FILE}" fi ZONE="${SCW_DEFAULT_ZONE:-fr-par-1}" PROJECT_ID="${SCW_DEFAULT_PROJECT_ID:-}" } require_env () { [[ -n "${SCW_SECRET_KEY:-}" ]] || die "SCW_SECRET_KEY absent de l'environnement" [[ -n "${PROJECT_ID}" ]] || die "SCW_DEFAULT_PROJECT_ID absent de l'environnement" command -v jq >/dev/null 2>&1 || die "jq est requis" command -v curl >/dev/null 2>&1 || die "curl est requis" } http_code () { cat "${STATE_DIR}/last_http_code" 2>/dev/null } api () { local METHOD="${1}" local URL_PATH="${2}" local BODY="${3:-}" local OUT CODE local -a ARGS=(-sS -X "${METHOD}" -H "Content-Type: application/json" -w '\n%{http_code}' --connect-timeout "${HTTP_CONNECT_TIMEOUT}" --max-time "${HTTP_TIMEOUT}") : > "${STATE_DIR}/last_http_code" [[ -n "${BODY}" ]] && ARGS+=(--data "${BODY}") OUT=$(curl "${ARGS[@]}" -H @<(printf 'X-Auth-Token: %s\n' "${SCW_SECRET_KEY}") "${API_URL}${URL_PATH}") \ || { warn "${METHOD} ${URL_PATH} : échec réseau"; return 1; } CODE="${OUT##*$'\n'}" OUT="${OUT%$'\n'*}" printf '%s' "${CODE}" > "${STATE_DIR}/last_http_code" if [[ "${CODE}" -lt 200 || "${CODE}" -ge 300 ]]; then warn "${METHOD} ${URL_PATH} : HTTP ${CODE} ${OUT}" return 1 fi printf '%s\n' "${OUT}" } http_failure_is_final () { local CODE CODE=$(http_code) [[ "${CODE}" =~ ^4[0-9][0-9]$ && "${CODE}" != "429" ]] } money () { jq -r "${1} | if . == null then \"?\" else \"\\(.units + .nanos / 1000000000) \\(.currency_code)\" end" } resolve_offer () { local JSON OFFER COUNT JSON=$(api GET "/baremetal/v1/zones/${ZONE}/offers?subscription_period=hourly&name=${OFFER_NAME}&page_size=100") \ || die "impossible de lister les offres" OFFER=$(jq -c --arg n "${OFFER_NAME}" \ '[.offers[] | select(.name == $n and .subscription_period == "hourly")]' <<< "${JSON}") COUNT=$(jq 'length' <<< "${OFFER}") [[ "${COUNT}" -eq 1 ]] || die "offre horaire ${OFFER_NAME} : ${COUNT} correspondance(s) en ${ZONE}, une seule attendue" OFFER=$(jq -c '.[0]' <<< "${OFFER}") [[ $(jq -r '.enable' <<< "${OFFER}") == "true" ]] || die "offre ${OFFER_NAME} désactivée" [[ $(jq -r '.stock' <<< "${OFFER}") != "empty" ]] || die "offre ${OFFER_NAME} en rupture de stock en ${ZONE}" [[ $(jq -r '((.fee.units // 0) == 0) and ((.fee.nanos // 0) == 0)' <<< "${OFFER}") == "true" ]] \ || die "offre ${OFFER_NAME} : frais de mise en service non nuls ($(money '.fee' <<< "${OFFER}")), refus" printf '%s\n' "${OFFER}" } resolve_os () { local OFFER_ID="${1}" local JSON OS COUNT JSON=$(api GET "/baremetal/v1/zones/${ZONE}/os?offer_id=${OFFER_ID}&page_size=100") \ || die "impossible de lister les OS" OS=$(jq -c --arg n "${OS_NAME}" --arg v "${OS_VERSION}" \ '[.os[] | select(.enabled and .allowed and (.name | ascii_downcase | contains($n | ascii_downcase)) and (.version | split(" ")[0] | split(".")[0]) == $v)]' <<< "${JSON}") COUNT=$(jq 'length' <<< "${OS}") if [[ "${COUNT}" -ne 1 ]]; then warn "OS disponibles pour ${OFFER_NAME} :" jq -r '.os[] | " \(.name) \(.version) (\(.id))"' <<< "${JSON}" >&2 die "OS ${OS_NAME} ${OS_VERSION} : ${COUNT} correspondance(s), une seule attendue" fi jq -c '.[0]' <<< "${OS}" } resolve_ssh_keys () { local JSON JSON=$(api GET "/iam/v1alpha1/ssh-keys?project_id=${PROJECT_ID}&disabled=false&page_size=100") \ || die "impossible de lister les clés SSH du projet" JSON=$(jq -c '[.ssh_keys[] | select(.disabled | not) | .id]' <<< "${JSON}") [[ $(jq 'length' <<< "${JSON}") -gt 0 ]] || die "aucune clé SSH active dans le projet ${PROJECT_ID}" printf '%s\n' "${JSON}" } build_request () { local OFFER="${1}" local OS="${2}" local KEYS="${3}" jq -nc --argjson offer "${OFFER}" --argjson os "${OS}" --argjson keys "${KEYS}" \ --arg project "${PROJECT_ID}" --arg name "${LAB_NAME}" --arg tag "${LAB_TAG}" \ '{offer_id: $offer.id, project_id: $project, name: $name, description: "lab two (#50)", tags: [$tag], install: {os_id: $os.id, hostname: $name, ssh_key_ids: $keys}}' } lab_servers () { local JSON JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers?project_id=${PROJECT_ID}&tags=${LAB_TAG}&page_size=100") \ || return 1 jq -c --arg p "${PROJECT_ID}" --arg t "${LAB_TAG}" \ '[.servers[] | select(.project_id == $p and (.tags | index($t)))]' <<< "${JSON}" } server_ip () { jq -r '[.ips[] | select(.version == "IPv4") | .address][0] // empty' } server_is_installing () { jq -e '.status == "delivering" or .status == "ordered" or (.install.status // "") == "to_install" or (.install.status // "") == "installing"' >/dev/null } cmd_plan () { local OFFER OS KEYS OFFER=$(resolve_offer) || exit 1 OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1 KEYS=$(resolve_ssh_keys) || exit 1 info "offre : ${OFFER_NAME} ($(jq -r '.id' <<< "${OFFER}")), facturation $(jq -r '.subscription_period' <<< "${OFFER}"), stock $(jq -r '.stock' <<< "${OFFER}")" info "prix : $(money '.price_per_hour' <<< "${OFFER}") HT par heure, frais de mise en service $(money '.fee' <<< "${OFFER}")" info "os : $(jq -r '"\(.name) \(.version) (\(.id))"' <<< "${OS}"), utilisateur $(jq -r '.user.default_value // "?"' <<< "${OS}")" info "clés : $(jq -r 'length' <<< "${KEYS}") clé(s) SSH du projet" info "requête : POST /baremetal/v1/zones/${ZONE}/servers" build_request "${OFFER}" "${OS}" "${KEYS}" | jq . } wait_installed () { local ID="${1}" local DEADLINE=$(( SECONDS + INSTALL_TIMEOUT )) local JSON STATUS INSTALL while (( SECONDS < DEADLINE )); do if ! JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}"); then if http_failure_is_final; then warn "erreur définitive HTTP $(http_code) en attendant l'installation" return 1 fi sleep "${POLL_INTERVAL}" continue fi STATUS=$(jq -r '.status' <<< "${JSON}") INSTALL=$(jq -r '.install.status // "none"' <<< "${JSON}") info "serveur ${ID} : ${STATUS}, installation ${INSTALL}" case "${STATUS}/${INSTALL}" in ready/completed) printf '%s\n' "${JSON}"; return 0 ;; error/*|*/error|out_of_stock/*|locked/*|deleting/*|stopped/*|stopping/*) warn "état terminal en échec : ${STATUS}/${INSTALL}"; return 1 ;; esac sleep "${POLL_INTERVAL}" done warn "installation non terminée après ${INSTALL_TIMEOUT}s" return 1 } ssh_run () { local -a OPTS=() while [[ $# -gt 0 && "${1}" != "--" ]]; do OPTS+=("${1}") shift done [[ "${1:-}" == "--" ]] && shift [[ -f "${SSH_KEY}" ]] && OPTS+=(-i "${SSH_KEY}" -o IdentitiesOnly=yes -o IdentityAgent=none) env -u SCW_SECRET_KEY ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="${STATE_DIR}/known_hosts" \ "${OPTS[@]}" "$(cat "${STATE_DIR}/user")@$(cat "${STATE_DIR}/ip")" "$@" } wait_ssh () { local DEADLINE=$(( SECONDS + SSH_TIMEOUT )) while (( SECONDS < DEADLINE )); do ssh_run -o BatchMode=yes -o ConnectTimeout=5 -- true >/dev/null 2>&1 && return 0 info "SSH pas encore joignable, nouvel essai dans ${POLL_INTERVAL}s" sleep "${POLL_INTERVAL}" done return 1 } require_no_lab_server () { local EXISTING EXISTING=$(lab_servers) || die "impossible de lister les serveurs de lab" [[ $(jq 'length' <<< "${EXISTING}") -eq 0 ]] \ || die "un serveur de lab existe déjà dans le projet ; '${0##*/} status' puis '${0##*/} down'" } report_uncertain_creation () { local FOUND if FOUND=$(lab_servers) && [[ $(jq 'length' <<< "${FOUND}") -gt 0 ]]; then warn "un serveur de lab existe bien : $(jq -r '[.[].id] | join(", ")' <<< "${FOUND}")" fi die "création incertaine : le serveur a pu être créé et être facturé ; '${0##*/} status' puis '${0##*/} down'" } cmd_up () { local OFFER OS KEYS BODY JSON ID IP SSH_USER require_no_lab_server OFFER=$(resolve_offer) || exit 1 OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1 KEYS=$(resolve_ssh_keys) || exit 1 BODY=$(build_request "${OFFER}" "${OS}" "${KEYS}") info "création de ${LAB_NAME} (${OFFER_NAME}, $(money '.price_per_hour' <<< "${OFFER}")/h HT)" JSON=$(api POST "/baremetal/v1/zones/${ZONE}/servers" "${BODY}") || report_uncertain_creation ID=$(jq -r '.id // empty' <<< "${JSON}" 2>/dev/null) [[ -n "${ID}" ]] || report_uncertain_creation CREATED_ID="${ID}" info "serveur ${ID} créé, facturé jusqu'à '${0##*/} down'" JSON=$(wait_installed "${ID}") || die "serveur ${ID} inutilisable — il est toujours facturé : '${0##*/} down'" IP=$(server_ip <<< "${JSON}") [[ -n "${IP}" ]] || die "serveur ${ID} sans IPv4 publique — il est toujours facturé : '${0##*/} down'" SSH_USER=$(jq -r '.user.default_value // empty' <<< "${OS}") printf '%s\n' "${IP}" > "${STATE_DIR}/ip" printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user" : > "${STATE_DIR}/known_hosts" wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'" info "prêt : ${SSH_USER:-root}@${IP}" } cmd_status () { local SERVERS SERVERS=$(lab_servers) || die "impossible de lister les serveurs de lab" if [[ $(jq 'length' <<< "${SERVERS}") -eq 0 ]]; then info "aucun serveur de lab dans le projet ${PROJECT_ID} (${ZONE})" return 0 fi jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}" } cmd_ssh () { [[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord" ssh_run -- "$@" } delete_server () { local ID="${1}" local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) local HARD_DEADLINE=$(( SECONDS + DELETE_TIMEOUT + INSTALL_TIMEOUT )) local JSON while (( SECONDS < DEADLINE )); do api DELETE "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null && return 0 [[ $(http_code) == "404" ]] && return 0 if JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" 2>/dev/null) && server_is_installing <<< "${JSON}"; then DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) (( DEADLINE > HARD_DEADLINE )) && DEADLINE="${HARD_DEADLINE}" warn "suppression de ${ID} refusée pendant la livraison ou l'installation, nouvel essai dans ${POLL_INTERVAL}s" else warn "suppression de ${ID} refusée, nouvel essai dans ${POLL_INTERVAL}s" fi sleep "${POLL_INTERVAL}" done return 1 } wait_gone () { local ID="${1}" local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) while (( SECONDS < DEADLINE )); do if ! api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null 2>&1; then [[ $(http_code) == "404" ]] && return 0 fi sleep "${POLL_INTERVAL}" done return 1 } lab_server_ids () { local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) local SERVERS while (( SECONDS < DEADLINE )); do if SERVERS=$(lab_servers); then jq -r '.[].id' <<< "${SERVERS}" return 0 fi warn "liste des serveurs de lab indisponible, nouvel essai dans ${POLL_INTERVAL}s" sleep "${POLL_INTERVAL}" done return 1 } cmd_down () { local IDS ID FAILED=0 if ! IDS=$(lab_server_ids); then warn "impossible de lister les serveurs de lab après ${DELETE_TIMEOUT}s" FAILED=1 IDS="" fi IDS=$(printf '%s\n%s\n' "${IDS}" "${CREATED_ID}" | grep -v '^$' | sort -u) for ID in ${IDS}; do info "suppression de ${ID}" if ! delete_server "${ID}"; then warn "suppression de ${ID} toujours refusée" FAILED=1 continue fi if ! wait_gone "${ID}"; then warn "${ID} toujours présent après ${DELETE_TIMEOUT}s" FAILED=1 fi done rm -f "${STATE_DIR}/ip" "${STATE_DIR}/user" "${STATE_DIR}/known_hosts" [[ "${FAILED}" -eq 0 ]] || die "SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S) — '${0##*/} status', puis la console Scaleway" info "aucun serveur de lab ne reste dans le projet" } cmd_session () { local RC require_no_lab_server trap 'trap - EXIT; trap "" INT TERM HUP; cmd_down' EXIT trap 'exit 130' INT trap 'exit 143' TERM trap 'exit 129' HUP cmd_up cmd_ssh "$@" RC=$? info "session terminée (code ${RC}), suppression du serveur" return "${RC}" } main () { local COMMAND="${1:-}" [[ $# -gt 0 ]] && shift load_credentials [[ -n "${COMMAND}" ]] || usage mkdir -p "${STATE_DIR}" || die "impossible de créer ${STATE_DIR}" chmod 700 "${STATE_DIR}" case "${COMMAND}" in plan) require_env; cmd_plan ;; up) require_env; cmd_up ;; status) require_env; cmd_status ;; ssh) cmd_ssh "$@" ;; down) require_env; cmd_down ;; session) require_env; cmd_session "$@" ;; *) usage ;; esac } if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then main "$@" fi