Skip to content

Commit 703bc61

Browse files
sownakpetermetz
authored andcommitted
feat(fabric-all-in-one): runs-a-Fabric-Network-in-one-docker-container
Fix #132 Signed-off-by: Roy,Sownak <[email protected]>
1 parent a51684c commit 703bc61

File tree

15 files changed

+770
-0
lines changed

15 files changed

+770
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import Docker, { Container, ContainerInfo } from "dockerode";
2+
import axios from "axios";
3+
import Joi from "joi";
4+
import { EventEmitter } from "events";
5+
import { ITestLedger } from "../i-test-ledger";
6+
7+
/*
8+
* Contains options for Fabric container
9+
*/
10+
export interface IFabricTestLedgerV1ConstructorOptions {
11+
imageVersion?: string;
12+
imageName?: string;
13+
opsApiHttpPort?: number;
14+
}
15+
16+
/*
17+
* Provides default options for Fabric container
18+
*/
19+
const DEFAULT_OPTS = Object.freeze({
20+
imageVersion: "latest",
21+
imageName: "hyperledger/cactus-fabric-all-in-one",
22+
opsApiHttpPort: 9443,
23+
});
24+
export const FABRIC_TEST_LEDGER_DEFAULT_OPTIONS = DEFAULT_OPTS;
25+
26+
/*
27+
* Provides validations for the Fabric container's options
28+
*/
29+
const OPTS_JOI_SCHEMA: Joi.Schema = Joi.object().keys({
30+
imageVersion: Joi.string().min(5).required(),
31+
imageName: Joi.string().min(1).required(),
32+
opsApiHttpPort: Joi.number().integer().min(1024).max(65535).required(),
33+
});
34+
35+
export const FABRIC_TEST_LEDGER_OPTIONS_JOI_SCHEMA = OPTS_JOI_SCHEMA;
36+
37+
export class FabricTestLedgerV1 implements ITestLedger {
38+
public readonly imageVersion: string;
39+
public readonly imageName: string;
40+
public readonly opsApiHttpPort: number;
41+
42+
private container: Container | undefined;
43+
44+
constructor(
45+
public readonly options: IFabricTestLedgerV1ConstructorOptions = {}
46+
) {
47+
if (!options) {
48+
throw new TypeError(`FabricTestLedgerV1#ctor options was falsy.`);
49+
}
50+
this.imageVersion = options.imageVersion || DEFAULT_OPTS.imageVersion;
51+
this.imageName = options.imageName || DEFAULT_OPTS.imageName;
52+
this.opsApiHttpPort = options.opsApiHttpPort || DEFAULT_OPTS.opsApiHttpPort;
53+
54+
this.validateConstructorOptions();
55+
}
56+
57+
public getContainer(): Container {
58+
const fnTag = "FabricTestLedgerV1#getContainer()";
59+
if (!this.container) {
60+
throw new Error(`${fnTag} container not yet started by this instance.`);
61+
} else {
62+
return this.container;
63+
}
64+
}
65+
66+
public getContainerImageName(): string {
67+
return `${this.imageName}:${this.imageVersion}`;
68+
}
69+
70+
public async getOpsApiHttpHost(): Promise<string> {
71+
const ipAddress: string = "127.0.0.1";
72+
const hostPort: number = await this.getOpsApiPublicPort();
73+
return `http://${ipAddress}:${hostPort}/version`;
74+
}
75+
76+
public async start(): Promise<Container> {
77+
const containerNameAndTag = this.getContainerImageName();
78+
79+
if (this.container) {
80+
await this.container.stop();
81+
await this.container.remove();
82+
}
83+
const docker = new Docker();
84+
85+
await this.pullContainerImage(containerNameAndTag);
86+
87+
return new Promise<Container>((resolve, reject) => {
88+
const eventEmitter: EventEmitter = docker.run(
89+
containerNameAndTag,
90+
[],
91+
[],
92+
{
93+
ExposedPorts: {
94+
[`${this.opsApiHttpPort}/tcp`]: {}, // Fabric Peer GRPC - HTTP
95+
"7050/tcp": {}, // Orderer GRPC - HTTP
96+
"7051/tcp": {}, // Peer additional - HTTP
97+
"7052/tcp": {}, // Peer Chaincode - HTTP
98+
"7053/tcp": {}, // Peer additional - HTTP
99+
"7054/tcp": {}, // Fabric CA - HTTP
100+
"9001/tcp": {}, // supervisord - HTTP
101+
},
102+
// This is a workaround needed for macOS which has issues with routing
103+
// to docker container's IP addresses directly...
104+
// https://stackoverflow.com/a/39217691
105+
PublishAllPorts: true,
106+
},
107+
{},
108+
(err: any) => {
109+
if (err) {
110+
reject(err);
111+
}
112+
}
113+
);
114+
115+
eventEmitter.once("start", async (container: Container) => {
116+
this.container = container;
117+
try {
118+
await this.waitForHealthCheck();
119+
resolve(container);
120+
} catch (ex) {
121+
reject(ex);
122+
}
123+
});
124+
});
125+
}
126+
127+
public async waitForHealthCheck(timeoutMs: number = 120000): Promise<void> {
128+
const fnTag = "FabricTestLedgerV1#waitForHealthCheck()";
129+
const httpUrl = await this.getOpsApiHttpHost();
130+
const startedAt = Date.now();
131+
let reachable: boolean = false;
132+
do {
133+
try {
134+
const res = await axios.get(httpUrl);
135+
reachable = res.status > 199 && res.status < 300;
136+
} catch (ex) {
137+
reachable = false;
138+
if (Date.now() >= startedAt + timeoutMs) {
139+
throw new Error(`${fnTag} timed out (${timeoutMs}ms) -> ${ex.stack}`);
140+
}
141+
}
142+
await new Promise((resolve2) => setTimeout(resolve2, 100));
143+
} while (!reachable);
144+
}
145+
146+
public stop(): Promise<any> {
147+
const fnTag = "FabricTestLedgerV1#stop()";
148+
return new Promise((resolve, reject) => {
149+
if (this.container) {
150+
this.container.stop({}, (err: any, result: any) => {
151+
if (err) {
152+
reject(err);
153+
} else {
154+
resolve(result);
155+
}
156+
});
157+
} else {
158+
return reject(new Error(`${fnTag} Container was not running.`));
159+
}
160+
});
161+
}
162+
163+
public async destroy(): Promise<any> {
164+
const fnTag = "FabricTestLedgerV1#destroy()";
165+
if (this.container) {
166+
return this.container.remove();
167+
} else {
168+
throw new Error(`${fnTag} Containernot found, nothing to destroy.`);
169+
}
170+
}
171+
172+
protected async getContainerInfo(): Promise<ContainerInfo> {
173+
const fnTag = "FabricTestLedgerV1#getContainerInfo()";
174+
const docker = new Docker();
175+
const image = this.getContainerImageName();
176+
const containerInfos = await docker.listContainers({});
177+
178+
const aContainerInfo = containerInfos.find((ci) => ci.Image === image);
179+
180+
if (aContainerInfo) {
181+
return aContainerInfo;
182+
} else {
183+
throw new Error(`${fnTag} no image "${image}"`);
184+
}
185+
}
186+
187+
public async getOpsApiPublicPort(): Promise<number> {
188+
const fnTag = "FabricTestLedgerV1#getOpsApiPublicPort()";
189+
const aContainerInfo = await this.getContainerInfo();
190+
const { opsApiHttpPort: thePort } = this;
191+
const { Ports: ports } = aContainerInfo;
192+
193+
if (ports.length < 1) {
194+
throw new Error(`${fnTag} no ports exposed or mapped at all`);
195+
}
196+
const mapping = ports.find((x) => x.PrivatePort === thePort);
197+
if (mapping) {
198+
if (!mapping.PublicPort) {
199+
throw new Error(`${fnTag} port ${thePort} mapped but not public`);
200+
} else if (mapping.IP !== "0.0.0.0") {
201+
throw new Error(`${fnTag} port ${thePort} mapped to localhost`);
202+
} else {
203+
return mapping.PublicPort;
204+
}
205+
} else {
206+
throw new Error(`${fnTag} no mapping found for ${thePort}`);
207+
}
208+
}
209+
210+
public async getContainerIpAddress(): Promise<string> {
211+
const fnTag = "FabricTestLedgerV1#getContainerIpAddress()";
212+
const aContainerInfo = await this.getContainerInfo();
213+
214+
if (aContainerInfo) {
215+
const { NetworkSettings } = aContainerInfo;
216+
const networkNames: string[] = Object.keys(NetworkSettings.Networks);
217+
if (networkNames.length < 1) {
218+
throw new Error(`${fnTag} container not connected to any networks`);
219+
} else {
220+
// return IP address of container on the first network that we found it connected to. Make this configurable?
221+
return NetworkSettings.Networks[networkNames[0]].IPAddress;
222+
}
223+
} else {
224+
throw new Error(`${fnTag} cannot find docker image ${this.imageName}`);
225+
}
226+
}
227+
228+
private pullContainerImage(containerNameAndTag: string): Promise<any[]> {
229+
return new Promise((resolve, reject) => {
230+
const docker = new Docker();
231+
docker.pull(containerNameAndTag, (pullError: any, stream: any) => {
232+
if (pullError) {
233+
reject(pullError);
234+
} else {
235+
docker.modem.followProgress(
236+
stream,
237+
(progressError: any, output: any[]) => {
238+
if (progressError) {
239+
reject(progressError);
240+
} else {
241+
resolve(output);
242+
}
243+
},
244+
(event: any) => null // ignore the spammy docker download log, we get it in the output variable anyway
245+
);
246+
}
247+
});
248+
});
249+
}
250+
251+
private validateConstructorOptions(): void {
252+
const fnTag = "FabricTestLedgerV1#validateConstructorOptions()";
253+
const result = Joi.validate<IFabricTestLedgerV1ConstructorOptions>(
254+
{
255+
imageVersion: this.imageVersion,
256+
imageName: this.imageName,
257+
opsApiHttpPort: this.opsApiHttpPort,
258+
},
259+
OPTS_JOI_SCHEMA
260+
);
261+
262+
if (result.error) {
263+
throw new Error(`${fnTag} ${result.error.annotate()}`);
264+
}
265+
}
266+
}

