Skip to content

Commit

Permalink
Remove plugins. Utilize rest api instead of fileipc.
Browse files Browse the repository at this point in the history
  • Loading branch information
Hexagon committed Apr 20, 2024
1 parent 6f95a6a commit f963ff7
Show file tree
Hide file tree
Showing 30 changed files with 466 additions and 2,451 deletions.
6 changes: 5 additions & 1 deletion docs/src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ All notable changes to this project will be documented in this section.

- fix(core): Remove stray console.log
- fix(core): Fix working dir different from current dir
- change(plugins): Separate core api from plugin api
- feat(rest): Add rest API with JWT Bearer auth
- change(cli): Use rest api instead of file ipc for cli commands
- change(telemetry): Use rest api for child -> host process communcation
- change(core): Remove FileIPC entirely from the main process
- fix(core): Update dependency @cross/env to fix a bug in windows caused by legacy environment variable keys such as `=C:`
- feat(cli): Add cli command `monitor` for streaming logs
- change(plugins): Remove programmatic api and bundled plugins. Plugins will now be entirely separate from pup, and communicate through the Rest API.

## [1.0.0-rc.25] - 2023-04-17

Expand Down
1 change: 1 addition & 0 deletions lib/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function checkArguments(args: ArgsParser): ArgsParser {
"enable-service",
"disable-service",
"logs",
"monitor",
"upgrade",
"update",
"setup",
Expand Down
182 changes: 112 additions & 70 deletions lib/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,18 @@
*/

// Import core dependencies
import { type InstructionResponse, Pup } from "../core/pup.ts"
import { type Configuration, generateConfiguration, validateConfiguration } from "../core/configuration.ts"
import { FileIPC } from "../common/ipc.ts"
import { Pup } from "../core/pup.ts"
import { type Configuration, DEFAULT_REST_API_HOSTNAME, DEFAULT_REST_API_PORT, generateConfiguration, validateConfiguration } from "../core/configuration.ts"

// Import CLI utilities
import { printFlags, printHeader, printUsage } from "./output.ts"
import { checkArguments, parseArguments } from "./args.ts"
import { appendConfigurationFile, createConfigurationFile, findConfigFile, removeFromConfigurationFile } from "./config.ts"
import { getStatus, printStatus } from "./status.ts"
import { printStatus } from "./status.ts"
import { upgrade } from "./upgrade.ts"

// Import common utilities
import { toPersistentPath, toResolvedAbsolutePath, toTempPath } from "../common/utils.ts"
import { toPersistentPath, toResolvedAbsolutePath } from "../common/utils.ts"
import { exists, readFile } from "@cross/fs"

// Import external dependencies
Expand All @@ -31,6 +30,10 @@ import { args } from "@cross/utils/args"
import { installService, uninstallService } from "@cross/service"
import { Colors, exit } from "@cross/utils"
import { chdir, cwd } from "@cross/fs"
import { Secret } from "../core/secret.ts"
import { GenerateToken } from "../common/token.ts"
import { RestClient } from "../common/restclient.ts"
import { ApiApplicationState } from "../core/api.ts"

/**
* Define the main entry point of the CLI application
Expand Down Expand Up @@ -166,13 +169,28 @@ async function main() {
checkedArgs.get("name"),
)
}
// Prepare for IPC
let ipcFile
if (useConfigFile) ipcFile = `${await toTempPath(configFile as string)}/.main.ipc`
// Prepare status file
let statusFile
if (useConfigFile) statusFile = `${await toPersistentPath(configFile as string)}/.main.status`
// Prepare secret file
let client
let token
if (useConfigFile) {
const secretFile = `${await toPersistentPath(configFile as string)}/.main.secret`
let secret
// Get secret
const secretInstance = new Secret(secretFile)
try {
secret = await secretInstance.loadOrGenerate()
} catch (_e) {
console.error("Could not connect to instance, secret could not be read.")
return exit(1)
}

// Generate a short lived (2 minute) cli token
token = await GenerateToken(secret, { user: "cli" }, new Date().getTime() + 120_000)

// Send api request
const apiBaseUrl = `http://${configuration.api?.hostname || DEFAULT_REST_API_HOSTNAME}:${configuration.api?.port || DEFAULT_REST_API_PORT}`
client = new RestClient(apiBaseUrl, token!)
}
/**
* Base argument: init
*
Expand Down Expand Up @@ -287,6 +305,59 @@ async function main() {
}
}

/**
* Base argument: monitor
*
* Starts a monitoring function, which connects to the REST API endpoint("/")
* using websockets, and prints all received messages
*/
if (baseArgument === "monitor") {
const apiHostname = configuration.api?.hostname || DEFAULT_REST_API_HOSTNAME
const apiPort = configuration.api?.port || DEFAULT_REST_API_PORT
const wsUrl = `ws://${apiHostname}:${apiPort}/wss`

const wss = new WebSocketStream(wsUrl, {
headers: {
"Authorization": `Bearer ${token}`,
},
})
const { readable } = await wss.opened
const reader = readable.getReader()
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
try {
const v = JSON.parse(value.toString())
const logWithColors = configuration!.logger?.colors ?? true
const { processId, severity, category, timeStamp, text } = v.d
const isStdErr = severity === "error" || category === "stderr"
const decoratedLogText = `${new Date(timeStamp).toISOString()} [${severity.toUpperCase()}] [${processId}:${category}] ${text}`
let color = null
// Apply coloring rules
if (logWithColors) {
if (processId === "core") color = "gray"
if (category === "starting") color = "green"
if (category === "finished") color = "yellow"
if (isStdErr) color = "red"
}
let logFn = console.log
if (severity === "warn") logFn = console.warn
if (severity === "info") logFn = console.info
if (severity === "error") logFn = console.error
if (color !== null) {
logFn(`%c${decoratedLogText}`, `color: ${color}`)
} else {
logFn(decoratedLogText)
}
} catch (_e) {
console.error("Error in log streamer: " + _e)
}
}
return
}

