1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Use JSON patch for editing components (#3674)

This commit is contained in:
Sebastian Malton 2021-10-04 16:28:19 -04:00 committed by GitHub
parent 8b1de233f2
commit 4705a66632
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 242 additions and 106 deletions

View File

@ -237,6 +237,7 @@
"readable-stream": "^3.6.0", "readable-stream": "^3.6.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.9", "request-promise-native": "^1.0.9",
"rfc6902": "^4.0.2",
"semver": "^7.3.2", "semver": "^7.3.2",
"serializr": "^2.0.5", "serializr": "^2.0.5",
"shell-env": "^3.0.1", "shell-env": "^3.0.1",

View File

@ -22,19 +22,27 @@
import jsYaml from "js-yaml"; import jsYaml from "js-yaml";
import type { KubeJsonApiData } from "../kube-json-api"; import type { KubeJsonApiData } from "../kube-json-api";
import { apiBase } from "../index"; import { apiBase } from "../index";
import type { Patch } from "rfc6902";
export const resourceApplierApi = { export const annotations = [
annotations: [ "kubectl.kubernetes.io/last-applied-configuration"
"kubectl.kubernetes.io/last-applied-configuration" ];
],
async update(resource: object | string): Promise<KubeJsonApiData | null> { export async function update(resource: object | string): Promise<KubeJsonApiData> {
if (typeof resource === "string") { if (typeof resource === "string") {
resource = jsYaml.safeLoad(resource); resource = jsYaml.safeLoad(resource);
}
const [data = null] = await apiBase.post<KubeJsonApiData[]>("/stack", { data: resource });
return data;
} }
};
return apiBase.post<KubeJsonApiData>("/stack", { data: resource });
}
export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise<KubeJsonApiData> {
return apiBase.patch<KubeJsonApiData>("/stack", {
data: {
name,
kind,
ns,
patch,
},
});
}

View File

@ -32,6 +32,7 @@ import { parseKubeApi } from "./kube-api-parse";
import type { KubeJsonApiData } from "./kube-json-api"; import type { KubeJsonApiData } from "./kube-json-api";
import type { RequestInit } from "node-fetch"; import type { RequestInit } from "node-fetch";
import AbortController from "abort-controller"; import AbortController from "abort-controller";
import type { Patch } from "rfc6902";
export interface KubeObjectStoreLoadingParams<K extends KubeObject> { export interface KubeObjectStoreLoadingParams<K extends KubeObject> {
namespaces: string[]; namespaces: string[];
@ -279,19 +280,29 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
return newItem; return newItem;
} }
async update(item: T, data: Partial<T>): Promise<T> { private postUpdate(rawItem: KubeJsonApiData): T {
const rawItem = await item.update(data);
const newItem = new this.api.objectConstructor(rawItem); const newItem = new this.api.objectConstructor(rawItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId());
ensureObjectSelfLink(this.api, newItem); ensureObjectSelfLink(this.api, newItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId()); if (index < 0) {
this.items.push(newItem);
this.items.splice(index, 1, newItem); } else {
this.items[index] = newItem;
}
return newItem; return newItem;
} }
async patch(item: T, patch: Patch): Promise<T> {
return this.postUpdate(await item.patch(patch));
}
async update(item: T, data: Partial<T>): Promise<T> {
return this.postUpdate(await item.update(data));
}
async remove(item: T) { async remove(item: T) {
await item.delete(); await item.delete();
this.items.remove(item); this.items.remove(item);

View File

@ -27,9 +27,9 @@ import { autoBind, formatDuration } from "../utils";
import type { ItemObject } from "../item.store"; import type { ItemObject } from "../item.store";
import { apiKube } from "./index"; import { apiKube } from "./index";
import type { JsonApiParams } from "./json-api"; import type { JsonApiParams } from "./json-api";
import { resourceApplierApi } from "./endpoints/resource-applier.api"; import * as resourceApplierApi from "./endpoints/resource-applier.api";
import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing";
import _ from "lodash"; import type { Patch } from "rfc6902";
export type KubeObjectConstructor<K extends KubeObject> = (new (data: KubeJsonApiData | any) => K) & { export type KubeObjectConstructor<K extends KubeObject> = (new (data: KubeJsonApiData | any) => K) & {
kind?: string; kind?: string;
@ -191,18 +191,28 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return Object.entries(labels).map(([name, value]) => `${name}=${value}`); return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
} }
protected static readonly nonEditableFields = [ /**
"apiVersion", * These must be RFC6902 compliant paths
"kind", */
"metadata.name", private static readonly nonEditiablePathPrefixes = [
"metadata.selfLink", "/metadata/managedFields",
"metadata.resourceVersion", "/status",
"metadata.uid",
"managedFields",
"status",
]; ];
private static readonly nonEditablePaths = new Set([
"/apiVersion",
"/kind",
"/metadata/name",
"/metadata/selfLink",
"/metadata/resourceVersion",
"/metadata/uid",
...KubeObject.nonEditiablePathPrefixes,
]);
constructor(data: KubeJsonApiData) { constructor(data: KubeJsonApiData) {
if (typeof data !== "object") {
throw new TypeError(`Cannot create a KubeObject from ${typeof data}`);
}
Object.assign(this, data); Object.assign(this, data);
autoBind(this); autoBind(this);
} }
@ -286,14 +296,31 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return JSON.parse(JSON.stringify(this)); return JSON.parse(JSON.stringify(this));
} }
// use unified resource-applier api for updating all k8s objects async patch(patch: Patch): Promise<KubeJsonApiData | null> {
async update(data: Partial<this>): Promise<KubeJsonApiData | null> { for (const op of patch) {
for (const field of KubeObject.nonEditableFields) { if (KubeObject.nonEditablePaths.has(op.path)) {
if (!_.isEqual(_.get(this, field), _.get(data, field))) { throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`);
throw new Error(`Failed to update Kube Object: ${field} has been modified`); }
for (const pathPrefix of KubeObject.nonEditiablePathPrefixes) {
if (op.path.startsWith(`${pathPrefix}/`)) {
throw new Error(`Failed to update ${this.kind}: Child JSON pointer of ${op.path} has been modified`);
}
} }
} }
return resourceApplierApi.patch(this.getName(), this.kind, this.getNs(), patch);
}
/**
* Perform a full update (or more specifically a replace)
*
* Note: this is brittle if `data` is not actually partial (but instead whole).
* As fields such as `resourceVersion` will probably out of date. This is a
* common race condition.
*/
async update(data: Partial<this>): Promise<KubeJsonApiData | null> {
// use unified resource-applier api for updating all k8s objects
return resourceApplierApi.update({ return resourceApplierApi.update({
...this.toPlainObject(), ...this.toPlainObject(),
...data, ...data,

View File

@ -22,55 +22,96 @@
import type { Cluster } from "./cluster"; import type { Cluster } from "./cluster";
import type { KubernetesObject } from "@kubernetes/client-node"; import type { KubernetesObject } from "@kubernetes/client-node";
import { exec } from "child_process"; import { exec } from "child_process";
import fs from "fs"; import fs from "fs-extra";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import path from "path"; import path from "path";
import * as tempy from "tempy"; import * as tempy from "tempy";
import logger from "./logger"; import logger from "./logger";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
import { cloneJsonObject } from "../common/utils"; import { cloneJsonObject } from "../common/utils";
import type { Patch } from "rfc6902";
import { promiseExecFile } from "./promise-exec";
export class ResourceApplier { export class ResourceApplier {
constructor(protected cluster: Cluster) { constructor(protected cluster: Cluster) {}
/**
* Patch a kube resource's manifest, throwing any error that occurs.
* @param name The name of the kube resource
* @param kind The kind of the kube resource
* @param patch The list of JSON operations
* @param ns The optional namespace of the kube resource
*/
async patch(name: string, kind: string, patch: Patch, ns?: string): Promise<string> {
appEventBus.emit({ name: "resource", action: "patch" });
const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
const args = [
"--kubeconfig", proxyKubeconfigPath,
"patch",
kind,
name,
];
if (ns) {
args.push("--namespace", ns);
}
args.push(
"--type", "json",
"--patch", JSON.stringify(patch),
"-o", "json"
);
try {
const { stdout } = await promiseExecFile(kubectlPath, args);
return stdout;
} catch (error) {
throw error.stderr ?? error;
}
} }
async apply(resource: KubernetesObject | any): Promise<string> { async apply(resource: KubernetesObject | any): Promise<string> {
resource = this.sanitizeObject(resource); resource = this.sanitizeObject(resource);
appEventBus.emit({ name: "resource", action: "apply" }); appEventBus.emit({ name: "resource", action: "apply" });
return await this.kubectlApply(yaml.safeDump(resource)); return this.kubectlApply(yaml.safeDump(resource));
} }
protected async kubectlApply(content: string): Promise<string> { protected async kubectlApply(content: string): Promise<string> {
const kubectl = await this.cluster.ensureKubectl(); const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath(); const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
const fileName = tempy.file({ name: "resource.yaml" });
const args = [
"apply",
"--kubeconfig", proxyKubeconfigPath,
"-o", "json",
"-f", fileName,
];
return new Promise<string>((resolve, reject) => { logger.debug(`shooting manifests with ${kubectlPath}`, { args });
const fileName = tempy.file({ name: "resource.yaml" });
fs.writeFileSync(fileName, content); const execEnv = { ...process.env };
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`; const httpsProxy = this.cluster.preferences?.httpsProxy;
logger.debug(`shooting manifests with: ${cmd}`); if (httpsProxy) {
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env); execEnv.HTTPS_PROXY = httpsProxy;
const httpsProxy = this.cluster.preferences?.httpsProxy; }
if (httpsProxy) { try {
execEnv["HTTPS_PROXY"] = httpsProxy; await fs.writeFile(fileName, content);
} const { stdout } = await promiseExecFile(kubectlPath, args);
exec(cmd, { env: execEnv },
(error, stdout, stderr) => {
if (stderr != "") {
fs.unlinkSync(fileName);
reject(stderr);
return; return stdout;
} } catch (error) {
fs.unlinkSync(fileName); throw error?.stderr ?? error;
resolve(JSON.parse(stdout)); } finally {
}); await fs.unlink(fileName);
}); }
} }
public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise<string> { public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise<string> {

View File

@ -198,5 +198,6 @@ export class Router {
// Resource Applier API // Resource Applier API
this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource); this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource);
this.router.add({ method: "patch", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.patchResource);
} }
} }

