diff --git a/package.json b/package.json index 136a3cc..7363c9b 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,6 @@ "title": "Run Setup Wizard", "category": "LocalStack" }, - { - "command": "localstack.refreshStatusBar", - "title": "Refresh Status Bar", - "category": "LocalStack", - "enablement": "false" - }, { "command": "localstack.showCommands", "title": "Show Commands", diff --git a/src/constants.ts b/src/constants.ts index 4a94541..d0d60af 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,20 +15,26 @@ export const GLOBAL_CLI_INSTALLATION_DIRNAME = join( ); const CLI_UNIX_PATHS = [ + // The local installation path takes precedence. + join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack"), + // Check if it's in the PATH. "localstack", + // Common installation paths. join("/", "usr", "bin", "localstack"), join("/", "usr", "local", "bin", "localstack"), join("/", "opt", "homebrew", "bin", "localstack"), join("/", "home", "linuxbrew", ".linuxbrew", "bin", "localstack"), join(homedir(), ".linuxbrew", "bin", "localstack"), join(homedir(), ".local", "bin", "localstack"), - join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack"), ]; const CLI_WINDOWS_PATHS = [ + // The local installation path takes precedence. + join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack.exe"), + // Check if it's in the PATH. "localstack.exe", + // Common installation paths. join(GLOBAL_CLI_INSTALLATION_DIRNAME, "localstack", "localstack.exe"), - join(LOCAL_CLI_INSTALLATION_DIRNAME, "localstack.exe"), ]; export const CLI_PATHS = diff --git a/src/extension.ts b/src/extension.ts index 1eeac76..75d96e6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,10 +8,14 @@ import manage from "./plugins/manage.ts"; import setup from "./plugins/setup.ts"; import statusBar from "./plugins/status-bar.ts"; import { PluginManager } from "./plugins.ts"; -import { createContainerStatusTracker } from "./utils/container-status.ts"; -import { createLocalStackStatusTracker } from "./utils/localstack-status.ts"; +import { createCliStatusTracker } from "./utils/cli.ts"; +import { createLocalStackContainerStatusTracker } from "./utils/localstack-container.ts"; +import { + createHealthStatusTracker, + createLocalStackInstanceStatusTracker, +} from "./utils/localstack-instance.ts"; import { getOrCreateExtensionSessionId } from "./utils/manage.ts"; -import { createSetupStatusTracker } from "./utils/setup-status.ts"; +import { createSetupStatusTracker } from "./utils/setup.ts"; import { createTelemetry } from "./utils/telemetry.ts"; import { createTimeTracker } from "./utils/time-tracker.ts"; @@ -29,6 +33,9 @@ export async function activate(context: ExtensionContext) { }); context.subscriptions.push(outputChannel); + const cliStatusTracker = createCliStatusTracker(outputChannel); + context.subscriptions.push(cliStatusTracker); + const timeTracker = createTimeTracker({ outputChannel }); const { @@ -46,45 +53,37 @@ export async function activate(context: ExtensionContext) { statusBarItem.text = "$(loading~spin) LocalStack"; statusBarItem.show(); - const containerStatusTracker = await createContainerStatusTracker( + const containerStatusTracker = createLocalStackContainerStatusTracker( "localstack-main", outputChannel, timeTracker, ); context.subscriptions.push(containerStatusTracker); - const localStackStatusTracker = createLocalStackStatusTracker( + const healthCheckStatusTracker = createHealthStatusTracker(timeTracker); + const localStackStatusTracker = createLocalStackInstanceStatusTracker( containerStatusTracker, + healthCheckStatusTracker, outputChannel, - timeTracker, ); context.subscriptions.push(localStackStatusTracker); - outputChannel.trace(`[setup-status]: Starting...`); - const startStatusTracker = Date.now(); - const setupStatusTracker = await createSetupStatusTracker( - outputChannel, - timeTracker, + const setupStatusTracker = await timeTracker.run( + "setup-status", + async () => { + return await createSetupStatusTracker( + outputChannel, + timeTracker, + cliStatusTracker, + ); + }, ); context.subscriptions.push(setupStatusTracker); - const endStatusTracker = Date.now(); - outputChannel.trace( - `[setup-status]: Completed in ${ms( - endStatusTracker - startStatusTracker, - { long: true }, - )}`, - ); - const startTelemetry = Date.now(); - outputChannel.trace(`[telemetry]: Starting...`); - const sessionId = await getOrCreateExtensionSessionId(context); - const telemetry = createTelemetry(outputChannel, sessionId); - const endTelemetry = Date.now(); - outputChannel.trace( - `[telemetry]: Completed in ${ms(endTelemetry - startTelemetry, { - long: true, - })}`, - ); + const telemetry = await timeTracker.run("telemetry", async () => { + const sessionId = await getOrCreateExtensionSessionId(context); + return createTelemetry(outputChannel, sessionId); + }); return { statusBarItem, @@ -100,6 +99,7 @@ export async function activate(context: ExtensionContext) { context, outputChannel, statusBarItem, + cliStatusTracker, containerStatusTracker, localStackStatusTracker, setupStatusTracker, diff --git a/src/plugins.ts b/src/plugins.ts index b35d3f9..e136cc3 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,9 +1,10 @@ import ms from "ms"; import type { ExtensionContext, LogOutputChannel, StatusBarItem } from "vscode"; -import type { ContainerStatusTracker } from "./utils/container-status.ts"; -import type { LocalStackStatusTracker } from "./utils/localstack-status.ts"; -import type { SetupStatusTracker } from "./utils/setup-status.ts"; +import type { CliStatusTracker } from "./utils/cli.ts"; +import type { LocalStackContainerStatusTracker } from "./utils/localstack-container.ts"; +import type { LocalStackInstanceStatusTracker } from "./utils/localstack-instance.ts"; +import type { SetupStatusTracker } from "./utils/setup.ts"; import type { Telemetry } from "./utils/telemetry.ts"; import type { TimeTracker } from "./utils/time-tracker.ts"; @@ -13,8 +14,9 @@ export interface PluginOptions { context: ExtensionContext; outputChannel: LogOutputChannel; statusBarItem: StatusBarItem; - containerStatusTracker: ContainerStatusTracker; - localStackStatusTracker: LocalStackStatusTracker; + cliStatusTracker: CliStatusTracker; + containerStatusTracker: LocalStackContainerStatusTracker; + localStackStatusTracker: LocalStackInstanceStatusTracker; setupStatusTracker: SetupStatusTracker; telemetry: Telemetry; timeTracker: TimeTracker; diff --git a/src/plugins/manage.ts b/src/plugins/manage.ts index 126cd74..2fb63c5 100644 --- a/src/plugins/manage.ts +++ b/src/plugins/manage.ts @@ -9,16 +9,30 @@ import { export default createPlugin( "manage", - ({ context, outputChannel, telemetry, localStackStatusTracker }) => { + ({ + context, + outputChannel, + telemetry, + localStackStatusTracker, + cliStatusTracker, + }) => { context.subscriptions.push( commands.registerCommand("localstack.start", async () => { + const cliPath = cliStatusTracker.cliPath(); + if (!cliPath) { + void window.showInformationMessage( + "LocalStack CLI could not be found. Please, run the setup wizard.", + ); + return; + } + if (localStackStatusTracker.status() !== "stopped") { window.showInformationMessage("LocalStack is already running."); return; } localStackStatusTracker.forceContainerStatus("running"); try { - await startLocalStack(outputChannel, telemetry); + await startLocalStack(cliPath, outputChannel, telemetry); } catch { localStackStatusTracker.forceContainerStatus("stopped"); } @@ -27,12 +41,20 @@ export default createPlugin( context.subscriptions.push( commands.registerCommand("localstack.stop", () => { + const cliPath = cliStatusTracker.cliPath(); + if (!cliPath) { + void window.showInformationMessage( + "LocalStack CLI could not be found. Please, run the setup wizard.", + ); + return; + } + if (localStackStatusTracker.status() !== "running") { window.showInformationMessage("LocalStack is not running."); return; } localStackStatusTracker.forceContainerStatus("stopping"); - void stopLocalStack(outputChannel, telemetry); + void stopLocalStack(cliPath, outputChannel, telemetry); }), ); diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 6f92d12..4c9e9fa 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -7,6 +7,7 @@ import { saveAuthToken, readAuthToken, } from "../utils/authenticate.ts"; +import { findLocalStack } from "../utils/cli.ts"; import { configureAwsProfiles } from "../utils/configure-aws.ts"; import { runInstallProcess } from "../utils/install.ts"; import { @@ -14,10 +15,18 @@ import { checkIsLicenseValid, activateLicenseUntilValid, } from "../utils/license.ts"; -import { minDelay } from "../utils/promises.ts"; +import { minDelay } from "../utils/min-delay.ts"; import { updateDockerImage } from "../utils/setup.ts"; import { get_setup_ended } from "../utils/telemetry.ts"; +async function getValidCliPath() { + const cli = await findLocalStack(); + if (!cli.cliPath || !cli.executable || !cli.found || !cli.upToDate) { + return; + } + return cli.cliPath; +} + export default createPlugin( "setup", ({ @@ -25,6 +34,7 @@ export default createPlugin( outputChannel, setupStatusTracker, localStackStatusTracker, + cliStatusTracker, telemetry, }) => { context.subscriptions.push( @@ -43,7 +53,7 @@ export default createPlugin( }, }); - window.withProgress( + void window.withProgress( { location: ProgressLocation.Notification, title: "Setup LocalStack", @@ -55,13 +65,14 @@ export default createPlugin( let authenticationStatus: "COMPLETED" | "SKIPPED" = "COMPLETED"; { const installationStartedAt = new Date().toISOString(); - const { cancelled, skipped } = await runInstallProcess( + const { cancelled, skipped } = await runInstallProcess({ + cliPath: cliStatusTracker.cliPath(), progress, cancellationToken, outputChannel, telemetry, - origin_trigger, - ); + origin: origin_trigger, + }); cliStatus = skipped === true ? "SKIPPED" : "COMPLETED"; if (cancelled || cancellationToken.isCancellationRequested) { telemetry.track({ @@ -221,14 +232,52 @@ export default createPlugin( ///////////////////////////////////////////////////////////////////// progress.report({ message: "Checking LocalStack license..." }); + // If the CLI status tracker doesn't have a valid CLI path yet, + // we must find it manually. This may occur when installing the + // CLI as part of the setup process: the CLI status tracker will + // detect the CLI path the next tick. + const cliPath = + cliStatusTracker.cliPath() ?? (await getValidCliPath()); + if (!cliPath) { + telemetry.track( + get_setup_ended( + cliStatus, + authenticationStatus, + "CANCELLED", + "CANCELLED", + "FAILED", + origin_trigger, + await readAuthToken(), + ), + ); + void window + .showErrorMessage( + "Could not access the LocalStack CLI.", + { + title: "Restart Setup", + command: "localstack.setup", + }, + { + title: "View Logs", + command: "localstack.viewLogs", + }, + ) + .then((selection) => { + if (selection) { + void commands.executeCommand(selection.command); + } + }); + return; + } + // If an auth token has just been obtained or LocalStack has never been started, // then there will be no license info to be reported by `localstack license info`. // Also, an expired license could be cached. // Activating the license pre-emptively to know its state during the setup process. const licenseCheckStartedAt = new Date().toISOString(); const licenseIsValid = await minDelay( - activateLicense(outputChannel).then(() => - checkIsLicenseValid(outputChannel), + activateLicense(cliPath, outputChannel).then(() => + checkIsLicenseValid(cliPath, outputChannel), ), ); if (!licenseIsValid) { @@ -240,6 +289,7 @@ export default createPlugin( await commands.executeCommand("localstack.openLicensePage"); await activateLicenseUntilValid( + cliPath, outputChannel, cancellationToken, ); diff --git a/src/plugins/status-bar.ts b/src/plugins/status-bar.ts index e7f10ab..293f9a8 100644 --- a/src/plugins/status-bar.ts +++ b/src/plugins/status-bar.ts @@ -2,12 +2,32 @@ import { commands, QuickPickItemKind, ThemeColor, window } from "vscode"; import type { QuickPickItem } from "vscode"; import { createPlugin } from "../plugins.ts"; +import type { LocalStackInstanceStatus } from "../utils/localstack-instance.ts"; +import { createOnceImmediate } from "../utils/once-immediate.ts"; +import type { SetupStatus } from "../utils/setup.ts"; + +function getOverallStatusText(options: { + cliStatus: SetupStatus; + localStackStatus: LocalStackInstanceStatus; + cliOutdated: boolean | undefined; +}) { + if (options.cliStatus === "ok") { + return options.localStackStatus; + } + + if (options.cliOutdated) { + return "CLI outdated"; + } + + return "CLI not installed"; +} export default createPlugin( "status-bar", ({ context, statusBarItem, + cliStatusTracker, localStackStatusTracker, setupStatusTracker, outputChannel, @@ -15,10 +35,10 @@ export default createPlugin( context.subscriptions.push( commands.registerCommand("localstack.showCommands", async () => { const shouldShowLocalStackStart = () => - setupStatusTracker.statuses().isInstalled && + cliStatusTracker.status() === "ok" && localStackStatusTracker.status() === "stopped"; const shouldShowLocalStackStop = () => - setupStatusTracker.statuses().isInstalled && + cliStatusTracker.status() === "ok" && localStackStatusTracker.status() === "running"; const shouldShowRunSetupWizard = () => setupStatusTracker.status() === "setup_required"; @@ -77,63 +97,57 @@ export default createPlugin( }), ); - context.subscriptions.push( - commands.registerCommand("localstack.refreshStatusBar", () => { - const setupStatus = setupStatusTracker.status(); - const localStackStatus = localStackStatusTracker.status(); - const localStackInstalled = setupStatusTracker.statuses().isInstalled; - - statusBarItem.command = "localstack.showCommands"; - statusBarItem.backgroundColor = - setupStatus === "setup_required" - ? new ThemeColor("statusBarItem.errorBackground") - : undefined; - - const shouldSpin = - localStackStatus === "starting" || localStackStatus === "stopping"; - const icon = - setupStatus === "setup_required" - ? "$(error)" - : shouldSpin - ? "$(sync~spin)" - : "$(localstack-logo)"; - - const statusText = localStackInstalled - ? `${localStackStatus}` - : "not installed"; - statusBarItem.text = `${icon} LocalStack: ${statusText}`; - - statusBarItem.tooltip = "Show LocalStack commands"; - statusBarItem.show(); - }), - ); - - let refreshStatusBarImmediateId: NodeJS.Immediate | undefined; - const refreshStatusBarImmediate = () => { - if (!refreshStatusBarImmediateId) { - refreshStatusBarImmediateId = setImmediate(() => { - void commands.executeCommand("localstack.refreshStatusBar"); - refreshStatusBarImmediateId = undefined; - }); + const renderStatusBar = createOnceImmediate(() => { + const setupStatus = setupStatusTracker.status(); + const localStackStatus = localStackStatusTracker.status(); + const cliStatus = cliStatusTracker.status(); + const cliOutdated = cliStatusTracker.outdated(); + outputChannel.trace( + `[status-bar] setupStatus=${setupStatus} localStackStatus=${localStackStatus} cliStatus=${cliStatus}`, + ); + + // Skip rendering the status bar if any of the status checks is not ready. + if ( + setupStatus === undefined || + localStackStatus === undefined || + cliStatus === undefined + ) { + return; } - }; - context.subscriptions.push({ - dispose() { - clearImmediate(refreshStatusBarImmediateId); - }, + statusBarItem.command = "localstack.showCommands"; + statusBarItem.backgroundColor = + setupStatus === "setup_required" + ? new ThemeColor("statusBarItem.errorBackground") + : undefined; + + const shouldSpin = + localStackStatus === "starting" || localStackStatus === "stopping"; + const icon = + setupStatus === "setup_required" + ? "$(error)" + : shouldSpin + ? "$(sync~spin)" + : "$(localstack-logo)"; + + const statusText = getOverallStatusText({ + cliOutdated, + cliStatus, + localStackStatus, + }); + statusBarItem.text = `${icon} LocalStack: ${statusText}`; + + statusBarItem.tooltip = "Show LocalStack commands"; }); - refreshStatusBarImmediate(); - localStackStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: localStackStatusTracker changed"); - refreshStatusBarImmediate(); + renderStatusBar(); }); setupStatusTracker.onChange(() => { outputChannel.trace("[status-bar]: setupStatusTracker changed"); - refreshStatusBarImmediate(); + renderStatusBar(); }); }, ); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts deleted file mode 100644 index 3ecf628..0000000 --- a/src/test/extension.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as assert from "node:assert"; - -import { window } from "vscode"; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it - -// import * as myExtension from '../../extension'; - -suite("Extension Test Suite", () => { - window.showInformationMessage("Start all tests."); - - test("Sample test", () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); -}); diff --git a/src/test/localstack-instance.test.ts b/src/test/localstack-instance.test.ts new file mode 100644 index 0000000..065d45c --- /dev/null +++ b/src/test/localstack-instance.test.ts @@ -0,0 +1,110 @@ +import * as assert from "node:assert"; +import { setImmediate } from "node:timers/promises"; + +import { window } from "vscode"; + +import { createValueEmitter } from "../utils/emitter.ts"; +import type { + LocalStackContainerStatus, + LocalStackContainerStatusTracker, +} from "../utils/localstack-container.ts"; +import { createLocalStackInstanceStatusTracker } from "../utils/localstack-instance.ts"; +import type { + HealthStatus, + HealthStatusTracker, +} from "../utils/localstack-instance.ts"; + +function createFixtures() { + const containerStatus = createValueEmitter(); + const containerStatusTracker: LocalStackContainerStatusTracker = { + status() { + return containerStatus.value(); + }, + onChange(callback) { + containerStatus.onChange(callback); + }, + dispose() {}, + }; + + const healthStatus = createValueEmitter(); + const healthCheckStatusTracker: HealthStatusTracker = { + start() {}, + stop() {}, + status() { + return healthStatus.value(); + }, + onChange(callback) { + healthStatus.onChange(callback); + }, + dispose() {}, + }; + + const outputChannel = window.createOutputChannel("LocalStack", { + log: true, + }); + + const tracker = createLocalStackInstanceStatusTracker( + containerStatusTracker, + healthCheckStatusTracker, + outputChannel, + ); + + return { + containerStatus, + healthStatus, + tracker, + }; +} + +suite("LocalStack Instance Test Suite", () => { + test("Derives instance status correctly", async () => { + const { containerStatus, healthStatus, tracker } = createFixtures(); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue(undefined); + healthStatus.setValue(undefined); + await setImmediate(); + assert.strictEqual(tracker.status(), undefined); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("running"); + healthStatus.setValue("unhealthy"); + await setImmediate(); + assert.strictEqual(tracker.status(), "starting"); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("running"); + healthStatus.setValue("healthy"); + await setImmediate(); + assert.strictEqual(tracker.status(), "running"); + }); + + test("Forcing container status derives instance status correctly", async () => { + const { containerStatus, healthStatus, tracker } = createFixtures(); + + /////////////////////////////////////////////////////////////////////////// + tracker.forceContainerStatus("running"); + await setImmediate(); + assert.strictEqual(tracker.status(), "starting"); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("running"); + await setImmediate(); + assert.strictEqual(tracker.status(), "starting"); + + /////////////////////////////////////////////////////////////////////////// + healthStatus.setValue("healthy"); + await setImmediate(); + assert.strictEqual(tracker.status(), "running"); + + /////////////////////////////////////////////////////////////////////////// + tracker.forceContainerStatus("stopping"); + await setImmediate(); + assert.strictEqual(tracker.status(), "stopping"); + + /////////////////////////////////////////////////////////////////////////// + containerStatus.setValue("stopped"); + await setImmediate(); + assert.strictEqual(tracker.status(), "stopped"); + }); +}); diff --git a/src/utils/authenticate.ts b/src/utils/authenticate.ts index ca2cba4..e9201ca 100644 --- a/src/utils/authenticate.ts +++ b/src/utils/authenticate.ts @@ -10,6 +10,8 @@ import type { import { env, Uri, window } from "vscode"; import { assertIsError } from "./assert.ts"; +import { createFileStatusTracker } from "./file-status-tracker.ts"; +import type { StatusTracker } from "./file-status-tracker.ts"; /** * Registers a {@link UriHandler} that waits for an authentication token from the browser, @@ -160,3 +162,23 @@ export async function readAuthToken(): Promise { export async function checkIsAuthenticated() { return (await readAuthToken()) !== ""; } + +/** + * Creates a status tracker that monitors the LocalStack authentication file for changes. + * When the file is changed, the provided check function is called to determine the current setup status. + * Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @param outputChannel + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createLocalStackAuthenticationStatusTracker( + outputChannel: LogOutputChannel, +): StatusTracker { + return createFileStatusTracker( + outputChannel, + "[setup-status.localstack-authentication]", + [LOCALSTACK_AUTH_FILENAME], + async () => ((await checkIsAuthenticated()) ? "ok" : "setup_required"), + ); +} diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 3db15a1..f6621f1 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -1,60 +1,133 @@ import { constants } from "node:fs"; import { access } from "node:fs/promises"; +import { isAbsolute } from "node:path"; -import type { CancellationToken, LogOutputChannel } from "vscode"; +import { watch } from "chokidar"; import { workspace } from "vscode"; +import type { CancellationToken, LogOutputChannel, Disposable } from "vscode"; import { CLI_PATHS, LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; +import { createValueEmitter } from "./emitter.ts"; import { exec } from "./exec.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; +import type { SetupStatus } from "./setup.ts"; import { spawn } from "./spawn.ts"; import type { SpawnOptions } from "./spawn.ts"; const IMAGE_NAME = LOCALSTACK_DOCKER_IMAGE_NAME; // not using the import directly as the constant name should match the env var const LOCALSTACK_LDM_PREVIEW = "1"; -const findLocalStack = async (): Promise => { +async function getLocalStackVersion( + cliPath: string, +): Promise { + try { + const { stdout } = await exec([cliPath, "--version"].join(" "), { + env: { + ...process.env, + IMAGE_NAME, + LOCALSTACK_LDM_PREVIEW, + }, + }); + + const versionMatch = stdout.match(/\b([\d]+\.[\d]+.(?:\d[\d\w]*))\b/); + if (!versionMatch) { + return undefined; + } + const [, stdoutVersion] = versionMatch; + return stdoutVersion; + } catch { + return undefined; + } +} + +async function getLocalStackMajorVersion( + cliPath: string, +): Promise { + const version = await getLocalStackVersion(cliPath); + if (!version) { + return undefined; + } + const majorVersionMatch = version.match(/^(\d+)\./); + if (!majorVersionMatch) { + return undefined; + } + const [, majorVersionStr] = majorVersionMatch; + const majorVersion = parseInt(majorVersionStr, 10); + if (Number.isNaN(majorVersion)) { + return undefined; + } + return majorVersion; +} + +async function verifyLocalStackCli(cliPath: string) { + const [found, executable, version] = await Promise.all([ + access(cliPath, constants.F_OK) + .then(() => true) + .catch(() => false), + access(cliPath, constants.X_OK) + .then(() => true) + .catch(() => false), + getLocalStackMajorVersion(cliPath), + ]); + return { + found, + executable, + upToDate: version !== undefined ? version >= 4 : undefined, + }; +} + +interface CliCheckResult { + cliPath: string | undefined; + found: boolean; + executable: boolean | undefined; + upToDate: boolean | undefined; +} + +export async function findLocalStack(): Promise { // Check if a custom path is configured const config = workspace.getConfiguration("localstack"); const customLocation = config.get("cli.location"); - if (customLocation) { - try { - await access(customLocation, constants.X_OK); - return customLocation; - } catch (error) { - throw new Error( - `Configured LocalStack CLI location '${customLocation}' is not accessible: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } + const { found, executable, upToDate } = + await verifyLocalStackCli(customLocation); + return { + cliPath: customLocation, + found, + executable, + upToDate, + }; } // Fall back to default search paths for (const CLI_PATH of CLI_PATHS) { - try { - await access(CLI_PATH, constants.X_OK); - return CLI_PATH; - } catch { - // Continue to next path + const { found, executable, upToDate } = await verifyLocalStackCli(CLI_PATH); + if (found) { + return { + cliPath: CLI_PATH, + found, + executable, + upToDate, + }; } } - throw new Error( - "LocalStack CLI could not be found in any of the default locations", - ); -}; + return { + cliPath: undefined, + found: false, + executable: undefined, + upToDate: undefined, + }; +} export const execLocalStack = async ( + cliPath: string, args: string[], - options: { + options?: { outputChannel: LogOutputChannel; - // cancellationToken?: CancellationToken; }, ) => { - const cli = await findLocalStack(); - - const response = await exec([`"${cli}"`, ...args].join(" "), { + const response = await exec([cliPath, ...args].join(" "), { env: { ...process.env, IMAGE_NAME, @@ -65,6 +138,7 @@ export const execLocalStack = async ( }; export const spawnLocalStack = async ( + cliPath: string, args: string[], options: { outputChannel: LogOutputChannel; @@ -72,9 +146,7 @@ export const spawnLocalStack = async ( onStderr?: SpawnOptions["onStderr"]; }, ) => { - const cli = await findLocalStack(); - - return spawn(`"${cli}"`, args, { + return spawn(cliPath, args, { outputChannel: options.outputChannel, outputLabel: `localstack.${args[0]}`, cancellationToken: options.cancellationToken, @@ -86,3 +158,86 @@ export const spawnLocalStack = async ( onStderr: options.onStderr, }); }; + +export type LocalStackCliStatus = "not_found" | "outdated" | "ok"; + +export interface CliStatusTracker extends Disposable { + status(): SetupStatus | undefined; + onStatusChange(callback: (status: SetupStatus | undefined) => void): void; + cliPath(): string | undefined; + onCliPathChange(callback: (cliPath: string | undefined) => void): void; + outdated(): boolean | undefined; + onOutdatedChange(callback: (outdated: boolean | undefined) => void): void; +} + +export function createCliStatusTracker( + outputChannel: LogOutputChannel, +): CliStatusTracker { + const status = createValueEmitter(); + const cliPath = createValueEmitter(); + const outdated = createValueEmitter(); + + const track = createOnceImmediate(async () => { + const newCli = await findLocalStack().catch(() => undefined); + outputChannel.info(`[cli]: findLocalStack = ${newCli?.cliPath}`); + + status.setValue( + newCli?.found && newCli.executable && newCli.upToDate + ? "ok" + : "setup_required", + ); + cliPath.setValue(status.value() === "ok" ? newCli?.cliPath : undefined); + outdated.setValue( + newCli?.upToDate !== undefined ? !newCli.upToDate : undefined, + ); + }); + + const watcher = watch( + // Watch absolute paths only, since `localstack` is not a real path. + CLI_PATHS.filter((path) => isAbsolute(path)), + ) + .on("add", (path) => { + outputChannel.trace( + `[cli]: Detected new file at ${path}, re-checking CLI`, + ); + track(); + }) + .on("change", (path) => { + outputChannel.trace( + `[cli]: Detected change to file at ${path}, re-checking CLI`, + ); + track(); + }) + .on("unlink", (path) => { + outputChannel.trace( + `[cli]: Detected removal of file at ${path}, re-checking CLI`, + ); + track(); + }); + + track(); + + return { + cliPath() { + return cliPath.value(); + }, + onCliPathChange(callback) { + cliPath.onChange(callback); + }, + status() { + return status.value(); + }, + onStatusChange(callback) { + status.onChange(callback); + }, + outdated() { + return outdated.value(); + }, + onOutdatedChange(callback) { + outdated.onChange(callback); + }, + async dispose() { + await watcher.close(); + }, + }; +} diff --git a/src/utils/configure-aws.ts b/src/utils/configure-aws.ts index f61e07f..51cc80f 100644 --- a/src/utils/configure-aws.ts +++ b/src/utils/configure-aws.ts @@ -7,6 +7,8 @@ import { window } from "vscode"; import type { LogOutputChannel } from "vscode"; import { readAuthToken } from "./authenticate.ts"; +import { createFileStatusTracker } from "./file-status-tracker.ts"; +import type { StatusTracker } from "./file-status-tracker.ts"; import { parseIni, serializeIni, updateIniSection } from "./ini-parser.ts"; import type { IniFile, IniSection } from "./ini-parser.ts"; import type { Telemetry } from "./telemetry.ts"; @@ -501,3 +503,22 @@ export async function checkIsProfileConfigured(): Promise { return false; } } + +/** + * Creates a status tracker that monitors the AWS profile files for changes. + * When the file is changed, the provided check function is called to determine the current setup status. + * Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createAwsProfileStatusTracker( + outputChannel: LogOutputChannel, +): StatusTracker { + return createFileStatusTracker( + outputChannel, + "[setup-status.aws-profile]", + [AWS_CONFIG_FILENAME, AWS_CREDENTIALS_FILENAME], + async () => ((await checkIsProfileConfigured()) ? "ok" : "setup_required"), + ); +} diff --git a/src/utils/emitter.ts b/src/utils/emitter.ts index 648b300..d28f3ac 100644 --- a/src/utils/emitter.ts +++ b/src/utils/emitter.ts @@ -1,27 +1,38 @@ -import type { LogOutputChannel } from "vscode"; +import { createOnceImmediate } from "./once-immediate.ts"; -export type Callback = (value: T) => Promise | void; +export type Callback = (value: T | undefined) => Promise | void; -export interface Emitter { - on(callback: Callback): void; - emit(value: T): Promise; +export interface ValueEmitter { + value(): T | undefined; + setValue(value: T | undefined): void; + onChange(callback: Callback): void; } -export function createEmitter(outputChannel: LogOutputChannel): Emitter { +export function createValueEmitter(): ValueEmitter { + let currentValue: T | undefined; const callbacks: Callback[] = []; + const emit = createOnceImmediate(async () => { + for (const callback of callbacks) { + try { + await callback(currentValue); + } catch {} + } + }); + return { - on(callback) { - callbacks.push(callback); + value() { + return currentValue; }, - async emit(value) { - for (const callback of callbacks) { - try { - await callback(value); - } catch (error) { - outputChannel.error(error instanceof Error ? error : String(error)); - } + setValue(value) { + if (currentValue !== value) { + currentValue = value; + emit(); } }, + onChange(callback) { + callbacks.push(callback); + void Promise.resolve(callback(currentValue)).catch(() => {}); + }, }; } diff --git a/src/utils/file-status-tracker.ts b/src/utils/file-status-tracker.ts new file mode 100644 index 0000000..71cf017 --- /dev/null +++ b/src/utils/file-status-tracker.ts @@ -0,0 +1,73 @@ +import { watch } from "chokidar"; +import type { LogOutputChannel } from "vscode"; + +import { createValueEmitter } from "./emitter.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; +import type { SetupStatus } from "./setup.ts"; + +export interface StatusTracker { + status(): SetupStatus | undefined; + onChange(callback: (status: SetupStatus | undefined) => void): void; + dispose(): Promise; + check(): void; +} +/** + * Creates a status tracker that monitors the given files for changes. + * When a file is added, changed, or deleted, the provided check function is called + * to determine the current setup status. Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @param outputChannelPrefix - Prefix for log messages. + * @param files - Array of file paths to watch. + * @param check - Function that returns the current SetupStatus (sync or async). + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createFileStatusTracker( + outputChannel: LogOutputChannel, + outputChannelPrefix: string, + files: string[], + check: () => Promise | SetupStatus | undefined, +): StatusTracker { + const status = createValueEmitter(); + + const updateStatus = createOnceImmediate(async () => { + const newStatus = await Promise.resolve(check()); + status.setValue(newStatus); + }); + + const watcher = watch(files) + .on("change", (path) => { + outputChannel.trace(`${outputChannelPrefix} ${path} changed`); + updateStatus(); + }) + .on("unlink", (path) => { + outputChannel.trace(`${outputChannelPrefix} ${path} deleted`); + updateStatus(); + }) + .on("add", (path) => { + outputChannel.trace(`${outputChannelPrefix} ${path} added`); + updateStatus(); + }) + .on("error", (error) => { + outputChannel.error(`${outputChannelPrefix} Error watching file`); + outputChannel.error(error instanceof Error ? error : String(error)); + }); + + // Update the status immediately on file tracker initialization + void updateStatus(); + + return { + status() { + return status.value(); + }, + onChange(callback) { + status.onChange(callback); + }, + async dispose() { + await watcher.close(); + }, + check() { + return updateStatus(); + }, + }; +} diff --git a/src/utils/install.ts b/src/utils/install.ts index 7acfaaf..42b61fe 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -12,9 +12,8 @@ import { LOCAL_CLI_INSTALLATION_DIRNAME, } from "../constants.ts"; -import { execLocalStack } from "./cli.ts"; import { exec } from "./exec.ts"; -import { minDelay } from "./promises.ts"; +import { minDelay } from "./min-delay.ts"; import { spawnElevatedDarwin, spawnElevatedLinux, @@ -23,33 +22,33 @@ import { import { spawn } from "./spawn.ts"; import type { Telemetry } from "./telemetry.ts"; -export async function checkLocalstackInstalled( - outputChannel: LogOutputChannel, -): Promise { - try { - await execLocalStack(["--version"], { outputChannel }); - return true; - } catch (error) { - return false; - } +export interface RunInstallProcessOptions { + progress: Progress<{ message: string }>; + cancellationToken: CancellationToken; + outputChannel: LogOutputChannel; + telemetry: Telemetry; + origin?: "extension_startup" | "manual_trigger"; + cliPath: string | undefined; } export async function runInstallProcess( - progress: Progress<{ message: string }>, - cancellationToken: CancellationToken, - outputChannel: LogOutputChannel, - telemetry: Telemetry, - origin?: "extension_startup" | "manual_trigger", + options: RunInstallProcessOptions, ): Promise<{ cancelled: boolean; skipped?: boolean }> { + const { + progress, + cancellationToken, + outputChannel, + telemetry, + origin, + cliPath, + } = options; ///////////////////////////////////////////////////////////////////// const origin_trigger = origin ? origin : "manual_trigger"; progress.report({ message: "Verifying CLI installation...", }); const startedAt = new Date().toISOString(); - const isLocalStackInstalled = await minDelay( - checkLocalstackInstalled(outputChannel), - ); + const isLocalStackInstalled = cliPath !== undefined; if (cancellationToken.isCancellationRequested) { return { cancelled: true }; } diff --git a/src/utils/license.ts b/src/utils/license.ts index 631d01e..4b61abc 100644 --- a/src/utils/license.ts +++ b/src/utils/license.ts @@ -4,6 +4,9 @@ import { join } from "node:path"; import type { CancellationToken, LogOutputChannel } from "vscode"; import { execLocalStack } from "./cli.ts"; +import type { CliStatusTracker } from "./cli.ts"; +import { createFileStatusTracker } from "./file-status-tracker.ts"; +import type { StatusTracker } from "./file-status-tracker.ts"; /** * See https://github.com/localstack/localstack/blob/de861e1f656a52eaa090b061bd44fc1a7069715e/localstack-core/localstack/utils/files.py#L38-L55. @@ -33,11 +36,18 @@ export const LICENSE_FILENAME = join( const LICENSE_VALIDITY_MARKER = "license validity: valid"; -export async function checkIsLicenseValid(outputChannel: LogOutputChannel) { +export async function checkIsLicenseValid( + cliPath: string, + outputChannel: LogOutputChannel, +) { try { - const licenseInfoResponse = await execLocalStack(["license", "info"], { - outputChannel, - }); + const licenseInfoResponse = await execLocalStack( + cliPath, + ["license", "info"], + { + outputChannel, + }, + ); return licenseInfoResponse.stdout.includes(LICENSE_VALIDITY_MARKER); } catch (error) { outputChannel.error(error instanceof Error ? error : String(error)); @@ -46,9 +56,12 @@ export async function checkIsLicenseValid(outputChannel: LogOutputChannel) { } } -export async function activateLicense(outputChannel: LogOutputChannel) { +export async function activateLicense( + cliPath: string, + outputChannel: LogOutputChannel, +) { try { - await execLocalStack(["license", "activate"], { + await execLocalStack(cliPath, ["license", "activate"], { outputChannel, }); } catch (error) { @@ -57,6 +70,7 @@ export async function activateLicense(outputChannel: LogOutputChannel) { } export async function activateLicenseUntilValid( + cliPath: string, outputChannel: LogOutputChannel, cancellationToken: CancellationToken, ): Promise { @@ -64,12 +78,60 @@ export async function activateLicenseUntilValid( if (cancellationToken.isCancellationRequested) { break; } - const licenseIsValid = await checkIsLicenseValid(outputChannel); + const licenseIsValid = await checkIsLicenseValid(cliPath, outputChannel); if (licenseIsValid) { break; } - await activateLicense(outputChannel); + await activateLicense(cliPath, outputChannel); // Wait before trying again await new Promise((resolve) => setTimeout(resolve, 1000)); } } + +/** + * Creates a status tracker that monitors the LocalStack license file for changes. + * When the file is changed, the provided check function is called to determine the current setup status. + * Emits status changes to registered listeners. + * + * @param outputChannel - Channel for logging output and trace messages. + * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. + */ +export function createLicenseStatusTracker( + cliTracker: CliStatusTracker, + authTracker: StatusTracker, + outputChannel: LogOutputChannel, +): StatusTracker { + const licenseTracker = createFileStatusTracker( + outputChannel, + "[setup-status.license]", + [LICENSE_FILENAME], + async () => { + if (cliTracker.outdated()) { + return "setup_required"; + } + + const cliPath = cliTracker.cliPath(); + if (!cliPath) { + return "waiting_for_dependencies"; + } + + const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); + + return isLicenseValid ? "ok" : "setup_required"; + }, + ); + + authTracker.onChange(() => { + licenseTracker.check(); + }); + + cliTracker.onCliPathChange(() => { + licenseTracker.check(); + }); + + cliTracker.onOutdatedChange(() => { + licenseTracker.check(); + }); + + return licenseTracker; +} diff --git a/src/utils/container-status.ts b/src/utils/localstack-container.ts similarity index 79% rename from src/utils/container-status.ts rename to src/utils/localstack-container.ts index d09fff3..c09d82a 100644 --- a/src/utils/container-status.ts +++ b/src/utils/localstack-container.ts @@ -3,56 +3,58 @@ import { exec, spawn } from "node:child_process"; import type { Disposable, LogOutputChannel } from "vscode"; import * as z from "zod/v4-mini"; -import { createEmitter } from "./emitter.ts"; +import { createValueEmitter } from "./emitter.ts"; import { JsonLinesStream } from "./json-lines-stream.ts"; import type { TimeTracker } from "./time-tracker.ts"; -export type ContainerStatus = "running" | "stopping" | "stopped"; +export type LocalStackContainerStatus = "running" | "stopping" | "stopped"; -export interface ContainerStatusTracker extends Disposable { - status(): ContainerStatus; - onChange(callback: (status: ContainerStatus) => void): void; +export interface LocalStackContainerStatusTracker extends Disposable { + status(): LocalStackContainerStatus | undefined; + onChange( + callback: (status: LocalStackContainerStatus | undefined) => void, + ): void; } /** * Checks the status of a docker container in realtime. */ -export async function createContainerStatusTracker( +export function createLocalStackContainerStatusTracker( containerName: string, outputChannel: LogOutputChannel, timeTracker: TimeTracker, -): Promise { - let status: ContainerStatus | undefined; - const emitter = createEmitter(outputChannel); +): LocalStackContainerStatusTracker { + const status = createValueEmitter(); const disposable = listenToContainerStatus( containerName, outputChannel, (newStatus) => { - if (status !== newStatus) { - status = newStatus; - void emitter.emit(status); - } + status.setValue(newStatus); }, ); - await timeTracker.run("container-status.getContainerStatus", async () => { + void timeTracker.run("container-status.getContainerStatus", async () => { await getContainerStatus(containerName).then((newStatus) => { - status ??= newStatus; - void emitter.emit(status); + outputChannel.trace( + `[localstack-container-status] getContainerStatus=${newStatus} previousStatus=${status.value()}`, + ); + if (status.value() === undefined) { + status.setValue(newStatus); + } }); }); + status.onChange((status) => { + outputChannel.trace(`[localstack-container-status] container=${status}`); + }); + return { status() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return status!; + return status.value(); }, onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } + status.onChange(callback); }, dispose() { disposable.dispose(); @@ -72,7 +74,7 @@ const DockerEventsSchema = z.object({ function listenToContainerStatus( containerName: string, outputChannel: LogOutputChannel, - onStatusChange: (status: ContainerStatus) => void, + onStatusChange: (status: LocalStackContainerStatus) => void, ): Disposable { let dockerEvents: ReturnType | undefined; let isDisposed = false; @@ -195,7 +197,7 @@ function listenToContainerStatus( async function getContainerStatus( containerName: string, -): Promise { +): Promise { return new Promise((resolve) => { // timeout after 1s setTimeout(() => resolve("stopped"), 1_000); diff --git a/src/utils/localstack-instance.ts b/src/utils/localstack-instance.ts new file mode 100644 index 0000000..fc083cf --- /dev/null +++ b/src/utils/localstack-instance.ts @@ -0,0 +1,209 @@ +import type { Disposable, LogOutputChannel } from "vscode"; + +import { createValueEmitter } from "./emitter.ts"; +import type { + LocalStackContainerStatus, + LocalStackContainerStatusTracker, +} from "./localstack-container.ts"; +import { fetchHealth } from "./manage.ts"; +import type { TimeTracker } from "./time-tracker.ts"; + +export type LocalStackInstanceStatus = + | "starting" + | "running" + | "stopping" + | "stopped"; + +export interface LocalStackInstanceStatusTracker extends Disposable { + status(): LocalStackInstanceStatus | undefined; + forceContainerStatus(status: LocalStackContainerStatus): void; + onChange( + callback: (status: LocalStackInstanceStatus | undefined) => void, + ): void; +} + +/** + * Checks the status of the LocalStack instance in realtime. + */ +export function createLocalStackInstanceStatusTracker( + containerStatusTracker: LocalStackContainerStatusTracker, + healthCheckStatusTracker: HealthStatusTracker, + outputChannel: LogOutputChannel, +): LocalStackInstanceStatusTracker { + let containerStatus: LocalStackContainerStatus | undefined; + const status = createValueEmitter(); + + const setStatus = (newStatus: LocalStackInstanceStatus) => { + status.setValue(newStatus); + }; + + const deriveStatus = () => { + outputChannel.trace( + `[localstack-instance-status] containerStatus=${containerStatus} healthCheckStatusTracker=${healthCheckStatusTracker.status()} previousStatus=${status.value()}`, + ); + const newStatus = getLocalStackStatus( + containerStatus, + healthCheckStatusTracker.status(), + status.value(), + ); + if (newStatus) { + setStatus(newStatus); + } + }; + + containerStatusTracker.onChange((newContainerStatus) => { + if (containerStatus !== newContainerStatus) { + containerStatus = newContainerStatus; + deriveStatus(); + } + }); + + status.onChange((newStatus) => { + outputChannel.trace(`[localstack-instances-status] status=${newStatus}`); + + if (newStatus === "running") { + healthCheckStatusTracker.stop(); + } + }); + + containerStatusTracker.onChange((newContainerStatus) => { + outputChannel.trace( + `[localstack-instance-status] container=${newContainerStatus} (localstack=${status.value()})`, + ); + + if (newContainerStatus === "running" && status.value() !== "running") { + healthCheckStatusTracker.start(); + } + }); + + healthCheckStatusTracker.onChange(() => { + deriveStatus(); + }); + + deriveStatus(); + + return { + status() { + return status.value(); + }, + forceContainerStatus(newContainerStatus) { + containerStatus = newContainerStatus; + if (newContainerStatus === "running") { + status.setValue("starting"); + } else if (newContainerStatus === "stopping") { + status.setValue("stopping"); + } + }, + onChange(callback) { + status.onChange(callback); + }, + dispose() { + healthCheckStatusTracker.dispose(); + }, + }; +} + +function getLocalStackStatus( + containerStatus: LocalStackContainerStatus | undefined, + healthStatus: HealthStatus | undefined, + previousStatus?: LocalStackInstanceStatus, +): LocalStackInstanceStatus | undefined { + // There's no LS container status yet, so can't derive LS instance status. + if (containerStatus === undefined) { + return undefined; + } + + if (containerStatus === "running" && healthStatus === "healthy") { + return "running"; + } + + if (containerStatus === "running" && healthStatus === "unhealthy") { + // When the LS container is running, and the health check fails: + // - If the previous status was "running", we are likely stopping LS + // - If the previous status was "stopping", we are still stopping LS + if (previousStatus === "running" || previousStatus === "stopping") { + return "stopping"; + } + + return "starting"; + } + + if (containerStatus === "running" && healthStatus === undefined) { + return undefined; + } + + if (containerStatus === "stopping") { + return "stopping"; + } + + return "stopped"; +} + +export type HealthStatus = "healthy" | "unhealthy"; + +export interface HealthStatusTracker extends Disposable { + status(): HealthStatus | undefined; + start(): void; + stop(): void; + onChange(callback: (status: HealthStatus | undefined) => void): void; +} + +export function createHealthStatusTracker( + timeTracker: TimeTracker, +): HealthStatusTracker { + const status = createValueEmitter(); + + let healthCheckTimeout: NodeJS.Timeout | undefined; + + const updateStatus = (newStatus: HealthStatus | undefined) => { + status.setValue(newStatus); + }; + + const fetchAndUpdateStatus = async () => { + await timeTracker.run("localstack-status.health", async () => { + const newStatus = (await fetchHealth()) ? "healthy" : "unhealthy"; + updateStatus(newStatus); + }); + }; + + let enqueueAgain = false; + + const enqueueUpdateStatus = () => { + if (healthCheckTimeout) { + return; + } + + healthCheckTimeout = setTimeout(() => { + void fetchAndUpdateStatus().then(() => { + if (!enqueueAgain) { + return; + } + + healthCheckTimeout = undefined; + enqueueUpdateStatus(); + }); + }, 1_000); + }; + + return { + status() { + return status.value(); + }, + start() { + enqueueAgain = true; + enqueueUpdateStatus(); + }, + stop() { + status.setValue(undefined); + enqueueAgain = false; + clearTimeout(healthCheckTimeout); + healthCheckTimeout = undefined; + }, + onChange(callback) { + status.onChange(callback); + }, + dispose() { + clearTimeout(healthCheckTimeout); + }, + }; +} diff --git a/src/utils/localstack-status.ts b/src/utils/localstack-status.ts deleted file mode 100644 index 8084fa6..0000000 --- a/src/utils/localstack-status.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { Disposable, LogOutputChannel } from "vscode"; - -import type { - ContainerStatus, - ContainerStatusTracker, -} from "./container-status.ts"; -import { createEmitter } from "./emitter.ts"; -import { fetchHealth } from "./manage.ts"; -import type { TimeTracker } from "./time-tracker.ts"; - -export type LocalStackStatus = "starting" | "running" | "stopping" | "stopped"; - -export interface LocalStackStatusTracker extends Disposable { - status(): LocalStackStatus; - forceContainerStatus(status: ContainerStatus): void; - onChange(callback: (status: LocalStackStatus) => void): void; -} - -/** - * Checks the status of the LocalStack instance in realtime. - */ -export function createLocalStackStatusTracker( - containerStatusTracker: ContainerStatusTracker, - outputChannel: LogOutputChannel, - timeTracker: TimeTracker, -): LocalStackStatusTracker { - let containerStatus: ContainerStatus | undefined; - let status: LocalStackStatus | undefined; - const emitter = createEmitter(outputChannel); - - const healthCheckStatusTracker = createHealthStatusTracker( - outputChannel, - timeTracker, - ); - - const setStatus = (newStatus: LocalStackStatus) => { - if (status !== newStatus) { - status = newStatus; - void emitter.emit(status); - } - }; - - const deriveStatus = () => { - const newStatus = getLocalStackStatus( - containerStatus, - healthCheckStatusTracker.status(), - status, - ); - setStatus(newStatus); - }; - - containerStatusTracker.onChange((newContainerStatus) => { - if (containerStatus !== newContainerStatus) { - containerStatus = newContainerStatus; - deriveStatus(); - } - }); - - emitter.on((newStatus) => { - outputChannel.trace(`[localstack-status] localstack=${newStatus}`); - - if (newStatus === "running") { - healthCheckStatusTracker.stop(); - } - }); - - containerStatusTracker.onChange((newContainerStatus) => { - outputChannel.trace( - `[localstack-status] container=${newContainerStatus} (localstack=${status})`, - ); - - if (newContainerStatus === "running" && status !== "running") { - healthCheckStatusTracker.start(); - } - }); - - healthCheckStatusTracker.onChange(() => { - deriveStatus(); - }); - - return { - status() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return status!; - }, - forceContainerStatus(newContainerStatus) { - if (containerStatus !== newContainerStatus) { - containerStatus = newContainerStatus; - deriveStatus(); - } - }, - onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } - }, - dispose() { - healthCheckStatusTracker.dispose(); - }, - }; -} - -function getLocalStackStatus( - containerStatus: ContainerStatus | undefined, - healthStatus: HealthStatus | undefined, - previousStatus?: LocalStackStatus, -): LocalStackStatus { - if (containerStatus === "running") { - if (healthStatus === "healthy") { - return "running"; - } else { - // When the LS container is running, and the health check fails: - // - If the previous status was "running", we are likely stopping LS - // - If the previous status was "stopping", we are still stopping LS - if (previousStatus === "running" || previousStatus === "stopping") { - return "stopping"; - } - return "starting"; - } - } else if (containerStatus === "stopping") { - return "stopping"; - } else { - return "stopped"; - } -} - -type HealthStatus = "healthy" | "unhealthy"; - -interface HealthStatusTracker extends Disposable { - status(): HealthStatus | undefined; - start(): void; - stop(): void; - onChange(callback: (status: HealthStatus | undefined) => void): void; -} - -function createHealthStatusTracker( - outputChannel: LogOutputChannel, - timeTracker: TimeTracker, -): HealthStatusTracker { - let status: HealthStatus | undefined; - const emitter = createEmitter(outputChannel); - - let healthCheckTimeout: NodeJS.Timeout | undefined; - - const updateStatus = (newStatus: HealthStatus | undefined) => { - if (status !== newStatus) { - status = newStatus; - void emitter.emit(status); - } - }; - - const fetchAndUpdateStatus = async () => { - await timeTracker.run("localstack-status.health", async () => { - const newStatus = (await fetchHealth()) ? "healthy" : "unhealthy"; - updateStatus(newStatus); - }); - }; - - let enqueueAgain = false; - - const enqueueUpdateStatus = () => { - if (healthCheckTimeout) { - return; - } - - healthCheckTimeout = setTimeout(() => { - void fetchAndUpdateStatus().then(() => { - if (!enqueueAgain) { - return; - } - - healthCheckTimeout = undefined; - enqueueUpdateStatus(); - }); - }, 1_000); - }; - - return { - status() { - return status; - }, - start() { - enqueueAgain = true; - enqueueUpdateStatus(); - }, - stop() { - status = undefined; - enqueueAgain = false; - clearTimeout(healthCheckTimeout); - healthCheckTimeout = undefined; - }, - onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } - }, - dispose() { - clearTimeout(healthCheckTimeout); - }, - }; -} diff --git a/src/utils/manage.ts b/src/utils/manage.ts index 837f739..3b55644 100644 --- a/src/utils/manage.ts +++ b/src/utils/manage.ts @@ -38,6 +38,7 @@ async function fetchLocalStackSessionId(): Promise { } export async function startLocalStack( + cliPath: string, outputChannel: LogOutputChannel, telemetry: Telemetry, ): Promise { @@ -49,6 +50,7 @@ export async function startLocalStack( const authToken = await readAuthToken(); try { await spawnLocalStack( + cliPath, [ "start", // DO NOT REMOVE! @@ -89,7 +91,7 @@ export async function startLocalStack( }, }); } catch (error) { - const isLicenseValid = await checkIsLicenseValid(outputChannel); + const isLicenseValid = await checkIsLicenseValid(cliPath, outputChannel); if (isLicenseValid === false) { void showErrorMessage("No valid LocalStack license found.", { title: "Go to License settings", @@ -116,6 +118,7 @@ export async function startLocalStack( } export async function stopLocalStack( + cliPath: string, outputChannel: LogOutputChannel, telemetry: Telemetry, ) { @@ -126,7 +129,7 @@ export async function stopLocalStack( // get session id before killing container const emulatorSessionId = await fetchLocalStackSessionId(); - await spawnLocalStack(["stop"], { + await spawnLocalStack(cliPath, ["stop"], { outputChannel, }); diff --git a/src/utils/promises.ts b/src/utils/min-delay.ts similarity index 89% rename from src/utils/promises.ts rename to src/utils/min-delay.ts index 725e59e..157133a 100644 --- a/src/utils/promises.ts +++ b/src/utils/min-delay.ts @@ -32,8 +32,3 @@ export function minDelay( MIN_TIME_BETWEEN_STEPS_MS, ); } - -/** - * Extracts the resolved type from a Promise. - */ -export type UnwrapPromise = T extends Promise ? U : T; diff --git a/src/utils/immediate-once.ts b/src/utils/once-immediate.ts similarity index 58% rename from src/utils/immediate-once.ts rename to src/utils/once-immediate.ts index 8dac27f..4332663 100644 --- a/src/utils/immediate-once.ts +++ b/src/utils/once-immediate.ts @@ -1,12 +1,12 @@ /** - * Creates a function that calls the given callback immediately once. + * Creates a function that calls the given callback on the next tick, once per tick. * * Multiple calls during the same tick are ignored. * * @param callback - The callback to call. - * @returns A function that calls the callback immediately once. + * @returns A function that calls the callback on the next tick, once per tick. */ -export function immediateOnce(callback: () => T): () => void { +export function createOnceImmediate(callback: () => T): () => void { let timeout: NodeJS.Immediate | undefined; return () => { diff --git a/src/utils/setup-status.ts b/src/utils/setup-status.ts deleted file mode 100644 index c3b70e0..0000000 --- a/src/utils/setup-status.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { watch } from "chokidar"; -import ms from "ms"; -import type { Disposable, LogOutputChannel } from "vscode"; - -import { - checkIsAuthenticated, - LOCALSTACK_AUTH_FILENAME, -} from "./authenticate.ts"; -import { - AWS_CONFIG_FILENAME, - AWS_CREDENTIALS_FILENAME, - checkIsProfileConfigured, -} from "./configure-aws.ts"; -import { createEmitter } from "./emitter.ts"; -import { immediateOnce } from "./immediate-once.ts"; -import { checkIsLicenseValid, LICENSE_FILENAME } from "./license.ts"; -import type { UnwrapPromise } from "./promises.ts"; -import { checkSetupStatus } from "./setup.ts"; -import type { TimeTracker } from "./time-tracker.ts"; - -export type SetupStatus = "ok" | "setup_required"; - -export interface SetupStatusTracker extends Disposable { - status(): SetupStatus; - statuses(): UnwrapPromise>; - onChange(callback: (status: SetupStatus) => void): void; -} - -/** - * Checks the status of the LocalStack installation. - */ -export async function createSetupStatusTracker( - outputChannel: LogOutputChannel, - timeTracker: TimeTracker, -): Promise { - const start = Date.now(); - let statuses: UnwrapPromise> | undefined; - let status: SetupStatus | undefined; - const emitter = createEmitter(outputChannel); - const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); - const localStackAuthenticationTracker = - createLocalStackAuthenticationStatusTracker(outputChannel); - const licenseTracker = createLicenseStatusTracker(outputChannel); - const end = Date.now(); - outputChannel.trace( - `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, - ); - - const checkStatusNow = async () => { - const allStatusesInitialized = Object.values({ - awsProfileTracker: awsProfileTracker.status(), - authTracker: localStackAuthenticationTracker.status(), - licenseTracker: licenseTracker.status(), - }).every((check) => check !== undefined); - - if (!allStatusesInitialized) { - outputChannel.trace( - `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( - { - awsProfileTracker: awsProfileTracker.status() ?? "undefined", - authTracker: - localStackAuthenticationTracker.status() ?? "undefined", - licenseTracker: licenseTracker.status() ?? "undefined", - }, - )}`, - ); - return; - } - - statuses = await checkSetupStatus(outputChannel); - - const setupRequired = [ - ...Object.values(statuses), - awsProfileTracker.status() === "ok", - localStackAuthenticationTracker.status() === "ok", - licenseTracker.status() === "ok", - ].some((check) => check === false); - - const newStatus = setupRequired ? "setup_required" : "ok"; - if (status !== newStatus) { - status = newStatus; - outputChannel.trace( - `[setup-status] Status changed to ${JSON.stringify({ - ...statuses, - awsProfileTracker: awsProfileTracker.status() ?? "undefined", - authTracker: localStackAuthenticationTracker.status() ?? "undefined", - licenseTracker: licenseTracker.status() ?? "undefined", - })}`, - ); - await emitter.emit(status); - } - }; - - const checkStatus = immediateOnce(async () => { - await checkStatusNow(); - }); - - awsProfileTracker.onChange(() => { - checkStatus(); - }); - - localStackAuthenticationTracker.onChange(() => { - checkStatus(); - }); - - licenseTracker.onChange(() => { - checkStatus(); - }); - - let timeout: NodeJS.Timeout | undefined; - const startChecking = () => { - checkStatus(); - - // TODO: Find a smarter way to check the status (e.g. watch for changes in AWS credentials or LocalStack installation) - timeout = setTimeout(() => void startChecking(), 1_000); - }; - - await timeTracker.run("setup-status.checkIsSetupRequired", () => { - startChecking(); - return Promise.resolve(); - }); - - await checkStatusNow(); - - return { - status() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return status!; - }, - statuses() { - // biome-ignore lint/style/noNonNullAssertion: false positive - return statuses!; - }, - onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } - }, - async dispose() { - clearTimeout(timeout); - await Promise.all([ - awsProfileTracker.dispose(), - localStackAuthenticationTracker.dispose(), - ]); - }, - }; -} - -interface StatusTracker { - status(): SetupStatus | undefined; - onChange(callback: (status: SetupStatus) => void): void; - dispose(): Promise; -} - -/** - * Creates a status tracker that monitors the given files for changes. - * When a file is added, changed, or deleted, the provided check function is called - * to determine the current setup status. Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @param outputChannelPrefix - Prefix for log messages. - * @param files - Array of file paths to watch. - * @param check - Function that returns the current SetupStatus (sync or async). - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createFileStatusTracker( - outputChannel: LogOutputChannel, - outputChannelPrefix: string, - files: string[], - check: () => Promise | SetupStatus, -): StatusTracker { - let status: SetupStatus | undefined; - - const emitter = createEmitter(outputChannel); - - const updateStatus = immediateOnce(async () => { - const newStatus = await Promise.resolve(check()); - if (status !== newStatus) { - status = newStatus; - outputChannel.trace( - `${outputChannelPrefix} File status changed to ${status}`, - ); - await emitter.emit(status); - } - }); - - const watcher = watch(files) - .on("change", (path) => { - outputChannel.trace(`${outputChannelPrefix} ${path} changed`); - updateStatus(); - }) - .on("unlink", (path) => { - outputChannel.trace(`${outputChannelPrefix} ${path} deleted`); - updateStatus(); - }) - .on("add", (path) => { - outputChannel.trace(`${outputChannelPrefix} ${path} added`); - updateStatus(); - }) - .on("error", (error) => { - outputChannel.error(`${outputChannelPrefix} Error watching file`); - outputChannel.error(error instanceof Error ? error : String(error)); - }); - - // Update the status immediately on file tracker initialization - void updateStatus(); - - return { - status() { - return status; - }, - onChange(callback) { - emitter.on(callback); - if (status) { - callback(status); - } - }, - async dispose() { - await watcher.close(); - }, - }; -} - -/** - * Creates a status tracker that monitors the AWS profile files for changes. - * When the file is changed, the provided check function is called to determine the current setup status. - * Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createAwsProfileStatusTracker( - outputChannel: LogOutputChannel, -): StatusTracker { - return createFileStatusTracker( - outputChannel, - "[setup-status.aws-profile]", - [AWS_CONFIG_FILENAME, AWS_CREDENTIALS_FILENAME], - async () => ((await checkIsProfileConfigured()) ? "ok" : "setup_required"), - ); -} - -/** - * Creates a status tracker that monitors the LocalStack authentication file for changes. - * When the file is changed, the provided check function is called to determine the current setup status. - * Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @param outputChannel - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createLocalStackAuthenticationStatusTracker( - outputChannel: LogOutputChannel, -): StatusTracker { - return createFileStatusTracker( - outputChannel, - "[setup-status.localstack-authentication]", - [LOCALSTACK_AUTH_FILENAME], - async () => ((await checkIsAuthenticated()) ? "ok" : "setup_required"), - ); -} - -/** - * Creates a status tracker that monitors the LocalStack license file for changes. - * When the file is changed, the provided check function is called to determine the current setup status. - * Emits status changes to registered listeners. - * - * @param outputChannel - Channel for logging output and trace messages. - * @returns A {@link StatusTracker} instance for querying status, subscribing to changes, and disposing resources. - */ -function createLicenseStatusTracker( - outputChannel: LogOutputChannel, -): StatusTracker { - return createFileStatusTracker( - outputChannel, - "[setup-status.license]", - [LOCALSTACK_AUTH_FILENAME, LICENSE_FILENAME], //TODO rewrite to depend on change in localStackAuthenticationTracker - async () => - (await checkIsLicenseValid(outputChannel)) ? "ok" : "setup_required", - ); -} diff --git a/src/utils/setup.ts b/src/utils/setup.ts index 535acc2..400c009 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -1,21 +1,18 @@ -import type { CancellationToken, LogOutputChannel } from "vscode"; +import ms from "ms"; +import type { CancellationToken, Disposable, LogOutputChannel } from "vscode"; import * as z from "zod/v4-mini"; import { LOCALSTACK_DOCKER_IMAGE_NAME } from "../constants.ts"; +import { createLocalStackAuthenticationStatusTracker } from "./authenticate.ts"; +import type { CliStatusTracker } from "./cli.ts"; +import { createAwsProfileStatusTracker } from "./configure-aws.ts"; +import { createValueEmitter } from "./emitter.ts"; import { exec } from "./exec.ts"; -import { checkLocalstackInstalled } from "./install.ts"; +import { createLicenseStatusTracker } from "./license.ts"; +import { createOnceImmediate } from "./once-immediate.ts"; import { spawn } from "./spawn.ts"; - -export async function checkSetupStatus(outputChannel: LogOutputChannel) { - const [isInstalled] = await Promise.all([ - checkLocalstackInstalled(outputChannel), - ]); - - return { - isInstalled, - }; -} +import type { TimeTracker } from "./time-tracker.ts"; export async function updateDockerImage( outputChannel: LogOutputChannel, @@ -79,3 +76,124 @@ async function pullDockerImage( outputChannel.error(error instanceof Error ? error : String(error)); } } + +export type SetupStatus = "ok" | "setup_required" | "waiting_for_dependencies"; + +export interface SetupStatusTracker extends Disposable { + status(): SetupStatus | undefined; + onChange(callback: (status: SetupStatus | undefined) => void): void; +} +/** + * Checks the status of the LocalStack installation. + */ + +export async function createSetupStatusTracker( + outputChannel: LogOutputChannel, + timeTracker: TimeTracker, + cliTracker: CliStatusTracker, +): Promise { + const start = Date.now(); + const status = createValueEmitter(); + const awsProfileTracker = createAwsProfileStatusTracker(outputChannel); + const localStackAuthenticationTracker = + createLocalStackAuthenticationStatusTracker(outputChannel); + const licenseTracker = createLicenseStatusTracker( + cliTracker, + localStackAuthenticationTracker, + outputChannel, + ); + const end = Date.now(); + outputChannel.trace( + `[setup-status]: Initialized dependencies in ${ms(end - start, { long: true })}`, + ); + + const checkStatusNow = async () => { + const statuses = { + cliTracker: cliTracker.status(), + awsProfileTracker: awsProfileTracker.status(), + authTracker: localStackAuthenticationTracker.status(), + licenseTracker: licenseTracker.status(), + }; + + const notInitialized = Object.values(statuses).some( + (check) => check === undefined, + ); + if (notInitialized) { + outputChannel.trace( + `[setup-status] File watchers not initialized yet, skipping status check : ${JSON.stringify( + { + cliTracker: cliTracker.status() ?? "undefined", + awsProfileTracker: awsProfileTracker.status() ?? "undefined", + authTracker: + localStackAuthenticationTracker.status() ?? "undefined", + licenseTracker: licenseTracker.status() ?? "undefined", + }, + )}`, + ); + return; + } + + const setupRequired = Object.values(statuses).some( + (status) => status === "setup_required", + ); + const newStatus = setupRequired ? "setup_required" : "ok"; + if (status.value() !== newStatus) { + outputChannel.trace( + `[setup-status] Status changed to ${JSON.stringify({ + cliTracker: cliTracker.status() ?? "undefined", + awsProfileTracker: awsProfileTracker.status() ?? "undefined", + authTracker: localStackAuthenticationTracker.status() ?? "undefined", + licenseTracker: licenseTracker.status() ?? "undefined", + })}`, + ); + } + status.setValue(newStatus); + }; + + const checkStatus = createOnceImmediate(async () => { + await checkStatusNow(); + }); + + awsProfileTracker.onChange(() => { + checkStatus(); + }); + + localStackAuthenticationTracker.onChange(() => { + checkStatus(); + }); + + licenseTracker.onChange(() => { + checkStatus(); + }); + + let timeout: NodeJS.Timeout | undefined; + const startChecking = () => { + checkStatus(); + + // TODO: Find a smarter way to check the status (e.g. watch for changes in AWS credentials or LocalStack installation) + timeout = setTimeout(() => void startChecking(), 1000); + }; + + await timeTracker.run("setup-status.checkIsSetupRequired", () => { + startChecking(); + return Promise.resolve(); + }); + + await checkStatusNow(); + + return { + status() { + return status.value(); + }, + onChange(callback) { + status.onChange(callback); + }, + async dispose() { + clearTimeout(timeout); + await Promise.all([ + awsProfileTracker.dispose(), + localStackAuthenticationTracker.dispose(), + ]); + }, + }; +} diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts index 824a97a..d1e72c3 100644 --- a/src/utils/telemetry.ts +++ b/src/utils/telemetry.ts @@ -220,7 +220,7 @@ export function get_setup_ended( authentication_status: "COMPLETED" | "SKIPPED" | "CANCELLED", license_setup_status: "COMPLETED" | "SKIPPED" | "CANCELLED", aws_profile_status: "COMPLETED" | "SKIPPED" | "CANCELLED", - overall_status: "CANCELLED" | "COMPLETED", + overall_status: "CANCELLED" | "COMPLETED" | "FAILED", origin: "manual_trigger" | "extension_startup", auth_token: string = "", ): Events {