/**
* Base argument: logs
*/
Expand Down Expand Up @@ -342,14 +413,23 @@ async function main() {
* Print status for current running instance, and exit.
*/
if (baseArgument === "status") {
if (!statusFile || !configFile) {
console.error("Can not print status, no configuration file found")
if (!client) {
console.error("Can not print status, could not create api client.")
return exit(1)
}
const responseState = await client.get("/state")
if (responseState.ok) {
const dataState: ApiApplicationState = await responseState.json()
console.log("")
printHeader()
await printStatus(configFile!, configuration!, cwd(), dataState)
exit(0)
console.log("Action completed successfully")
exit(0)
} else {
console.error("Action failed: Invalid response received.")
exit(1)
}
console.log("")
printHeader()
await printStatus(configFile!, statusFile!, configuration!, cwd())
exit(0)
}

/**
Expand All @@ -361,47 +441,16 @@ async function main() {
exit(1)
}
if (baseArgument === op) {
// If status file doesn't exist, don't even try to communicate
try {
if (await getStatus(configFile, statusFile) && ipcFile) {
const ipc = new FileIPC(ipcFile)
const senderId = crypto.randomUUID()

const responseFile = `${ipcFile}.${senderId}`
const ipcResponse = new FileIPC(responseFile)

await ipc.sendData(JSON.stringify({ [op]: secondaryBaseArgument || true, senderUuid: senderId }))

const responseTimeout = setTimeout(() => {
console.error("Response timeout after 10 seconds")
exit(1)
}, 10000) // wait at most 10 seconds

for await (const message of ipcResponse.receiveData()) {
clearTimeout(responseTimeout) // clear the timeout when a response is received
if (message.length > 0 && message[0].data) {
const parsedMessage: InstructionResponse = JSON.parse(message[0].data)
if (parsedMessage.success) {
console.log("Action completed successfully")
} else {
console.error("Action failed.")
exit(1)
}
} else {
console.error("Action failed: Invalid response received.")
exit(1)
}

break // break out of the loop after receiving the response
}

exit(0)
} else {
console.error(`No running instance found, cannot send command '${op}' over IPC.`)
exit(1)
}
} catch (e) {
console.error(e.message)
let url = `/${op.toLowerCase().trim()}`
if (secondaryBaseArgument) {
url = `/processes/${secondaryBaseArgument.toLocaleLowerCase().trim()}${url}`
}
const result = await client!.post(url, undefined)
if (result.ok) {
console.log("Action completed successfully")
exit(0)
} else {
console.error("Action failed: Invalid response received.")
exit(1)
}
}
Expand All @@ -414,20 +463,13 @@ async function main() {
/**
* Error handling: Pup already running
*/
if (statusFile && await exists(statusFile)) {
try {
// A valid status file were found
if (!await getStatus(configFile, statusFile)) {
/* ignore */
} else {
console.warn(`An active status file were found at '${statusFile}', pup already running. Exiting.`)
exit(1)
}
} catch (e) {
console.error(e.message)
try {
const response = await client?.get("/state")
if (response?.ok) {
console.warn(`Pup already running. Exiting.`)
exit(1)
}
}
} catch (_e) { /* Expected! ^*/ }

/**
* Error handling: Require at least one configured process
Expand Down
1 change: 1 addition & 0 deletions lib/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function createFlagsMessage(externalInstaller: boolean): string {
{ long: "run", description: "Run a pup instance standalone" },
{ long: "terminate", description: "Terminate pup instance using IPC" },
{ long: "status", description: "Show status for a pup instance" },
{ long: "monitor", description: "Stream logs from a running instance" },
{ separator: "empty" },
{ short: "-c", long: "--config <path>", description: "Optional. Use specific configuration file." },
{ long: "start <all|proc-id>", description: "Start process using IPC" },
Expand Down
52 changes: 3 additions & 49 deletions lib/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
*/

import { type ProcessInformation, ProcessState } from "../core/process.ts"
import type { ApplicationState } from "../core/status.ts"
import { type Column, Columns, type Row } from "./columns.ts"
import { Colors, exit } from "@cross/utils"
import { Colors } from "@cross/utils"
import { filesize } from "filesize"
import { blockedFormatter, codeFormatter, naFormatter, statusFormatter } from "./formatters/strings.ts"
import { timeagoFormatter } from "./formatters/times.ts"
import { Configuration } from "../core/configuration.ts"
import { resolve } from "@std/path"
import { ApiApplicationState } from "../core/api.ts"

/**
* Helper which print the status of all running processes,
Expand All @@ -25,19 +25,7 @@ import { resolve } from "@std/path"
* @private
* @async
*/
export async function printStatus(configFile: string, statusFile: string, configuration: Configuration, cwd: string | undefined) {
let status
try {
status = await getStatus(configFile, statusFile)
if (!status) {
console.error("\nNo running instance found.\n")
exit(1)
}
} catch (e) {
console.error(e.message)
exit(1)
}

export function printStatus(configFile: string, configuration: Configuration, cwd: string | undefined, status: ApiApplicationState) {
// Print configuration
console.log("")
console.log(Colors.bold("Configuration:") + "\t" + resolve(configFile))
Expand Down Expand Up @@ -87,37 +75,3 @@ export async function printStatus(configFile: string, statusFile: string, config

console.log(`\n${Columns(taskTable, tableColumns)}\n`)
}

export async function getStatus(configFile?: string, statusFile?: string) {
if (!configFile) {
throw new Error(`Could not read status for config file '${configFile}' from '${statusFile}', no instance running.`)
}

if (!statusFile) {
throw new Error(`Could not read config file '${configFile}' from '${statusFile}'. Exiting.`)
}

let status: ApplicationState | undefined = undefined
try {
const kv = await Deno.openKv(statusFile)
const result = await kv.get(["last_application_state"])
kv.close()
if (result) {
status = result.value as ApplicationState
}
} catch (e) {
throw new Error(`Could not read status for config file '${configFile}' from '${statusFile}', could not read store: ${e.message}.`)
}
// A valid status file were found, figure out if it is stale or not
if (status && status.updated) {
const parsedDate = Date.parse(status.updated)
// Watchdog interval is 2 seconds, allow an extra 3 seconds to pass before allowing a new instance to start after a dirty shutdown
if (new Date().getTime() - parsedDate > 5000) {
// Everything is ok, this is definitely a stale file, just continue
return undefined
}
return status
}

return undefined
}
45 changes: 45 additions & 0 deletions lib/common/restclient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export class RestClient {
private baseUrl: string // Declare the types
private token: string

constructor(baseUrl: string, token: string) {
this.baseUrl = baseUrl
this.token = token
}

get(path: string) {
return this.fetch(path, { method: "GET" })
}

// deno-lint-ignore no-explicit-any
post(path: string, data: any) { // 'any' for flexibility on the data type
return this.fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: data ? JSON.stringify(data) : undefined,
})
}

/**
* @throws
*/
// deno-lint-ignore no-explicit-any
async fetch(path: string, options: RequestInit): Promise<any> {
// Use RequestInit for options and allow 'any' for the response for now
const headers = {
"Authorization": "Bearer " + this.token,
...options.headers,
}
const response = await fetch(this.baseUrl + path, {
...options,
headers,
})

if (!response.ok) {
const errorText = `Request failed: ${response.statusText}`
throw new Error(errorText)
}

return response
}
}
Loading

0 comments on commit f963ff7

Please sign in to comment.