packages/cactus-test-tooling/src/main/typescript/public-api.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
export { ITestLedger } from "./i-test-ledger";
22
export { IKeyPair, isIKeyPair } from "./i-key-pair";
3+
34
export {
45
BesuTestLedger,
56
IBesuTestLedgerConstructorOptions,
67
BESU_TEST_LEDGER_DEFAULT_OPTIONS,
78
BESU_TEST_LEDGER_OPTIONS_JOI_SCHEMA,
89
} from "./besu/besu-test-ledger";
10+
911
export {
1012
QuorumTestLedger,
1113
IQuorumTestLedgerConstructorOptions,
@@ -14,9 +16,17 @@ export {
1416
} from "./quorum/quorum-test-ledger";
1517
export * from "./quorum/i-quorum-genesis-options";
1618
export { Containers } from "./common/containers";
19+
1720
export {
1821
HttpEchoContainer,
1922
IHttpEchoContainerConstructorOptions,
2023
HTTP_ECHO_CONTAINER_CTOR_DEFAULTS,
2124
HTTP_ECHO_CONTAINER_OPTS_SCHEMA,
2225
} from "./http-echo/http-echo-container";
26+
27+
export {
28+
FabricTestLedgerV1,
29+
IFabricTestLedgerV1ConstructorOptions,
30+
FABRIC_TEST_LEDGER_DEFAULT_OPTIONS,
31+
FABRIC_TEST_LEDGER_OPTIONS_JOI_SCHEMA,
32+
} from "./fabric/fabric-test-ledger-v1";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// tslint:disable-next-line: no-var-requires
2+
const tap = require("tap");
3+
import isPortReachable from "is-port-reachable";
4+
import { Container } from "dockerode";
5+
import { FabricTestLedgerV1 } from "../../../../../main/typescript/public-api";
6+
7+
tap.test("constructor throws if invalid input is provided", (assert: any) => {
8+
assert.ok(FabricTestLedgerV1);
9+
assert.throws(() => new FabricTestLedgerV1({ imageVersion: "nope" }));
10+
assert.end();
11+
});
12+
13+
tap.test(
14+
"constructor does not throw if valid input is provided",
15+
(assert: any) => {
16+
assert.ok(FabricTestLedgerV1);
17+
assert.doesNotThrow(() => new FabricTestLedgerV1());
18+
assert.end();
19+
}
20+
);
21+
22+
tap.test("starts/stops/destroys a docker container", async (assert: any) => {
23+
const fabricTestLedger = new FabricTestLedgerV1();
24+
assert.tearDown(() => fabricTestLedger.stop());
25+
assert.tearDown(() => fabricTestLedger.destroy());
26+
27+
const container: Container = await fabricTestLedger.start();
28+
assert.ok(container);
29+
const ipAddress: string = await fabricTestLedger.getContainerIpAddress();
30+
assert.ok(ipAddress);
31+
assert.ok(ipAddress.length);
32+
33+
const hostPort: number = await fabricTestLedger.getOpsApiPublicPort();
34+
assert.ok(hostPort, "getOpsApiPublicPort() returns truthy OK");
35+
assert.ok(isFinite(hostPort), "getOpsApiPublicPort() returns finite OK");
36+
37+
const isReachable = await isPortReachable(hostPort, { host: "localhost" });
38+
assert.ok(isReachable, `HostPort ${hostPort} is reachable via localhost`);
39+
40+
assert.end();
41+
});

0 commit comments

Comments
 (0)