UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

564 lines 26.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Uninstall = exports.Install = exports.OperatorCmd = void 0; const command_1 = require("../cli/command"); const options_1 = require("./options"); const functions_1 = require("../util/functions"); const child_process_1 = require("child_process"); const client_1 = require("../session/client"); const resolver_1 = require("./resolver"); const links_1 = require("../util/links"); const client_node_1 = require("@kubernetes/client-node"); // ANCHOR - Constants const K8S_CONTROLPLANE_NAMESPACE = 'controlplane'; const K8S_SECRET_SERVICE_ACCOUNT_ANNOTATION = 'cpln/serviceaccount'; const RECOMMENDED_GROUP = 'superusers'; const UNRECOMMENDED_GROUP_WARNING = `WARNING: Assigning the service account to a group other than the '${RECOMMENDED_GROUP}' group is not recommended.`; const SERVICE_ACCOUNT_DESCRIPTION = 'Contains the cpln operator key'; const SERVICE_ACCOUNT_KEY_DESCRIPTION = 'The cpln operator key'; const K8S_OPERATOR_LABEL = { KEY: 'app.kubernetes.io/managed-by', VALUE: 'cpln-operator', }; // SECTION - Functions function withInstallOptions(yargs) { return yargs.options({ serviceaccount: { required: true, requiresArg: true, description: `The service account name for the operator. If the service account does not exist, a new one will be created`, alias: 's', }, 'serviceaccount-group': { requiresArg: true, description: `The group to assign to the service account if it will be created.`, alias: 'g', default: RECOMMENDED_GROUP, }, export: { boolean: true, description: `Export the Kubernetes resources to a file instead of applying them to the cluster.`, }, }); } // !SECTION // ANCHOR - Commands class OperatorCmd extends command_1.Command { constructor() { super(...arguments); this.command = 'operator'; this.describe = 'A Kubernetes operator for Control Plane'; } builder(yargs) { return (yargs .demandCommand() .version(false) .help() // specific .command(new Install().toYargs()) .command(new Uninstall().toYargs())); } handle() { } } exports.OperatorCmd = OperatorCmd; class OperatorCommandBase extends command_1.Command { // Protected Methods // /** * Checks required prerequisites: Helm, kubectl, and control plane access. */ async checkForPrerequisites() { // An organization is required this.requireOrg(); // Check if kubectl is installed with a reachable cluster this.checkForKubectl(); // Check if the organization is reachable await this.ensureControlPlaneAccess(); } /** * Checks for a Kubernetes secret with the specified namespace and org name. * * @param {CoreV1Api} k8sApi - Kubernetes API client. * @param {string} namespace - Namespace where the secret is located. * * @returns {Promise<V1Secret | null>} The secret if found, otherwise null. */ async checkK8sSecretExistence(k8sApi, namespace) { // Attempt to fetch the secret and read its annotations try { return (await k8sApi.readNamespacedSecret(this.session.context.org, namespace)).body; } catch (error) { client_1.wire.debug(error); } // Return null if the secret does not exist with the annotation return null; } // Private Methods // /** * Ensures kubectl is installed and the Kubernetes cluster is reachable. */ checkForKubectl() { // Check if kubectl is installed try { const versionOutput = (0, child_process_1.execSync)('kubectl version --client', { encoding: 'utf8' }); client_1.wire.debug(`Kubectl is installed: ${versionOutput}`); } catch (err) { this.session.abort({ message: 'ERROR: Kubectl CLI is not reachable or it is not installed. Please, make sure kubectl is installed and is configured correctly.', }); } // Check if the cluster is reachable try { const clusterInfo = (0, child_process_1.execSync)('kubectl cluster-info', { encoding: 'utf8' }); client_1.wire.debug(`Cluster is reachable: ${clusterInfo}`); } catch (error) { this.session.abort({ message: 'ERROR: Cluster is not reachable or kubectl configuration is incorrect. Please, check your kubectl configuration and try again.', }); } } /** * Confirms access to the platform by retrieving organization data. */ async ensureControlPlaneAccess() { // Construct the organization path const orgLink = (0, resolver_1.resolveToLink)('org', this.session.context.org, this.session.context); // Try to fetch the organization to see if the user has access to it try { const org = await this.client.get(orgLink); client_1.wire.debug(`Organization ${org.name} is reachable, version ${org.version}.`); } catch (error) { this.session.err(`ERROR: Organization ${this.session.context.org} is not reachable.`); this.session.abort({ error }); } } } class Install extends OperatorCommandBase { constructor() { super(...arguments); this.command = 'install'; this.describe = 'Install the Kubernetes operator to your cluster'; } // Public Methods // builder(yargs) { return (0, functions_1.pipe)( // withInstallOptions, options_1.withOrgOptions, options_1.withStandardOptions)(yargs); } async handle(args) { var _a, _b; // Check for prerequisites await this.checkForPrerequisites(); // Load kubeconfig from default location const kubeConfig = new client_node_1.KubeConfig(); kubeConfig.loadFromDefault(); // Get the API client const k8sApi = kubeConfig.makeApiClient(client_node_1.CoreV1Api); // Check if the service account annotation exists in an existing K8s secret for the specified org let secret = await this.checkK8sSecretExistence(k8sApi, K8S_CONTROLPLANE_NAMESPACE); // If the secret exists and is not being managed by the operator, abort! if (secret !== null) { // This label should indicate that the secret is managed by our operator const managedBy = (_b = (_a = secret.metadata) === null || _a === void 0 ? void 0 : _a.labels) === null || _b === void 0 ? void 0 : _b[K8S_OPERATOR_LABEL.KEY]; // If the label does not match the expected operator value, abort the installation // This prevents overwriting or conflicting with secrets that are not managed by the operator if (managedBy !== K8S_OPERATOR_LABEL.VALUE) { this.session.abort({ message: `ERROR: An existing secret for organization '${this.session.context.org}' was found, but it is not managed by '${K8S_OPERATOR_LABEL.VALUE}'. Aborting installation to avoid conflict.`, }); } } // Check if the secret has the service account annotation const hasServiceAccountAnnotation = await this.secretHasServiceAccountAnnotation(secret, args.serviceaccount); // Skip K8s secret creation if it already exists if (secret !== null && hasServiceAccountAnnotation) { this.session.err(`Secret for organization '${this.session.context.org}' already exists in the '${K8S_CONTROLPLANE_NAMESPACE}' namespace.`); } else { // Delete the secret if it already exists in the cluster, this way we always adhere to user's desired state if (!args.export && secret !== null) { await this.deleteK8sSecret(k8sApi, K8S_CONTROLPLANE_NAMESPACE, secret); } // Get the specified group before creating the service account, this is because the group might not exist or the user has no permissions to view it const group = await this.getGroup(args.serviceaccountGroup); // Get the service account; create it if it does not exist const serviceAccount = await this.resolveServiceAccount(args.serviceaccount); // Assign the service account to the recommended group or to the specified group await this.assignServiceAccountToGroup(serviceAccount.name, group); // Generate a new key for the service account const key = await this.generateKey(serviceAccount.name); // Construct the Kubernetes secret secret = this.initializeK8sSecret(K8S_CONTROLPLANE_NAMESPACE, serviceAccount.name, key); // Install the Kubernetes secret in the cluster if export is not specified if (!args.export) { await this.installK8sSecret(k8sApi, K8S_CONTROLPLANE_NAMESPACE, secret); } } // Export the secret if requested if (args.export) { this.exportSecret(secret); } } // Private Methods // /** * Checks if a Kubernetes secret has an annotation matching the specified service account. * * @param {V1Secret} secret - The Kubernetes secret to check. * @param {string} serviceAccountName - Service account name to match in the secret annotations. * * @returns {Promise<boolean>} True if the secret has the annotation, otherwise false. */ async secretHasServiceAccountAnnotation(secret, serviceAccountName) { var _a, _b; // Skip if the secret is null if (secret === null) { return false; } // Retrieve the service account annotation from the secret const cplnServiceAccount = (_b = (_a = secret.metadata) === null || _a === void 0 ? void 0 : _a.annotations) === null || _b === void 0 ? void 0 : _b[K8S_SECRET_SERVICE_ACCOUNT_ANNOTATION]; // Return true if the annotation exists and matches the service account name return cplnServiceAccount === serviceAccountName; } /** * Retrieves an existing service account or creates a new one if not found. * * @param {string} serviceAccountName - Name of the service account. * * @returns {Promise<ServiceAccount>} The existing or newly created service account. */ async resolveServiceAccount(serviceAccountName) { var _a; // Check if the service account exists, otherwise create a new one with the specified name try { // Construct the service account path const serviceAccountLink = (0, resolver_1.resolveToLink)('serviceaccount', serviceAccountName, this.session.context); // Fetch and return the service account return await this.client.get(serviceAccountLink); } catch (error) { if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) !== 404) { this.session.err(`ERROR: Attempted to fetch the service account '${serviceAccountName}' before creation and an unexpected error occurred:`); this.session.abort({ error }); } } // If it does not exist, create a new one return await this.createServiceAccount(serviceAccountName); } /** * Creates a new service account. * * @param {string} serviceAccountName - Name for the new service account. * * @returns {Promise<ServiceAccount>} The newly created service account. */ async createServiceAccount(serviceAccountName) { // Construct service account home link const serviceAccountHomeLink = (0, resolver_1.kindResolver)('serviceaccount').homeLink(this.session.context); // Construct the body for the service account const body = { name: serviceAccountName, description: SERVICE_ACCOUNT_DESCRIPTION, }; // Create a new service account const serviceAccount = await this.client.create(serviceAccountHomeLink, body); this.session.err(`Created ${serviceAccountHomeLink}/${serviceAccount.name}`); // Return the new service account return serviceAccount; } /** * Fetch the group by name. If the group does not exist or the user does not have access, the session will be aborted. * * @param group The group to fetch. */ async getGroup(group) { var _a, _b; try { const groupLink = (0, resolver_1.resolveToLink)('group', group, this.session.context); return await this.client.get(groupLink); } catch (error) { if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 404) { this.session.abort({ message: `ERROR: The group '${group}' does not exist.` }); } else if (((_b = error.response) === null || _b === void 0 ? void 0 : _b.status) === 403) { this.session.abort({ message: `ERROR: You do not have permission to access the group '${group}'.` }); } this.session.abort({ error }); } } /** * Assigns a service account to a specified group and updates the group's member list. * * @param {string} serviceAccountName - Service account name to assign. * @param {Group} group - Target group for assignment. */ async assignServiceAccountToGroup(serviceAccountName, group) { var _a, _b, _c; // Construct the member link for the service account that we will assign to the group const memberLink = `//serviceaccount/${serviceAccountName}`; const serviceAccountSelfLink = (0, resolver_1.resolveToLink)('serviceaccount', serviceAccountName, this.session.context); // Warn the user that it is not recommended to assign the service account to a group other than the recommended group if (group.name !== RECOMMENDED_GROUP) { this.session.err(UNRECOMMENDED_GROUP_WARNING); } // Check if the member link already exists in the group, and skip assigning if that is the case if ((_a = group.memberLinks) === null || _a === void 0 ? void 0 : _a.includes(serviceAccountSelfLink)) { return; } // Construct the group links const groupLink = (0, resolver_1.resolveToLink)('group', group.name, this.session.context); const groupParentLink = (0, links_1.parentLink)(groupLink); // Assign the service account to the group object const modifiedGroup = { ...group, memberLinks: [...((_b = group.memberLinks) !== null && _b !== void 0 ? _b : []), memberLink], }; // Apply the changes to the group and handle errors accordingly try { await this.client.put(groupParentLink, modifiedGroup); this.session.err(`Updated ${groupLink}`); } catch (error) { // Handle the permission error if (((_c = error.response) === null || _c === void 0 ? void 0 : _c.status) === 403) { this.session.abort({ message: `ERROR: You do not have permission to assign the service account '${serviceAccountName}' to the group '${group.name}'.`, }); } // Handle other errors differently this.session.err(`ERROR: Could not assign the service account '${serviceAccountName}' to the group '${group.name}' due to the following error:`); this.session.abort({ error }); } } /** * Generates a new key for the specified service account. * * @param {string} serviceAccountName - Name of the service account. * * @returns {Promise<string>} The generated key. */ async generateKey(serviceAccountName) { // Construct the service account link const serviceAccountLink = (0, resolver_1.resolveToLink)('serviceaccount', serviceAccountName, this.session.context); // Construct the service account add key link const serviceAccountKeyLink = `${serviceAccountLink}/-addKey`; // Construct the body for adding the key const body = { description: SERVICE_ACCOUNT_KEY_DESCRIPTION, }; // Generate a new key for the service account const serviceAccountKey = await this.client.post(serviceAccountKeyLink, body); // Safely handle the key property being undefiend, most likely this won't happen if (!serviceAccountKey.key) { this.session.abort({ message: `ERROR: An unexpected error occurred; Could not generate a key for the service account '${serviceAccountName}'. Please try again.`, }); } // Return the generated key return serviceAccountKey.key; } /** * Initializes a Kubernetes secret object with the organization name. * * @param {string} namespace - Namespace where the secret will be created. * @param {string} serviceAccountName - Service account associated with the secret. * @param {string} key - Key to store in the secret, which will be encoded in base64. * * @returns {V1Secret} The initialized Kubernetes secret object. */ initializeK8sSecret(namespace, serviceAccountName, key) { const secret = { metadata: { name: this.session.context.org, namespace, labels: { [K8S_OPERATOR_LABEL.KEY]: K8S_OPERATOR_LABEL.VALUE, }, annotations: { [K8S_SECRET_SERVICE_ACCOUNT_ANNOTATION]: serviceAccountName, }, }, data: { token: Buffer.from(key).toString('base64'), }, }; return secret; } /** * Deletes a Kubernetes secret from the specified namespace. * * @param {CoreV1Api} k8sApi - Kubernetes API client. * @param {string} namespace - Namespace where the secret is located. * @param {V1Secret} secret - The Kubernetes secret to delete. */ async deleteK8sSecret(k8sApi, namespace, secret) { // Attempt to delete the secret and abort on error try { // Delete the secret in the specified namespace await k8sApi.deleteNamespacedSecret(secret.metadata.name, namespace); this.session.err(`Secret for organization '${this.session.context.org}' deleted successfully in the '${namespace}' namespace.`); } catch (error) { this.session.err(`Failed to delete K8s secret for organization '${this.session.context.org}':`); this.session.abort({ error }); } } /** * Creates a Kubernetes secret containing the provided key for the organization. * * @param {CoreV1Api} k8sApi - Kubernetes API client. * @param {string} namespace - Namespace to create the secret in. * @param {V1Secret} secret - The Kubernetes secret to create. */ async installK8sSecret(k8sApi, namespace, secret) { var _a, _b; // Ensure that the namespace exists within the cluster await this.ensureNamespaceExists(k8sApi, namespace); // Attempt to create the secret and abort on error try { // Create the secret in the specified namespace await k8sApi.createNamespacedSecret(namespace, secret); this.session.err(`Secret for organization '${this.session.context.org}' created successfully in the '${namespace}' namespace.`); } catch (error) { this.session.err(`Failed to create K8s secret for organization '${this.session.context.org}':`); // Attempt to extract the error message from the response body if ((_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.body) === null || _b === void 0 ? void 0 : _b.message) { this.session.abort({ error: error.response.body.message }); } // Otherwise, just abort with the error this.session.abort({ error }); } } /** * Exports the Kubernetes secret as YAML after removing unnecessary fields. * * @param {V1Secret} secret - The Kubernetes secret to export. */ exportSecret(secret) { // Deep clone the secret to avoid mutating the original const clean = JSON.parse(JSON.stringify(secret)); // Remove managedFields from metadata if present if (clean.metadata && clean.metadata.managedFields) { delete clean.metadata.managedFields; } // Export the secret client_1.wire.debug('Exporting the K8s secret for the operator...'); this.session.outFormat(clean); } /** * Ensure that the specified namespace exists, it will fetch the namespace and create it * if it does not exist. * * @param {CoreV1Api} k8sApi Kubernetes API client. * @param {string} namespace The namespace name to ensure that it exists. */ async ensureNamespaceExists(k8sApi, namespace) { var _a, _b, _c; try { // Try to read the namespace await k8sApi.readNamespace(namespace); } catch (error) { // Check if the error indicates that the namespace doesn't exist (HTTP 404) if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 404) { this.session.err(`Namespace "${namespace}" not found. Creating...`); // Create the namespace await this.createNamespace(k8sApi, namespace); // Error has been handled, no need to continue return; } // Some other error occurred during namespace fetch operation this.session.err(`ERROR: Could not fetch namespace '${namespace}', details:`); // Attempt to extract the error message from the response body if ((_c = (_b = error.response) === null || _b === void 0 ? void 0 : _b.body) === null || _c === void 0 ? void 0 : _c.message) { this.session.abort({ error: error.response.body.message }); } // Otherwise, just abort with the error this.session.abort({ error }); } } /** * Create a namespace within a cluster. * * @param {CoreV1Api} k8sApi Kubernetes API client. * @param namespace The namespace to create. */ async createNamespace(k8sApi, namespace) { var _a, _b; try { // Construct the namespace manifest const namespaceManifest = { metadata: { name: namespace, }, }; // Create the namespace await k8sApi.createNamespace(namespaceManifest); this.session.err(`Namespace "${namespace}" created.`); } catch (error) { // Some other error occurred during namespace fetch operation this.session.err(`ERROR: Could not create namespace '${namespace}', details:`); // Attempt to extract the error message from the response body if ((_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.body) === null || _b === void 0 ? void 0 : _b.message) { this.session.abort({ error: error.response.body.message }); } // Otherwise, just abort with the error this.session.abort({ error }); } } } exports.Install = Install; class Uninstall extends OperatorCommandBase { constructor() { super(...arguments); this.command = 'uninstall'; this.describe = 'Uninstall the Kubernetes operator from your cluster'; } builder(yargs) { return (0, functions_1.pipe)( // options_1.withOrgOptions, options_1.withStandardOptions)(yargs); } async handle(args) { var _a, _b; // Check for prerequisites await this.checkForPrerequisites(); // Load kubeconfig from default location const kubeConfig = new client_node_1.KubeConfig(); kubeConfig.loadFromDefault(); // Get the API client const k8sApi = kubeConfig.makeApiClient(client_node_1.CoreV1Api); // Check if the secret (named after the org) exists in the control plane namespace const secret = await this.checkK8sSecretExistence(k8sApi, K8S_CONTROLPLANE_NAMESPACE); // This label should indicate that the secret is managed by our operator const managedBy = (_b = (_a = secret === null || secret === void 0 ? void 0 : secret.metadata) === null || _a === void 0 ? void 0 : _a.labels) === null || _b === void 0 ? void 0 : _b[K8S_OPERATOR_LABEL.KEY]; // If the secret exists, delete it if (secret !== null && managedBy === K8S_OPERATOR_LABEL.VALUE) { await this.deleteK8sSecret(k8sApi, K8S_CONTROLPLANE_NAMESPACE, secret); } else { // If not, notify that there's nothing to uninstall this.session.out(`Operator is not uninstalled for organization '${this.session.context.org}'.`); } } /** * Deletes a Kubernetes secret from the specified namespace. */ async deleteK8sSecret(k8sApi, namespace, secret) { try { await k8sApi.deleteNamespacedSecret(secret.metadata.name, namespace); this.session.err(`Secret for organization '${this.session.context.org}' deleted successfully from the '${namespace}' namespace.`); } catch (error) { this.session.err(`Failed to delete K8s secret for organization '${this.session.context.org}':`); this.session.abort({ error }); } } } exports.Uninstall = Uninstall; //# sourceMappingURL=operator.js.map