UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

486 lines 22 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 client_1 = require("../session/client"); const resolver_1 = require("./resolver"); const links_1 = require("../util/links"); const k8s_1 = require("../k8s"); // 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 { constructor() { super(...arguments); // Protected Properties // this.kubectl = new k8s_1.Kubectl(); } // 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 {string} namespace - Namespace where the secret is located. * * @returns {K8sSecret | null} The secret if found, otherwise null. */ checkK8sSecretExistence(namespace) { const secret = this.kubectl.getSecret(this.session.context.org, namespace); if (!secret) { client_1.wire.debug(`Secret '${this.session.context.org}' not found in namespace '${namespace}'`); } return secret; } // Private Methods // /** * Ensures kubectl is installed and the Kubernetes cluster is reachable. */ checkForKubectl() { const availability = this.kubectl.checkAvailability(); if (!availability.installed) { 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.', }); } client_1.wire.debug(`Kubectl is installed: ${availability.version}`); if (!availability.clusterReachable) { this.session.abort({ message: 'ERROR: Cluster is not reachable or kubectl configuration is incorrect. Please, check your kubectl configuration and try again.', }); } client_1.wire.debug('Cluster is reachable'); } /** * 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(); // Check if the service account annotation exists in an existing K8s secret for the specified org let secret = this.checkK8sSecretExistence(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 = 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) { this.deleteK8sSecret(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) { this.installK8sSecret(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 {K8sSecret} secret - The Kubernetes secret to check. * @param {string} serviceAccountName - Service account name to match in the secret annotations. * * @returns {boolean} True if the secret has the annotation, otherwise false. */ 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 {K8sSecret} The initialized Kubernetes secret object. */ initializeK8sSecret(namespace, serviceAccountName, key) { const secret = { apiVersion: 'v1', kind: '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 {string} namespace - Namespace where the secret is located. * @param {K8sSecret} secret - The Kubernetes secret to delete. */ deleteK8sSecret(namespace, secret) { try { this.kubectl.deleteSecret(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 {string} namespace - Namespace to create the secret in. * @param {K8sSecret} secret - The Kubernetes secret to create. */ installK8sSecret(namespace, secret) { // Ensure that the namespace exists within the cluster this.ensureNamespaceExists(namespace); try { this.kubectl.applySecret(secret, namespace); 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}':`); this.session.abort({ error }); } } /** * Exports the Kubernetes secret as YAML after removing unnecessary fields. * * @param {K8sSecret} secret - The Kubernetes secret to export. */ exportSecret(secret) { client_1.wire.debug('Exporting the K8s secret for the operator...'); const clean = this.kubectl.cleanForExport(secret); this.session.outFormat(clean); } /** * Ensure that the specified namespace exists, it will fetch the namespace and create it * if it does not exist. * * @param {string} namespace The namespace name to ensure that it exists. */ ensureNamespaceExists(namespace) { try { const created = this.kubectl.ensureNamespace(namespace); if (created) { this.session.err(`Namespace "${namespace}" created.`); } } catch (error) { this.session.err(`ERROR: Could not ensure namespace '${namespace}' exists, details:`); 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(); // Check if the secret (named after the org) exists in the control plane namespace const secret = this.checkK8sSecretExistence(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) { this.deleteK8sSecret(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. */ deleteK8sSecret(namespace, secret) { try { this.kubectl.deleteSecret(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