@controlplane/cli
Version:
Control Plane Corporation CLI
486 lines • 22 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 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