"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_IMAGE_TAG = exports.RelayContainer = void 0;
const tslib_1 = require("tslib");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const reqp = require("request-promise");
const os = require("os");
const fs = require("fs/promises");
const path = require("path");
const Nats = require("@nats-io/nats-core");
const Transport = require("@nats-io/transport-node");
const inversify_1 = require("inversify");
const Docker = require("dockerode");
const codiac_common_1 = require("@codiac.io/codiac-common");
const ops_1 = require("../ops");
const entities_1 = require("../ops/entities");
// Configuration defaults
const DEFAULT_CONFIG = {
    IMAGE_NAME: "codiacimages/codiac-relay",
    IMAGE_TAG: "1.6.62",
    CONTAINER_NAME: "codiac-relay",
    HEALTH_CHECK_URL: "http://localhost:5799/heartbeat-json",
    RETRY_TIMEOUT_MINUTES: 1,
    MAX_HEALTH_CHECK_RETRIES: 40,
    HEALTH_CHECK_INTERVAL_MS: 3000,
    EXPOSED_PORTS: {
        "5799/tcp": {},
        "5798/tcp": {},
        "4222/tcp": {},
        "9222/tcp": {},
    },
    PORT_BINDINGS: {
        "5799/tcp": [{ HostPort: "5799" }],
        "5798/tcp": [{ HostPort: "5798" }],
        "4222/tcp": [{ HostPort: "4222" }],
        "9222/tcp": [{ HostPort: "9222" }],
    },
    VOLUMES: { "/root/.codiac": {} },
    BINDS: [`${os.homedir()}/.codiac:/root/.codiac`],
};
/**
 * Manages a single version of a Codiac Relay Docker container, ensuring only the specified version runs.
 */
