Skip to content

Commit

Permalink
feat: add git-repository storage provider
Browse files Browse the repository at this point in the history
  • Loading branch information
WANZARGEN committed May 15, 2023
1 parent cb0e478 commit 42ba2a6
Show file tree
Hide file tree
Showing 9 changed files with 453 additions and 1 deletion.
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,11 @@ GCS_CLIENT_EMAIL=
GCS_PRIVATE_KEY=
# Azure Blob Storage
ABS_CONNECTION_STRING=
# Git Repository
GIT_REPOSITORY=
GIT_BRANCH=
GIT_REMOTE=
GIT_USER_NAME=
GIT_USER_EMAIL=
GIT_USER_PASSWORD=
GIT_HOST=
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ RUN rm -rf $PROJECT_WORKDIR/.pnpm-store
# start new image for lower size
FROM --platform=${TARGETPLATFORM} node:18.15.0-alpine3.17@sha256:ffc770cdc09c9e83cccd99d663bb6ed56cfaa1bab94baf1b12b626aebeca9c10

RUN apk update

# dumb-init registers signal handlers for every signal that can be caught
RUN apk update && apk add --no-cache dumb-init
RUN apk add --no-cache dumb-init

# Add git to the image for git-repository storage
RUN apk add --no-cache git

# Add curl to the image for healthcheck
RUN apk add --no-cache curl

# create use with no permissions
RUN addgroup -g 101 -S app && adduser -u 100 -S -G app -s /bin/false app
Expand Down
10 changes: 10 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum STORAGE_PROVIDERS {
s3 = 's3',
GOOGLE_CLOUD_STORAGE = 'google-cloud-storage',
AZURE_BLOB_STORAGE = 'azure-blob-storage',
GIT_REPOSITORY = 'git-repository',
}

const schema = Type.Object(
Expand Down Expand Up @@ -46,6 +47,15 @@ const schema = Type.Object(

// Azure Blob Storage credentials
ABS_CONNECTION_STRING: Type.Optional(Type.String()),

// Git Repository vars and credentials
GIT_REPOSITORY: Type.Optional(Type.String()),
GIT_BRANCH: Type.Optional(Type.String({ default: 'main' })),
GIT_REMOTE: Type.Optional(Type.String({ default: 'origin' })),
GIT_USER_NAME: Type.Optional(Type.String()),
GIT_USER_EMAIL: Type.Optional(Type.String()),
GIT_USER_PASSWORD: Type.Optional(Type.String()),
GIT_HOST: Type.Optional(Type.String({ default: 'github.com' })),
},
{
additionalProperties: false,
Expand Down
80 changes: 80 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import cp from 'child_process'

class ProcessError extends Error {
code: number

constructor(code: number, message: string) {
super(message)
Error.captureStackTrace(this)
this.code = code
}
}

/**
* Util function for handling spawned processes as promises.
* @param {string} exe Executable.
* @param {Array<string>} args Arguments.
* @param {string} cwd Working directory.
* @return {Promise} A promise.
*/
function spawn(exe: string, args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = cp.spawn(exe, args, { cwd: cwd || process.cwd() })
const buffer: string[] = []
child.stderr.on('data', chunk => {
buffer.push(chunk.toString())
})
child.stdout.on('data', chunk => {
buffer.push(chunk.toString())
})
child.on('close', code => {
const output = buffer.join('')
if (code) {
const msg = output || `Process failed: ${code}. (command: ${exe} ${args.join(' ')})`
reject(new ProcessError(code, msg))
} else {
resolve(output)
}
})
})
}

