Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic module updates. Closes filecoin-station/roadmap#53 #316

Merged
merged 20 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions lib/modules.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os from 'node:os'
import assert from 'node:assert'
import { join } from 'node:path'
import { mkdir, chmod } from 'node:fs/promises'
import { mkdir, chmod, rmdir } from 'node:fs/promises'
import { fetch } from 'undici'
import { pipeline } from 'node:stream/promises'
import gunzip from 'gunzip-maybe'
Expand Down Expand Up @@ -91,12 +91,48 @@ export const installBinaryModule = async ({
console.log(`[${module}] ✓ ${outFile}`)
}

export async function downloadSourceFiles ({ module, repo, distTag }) {
/** @typedef {{
* tag_name: string
* }?} GitHubRelease */
juliangruber marked this conversation as resolved.
Show resolved Hide resolved

async function getLatestDistTag (repo) {
const res = await fetch(
`https://api.github.com/repos/${repo}/releases/latest`,
{
headers: {
...(authorization ? { authorization } : {})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any concerns about calling GitHub API anonymously? If there are more clients in the same network calling their API, then we will start seeing "rate limit exceeded" errors.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can make unauthenticated requests if you are only fetching public data. Unauthenticated requests are associated with the originating IP address, not with the user or application that made the request.

The primary rate limit for unauthenticated requests is 60 requests per hour.

(source: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28_

Iiuc, this can only happen when there's many core instances on the same IP - which (for now) we don't want anyway.

I think otherwise we have to host our own updates api endpoint.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iiuc, this can only happen when there's many core instances on the same IP - which (for now) we don't want anyway.

Fair enough 👍🏻

So, what happens when Station Core hits this rate-limiting error? Is this something we should be concerned about?

Example: will it retry the requests and thus make the situation even worse?

I think otherwise we have to host our own updates api endpoint.

I like that idea - the endpoint can send authenticated requests to GitHub, return cache-control (e.g. 1 minute?) and then we can use Cloudflare to shield us from too many requests.

Having written that, I think we don't need to add this extra complexity yet.

I am happy to keep things simple and use your current approach as long as we understand what will happen when we start hitting the rate limit errors, and we are okay with that.

}
}
)
if (!res.ok) {
throw new Error(
`[${module}] Cannot fetch ${module} latest release: ${res.status}\n` +
await res.text()
)
}

const body = /** @type {GitHubRelease} */ (await res.json())
return body.tag_name
}

const lastModuleDistTag = new Map()

export async function updateSourceFiles ({ module, repo }) {
await mkdir(moduleBinaries, { recursive: true })
const outDir = join(moduleBinaries, module)

console.log(`[${module}] ⇣ downloading source files`)
if (lastModuleDistTag.has(module)) {
console.log(`[${module}] ⇣ checking for updates`)
}
juliangruber marked this conversation as resolved.
Show resolved Hide resolved

const distTag = await getLatestDistTag(repo)
const isUpdate = lastModuleDistTag.get(module) !== distTag
if (!isUpdate) {
console.log(`[${module}] ✓ no update available`)
return isUpdate
}

console.log(`[${module}] ⇣ downloading source files`)
const url = `https://github.com/${repo}/archive/refs/tags/${distTag}.tar.gz`
const res = await fetch(url, {
headers: {
Expand All @@ -118,7 +154,16 @@ export async function downloadSourceFiles ({ module, repo, distTag }) {
)
}

// `{ strip: 1}` tells tar to remove the top-level directory (e.g. `mod-peer-checker-v1.0.0`)
await pipeline(res.body, gunzip(), tar.extract(outDir, { strip: 1 }))
try {
// `{ strip: 1}` tells tar to remove the top-level directory (e.g. `mod-peer-checker-v1.0.0`)
await pipeline(res.body, gunzip(), tar.extract(outDir, { strip: 1 }))
} catch (err) {
await rmdir(outDir, { recursive: true })
throw err
}

lastModuleDistTag.set(module, distTag)
console.log(`[${module}] ✓ ${outDir}`)

return isUpdate
}
124 changes: 96 additions & 28 deletions lib/zinnia.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,38 @@
import { execa } from 'execa'
import Sentry from '@sentry/node'
import { installBinaryModule, downloadSourceFiles, getBinaryModuleExecutable } from './modules.js'
import { installBinaryModule, updateSourceFiles, getBinaryModuleExecutable } from './modules.js'
import { moduleBinaries } from './paths.js'
import os from 'node:os'
import { once } from 'node:events'
import { ethers } from 'ethers'
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import pRetry from 'p-retry'
import timers from 'node:timers/promises'

const ZINNIA_DIST_TAG = 'v0.16.0'
const ZINNIA_MODULES = [
{
module: 'spark',
repo: 'filecoin-station/spark',
distTag: 'v1.7.0'
repo: 'filecoin-station/spark'
}
]
const { TARGET_ARCH = os.arch() } = process.env

export async function install () {
await Promise.all([
installBinaryModule({
module: 'zinnia',
repo: 'filecoin-station/zinnia',
distTag: ZINNIA_DIST_TAG,
executable: 'zinniad',
arch: TARGET_ARCH,
targets: [
{ platform: 'darwin', arch: 'arm64', asset: 'zinniad-macos-arm64.zip' },
{ platform: 'darwin', arch: 'x64', asset: 'zinniad-macos-x64.zip' },
{ platform: 'linux', arch: 'arm64', asset: 'zinniad-linux-arm64.tar.gz' },
{ platform: 'linux', arch: 'x64', asset: 'zinniad-linux-x64.tar.gz' },
{ platform: 'win32', arch: 'x64', asset: 'zinniad-windows-x64.zip' }
]
}),

...Object.values(ZINNIA_MODULES).map(downloadSourceFiles)
])
}
export const install = () => installBinaryModule({
module: 'zinnia',
repo: 'filecoin-station/zinnia',
distTag: ZINNIA_DIST_TAG,
executable: 'zinniad',
arch: TARGET_ARCH,
targets: [
{ platform: 'darwin', arch: 'arm64', asset: 'zinniad-macos-arm64.zip' },
{ platform: 'darwin', arch: 'x64', asset: 'zinniad-macos-x64.zip' },
{ platform: 'linux', arch: 'arm64', asset: 'zinniad-linux-arm64.tar.gz' },
{ platform: 'linux', arch: 'x64', asset: 'zinniad-linux-x64.tar.gz' },
{ platform: 'win32', arch: 'x64', asset: 'zinniad-windows-x64.zip' }
]
})

let lastCrashReportedAt = 0
const maybeReportCrashToSentry = (/** @type {unknown} */ err) => {
Expand All @@ -49,13 +44,34 @@ const maybeReportCrashToSentry = (/** @type {unknown} */ err) => {
Sentry.captureException(err)
}

const updateAllSourceFiles = async () => {
const modules = await Promise.allSettled(
Object
.values(ZINNIA_MODULES)
.map(({ module, repo }) =>
pRetry(
() => updateSourceFiles({ module, repo }),
bajtos marked this conversation as resolved.
Show resolved Hide resolved
{
retries: 1000,
onFailedAttempt: () =>
console.error(`Failed to download ${module} source. Retrying...`)
}
)
)
)
const hasUpdated = modules
.find(res => res.status === 'fulfilled' && res.value === true)
return hasUpdated
}

export async function run ({
FIL_WALLET_ADDRESS,
ethAddress,
STATE_ROOT,
CACHE_ROOT,
onActivity,
onMetrics
onMetrics,
isUpdated = false
}) {
const fetchRequest = new ethers.FetchRequest(
'https://api.node.glif.io/rpc/v1'
Expand All @@ -81,10 +97,25 @@ export async function run ({
)

const zinniadExe = getBinaryModuleExecutable({ module: 'zinnia', executable: 'zinniad' })
const modules = [
// all paths are relative to `moduleBinaries`
'spark/main.js'
]
// all paths are relative to `moduleBinaries`
const modules = ZINNIA_MODULES.map(m => `${m.module}/main.js`)

if (!isUpdated) {
try {
onActivity({
type: 'info',
message: 'Downloading latest Zinnia module source files...'
})
await updateAllSourceFiles()
} catch (err) {
onActivity({
type: 'error',
message: 'Failed to download latest Zinnia module source files'
})
throw err
}
}

const childProcess = execa(zinniadExe, modules, {
cwd: moduleBinaries,
env: {
Expand Down Expand Up @@ -116,11 +147,35 @@ export async function run ({
onActivity({ type: 'info', message: msg })
})

let shouldRestart

await Promise.all([
(async () => {
while (true) {
await timers.setTimeout(10 * 60 * 1000) // 10 minutes
try {
shouldRestart = await updateAllSourceFiles()
} catch (err) {
onActivity({
type: 'error',
message: 'Failed to update Zinnia module source files'
})
}
if (shouldRestart) {
onActivity({
type: 'info',
message: 'Updated Zinnia module source files, restarting...'
})
childProcess.kill()
return
}
}
})(),
(async () => {
try {
await childProcess
} catch (err) {
if (shouldRestart) return
const errorMsg = err instanceof Error ? err.message : '' + err
const message = `Cannot start Zinnia: ${errorMsg}`
onActivity({ type: 'error', message })
Expand All @@ -133,11 +188,24 @@ export async function run ({
console.error(`Zinnia closed all stdio with code ${code ?? '<no code>'}`)
childProcess.stderr.removeAllListeners()
childProcess.stdout.removeAllListeners()
if (shouldRestart) return
const err = new Error(`Zinnia exited ${exitReason ?? 'for unknown reason'}`)
maybeReportCrashToSentry(err)
throw err
})()
])

if (shouldRestart) {
return run({
bajtos marked this conversation as resolved.
Show resolved Hide resolved
juliangruber marked this conversation as resolved.
Show resolved Hide resolved
FIL_WALLET_ADDRESS,
ethAddress,
STATE_ROOT,
CACHE_ROOT,
onActivity,
onMetrics,
isUpdated: true
})
}
}

async function handleEvents ({
Expand Down