@canonical/jujulib
Version:
Juju API client
520 lines (508 loc) • 22.8 kB
JavaScript
// 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