View File

@ -30,7 +30,19 @@ export class ResourceApplierApiRoute {
try { try {
const resource = await new ResourceApplier(cluster).apply(payload); const resource = await new ResourceApplier(cluster).apply(payload);
respondJson(response, [resource], 200); respondJson(response, resource, 200);
} catch (error) {
respondText(response, error, 422);
}
}
static async patchResource(request: LensApiRequest) {
const { response, cluster, payload } = request;
try {
const resource = await new ResourceApplier(cluster).patch(payload.name, payload.kind, payload.patch, payload.ns);
respondJson(response, resource, 200);
} catch (error) { } catch (error) {
respondText(response, error, 422); respondText(response, error, 422);
} }

View File

@ -21,14 +21,37 @@
import type http from "http"; import type http from "http";
export function respondJson(res: http.ServerResponse, content: any, status = 200) { /**
respond(res, JSON.stringify(content), "application/json", status); * Respond to a HTTP request with a body of JSON data
* @param res The HTTP response to write data to
* @param content The data or its JSON stringified version of it
* @param status [200] The status code to respond with
*/
export function respondJson(res: http.ServerResponse, content: Object | string, status = 200) {
const normalizedContent = typeof content === "object"
? JSON.stringify(content)
: content;
respond(res, normalizedContent, "application/json", status);
} }
/**
* Respond to a HTTP request with a body of plain text data
* @param res The HTTP response to write data to
* @param content The string data to respond with
* @param status [200] The status code to respond with
*/
export function respondText(res: http.ServerResponse, content: string, status = 200) { export function respondText(res: http.ServerResponse, content: string, status = 200) {
respond(res, content, "text/plain", status); respond(res, content, "text/plain", status);
} }
/**
* Respond to a HTTP request with a body of plain text data
* @param res The HTTP response to write data to
* @param content The string data to respond with
* @param contentType The HTTP Content-Type header value
* @param status [200] The status code to respond with
*/
export function respond(res: http.ServerResponse, content: string, contentType: string, status = 200) { export function respond(res: http.ServerResponse, content: string, contentType: string, status = 200) {
res.setHeader("Content-Type", contentType); res.setHeader("Content-Type", contentType);
res.statusCode = status; res.statusCode = status;

View File

@ -72,6 +72,8 @@ export class ConfigMapDetails extends React.Component<Props> {
<>ConfigMap <b>{configMap.getName()}</b> successfully updated.</> <>ConfigMap <b>{configMap.getName()}</b> successfully updated.</>
</p> </p>
); );
} catch (error) {
Notifications.error(`Failed to save config map: ${error}`);
} finally { } finally {
this.isSaving = false; this.isSaving = false;
} }

