UNPKG

@canonical/jujulib

Version:
520 lines (508 loc) 22.8 kB
// Copyright 2020 Canonical Ltd. // Licensed under the LGPLv3, see LICENSE.txt file for details. import AdminV3 from "./facades/admin/AdminV3.js"; import { createAsyncHandler, toError } from "./utils.js"; import AdminV4 from "./custom-facades/AdminV4.js"; export const CLIENT_VERSION = "3.3.2"; // The type of a Macaroon from the Admin facade does not match a real macaroon. const isMacaroonObject = (macaroon) => !!macaroon && typeof macaroon === "object"; /** Connect to the Juju controller or model at the given URL. @param url The WebSocket URL of the Juju controller or model. @param options Connections options, including: - facades (default=[]): the list of facade classes to include in the API connection object. Those classes are usually auto-generated and can be found in the facades directory of the project. When multiple versions of the same facade are included, the most recent version supported by the server is made available as part of the connection object; - debug (default=false): when enabled, all API messages are logged at debug level; - wsclass (default=W3C browser native WebSocket): the WebSocket class to use for opening the connection and sending/receiving messages. Server side, require('websocket').w3cwebsocket can be used safely, as it implements the W3C browser native WebSocket API; - bakery (default: null): the bakery client to use when macaroon discharges are required, in the case an external user is used to connect to Juju; see <https://www.npmjs.com/package/macaroon-bakery>; - closeCallback: a callback to be called with the exit code when the connection is closed. @param callback Called when the connection is made, the callback receives an error and a client object. If there are no errors, the client can be used to login and logout to Juju. See the docstring for the Client class for information on how to use the client. @return This promise will be rejected if there is an error connecting, or resolved with a new Client instance. Note that the promise will not be resolved or rejected if a callback is provided. */ function connect(url, options, callback) { if (!options) { options = { closeCallback: () => { } }; } if (!options.bakery) { options.bakery = null; } if (!options.closeCallback) { options.closeCallback = () => { }; } if (!options.debug) { options.debug = false; } if (!options.facades) { options.facades = []; } if (!options.wsclass) { console.error("No websocket provided. define 'wsclass'."); } return new Promise((resolve, reject) => { var _a; // Instantiate the WebSocket, and make the client available when the // connection is open. const ws = new options.wsclass(url); const handler = createAsyncHandler(callback, resolve, reject); ws.onopen = (_evt) => { handler.resolve(new Client(ws, options)); }; ws.onclose = (evt) => { handler.reject(toError("cannot connect WebSocket: " + evt.reason)); }; ws.onerror = (evt) => { console.log("--", evt); }; (_a = options === null || options === void 0 ? void 0 : options.onWSCreated) === null || _a === void 0 ? void 0 : _a.call(options, ws); }); } /** Connect to the Juju controller or model at the given URL and the authenticate using the given credentials. @param url The WebSocket URL of the Juju controller or model. @param credentials An object with the user and password fields for userpass authentication or the macaroons field for bakery authentication. If an empty object is provided a full bakery discharge will be attempted for logging in with macaroons. Any necessary third party discharges are performed using the bakery instance provided in the options (see below). @param options Connections options, including: - facades (default=[]): the list of facade classes to include in the API connection object. Those classes are usually auto-generated and can be found in the facades directory of the project. When multiple versions of the same facade are included, the most recent version supported by the server is made available as part of the connection object; - debug (default=false): when enabled, all API messages are logged at debug level; - wsclass (default=W3C browser native WebSocket): the WebSocket class to use for opening the connection and sending/receiving messages. Server side, require('websocket').w3cwebsocket can be used safely, as it implements the W3C browser native WebSocket API; - bakery (default: null): the bakery client to use when macaroon discharges are required, in the case an external user is used to connect to Juju; see <https://www.npmjs.com/package/macaroon-bakery>; - closeCallback: a callback to be called with the exit code when the connection is closed. @return This promise will be rejected if there is an error connecting, or resolved with a new {conn, logout} object. Note that the promise will not be resolved or rejected if a callback is provided. */ async function connectAndLogin(url, options, credentials, clientVersion = CLIENT_VERSION) { // Connect to Juju. const juju = await connect(url, options); try { const conn = await juju.login(credentials, clientVersion); return { conn, logout: juju.logout.bind(juju) }; } catch (error) { if (!juju.isRedirectionError(error)) { throw error; } // Redirect to the real model. juju.logout(); for (let i = 0; i < error.servers.length; i++) { const srv = error.servers[i][0]; // TODO(frankban): we should really try to connect to all servers and // just use the first connection available, without second guessing // that the public hostname is reachable. if (srv.type === "hostname" && srv.scope === "public") { // This is a public server with a dns-name, connect to it. const generateURL = (uuidOrURL, srv) => { let uuid = uuidOrURL; if (uuid.startsWith("wss://") || uuid.startsWith("ws://")) { const parts = uuid.split("/"); uuid = parts[parts.length - 2]; } return `wss://${srv.value}:${srv.port}/model/${uuid}/api`; }; return await connectAndLogin(generateURL(url, srv), options, credentials, clientVersion); } } throw new Error("cannot connect to model after redirection"); } } /** Returns a URL that is to be used to connect to a supplied model uuid on the supplied controller host. @param controllerHost The url that's used to connect to the controller. The `connectAndLogin` method handles redirections so the public URL is fine. @param modelUUID The UUID of the model to connect to. @returns The fully qualified wss URL to connect to the model. */ function generateModelURL(controllerHost, modelUUID) { return `wss://${controllerHost}/model/${modelUUID}/api`; } /** A Juju API client allowing for logging in and get access to facades. @param ws The WebSocket instance already connected to a Juju controller or model. @param options Connections options. See the connect documentation above for a description of available options. */ class Client { constructor(ws, options) { // Instantiate the transport, used for sending messages to the server. this._transport = new Transport(ws, options.closeCallback, Boolean(options.debug)); this._loginWithSessionCookie = options.loginWithSessionCookie || false; this._facades = options.facades || []; this._bakery = options.bakery; this._admin = new (this._loginWithSessionCookie ? AdminV4 : AdminV3)(this._transport, {}); } /** Log in to Juju. @param credentials An object with the user and password fields for userpass authentication or the macaroons field for bakery authentication. If an empty object is provided a full bakery discharge will be attempted for logging in with macaroons. Any necessary third party discharges are performed using the bakery instance originally provided to connect(). @param clientVersion The Juju controller version to target. @return This promise will be rejected if there is an error connecting, or resolved with a new connection instance. Note that the promise will not be resolved or rejected if a callback is provided. */ async login(credentials, clientVersion = CLIENT_VERSION) { var _a; const args = { "auth-tag": "", "client-version": clientVersion, credentials: "", macaroons: [], nonce: "", "user-data": "", }; const url = this._transport._ws.url; const origin = url; if (credentials && "username" in credentials) { args.credentials = credentials.password; args["auth-tag"] = `user-${credentials.username}`; } else if (credentials && "macaroons" in credentials) { const macaroons = (_a = this._bakery) === null || _a === void 0 ? void 0 : _a.storage.get(origin); let deserialized; if (macaroons) { deserialized = JSON.parse(atob(macaroons)); } args.macaroons = [deserialized]; } // eslint-disable-next-line no-async-promise-executor return await new Promise(async (resolve, reject) => { let response = null; try { try { response = this._loginWithSessionCookie && "loginWithSessionCookie" in this._admin ? await this._admin.loginWithSessionCookie() : await this._admin.login(args); } catch (error) { if (error instanceof Error && error.message === INVALIDCREDENTIALS_ERROR) { throw new Error("Have you been granted permission to a model on this controller?"); } else if (error instanceof Error && error.message === PERMISSIONDENIED_ERROR) { throw new Error("Ensure that you've been given 'login' permission on this controller."); } else { throw toError(error); } } const dischargeRequired = response["discharge-required"] || response["bakery-discharge-required"]; if (dischargeRequired) { if (!this._bakery) { reject(new Error("macaroon discharge required but no bakery instance provided")); return; } const onSuccess = (macaroons) => { var _a; // Store the macaroon in the bakery for the next connections. const serialized = btoa(JSON.stringify(macaroons)); (_a = this._bakery) === null || _a === void 0 ? void 0 : _a.storage.set(origin, serialized, () => { }); // Send the login request again including the discharge macaroons. return resolve(this.login({ ...credentials, macaroons: [macaroons] }, clientVersion)); }; const onFailure = (err) => { reject(new Error("macaroon discharge failed: " + (err instanceof Object && "Message" in err ? err.Message : err))); }; if (isMacaroonObject(dischargeRequired)) { this._bakery.discharge(dischargeRequired, onSuccess, onFailure); } else { throw new Error("Discharge macaroon doesn't appear to be a macaroon."); } return; } resolve(new Connection(this._transport, this._facades, response)); } catch (error) { if (!(error instanceof Error) || error.message !== REDIRECTION_ERROR) { reject(toError(error)); return; } // This is a model redirection error, fetch the redirection information. try { const info = await this._admin.redirectInfo(null); reject(new RedirectionError(info)); } catch (error) { reject(toError(error)); } } }); } /** Log out from Juju. @param callback Called when the logout process completes and the connection is closed, the callback receives the close code and optionally another callback. It is responsibility of the callback to call the provided callback if present. */ logout(callback) { this._transport.close(callback); } /** Report whether the given error is a redirection error from Juju. @param err The error returned by the login request. @returns Whether the given error is a redirection error. */ isRedirectionError(err) { return err instanceof RedirectionError; } } // Define the redirect error returned by Juju, and the one returned by the API. const REDIRECTION_ERROR = "redirection required"; const INVALIDCREDENTIALS_ERROR = "invalid entity name or password"; const PERMISSIONDENIED_ERROR = "permission denied"; class RedirectionError extends Error { constructor(info) { super(REDIRECTION_ERROR); this.servers = info.servers; this.caCert = info["ca-cert"]; Object.setPrototypeOf(this, RedirectionError.prototype); } } /** A transport providing the ability of sending and receiving WebSocket messages to and from Juju controllers and models. @param ws The WebSocket instance already connected to a Juju controller or model. @param closeCallback A callback to be called after the transport closes the connection. The callback receives the close code. @param debug When enabled, all API messages are logged at debug level. */ export class Transport { constructor(ws, closeCallback, debug) { this._ws = ws; this._counter = 0; this._callbacks = {}; this._closeCallback = closeCallback; this._debug = debug; ws.onmessage = (evt) => { if (this._debug) { console.debug("<--", evt.data); } this._handle(evt.data); }; ws.onclose = (evt) => { if (this._debug) { console.debug("close:", evt.code, evt.reason); } this._closeCallback(evt.code); }; } /** Send a message to Juju. @param req A Juju API request, typically in the form of an object like {type: 'Client', request: 'DoSomething', version: 1, params: {}}. The request must not be already serialized and must not include the request id, as those are responsibilities of the transport. @param resolve Function called when the request is successful. @param reject Function called when the request is not successful. */ write(req, resolve, reject) { // Check that the connection is ready and sane. const state = this._ws.readyState; if (state !== 1) { const reqStr = JSON.stringify(req); const error = new Error(`cannot send request ${reqStr}: connection state ${state} is not open`); reject(error); } this._counter += 1; // Include the current request id in the request. req["request-id"] = this._counter; this._callbacks[this._counter] = (error, result) => error ? reject(error) : resolve(result); const msg = JSON.stringify(req); if (this._debug) { console.debug("-->", msg); } // Send the request to Juju. this._ws.send(msg); } /** Close the transport, and therefore the connection. @param callback Called after the transport is closed, the callback receives the close code and optionally another callback. It is responsibility of the callback to call the provided callback if present. */ close(callback) { const closeCallback = this._closeCallback; this._closeCallback = (code) => { if (callback) { callback(code, closeCallback); return; } closeCallback(code); }; this._ws.close(); } /** Handle responses arriving from Juju. @param data: the raw response from Juju, usually as a JSON encoded string. */ _handle(data) { let resp; try { resp = JSON.parse(data); } catch (error) { console.error("Unable to parse the raw response from Juju:", data, error); return; } if (!(resp && typeof resp === "object" && "request-id" in resp && typeof resp["request-id"] === "number" && ("error" in resp || "response" in resp))) { console.error("Parsed raw response from Juju is in incorrect format:", resp); return; } const id = resp["request-id"]; const callback = this._callbacks[id]; delete this._callbacks[id]; if (!callback) { console.error("Parsed raw response from Juju can't be handled. No callback available."); return; } "error" in resp ? callback(toError(resp.error)) : callback(null, resp.response); } } /** A connection to a Juju controller or model. This is the object users use to perform Juju API calls, as it provides access to all available facades (conn.facades), to a transport connected to Juju (conn.transport) and to information about the connected Juju server (conn.info). @param transport The Transport instance used to communicate with Juju. The transport is available exposed to users via the transport property of the connection instance. See the Transport docstring for information on how to use the transport, typically calling transport.write. @param facades The facade classes provided in the facades property of the options provided to the connect function. When the connection is instantiated, the matching available facades as declared by Juju are instantiated and access to them is provided via the facades property of the connection. @param loginResult The result to the Juju login request. It includes information about the Juju server and available facades. This info is made available via the info property of the connection instance. */ class Connection { constructor(transport, facades, loginResult) { var _a; // Store the transport used for sending messages to Juju. this.transport = transport; // Populate info. this.info = { controllerTag: loginResult["controller-tag"], serverVersion: loginResult["server-version"], servers: loginResult.servers, user: loginResult["user-info"], getFacade: (name) => { return this.facades[name]; }, }; // Handle facades. const loginSupportedFacades = (_a = loginResult.facades) !== null && _a !== void 0 ? _a : []; const clientRequestedFacades = facades.reduce((facadeVersions, current) => { if ("versions" in current) { // generic facade, where we want the best version if (!facadeVersions[current.name]) facadeVersions[current.name] = []; facadeVersions[current.name].push(...current.versions); } else { // a specific version of a facade asked by the client const facade = current; if (!facadeVersions[facade.NAME]) facadeVersions[facade.NAME] = []; facadeVersions[facade.NAME].push(facade); } return facadeVersions; }, {}); // find the most suitable facade version this.facades = Object.entries(clientRequestedFacades).reduce((suitableFacades, [facadeName, requestedFacades]) => { const supportedFacades = loginSupportedFacades.find((facadeVersions) => facadeVersions.name === facadeName); if (!supportedFacades || !supportedFacades.versions.length) return suitableFacades; const supportedFacadeVersions = new Set(supportedFacades.versions); const facadeCandidates = requestedFacades.filter((facade) => supportedFacadeVersions.has(facade.VERSION)); // get the last element from the list (priority) if (facadeCandidates.length) { const mostSuitableFacade = facadeCandidates[facadeCandidates.length - 1]; const facadeName = uncapitalize(mostSuitableFacade.NAME); suitableFacades[facadeName] = new mostSuitableFacade(this.transport, this.info); } return suitableFacades; }, {}); } } /** Convert ThisString to thisString and THATString to thatString. @param text A StringLikeThis. @returns A stringLikeThis. */ function uncapitalize(text) { if (!text) { return ""; } const isLower = (char) => char.toLowerCase() === char; if (isLower(text[0])) { return text; } const uppers = []; for (let i = 0; i < text.length; i++) { const char = text.charAt(i); if (isLower(char)) { break; } uppers.push(char); } if (uppers.length > 1) { uppers.pop(); } const prefix = uppers.join(""); return prefix.toLowerCase() + text.slice(prefix.length); } export { Client, connect, connectAndLogin, Connection, generateModelURL, RedirectionError, }; //# sourceMappingURL=client.js.map