UNPKG

@cap-js-community/mtx-tool

Version:

Multitenancy and Extensibility Tool is a cli to reduce operational overhead for multitenant Cloud Foundry applications

290 lines (261 loc) 10.5 kB
"use strict"; const packageInfo = require("../../package.json"); const { parseIntWithFallback, randomString, sleep } = require("./static"); const { assert, fail } = require("./error"); const { request } = require("./request"); const { makeOneTime } = require("./execution-control"); const ENV = Object.freeze({ SVM_POLL_FREQUENCY: "MTX_SVM_POLL_FREQUENCY", }); const HTTP_ACCEPTED = 202; const SERVICE_MANAGER_POLL_FREQUENCY_FALLBACK = 6000; const SENSITIVE_FIELD_NAMES = ["uri"]; const SENSITIVE_FIELD_MARKERS = ["password", "key"]; const SENSITIVE_FIELD_HIDDEN_TEXT = "*** show with --reveal ***"; const QUERY_TYPE = Object.freeze({ FIELD: "field", LABEL: "label", }); const OPERATION_STATE = Object.freeze({ SUCCEEDED: "succeeded", FAILED: "failed", }); const pollFrequency = parseIntWithFallback( process.env[ENV.SVM_POLL_FREQUENCY], SERVICE_MANAGER_POLL_FREQUENCY_FALLBACK ); class ServiceManager { #credentials; #getToken; #clientName; #clientVersion; #pollFrequency; constructor({ credentials, getToken } = {}) { assert(credentials?.sm_url, "ServiceManager requires credentials with sm_url"); assert(typeof getToken === "function", "ServiceManager requires a getToken(credentials) function"); this.#credentials = credentials; this.#getToken = getToken; this.#clientName = packageInfo.name; this.#clientVersion = packageInfo.version; this.#pollFrequency = pollFrequency; } // NOTE: v2 encodes multiple label filters as one comma-separated `labels` query param // (e.g. `labels=tenant_id=abc,service_plan_id=xyz`) and non-label filters as plain query params. static #buildQuery(components) { const labels = []; const query = {}; for (const { predicate, type, key, value } of components) { if (!predicate) continue; if (type === QUERY_TYPE.LABEL) { labels.push(`${key}=${value}`); } else { query[key] = value; } } if (labels.length > 0) { query.labels = labels.join(","); } return Object.keys(query).length > 0 ? { query } : undefined; } async #requestBase(reqOptions = {}) { return await request({ url: this.#credentials.sm_url, auth: { token: await this.#getToken(this.#credentials) }, ...reqOptions, headers: { // NOTE: service-manager uses this client information for better consumption reporting and rate-limiting "Client-Name": this.#clientName, "Client-Version": this.#clientVersion, ...reqOptions?.headers, }, }); } // NOTE: service-manager v2 paginates via a Link header: </path?page_token=x>; rel="next" static #parseLinkNextPageToken(linkHeader) { if (!linkHeader) return undefined; const match = /[<]([^>]+)[>]; rel="next"/.exec(linkHeader); if (!match || !match[1]) return undefined; return new URL(match[1], "http://x").searchParams.get("page_token"); } async #requestPaginatedGet(reqOptions) { const pages = []; let pageToken; do { const response = await this.#requestBase({ ...reqOptions, ...(pageToken && { query: { ...reqOptions?.query, page_token: pageToken } }), }); const data = await response.json(); pages.push(data?.items ?? []); pageToken = ServiceManager.#parseLinkNextPageToken(response.headers.get("link")); } while (pageToken); return pages.flat(); } async #requestWithPolling(reqOptions) { const response = await this.#requestBase(reqOptions); assert( response.status === HTTP_ACCEPTED, "got unexpected response code %i for polling from %s", response.status, reqOptions.pathname ); const location = response.headers.get("location"); assert(location, "missing location header for polling from %s", reqOptions.pathname); let operation; do { await sleep(this.#pollFrequency); const pollResponse = await this.#requestBase({ pathname: location }); operation = await pollResponse.json(); } while (operation.state !== OPERATION_STATE.SUCCEEDED && operation.state !== OPERATION_STATE.FAILED); if (operation.state === OPERATION_STATE.FAILED) { const detail = operation.error?.description ?? operation.error?.broker_error?.description ?? ""; fail("service-manager operation failed for %s: %s", location, detail); } return operation; } #getOfferingsUnfiltered = makeOneTime( async () => await this.#requestPaginatedGet({ pathname: "/v2/service_offerings" }) ); async getOfferings({ filterOfferingName } = {}) { if (filterOfferingName) { return await this.#requestPaginatedGet({ pathname: "/v2/service_offerings", ...ServiceManager.#buildQuery([ { predicate: true, type: QUERY_TYPE.FIELD, key: "name", value: filterOfferingName }, ]), }); } return await this.#getOfferingsUnfiltered(); } #getPlansUnfiltered = makeOneTime(async () => await this.#requestPaginatedGet({ pathname: "/v2/service_plans" })); async getPlans({ filterPlanId, filterOfferingId, filterPlanName } = {}) { if (filterPlanId || filterOfferingId || filterPlanName) { return await this.#requestPaginatedGet({ pathname: "/v2/service_plans", ...ServiceManager.#buildQuery([ { predicate: filterPlanId, type: QUERY_TYPE.FIELD, key: "id", value: filterPlanId }, { predicate: filterOfferingId, type: QUERY_TYPE.FIELD, key: "service_offering_id", value: filterOfferingId }, { predicate: filterPlanName, type: QUERY_TYPE.FIELD, key: "name", value: filterPlanName }, ]), }); } return await this.#getPlansUnfiltered(); } async getPlanInfo(offeringName, planName) { assert(offeringName, "getPlanInfo requires offeringName"); assert(planName, "getPlanInfo requires planName"); const [offering] = await this.getOfferings({ filterOfferingName: offeringName }); assert(offering?.id, `could not find service offering "${offeringName}"`); const [plan] = await this.getPlans({ filterOfferingId: offering.id, filterPlanName: planName }); assert(plan?.id, `could not find service plan "${planName}" within offering "${offeringName}"`); return { offeringId: offering.id, offeringName, planId: plan.id, planName }; } async getInstances({ filterTenantId, filterPlanId, doEnsureUsable = false, doEnsureTenantLabel = false } = {}) { let instances = await this.#requestPaginatedGet({ pathname: "/v2/service_instances", ...ServiceManager.#buildQuery([ { predicate: filterPlanId, type: QUERY_TYPE.FIELD, key: "service_plan_id", value: filterPlanId }, { predicate: filterTenantId, type: QUERY_TYPE.LABEL, key: "tenant_id", value: filterTenantId }, ]), }); if (doEnsureUsable) { // NOTE: we rely on service brokers to implement usable: // https://github.com/cloudfoundry/servicebroker/blob/v2.17/spec.md#service-broker-errors instances = instances.filter((instance) => instance.usable); } if (doEnsureTenantLabel) { instances = instances.filter((instance) => instance.labels.tenant_id !== undefined); } return instances; } static #hideSensitiveDataInBinding(binding) { const fields = binding?.credentials ? Object.keys(binding.credentials) : []; for (const field of fields) { if (SENSITIVE_FIELD_MARKERS.some((marker) => field.includes(marker)) || SENSITIVE_FIELD_NAMES.includes(field)) { binding.credentials[field] = SENSITIVE_FIELD_HIDDEN_TEXT; } } } async getBindings({ filterTenantId, doEnsureTenantLabel = false, doAssertFoundSome = false, doReveal = false } = {}) { let bindings = await this.#requestPaginatedGet({ pathname: "/v2/service_bindings", ...ServiceManager.#buildQuery([ { predicate: filterTenantId, type: QUERY_TYPE.LABEL, key: "tenant_id", value: filterTenantId }, ]), }); if (doEnsureTenantLabel) { bindings = bindings.filter((binding) => binding.labels.tenant_id !== undefined); } if (doAssertFoundSome) { assert( bindings.length >= 1, filterTenantId ? `could not find service binding for tenant ${filterTenantId}` : "could not find any service bindings" ); } if (!doReveal) { bindings.forEach(ServiceManager.#hideSensitiveDataInBinding); } return bindings; } async createInstance(planId, { name = randomString(32), tenantId, parameters, labels: extraLabels } = {}) { assert(planId, "createInstance requires planId"); const labels = { ...(tenantId && { tenant_id: [tenantId] }), ...extraLabels, }; return await this.#requestWithPolling({ method: "POST", pathname: "/v2/service_instances", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, service_plan_id: planId, ...(Object.keys(labels).length > 0 && { labels }), ...(parameters && { parameters }), }), }); } async createBinding(instanceId, planId, { name = randomString(32), labels: extraLabels, parameters } = {}) { assert(instanceId, "createBinding requires instanceId"); assert(planId, "createBinding requires planId"); // NOTE: cds-mtxs relies on the service_plan_id label // NOTE: service-manager sets the container_id and subaccount_id itself and will block requests that set these const labels = Object.fromEntries( Object.entries({ ...extraLabels, service_plan_id: [planId], }).filter(([key]) => !["container_id", "subaccount_id"].includes(key)) ); return await this.#requestWithPolling({ method: "POST", pathname: "/v2/service_bindings", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, service_instance_id: instanceId, ...(Object.keys(labels).length > 0 && { labels }), ...(parameters && { parameters }), }), }); } async deleteInstance(instanceId) { assert(instanceId, "deleteInstance requires instanceId"); return await this.#requestWithPolling({ method: "DELETE", pathname: `/v2/service_instances/${instanceId}`, }); } async deleteBinding(bindingId) { assert(bindingId, "deleteBinding requires bindingId"); return await this.#requestWithPolling({ method: "DELETE", pathname: `/v2/service_bindings/${bindingId}`, }); } } module.exports = { OPERATION_STATE, ServiceManager, };