/**
* Create an object for executing git commands.
* @param {string} cwd Repository directory.
* @param {string} cmd Git executable (full path if not already on path).
* @function Object() { [native code] }
*/
export class Git {
cwd: string
cmd: string
output = ''

constructor(cwd: string, cmd?: string) {
this.cwd = cwd
this.cmd = cmd || 'git'
}

async exec(...args: string[]): Promise<Git> {
const output = await spawn(this.cmd, [...args], this.cwd)
this.output = output
return this
}

async add(_files: string | string[]) {
const files = Array.isArray(_files) ? _files : [_files]
return await this.exec('add', ...files)
}

async commit(message: string) {
try {
await this.exec('diff-index', '--quiet', 'HEAD')
} catch (e) {
await this.exec('commit', '-m', message)
}
}

async push(remote: string, branch: string) {
return await this.exec('push', '--tags', remote, branch)
}
}
7 changes: 7 additions & 0 deletions src/plugins/remote-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ async function turboRemoteCache(
projectId: instance.config.GCS_PROJECT_ID,
useTmp: !!instance.config.STORAGE_PATH_USE_TMP_FOLDER,
connectionString: instance.config.ABS_CONNECTION_STRING,
repo: instance.config.GIT_REPOSITORY,
branch: instance.config.GIT_BRANCH,
remote: instance.config.GIT_REMOTE,
userName: instance.config.GIT_USER_NAME,
userEmail: instance.config.GIT_USER_EMAIL,
userPassword: instance.config.GIT_USER_PASSWORD,
host: instance.config.GIT_HOST,
})
instance.decorate('location', location)

Expand Down
142 changes: 142 additions & 0 deletions src/plugins/remote-cache/storage/git-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Git } from '../../../git'
import fsBlob from 'fs-blob-store'
import fsExtra from 'fs-extra'
import fs from 'fs'
import path from 'path'
import { tmpdir } from 'os'
import { StorageProvider } from './index'

export type GitRepositoryOptions = {
repo: string
branch: string
remote: string
path?: string
userPassword: string
userName: string
userEmail: string
host: string
}

export async function createGitRepository(options: GitRepositoryOptions): Promise<StorageProvider> {
const tempDir = tmpdir()
const repoUrl = options.repo
const cacheDir = path.join(tempDir, 'git-repository', options.path ?? '')
const credentialPath = path.join(tempDir, 'git-repository', '.git-credentials')
const git: Git = new Git(cacheDir)
let skipClone = false

const ensureCacheDir = async () => {
if (fsExtra.pathExistsSync(cacheDir)) {
console.debug('Cache directory %s already exists', cacheDir)
if (fsExtra.pathExistsSync(path.join(cacheDir, '.git'))) {
console.debug('.git directory already exists')
await git.exec('config', '--get', 'remote.' + options.remote + '.url')
const url = git.output?.trim() ?? ''
if (url !== repoUrl) {
console.debug('Url mismatch. Got "%s" but expected "%s"', url, repoUrl)
console.debug('Empty cache directory %s', cacheDir)
fsExtra.emptyDirSync(cacheDir)
} else {
skipClone = true
}
} else {
console.debug('Empty cache directory %s', cacheDir)
fsExtra.emptyDirSync(cacheDir)
}
} else {
console.debug('Make cache directory %s', cacheDir)
fsExtra.mkdirpSync(cacheDir)
}
}
const clone = async () => {
if (skipClone) {
console.debug('Skipping clone')
return
}
console.debug('Cloning %s into %s', repoUrl, cacheDir)
await git.exec(
'clone',
repoUrl,
cacheDir,
'--branch',
options.branch,
'--single-branch',
'--origin',
options.remote,
)
}
const configGit = async () => {
console.debug('Configuring git user %s <%s>', options.userName, options.userEmail)
await git.exec('config', 'user.email', options.userEmail)
await git.exec('config', 'user.name', options.userName)
await git.exec('config', 'user.password', options.userPassword)
await git.exec('config', 'commit.gpgsign', 'false')
await git.exec('config', 'credential.helper', `store --file=${credentialPath}`)
if (!fs.existsSync(credentialPath)) {
fs.writeFileSync(
credentialPath,
`https://${options.userName}:${options.userPassword}@${options.host}`,
)
}
}

const pull = async () => {
console.debug('Pulling fast-forward only from %s/%s', options.remote, options.branch)
await git.exec('merge', '--ff-only', `${options.remote}/${options.branch}`)
}

const checkout = async () => {
console.debug('Checking out %s/%s ', options.remote, options.branch)
await git.exec('ls-remote', '--exit-code', '.', `${options.remote}/${options.branch}`)
await git.exec('checkout', options.branch)
await git.exec('reset', '--hard', `${options.remote}/${options.branch}`)
}

const init = async () => {
await ensureCacheDir()
await clone()
await checkout()
await configGit()
console.debug('Git storage is ready')
}

const commitAndPush = async (artifactPath: string) => {
console.debug('Adding %s', artifactPath)
await git.add(artifactPath)

console.debug('Committing')
await git.commit(`chore: update cache ${artifactPath}`)

console.debug('Pushing to %s/%s', options.remote, options.branch)
await git.push(options.remote, options.branch)
}

const location: StorageProvider = {
exists: (artifactPath, cb) => {
pull().then(() => {
const exists = fs.existsSync(path.join(cacheDir, artifactPath))
console.debug('exists: ', exists, path.join(cacheDir, artifactPath))
cb(null, exists)
})
},
createReadStream: artifactPath => {
return fsBlob(cacheDir).createReadStream(artifactPath)
},
createWriteStream: artifactPath => {
return fsBlob(cacheDir).createWriteStream(artifactPath)
},
afterCreateWriteStream: async artifactPath => {
try {
await pull()
await commitAndPush(artifactPath)
} catch (e) {
console.error(e)
throw e
}
},
}

await init()

return location
}
8 changes: 8 additions & 0 deletions src/plugins/remote-cache/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createAzureBlobStorage,
type AzureBlobStorageOptions as AzureBlobStorageOpts,
} from './azure-blob-storage'
import { createGitRepository, type GitRepositoryOptions as GROpts } from './git-repository'

