Skip to content

Commit

Permalink
Add lock, release, setup
Browse files Browse the repository at this point in the history
  • Loading branch information
antonmedv committed Jun 6, 2023
1 parent a1e3c81 commit 82ebf6c
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 20 deletions.
9 changes: 7 additions & 2 deletions src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {RemoteShell, ssh} from "./ssh.js";

export type Config = {
[key: `str:${string}`]: string
binSymlink: string
binSymlink: string[]
currentPath: string
defaultTimeout: string
deployPath: string
Expand All @@ -18,6 +18,8 @@ export type Config = {
symlinkArgs: string[]
useRelativeSymlink: boolean
userStartedDeploy: string
target: string
previousReleasePath: string
}

export type Value = number | boolean | string | string[] | { [key: string]: string }
Expand Down Expand Up @@ -51,7 +53,10 @@ export function createHost(hostname: string) {
config.hostname = hostname
}

const $ = ssh((config.remoteUser ? config.remoteUser + '@' : '') + config.hostname || 'localhost')
const addr = (config.remoteUser ? config.remoteUser + '@' : '') + config.hostname || 'localhost'
const $ = ssh(addr, {
verbose: true,
})
const host = new Proxy(config, {
async get(target, prop) {
let value = Reflect.get(target, prop)
Expand Down
2 changes: 2 additions & 0 deletions src/recipe/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import exec from '@webpod/exec'
import {define} from "../host.js"

import './deploy/lock.js'
import './deploy/release.js'
import './deploy/setup.js'
import {RemoteShell} from "../ssh.js"

define('userStartedDeploy', async () => {
if (process.env.CI) {
Expand Down
15 changes: 15 additions & 0 deletions src/recipe/deploy/lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {task} from "../../task.js"

task('deploy:lock', async ({$, host}) => {
const locked = await $`[ -f ${await host.deployPath}/.webpod/deploy.lock ] && printf +locked || echo ${await host.userStartedDeploy} > ${await host.deployPath}/.webpod/deploy.lock`
if (locked.stdout == '+locked') {
const lockedUser = await $`cat ${await host.deployPath}/.webpod/deploy.lock`
throw new Error(
`Deploy locked by ${lockedUser}. Execute "deploy:unlock" task to unlock.`
)
}
})

task('deploy:unlock', async ({$, host}) => {
await $`rm -f ${await host.deployPath}/.webpod/deploy.lock`
})
131 changes: 131 additions & 0 deletions src/recipe/deploy/release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {define} from "../../host.js"
import * as path from "path"
import {task} from "../../task.js"
import exec from "@webpod/exec"
import {commandSupportsOption} from "../../utils.js"

type Release = {
createdAt: string
releaseName: string
user: string
rev: string
}

define('releaseName', async ({$, host}) => {
const latest = await $`cd ${host.deployPath} cat .webpod/latest_release || echo 0`
return (parseInt(latest.stdout) + 1).toString()
})

define('releasesList', async ({$, host}) => {
$.cd(await host.deployPath)

// If there is no releases return an empty list.
if (!await $.test`[ -d releases ] && [ "$(ls -A releases)" ]`) {
return []
}

// Will list only dirs in releases.
const ll = (await $`cd releases && ls -t -1 -d */`)
.split('\n')
.map(x => x.trim().replace(/\/$/, ''))
.map(x => path.basename(x))

// Return releases from newest to oldest.
if (!await $.test`[ -f .webpod/releases_log ]`) {
return []
}

const releaseLogs: Release[] = (await $`tail -n 300 .webpod/releases_log`)
.split('\n')
.map(x => JSON.parse(x))
.filter(x => x)

const releases = []
for (const {releaseName} of releaseLogs) {
if (ll.includes(releaseName)) {
releases.push(releaseName)
}
}

return releases
})

// Return release path.
define('releasesPath', async ({$, host}) => {
const releaseExists = await $.test`[ -h ${host.deployPath}/release ]`
if (releaseExists) {
const link = await $`readlink ${host.deployPath}/release`
return link[0] === '/' ? link.toString() : `${host.deployPath}/${link}`
} else {
throw new Error(`The "release_path" (${host.deployPath}/release) does not exist.`)
}
})

// Current release revision. Usually a git hash.
define('releaseRevision', async ({$, host}) => {
return (await $`cat ${host.releasesPath}/REVISION`).toString();
});

define('useRelativeSymlink', async ({$}) => {
return commandSupportsOption($, 'ln', '--relative')
})

define('binSymlink', async ({host}) => {
return host.useRelativeSymlink ? ['ln', '-nfs', '--relative'] : ['ln', '-nfs']
})

// Return the release path during a deployment
// but fallback to the current path otherwise.
define('releaseOrCurrentPath', async ({$, host}) => {
const releaseExists = await $.test`[ -h ${host.deployPath}/release ]`
return releaseExists ? await host.releasesPath: await host.currentPath;
});

// Clean up unfinished releases and prepare next release
task('deploy:release', async ({host, $}) => {
$.cd(await host.deployPath)

// Clean up if there is unfinished release.
if (await $.test`[ -h release ]`) {
await $`rm release`
}

// We need to get releasesList at same point as releaseName,
// as standard releaseName's implementation depends on it and,
// if user overrides it, we need to get releasesList manually.
const releasesList = await host.releasesList
const releaseName = await host.releaseName
const releasePath = 'releases/' + releaseName;

// Check what there is no such release path.
if (await $.test`[ -d ${releasePath} ]`) {
throw new Error(`Release name "${releaseName}" already exists.`)
}

// Save release_name if it is a number.
if (releaseName.match(/^\d+$/)) {
await $`echo ${releaseName} > .webpod/latest_release`
}

// Save release info.
const metainfo: Release = {
createdAt: new Date().toISOString(),
releaseName: releaseName,
user: await host.userStartedDeploy,
rev: exec.git('rev-parse', 'HEAD').trim(),
}
await $`echo ${JSON.stringify(metainfo)} >> .webpod/releases_log`;

// Make new release.
await $`mkdir -p ${releasePath}`
await $`${host.binSymlink} ${releasePath} ${host.deployPath}/release`

// Add to releases list.
releasesList.unshift(releaseName)
host.releasesList = releasesList

// Set previous_release.
if (releasesList[1]) {
host.previousReleasePath = `${await host.deployPath}/releases/${releasesList[1]}`
}
})
19 changes: 19 additions & 0 deletions src/recipe/deploy/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {task} from '../../task.js'

task('deploy:setup', async ({host, $}) => {
await $`
[ -d ${await host.deployPath} ] || mkdir -p ${await host.deployPath};
cd ${await host.deployPath};
[ -d .webpod ] || mkdir .webpod;
[ -d releases ] || mkdir releases;
[ -d shared ] || mkdir shared;
`
// If current_path points to something like "/var/www/html", make sure it is
// a symlink and not a directory.
if (await $.test`[ ! -L ${await host.currentPath} ] && [ -d ${await host.currentPath} ]`) {
throw new Error(
`There is a directory (not symlink) at {{current_path}}.\n` +
`Remove this directory so it can be replaced with a symlink for atomic deployments.`
);
}
})
39 changes: 21 additions & 18 deletions src/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {spawn, spawnSync} from 'node:child_process'
import process from 'node:process'
import {controlPath, escapeshellarg} from './utils.js'

type Values = (string | string[] | Promise<string> | Promise<string[]>)[]

export type RemoteShell = {
(pieces: TemplateStringsArray, ...values: (string | Promise<string>)[]): Promise<Response>
(pieces: TemplateStringsArray, ...values: Values): Promise<Response>
with(config: Config): RemoteShell
exit(): void
check(): boolean
test(pieces: TemplateStringsArray, ...values: (string | Promise<string>)[]): Promise<boolean>;
test(pieces: TemplateStringsArray, ...values: Values): Promise<boolean>;
cd(path: string): void
}

Expand All @@ -19,6 +21,7 @@ export type Config = {
cwd?: string
nothrow?: boolean
multiplexing?: boolean
verbose?: boolean
options?: SshOptions
}

Expand All @@ -34,12 +37,6 @@ export function ssh(host: string, config: Config = {}): RemoteShell {
let resolve: (out: Response) => void, reject: (out: Response) => void
const promise = new Promise<Response>((...args) => ([resolve, reject] = args))

const stringValues: string[] = []
for (const value of values) {
stringValues.push(await value)
}
const cmd = composeCmd(pieces, stringValues)

let options: SshOptions = {
ControlMaster: 'auto',
ControlPath: controlPath(host),
Expand Down Expand Up @@ -70,15 +67,16 @@ export function ssh(host: string, config: Config = {}): RemoteShell {
`: ${id}; ${config.shell ?? 'bash -ls'}`
]

let input = config.prefix ?? 'set -euo pipefail; '
if (config.cwd != undefined) {
input += `cd ${escapeshellarg(config.cwd)}; `
}
input += cmd
const cmdPrefix = config.prefix ?? 'set -euo pipefail; '
const cmd = (config.cwd != undefined ? `cd ${escapeshellarg(config.cwd)}; ` : ``)
+ await composeCmd(pieces, values)

if (debug !== '') {
if (debug.includes('ssh')) args.unshift('-vvv')
console.error('ssh', args.map(escapeshellarg).join(' '), '<<<', escapeshellarg(input))
console.error('ssh', args.map(escapeshellarg).join(' '), '<<<', escapeshellarg(cmdPrefix + cmd))
}
if (config.verbose) {
console.error('$', cmd)
}

const child = spawn('ssh', args, {
Expand Down Expand Up @@ -108,7 +106,7 @@ export function ssh(host: string, config: Config = {}): RemoteShell {
reject(new Response(source, null, stdout, stderr, err))
})

child.stdin.write(input)
child.stdin.write(cmdPrefix + cmd)
child.stdin.end()

return promise
Expand Down Expand Up @@ -151,11 +149,16 @@ export class Response extends String {
}
}

export function composeCmd(pieces: TemplateStringsArray, values: string[]) {
export async function composeCmd(pieces: TemplateStringsArray, values: Values) {
let cmd = pieces[0], i = 0
while (i < values.length) {
let v = values[i]
let s = escapeshellarg(v.toString())
const v = await values[i]
let s = ''
if (Array.isArray(v)) {
s = v.map(escapeshellarg).join(' ')
} else {
s = escapeshellarg(v.toString())
}
cmd += s + pieces[++i]
}
return cmd
Expand Down
9 changes: 9 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs'
import os from 'node:os'
import process from 'node:process'
import {RemoteShell} from "./ssh.js"

export function isWritable(path: string): boolean {
try {
Expand Down Expand Up @@ -37,3 +38,11 @@ export function escapeshellarg(arg: string) {
`'`
)
}

export async function commandSupportsOption($: RemoteShell, command: string, option: string) {
const man = await $`(man $command 2>&1 || $command -h 2>&1 || $command --help 2>&1) | grep -- $option || true`
if (!man) {
return false
}
return man.includes(option)
}

0 comments on commit 82ebf6c

Please sign in to comment.