View File

@ -112,6 +112,7 @@ const dummyDeployment: Deployment = {
toPlainObject: jest.fn(), toPlainObject: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
patch: jest.fn(),
}; };
describe("<DeploymentScaleDialog />", () => { describe("<DeploymentScaleDialog />", () => {

View File

@ -107,6 +107,7 @@ const dummyReplicaSet: ReplicaSet = {
toPlainObject: jest.fn(), toPlainObject: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
patch: jest.fn(),
}; };
describe("<ReplicaSetScaleDialog />", () => { describe("<ReplicaSetScaleDialog />", () => {

View File

@ -117,6 +117,7 @@ const dummyStatefulSet: StatefulSet = {
toPlainObject: jest.fn(), toPlainObject: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
patch: jest.fn(),
}; };
describe("<StatefulSetScaleDialog />", () => { describe("<StatefulSetScaleDialog />", () => {

View File

@ -33,10 +33,10 @@ import { createResourceStore } from "./create-resource.store";
import type { DockTab } from "./dock.store"; import type { DockTab } from "./dock.store";
import { EditorPanel } from "./editor-panel"; import { EditorPanel } from "./editor-panel";
import { InfoPanel } from "./info-panel"; import { InfoPanel } from "./info-panel";
import { resourceApplierApi } from "../../../common/k8s-api/endpoints/resource-applier.api"; import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api";
import type { JsonApiErrorParsed } from "../../../common/k8s-api/json-api";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { monacoModelsManager } from "./monaco-model-manager"; import { monacoModelsManager } from "./monaco-model-manager";
import logger from "../../../common/logger";
interface Props { interface Props {
className?: string; className?: string;
@ -95,7 +95,7 @@ export class CreateResource extends React.Component<Props> {
}); });
}; };
create = async () => { create = async (): Promise<undefined> => {
if (this.error || !this.data.trim()) { if (this.error || !this.data.trim()) {
// do not save when field is empty or there is an error // do not save when field is empty or there is an error
return null; return null;
@ -103,31 +103,31 @@ export class CreateResource extends React.Component<Props> {
// skip empty documents if "---" pasted at the beginning or end // skip empty documents if "---" pasted at the beginning or end
const resources = jsYaml.safeLoadAll(this.data).filter(Boolean); const resources = jsYaml.safeLoadAll(this.data).filter(Boolean);
const createdResources: string[] = [];
const errors: string[] = [];
await Promise.all( if (resources.length === 0) {
resources.map(data => { return void logger.info("Nothing to create");
return resourceApplierApi.update(data)
.then(item => createdResources.push(item.metadata.name))
.catch((err: JsonApiErrorParsed) => errors.push(err.toString()));
})
);
if (errors.length) {
errors.forEach(error => Notifications.error(error));
if (!createdResources.length) throw errors[0];
} }
const successMessage = (
<p>
{createdResources.length === 1 ? "Resource" : "Resources"}{" "}
<b>{createdResources.join(", ")}</b> successfully created
</p>
);
Notifications.ok(successMessage); const createdResources: string[] = [];
return successMessage; for (const result of await Promise.allSettled(resources.map(resourceApplierApi.update))) {
if (result.status === "fulfilled") {
createdResources.push(result.value.metadata.name);
} else {
Notifications.error(result.reason.toString());
}
}
if (createdResources.length > 0) {
Notifications.ok((
<p>
{createdResources.length === 1 ? "Resource" : "Resources"}{" "}
<b>{createdResources.join(", ")}</b> successfully created
</p>
));
}
return undefined;
}; };
renderControls(){ renderControls(){

View File

@ -31,6 +31,7 @@ import {monacoModelsManager} from "./monaco-model-manager";
export interface EditingResource { export interface EditingResource {
resource: string; // resource path, e.g. /api/v1/namespaces/default resource: string; // resource path, e.g. /api/v1/namespaces/default
draft?: string; // edited draft in yaml draft?: string; // edited draft in yaml
firstDraft?: string;
} }
export class EditResourceStore extends DockTabStore<EditingResource> { export class EditResourceStore extends DockTabStore<EditingResource> {
@ -106,6 +107,10 @@ export class EditResourceStore extends DockTabStore<EditingResource> {
return dockStore.getTabById(tabId); return dockStore.getTabById(tabId);
} }
clearInitialDraft(tabId: TabId): void {
delete this.getData(tabId)?.firstDraft;
}
reset() { reset() {
super.reset(); super.reset();
Array.from(this.watchers).forEach(([tabId, dispose]) => { Array.from(this.watchers).forEach(([tabId, dispose]) => {

View File

@ -24,7 +24,7 @@ import "./edit-resource.scss";
import React from "react"; import React from "react";
import { action, computed, makeObservable, observable } from "mobx"; import { action, computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import jsYaml from "js-yaml"; import yaml from "js-yaml";
import type { DockTab } from "./dock.store"; import type { DockTab } from "./dock.store";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { editResourceStore } from "./edit-resource.store"; import { editResourceStore } from "./edit-resource.store";
@ -33,6 +33,7 @@ import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel"; import { EditorPanel } from "./editor-panel";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { createPatch } from "rfc6902";
interface Props { interface Props {
className?: string; className?: string;
@ -71,16 +72,17 @@ export class EditResource extends React.Component<Props> {
return draft; return draft;
} }
return jsYaml.safeDump(this.resource.toPlainObject()); // dump resource first time return yaml.safeDump(this.resource.toPlainObject()); // dump resource first time
} }
@action @action
saveDraft(draft: string | object) { saveDraft(draft: string | object) {
if (typeof draft === "object") { if (typeof draft === "object") {
draft = draft ? jsYaml.safeDump(draft) : undefined; draft = draft ? yaml.safeDump(draft) : undefined;
} }
editResourceStore.setData(this.tabId, { editResourceStore.setData(this.tabId, {
firstDraft: draft, // this must be before the next line
...editResourceStore.getData(this.tabId), ...editResourceStore.getData(this.tabId),
draft, draft,
}); });
@ -95,16 +97,18 @@ export class EditResource extends React.Component<Props> {
if (this.error) { if (this.error) {
return null; return null;
} }
const store = editResourceStore.getStore(this.tabId);
const updatedResource: KubeObject = await store.update(this.resource, jsYaml.safeLoad(this.draft));
this.saveDraft(updatedResource.toPlainObject()); // update with new resourceVersion to avoid further errors on save const store = editResourceStore.getStore(this.tabId);
const resourceType = updatedResource.kind; const currentVersion = yaml.safeLoad(this.draft);
const resourceName = updatedResource.getName(); const firstVersion = yaml.safeLoad(editResourceStore.getData(this.tabId).firstDraft ?? this.draft);
const patches = createPatch(firstVersion, currentVersion);
const updatedResource = await store.patch(this.resource, patches);
editResourceStore.clearInitialDraft(this.tabId);
return ( return (
<p> <p>
{resourceType} <b>{resourceName}</b> updated. {updatedResource.kind} <b>{updatedResource.getName()}</b> updated.
</p> </p>
); );
}; };
@ -126,9 +130,9 @@ export class EditResource extends React.Component<Props> {
submittingMessage="Applying.." submittingMessage="Applying.."
controls={( controls={(
<div className="resource-info flex gaps align-center"> <div className="resource-info flex gaps align-center">
<span>Kind:</span> <Badge label={resource.kind}/> <span>Kind:</span><Badge label={resource.kind}/>
<span>Name:</span><Badge label={resource.getName()}/> <span>Name:</span><Badge label={resource.getName()}/>
<span>Namespace:</span> <Badge label={resource.getNs() || "global"}/> <span>Namespace:</span><Badge label={resource.getNs() || "global"}/>
</div> </div>
)} )}
/> />

View File

@ -91,10 +91,7 @@ export class EditorPanel extends React.Component<Props> {
onChange = (value: string) => { onChange = (value: string) => {
this.validate(value); this.validate(value);
this.props.onChange?.(value, this.yamlError);
if (this.props.onChange) {
this.props.onChange(value, this.yamlError);
}
}; };
render() { render() {

View File

@ -44,15 +44,11 @@ export class KubeObjectMenu<T extends KubeObject> extends React.Component<KubeOb
} }
get isEditable() { get isEditable() {
const { editable } = this.props; return this.props.editable ?? Boolean(this.store?.patch);
return editable !== undefined ? editable : !!(this.store && this.store.update);
} }
get isRemovable() { get isRemovable() {
const { removable } = this.props; return this.props.removable ?? Boolean(this.store?.remove);
return removable !== undefined ? removable : !!(this.store && this.store.remove);
} }
@boundMethod @boundMethod

View File

@ -12277,6 +12277,11 @@ rfc4648@^1.3.0:
resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.3.0.tgz#2a69c76f05bc0e388feab933672de9b492af95f1" resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.3.0.tgz#2a69c76f05bc0e388feab933672de9b492af95f1"
integrity sha512-x36K12jOflpm1V8QjPq3I+pt7Z1xzeZIjiC8J2Oxd7bE1efTrOG241DTYVJByP/SxR9jl1t7iZqYxDX864jgBQ== integrity sha512-x36K12jOflpm1V8QjPq3I+pt7Z1xzeZIjiC8J2Oxd7bE1efTrOG241DTYVJByP/SxR9jl1t7iZqYxDX864jgBQ==
rfc6902@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/rfc6902/-/rfc6902-4.0.2.tgz#ce99d3562b9e3287d403462e6bcc81eead8fcea0"
integrity sha512-MJOC4iDSv3Qn5/QvhPbrNoRongti6moXSShcRmtbNqOk0WPxlviEdMV4bb9PaULhSxLUXzWd4AjAMKQ3j3y54w==
rimraf@2.6.3: rimraf@2.6.3:
version "2.6.3" version "2.6.3"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"