const pipeline = promisify(pipelineCallback)
const TURBO_CACHE_FOLDER_NAME = 'turborepocache' as const
Expand All @@ -21,6 +22,7 @@ type LocalOptions = Partial<LocalOpts>
type S3Options = Omit<S3Opts, 'bucket'> & LocalOptions
type GoogleCloudStorageOptions = Omit<GCSOpts, 'bucket'> & LocalOptions
type AzureBlobStorageOptions = Omit<AzureBlobStorageOpts, 'bucket'> & LocalOptions
type GitRepositoryOptions = Partial<GROpts> & LocalOptions

type ProviderOptions<Provider extends STORAGE_PROVIDERS> = Provider extends STORAGE_PROVIDERS.LOCAL
? LocalOptions
Expand All @@ -30,6 +32,8 @@ type ProviderOptions<Provider extends STORAGE_PROVIDERS> = Provider extends STOR
? AzureBlobStorageOptions
: Provider extends STORAGE_PROVIDERS.GOOGLE_CLOUD_STORAGE
? GoogleCloudStorageOptions
: Provider extends STORAGE_PROVIDERS.GIT_REPOSITORY
? GitRepositoryOptions
: never

// https://github.com/maxogden/abstract-blob-store#api
Expand Down Expand Up @@ -63,6 +67,10 @@ async function createStorageLocation<Provider extends STORAGE_PROVIDERS>(
const { connectionString } = providerOptions as AzureBlobStorageOptions
return createAzureBlobStorage({ containerName: path, connectionString })
}
case STORAGE_PROVIDERS.GIT_REPOSITORY: {
const obj = providerOptions as GROpts
return await createGitRepository(obj)
}
default:
throw new Error(
`Unsupported storage provider '${provider}'. Please select one of the following: ${Object.values(
Expand Down
12 changes: 12 additions & 0 deletions test/.env.git-repository
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
NODE_ENV=test
PORT=3000
TURBO_TOKEN=changeme
STORAGE_PROVIDER=git-repository
STORAGE_PATH=turborepo-remote-cache-test
GIT_REPOSITORY=https://github.com/your_id/your_repo.git
GIT_BRANCH=main
GIT_REMOTE=origin
GIT_USER_NAME="Your Name"
[email protected]
GIT_USER_PASSWORD=your_github_token
GIT_HOST=github.com
Loading

0 comments on commit 42ba2a6

Please sign in to comment.