@controlplane/cli
Version:
Control Plane Corporation CLI
564 lines • 26.1 kB
JavaScript
"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