let RelayContainer = class RelayContainer {
    get userMessages() {
        return this.userMessagesSubj.asObservable();
    }
    get ready$() {
        return this.readySubj.pipe((0, operators_1.distinctUntilChanged)());
    }
    constructor(styler, cliEnviro, logger, healthCheckUrl = DEFAULT_CONFIG.HEALTH_CHECK_URL, retryTimeOut = DEFAULT_CONFIG.RETRY_TIMEOUT_MINUTES, projectRoot, localEntities = false) {
        this.styler = styler;
        this.cliEnviro = cliEnviro;
        this.logger = logger;
        this.projectRoot = projectRoot;
        this.localEntities = localEntities;
        this.natsClient = new rxjs_1.BehaviorSubject(null);
        this.destroySubj = new rxjs_1.Subject();
        this.userMessagesSubj = new rxjs_1.BehaviorSubject(undefined);
        this.readySubj = new rxjs_1.ReplaySubject(1);
        this.readyFailedSubj = new rxjs_1.Subject();
        this.connectionRefusedCount = new rxjs_1.BehaviorSubject(0);
        this.silent = false;
        this.config = Object.assign(Object.assign({}, DEFAULT_CONFIG), { HEALTH_CHECK_URL: healthCheckUrl, RETRY_TIMEOUT_MINUTES: retryTimeOut });
        this.docker = this.instantiateDocker();
        this.checkDockerAvailability();
    }
    instantiateDocker() {
        this.logDebug("instantiating Docker client...");
        return new Docker(process.platform === "win32" ? { socketPath: "//./pipe/docker_engine" } : {});
    }
    init() {
        (0, rxjs_1.of)(`${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`)
            .pipe((0, operators_1.distinctUntilChanged)(), (0, operators_1.concatMap)(() => this.handleVersionMismatch()), (0, operators_1.takeUntil)(this.destroySubj))
            .subscribe();
        this.readyFailedSubj
            .pipe((0, operators_1.tap)(() => this.logNotice(this.styler.hilite("Starting Services..."))), (0, operators_1.concatMap)(() => (0, rxjs_1.interval)(this.config.HEALTH_CHECK_INTERVAL_MS)
            .pipe((0, operators_1.timeout)(this.config.RETRY_TIMEOUT_MINUTES * 60000), (0, operators_1.take)(this.config.MAX_HEALTH_CHECK_RETRIES), (0, operators_1.takeUntil)(this.ready$), (0, operators_1.takeUntil)(this.destroySubj), (0, operators_1.concatMap)(() => this.performHealthCheck()))))
            .subscribe();
    }
    logDebug(message) {
        this.logger.debug(message);
    }
    logNotice(message) {
        this.logger.notice(message);
    }
    createLocalDataFolders() {
        if (!this.localEntities)
            return (0, rxjs_1.of)(true);
        const folders = ["cluster-images", "templates", "data/db", "data/logs"];
        return (0, rxjs_1.from)(Promise.all(folders.map((folder) => fs.mkdir(path.posix.join(this.cliEnviro.localProgramFolder, folder), { recursive: true })
            .then(() => this.logDebug(`Created local data folder [${folder}]`))
            .catch((err) => {
            this.logger.error(`Error creating local data folder [${folder}]: ${err.message}`);
            throw err;
        })))).pipe((0, operators_1.map)(() => true), (0, operators_1.catchError)(() => (0, rxjs_1.of)(false)));
    }
    checkDockerAvailability() {
        this.logDebug("checking Docker daemon availability...");
        (0, rxjs_1.from)(this.docker.info())
            .pipe((0, operators_1.tap)(() => {
            this.logDebug("Docker daemon is available");
            this.init();
        }), (0, operators_1.catchError)((err) => {
            const msg = "Can you start Docker for me?";
            this.logger.error(msg, err);
            this.userMessagesSubj.next(msg);
            return (0, rxjs_1.of)(null).pipe((0, operators_1.delay)(6000), (0, operators_1.tap)(() => {
                this.docker = this.instantiateDocker();
                this.checkDockerAvailability();
            }));
        }), (0, operators_1.takeUntil)(this.destroySubj))
            .subscribe();
    }
    getRunningContainers() {
        return (0, rxjs_1.from)(this.docker.listContainers({ all: true })).pipe((0, operators_1.map)((containers) => containers.filter((c) => c.Image.includes(this.config.IMAGE_NAME) && c.State === "running")), (0, operators_1.concatMap)((matching) => matching.length === 0
            ? (0, rxjs_1.of)([])
            : (0, rxjs_1.from)(Promise.all(matching.map((c) => this.docker.getContainer(c.Id).inspect())))), (0, operators_1.catchError)((err) => {
            this.logger.error(`Failed to list containers: ${err.message}`);
            return (0, rxjs_1.of)([]);
        }));
    }
    handleVersionMismatch() {
        return this.getRunningContainers().pipe((0, operators_1.concatMap)((containers) => {
            if (containers.length === 0) {
                this.logDebug("No running containers found");
                return this.startContainer();
            }
            const mismatchChecks = containers.map((c) => {
                const imageName = c.Image;
                if (imageName.startsWith("sha256:")) {
                    return (0, rxjs_1.from)(this.docker.getImage(imageName).inspect()).pipe((0, operators_1.map)((info) => !(info.RepoTags || []).includes(`${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`)), (0, operators_1.catchError)((err) => {
                        this.logger.error(`Failed to inspect image ${imageName}: ${err.message}`);
                        return (0, rxjs_1.of)(true);
                    }));
                }
                return (0, rxjs_1.of)(!imageName.includes(this.config.IMAGE_TAG));
            });
            return (0, rxjs_1.forkJoin)(mismatchChecks).pipe((0, operators_1.map)((results) => results.some((hasMismatch) => hasMismatch)), (0, operators_1.concatMap)((hasMismatch) => {
                if (!hasMismatch) {
                    this.logDebug(`Container version matches ${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`);
                    return this.performHealthCheck();
                }
                this.userMessagesSubj.next(`Version mismatch detected. Updating to ${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`);
                return this.stopAndRemoveContainers().pipe((0, operators_1.concatMap)(() => this.deleteMismatchedImages()), (0, operators_1.concatMap)(() => this.pullImage()), (0, operators_1.concatMap)(() => this.startContainer()));
            }));
        }), (0, operators_1.tap)((success) => {
            this.logDebug(`handleVersionMismatch result: ${success}`);
            if (!success) {
                this.logDebug("startContainer failed, check logs or retry");
            }
        }), (0, operators_1.catchError)((err) => {
            this.logger.error(`handleVersionMismatch failed: ${err.message}`);
            this.userMessagesSubj.next(`Failed to handle version mismatch: ${err.message}`);
            return (0, rxjs_1.of)(false);
        }));
    }
    stopAndRemoveContainers() {
        return this.getRunningContainers().pipe((0, operators_1.concatMap)((containers) => {
            if (containers.length === 0) {
                this.logDebug("No containers to stop or remove");
                return (0, rxjs_1.of)(false);
            }
            return (0, rxjs_1.from)(Promise.all(containers.map((c) => this.docker.getContainer(c.Id).stop().catch((err) => {
                if (err.statusCode !== 404)
                    throw err;
            })))).pipe((0, operators_1.concatMap)(() => (0, rxjs_1.from)(Promise.all(containers.map((c) => this.docker.getContainer(c.Id).remove({ force: true }).catch((err) => {
                if (err.statusCode !== 404)
                    throw err;
            }))))), (0, operators_1.tap)(() => this.logDebug("All containers stopped and removed")), (0, operators_1.map)(() => true), (0, operators_1.catchError)((err) => {
                this.logger.error(`Failed to stop/remove containers: ${err.message}`);
                return (0, rxjs_1.of)(false);
            }));
        }));
    }
    deleteMismatchedImages() {
        const imageRef = `${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`;
        return (0, rxjs_1.from)(this.docker.listImages()).pipe((0, operators_1.map)((images) => images
            .filter((img) => img.RepoTags &&
            img.RepoTags.some((tag) => tag.includes(this.config.IMAGE_NAME) && tag !== imageRef))
            .map((img) => img.RepoTags[0])), (0, operators_1.concatMap)((imagesToRemove) => {
            if (imagesToRemove.length === 0) {
                this.logDebug("No mismatched images to delete");
                return (0, rxjs_1.of)(false);
            }
            return (0, rxjs_1.from)(Promise.all(imagesToRemove.map((tag) => this.docker.getImage(tag).remove()))).pipe((0, operators_1.tap)(() => {
                this.userMessagesSubj.next("Removing extra images complete");
                imagesToRemove.forEach((tag) => this.logDebug(`Removed image ${tag}`));
            }), (0, operators_1.map)(() => true), (0, operators_1.catchError)((err) => {
                this.logger.error(`Failed to delete images: ${err.message}`);
                return (0, rxjs_1.of)(false);
            }));
        }));
    }
    pullImage() {
        const imageRef = `${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`;
        return (0, rxjs_1.from)(this.docker.getImage(imageRef).inspect()).pipe((0, operators_1.tap)(() => {
            this.logDebug(`Image ${imageRef} already exists`);
            this.userMessagesSubj.next(`Image ${imageRef} already exists`);
        }), (0, operators_1.map)(() => true), (0, operators_1.catchError)((err) => {
            if (err.statusCode !== 404) {
                this.logger.error(`Error inspecting image ${imageRef}: ${err.message}`);
                this.userMessagesSubj.next(`Error inspecting image: ${err.message}`);
                this.readyFailedSubj.next();
                return (0, rxjs_1.of)(false);
            }
            this.logNotice(`Fetching image ${imageRef}. This may take a few minutes...`);
            this.userMessagesSubj.next(`Fetching image ${imageRef}...`);
            return (0, rxjs_1.from)(this.docker.pull(imageRef, {})).pipe((0, operators_1.concatMap)((stream) => new rxjs_1.Observable((observer) => {
                this.docker.modem.followProgress(stream, (err) => {
                    if (err) {
                        this.logger.error(`Failed to pull image ${imageRef}: ${err.message}`);
                        this.userMessagesSubj.next(`Fetching failed: ${err.message}`);
                        observer.error(err);
                        return;
                    }
                    this.logDebug(`Image ${imageRef} pulled successfully`);
                    this.userMessagesSubj.next("Relay fetch complete");
                    observer.next(true);
                    observer.complete();
                }, (progress) => { var _a; return this.userMessagesSubj.next(`Pulling ${(_a = progress.id) !== null && _a !== void 0 ? _a : imageRef} : Layer ${progress.status}`); });
            })), (0, operators_1.retry)(2), // Retry up to 2 times with 5-second delay
            (0, operators_1.catchError)((err) => {
                this.logger.error(`Failed to pull image ${imageRef}: ${err.message}`);
                this.userMessagesSubj.next(`Fetching failed: ${err.message}`);
                this.readyFailedSubj.next();
                return (0, rxjs_1.of)(false);
            })); // Type assertion to ensure Observable<boolean>
        }));
    }
    performHealthCheck() {
        this.logDebug(`Checking ${this.config.CONTAINER_NAME} health...`);
        return this.config.HEALTH_CHECK_URL.startsWith("http")
            ? this.performHttpHealthCheck()
            : this.performNatsHealthCheck();
    }
    performHttpHealthCheck() {
        return (0, rxjs_1.from)(reqp.get(this.config.HEALTH_CHECK_URL)).pipe((0, operators_1.map)(() => {
            this.logger.trace(`${this.config.CONTAINER_NAME} is healthy`);
            this.readySubj.next(true);
            this.connectionRefusedCount.next(0);
            return true;
        }), (0, operators_1.catchError)((err) => {
            var _a;
            if (((_a = err.error) === null || _a === void 0 ? void 0 : _a.code) === "ECONNREFUSED") {
                this.connectionRefusedCount.next(this.connectionRefusedCount.value + 1);
                if (this.connectionRefusedCount.value >= this.config.MAX_HEALTH_CHECK_RETRIES) {
                    this.logDebug("Max retries reached, restarting container");
                    this.startContainer().pipe((0, operators_1.take)(1)).subscribe();
                    this.connectionRefusedCount.next(0);
                }
            }
            this.logDebug(`Health check failed: ${err.message}`);
            this.readyFailedSubj.next();
            return (0, rxjs_1.of)(false);
        }));
    }
    performNatsHealthCheck() {
        return (0, rxjs_1.from)((() => tslib_1.__awaiter(this, void 0, void 0, function* () {
            if (!this.natsClient.value || this.natsClient.value.isClosed()) {
                this.natsClient.next(yield Transport.connect({ servers: this.config.HEALTH_CHECK_URL }));
            }
            return this.natsClient.value.request("dmz.get.heartbeat-json", Nats.Empty, { timeout: 2000 });
        }))()).pipe((0, operators_1.map)(() => {
            this.logger.trace(`${this.config.CONTAINER_NAME} NATS heartbeat received`);
            this.readySubj.next(true);
            this.connectionRefusedCount.next(0);
            return true;
        }), (0, operators_1.catchError)((err) => {
            this.connectionRefusedCount.next(this.connectionRefusedCount.value + 1);
            if (this.connectionRefusedCount.value >= this.config.MAX_HEALTH_CHECK_RETRIES) {
                this.logDebug("Max retries reached, restarting container");
                this.startContainer().pipe((0, operators_1.take)(1)).subscribe();
                this.connectionRefusedCount.next(0);
            }
            this.logDebug(`NATS health check failed: ${err.message}`);
            this.readyFailedSubj.next();
            return (0, rxjs_1.of)(false);
        }), (0, operators_1.tap)({
            complete: () => {
                if (this.natsClient.value && !this.natsClient.value.isClosed()) {
                    this.natsClient.value.close().catch((err) => this.logger.error(`Failed to close NATS client: ${err.message}`));
                    this.natsClient.next(null);
                }
            }
        }));
    }
    startContainer() {
        return this.createLocalDataFolders().pipe((0, operators_1.tap)(() => this.logDebug("Completed createLocalDataFolders")), (0, operators_1.concatMap)(() => this.pullImage()), (0, operators_1.tap)(() => this.logDebug("Completed pullImage")), (0, operators_1.concatMap)(() => this.createContainer()), (0, operators_1.tap)(() => this.logDebug("Completed createContainer")), (0, operators_1.concatMap)(() => this.startContainerInternal()), (0, operators_1.tap)(() => this.logDebug("Completed startContainerInternal")), (0, operators_1.timeout)(300000), // Simplified timeout configuration
        (0, operators_1.catchError)((err) => {
            this.logger.error(`Failed to start container: ${err.message}`);
            this.userMessagesSubj.next(`Failed to start container: ${err.message}`);
            this.readyFailedSubj.next(); // stop hanging on errors
            return (0, rxjs_1.of)(false);
        }));
    }
    createContainer() {
        var _a;
        const containerConfig = {
            Image: `${this.config.IMAGE_NAME}:${this.config.IMAGE_TAG}`,
            name: this.config.CONTAINER_NAME,
            Hostname: this.config.CONTAINER_NAME,
            ExposedPorts: Object.assign({}, DEFAULT_CONFIG.EXPOSED_PORTS),
            HostConfig: {
                Binds: [...DEFAULT_CONFIG.BINDS],
                PortBindings: Object.assign({}, DEFAULT_CONFIG.PORT_BINDINGS),
                RestartPolicy: { Name: "unless-stopped" },
            },
            Volumes: Object.assign({}, DEFAULT_CONFIG.VOLUMES),
            Env: [`HOST_PLATFORM=${process.platform}`],
        };
        if (this.localEntities) {
            containerConfig.ExposedPorts["27017/tcp"] = {};
            containerConfig.HostConfig.PortBindings["27017/tcp"] = [{ HostPort: "27017" }];
            (_a = containerConfig.HostConfig.Binds) === null || _a === void 0 ? void 0 : _a.push(`${os.homedir()}/.codiac/data/db:/data/db`, `${os.homedir()}/.codiac/data/logs:/data/logs`);
            containerConfig.Volumes["/data/db"] = {};
            containerConfig.Volumes["/data/logs"] = {};
            containerConfig.Entrypoint = ["./start-relay-w-nats-and-local-entities.sh"];
        }
        return (0, rxjs_1.from)(this.docker.createContainer(containerConfig)).pipe((0, operators_1.tap)(() => this.logDebug(`Created container ${this.config.CONTAINER_NAME}`)), (0, operators_1.map)(() => true), (0, operators_1.catchError)((err) => {
            if (err.statusCode === 409) {
                this.logDebug(`Container ${this.config.CONTAINER_NAME} already exists`);
                return (0, rxjs_1.of)(true);
            }
            this.logger.error(`Failed to create container: ${err.message}`);
            return this.pullImage();
        }));
    }
    startContainerInternal() {
        return (0, rxjs_1.from)(this.docker.getContainer(this.config.CONTAINER_NAME).inspect()).pipe((0, operators_1.concatMap)((info) => {
            if (info.State.Running) {
                this.logDebug(`Container ${this.config.CONTAINER_NAME} is already running`);
                return this.performHealthCheck();
            }
            return (0, rxjs_1.from)(this.docker.getContainer(this.config.CONTAINER_NAME).start()).pipe((0, operators_1.tap)(() => {
                this.logDebug(`Started container ${this.config.CONTAINER_NAME}`);
                this.userMessagesSubj.next("Starting Relay...");
                this.performHealthCheck().subscribe();
            }), (0, operators_1.map)(() => true));
        }), (0, operators_1.catchError)((err) => {
            if (err.statusCode === 404) {
                this.logDebug(`Container ${this.config.CONTAINER_NAME} not found, creating...`);
                return this.createContainer();
            }
            throw err; // throw error allow caller to handle if can't be handle here
        }));
    }
    stop() {
        return (0, rxjs_1.from)(this.docker.getContainer(this.config.CONTAINER_NAME).inspect()).pipe((0, operators_1.concatMap)((info) => {
            if (!info.State.Running) {
                this.logDebug(`Container ${this.config.CONTAINER_NAME} is not running`);
                return (0, rxjs_1.of)(false);
            }
            return (0, rxjs_1.from)(this.docker.getContainer(this.config.CONTAINER_NAME).stop()).pipe((0, operators_1.tap)(() => this.logDebug(`Stopped container ${this.config.CONTAINER_NAME}`)), (0, operators_1.map)(() => true));
        }), (0, operators_1.catchError)((err) => {
            if (err.statusCode === 404) {
                this.logDebug(`Container ${this.config.CONTAINER_NAME} does not exist`);
                return (0, rxjs_1.of)(false);
            }
            this.logger.error(`Failed to stop container: ${err.message}`);
            return (0, rxjs_1.of)(false);
        }));
    }
    remove() {
        return this.stop().pipe((0, operators_1.concatMap)(() => (0, rxjs_1.from)(this.docker.getContainer(this.config.CONTAINER_NAME).remove({ force: true })).pipe((0, operators_1.tap)(() => this.logDebug(`Removed container ${this.config.CONTAINER_NAME}`)), (0, operators_1.map)(() => true), (0, operators_1.catchError)((err) => {
            if (err.statusCode === 404) {
                this.logDebug(`Container ${this.config.CONTAINER_NAME} does not exist`);
                return (0, rxjs_1.of)(false);
            }
            this.logger.error(`Failed to remove container: ${err.message}`);
            return (0, rxjs_1.of)(false);
        }))));
    }
    completed() {
        if (this.natsClient.value && !this.natsClient.value.isClosed()) {
            this.natsClient.value.close().catch((err) => this.logger.error(`Failed to close NATS client: ${err.message}`));
            this.natsClient.next(null);
        }
        this.destroySubj.complete();
        this.readySubj.complete();
        this.readyFailedSubj.complete();
        this.userMessagesSubj.complete();
        this.connectionRefusedCount.complete();
    }
    destroy() {
        return this.stop().pipe((0, operators_1.concatMap)(() => this.remove()), (0, operators_1.tap)(() => {
            this.destroySubj.next();
            this.completed();
            this.logDebug("RelayContainer destroyed");
        }), (0, operators_1.map)(() => true), (0, operators_1.catchError)((err) => {
            this.logger.error(`Failed to destroy RelayContainer: ${err.message}`);
            return (0, rxjs_1.of)(false);
        }));
    }
};
RelayContainer = tslib_1.__decorate([
    tslib_1.__param(0, (0, inversify_1.inject)(ops_1.INTERFACES_CLI.IOutputStyler)),
    tslib_1.__param(1, (0, inversify_1.inject)(entities_1.CliEnviro)),
    tslib_1.__param(2, (0, inversify_1.inject)(codiac_common_1.INTERFACES.IBetterLogger)),
    tslib_1.__metadata("design:paramtypes", [Object, entities_1.CliEnviro, Object, String, Number, String, Boolean])
], RelayContainer);
exports.RelayContainer = RelayContainer;
exports.DEFAULT_IMAGE_TAG = DEFAULT_CONFIG.IMAGE_TAG;
//# sourceMappingURL=relay-container.js.map