diff --git a/Dockerfile b/Dockerfile index f54255e..67261c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,9 @@ COPY . /app/ RUN yarn bundle FROM node:alpine -RUN apk update && apk add git +COPY entry.sh /entry.sh +RUN chmod 711 /entry.sh && apk update && apk add git COPY --from=BuildEnv /app/dist /app -RUN ls /app -CMD ["node", "/app/index.js"] +CMD ["schedule"] +ENTRYPOINT ["/entry.sh"] VOLUME /backup diff --git a/README.md b/README.md index 12c2139..e0b9f3e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Simple Github Backup +# Simple GitHub Backup -A simple Github backup image, which will fetch a mirror backup of all repos the user is associated with from Github +A simple GitHub backup image, which will fetch a mirror backup of all repos the user is associated with from GitHub ## Usage ``` @@ -9,11 +9,11 @@ docker run -it --rm \ -v "$PWD/backup:/backup" \ --cap-drop=all \ --user "$UID:$GID" \ - ghcr.io/morten-olsen/github-backup + ghcr.io/morten-olsen/github-backup run ``` -_note: `--user` is not required, but recommended instead of running as root. Remember to give the user write access to the backup directory_ +_Note: `--user` is not required, but recommended instead of running as root. Remember to give the user write access to the backup directory_ -You can also limit which repositories to backup using the environment variabled `INCLUDE` and `EXCLUDE`, which supports a list or repos separated by `,` and with `*` as wildcard +You can also limit which repositories to back up using the environment variables `INCLUDE` and `EXCLUDE`, which supports a list or repos separated by `,` and with `*` as wildcard ``` -e INCLUDE="morten-olsen/*,morten-olsen-env/dotfiles" -e EXCLUDE="morten-olsen/something,*/test" diff --git a/entry.sh b/entry.sh new file mode 100644 index 0000000..cb77e8a --- /dev/null +++ b/entry.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exec node /app/index.js $@ diff --git a/package.json b/package.json index e9d9da9..2887627 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,11 @@ "devDependencies": { "@types/fs-extra": "^9.0.12", "@types/node": "^16.4.0", + "@types/node-cron": "^3.0.0", "@types/rimraf": "^3.0.1", "@vercel/ncc": "^0.29.0", + "commander": "^8.3.0", + "node-cron": "^3.0.0", "ts-node": "^10.1.0", "typescript": "^4.3.5" }, diff --git a/src/index.ts b/src/index.ts index dc75e99..178a2e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,89 +1,20 @@ require('dotenv').config(); -import { Octokit } from '@octokit/rest'; -import simpleGit from 'simple-git'; -import fs from 'fs-extra'; -import path from 'path'; -import ora from 'ora'; +import runBackup from './run'; +import { program } from 'commander'; +import cron from 'node-cron'; -const token = process.env.GITHUB_TOKEN; -const backupLocation = path.resolve(process.env.GITHUB_BACKUP_LOCATION || '/backup'); -const included = (process.env.INCLUDE || '*/*').split(','); -const excluded = (process.env.EXCLUDE || '').split(','); +const run = program.command('run'); +run.action(runBackup) -const mirror = async (target: string, repo: string) => { - const authRemote = `https://foo:${token}@github.com/${repo}` - - if (fs.existsSync(target)) { - const git = simpleGit(target); - const remotes = await git.getRemotes(); - const origin = remotes.find(r => r.name === 'origin'); - if (origin) { - await git.remote(['set-url', 'origin', authRemote]); - } else { - await git.addRemote('origin', authRemote); - } - await git.remote(['update']); - await git.remote(['set-url', 'origin', `https://github.com/${repo}`]); - } else { - await fs.mkdirp(target); - const git = simpleGit(target); - await git.mirror(authRemote, target); - await git.remote(['set-url', 'origin', `https://github.com/${repo}`]); - } -}; - -const shouldRun = (repoUser: string, repoName: string) => { - const isIncluded = included.reduce((result, current) => { - if (result) return result; - const [user = '*', repo = '*'] = current.split('/'); - return (user === '*' || user === repoUser) && (repo === '*' || repo === repoName); - }, false) - - const isExcluded = excluded.reduce((result, current) => { - if (result) return result; - const [user = '', repo = ''] = current.split('/'); - return (user === '*' || user === repoUser) && (repo === '*' || repo === repoName); - }, false) - - return isIncluded && !isExcluded; -} - -const run = async () => { - const github = new Octokit({ - auth: process.env.GITHUB_TOKEN, +const schedule = program.command('schedule'); +schedule.action(() => { + const schedule = process.env.SCHEDULE || '0 0 3 * * Sunday'; + cron.schedule(schedule, () => { + runBackup().catch((err) => { + console.error(err); + process.exit(-1); + }); }); +}); - const action = github.repos.listForAuthenticatedUser; - const errors: any[] = []; - for await (const repos of github.paginate.iterator(action, { visibility: 'all' })) { - for (const repo of repos.data) { - if (!shouldRun(repo.owner.login, repo.name)) { - console.log(`skipping ${repo.full_name}`) - continue; - } - const loader = ora('preparing'); - loader.prefixText = repo.full_name; - loader.start(); - try { - const repoBackupLocation = path.join(backupLocation, repo.full_name); - const infoLocation = path.join(repoBackupLocation, 'info.json'); - const gitLocation = path.join(repoBackupLocation, 'git'); - await fs.mkdirp(repoBackupLocation); - loader.text = 'fething info'; - await fs.writeFile(infoLocation, JSON.stringify(repo, null, ' '), 'utf-8'); - loader.text = 'mirroring'; - await mirror(gitLocation, repo.full_name); - loader.text = ''; - loader.succeed(); - } catch (err: any) { - loader.fail(err.toString()); - errors.push(err); - } - } - } - if (errors.length > 0) { - process.exit(-1); - } -}; - -run().catch(console.error); +program.parse(process.argv); diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..807eeaf --- /dev/null +++ b/src/run.ts @@ -0,0 +1,89 @@ +import { Octokit } from '@octokit/rest'; +import simpleGit from 'simple-git'; +import fs from 'fs-extra'; +import path from 'path'; +import ora from 'ora'; + +const token = process.env.GITHUB_TOKEN; +const backupLocation = path.resolve(process.env.GITHUB_BACKUP_LOCATION || '/backup'); +const included = (process.env.INCLUDE || '*/*').split(','); +const excluded = (process.env.EXCLUDE || '').split(','); + +const mirror = async (target: string, repo: string) => { + const authRemote = `https://foo:${token}@github.com/${repo}` + + if (fs.existsSync(target)) { + const git = simpleGit(target); + const remotes = await git.getRemotes(); + const origin = remotes.find(r => r.name === 'origin'); + if (origin) { + await git.remote(['set-url', 'origin', authRemote]); + } else { + await git.addRemote('origin', authRemote); + } + await git.remote(['update']); + await git.remote(['set-url', 'origin', `https://github.com/${repo}`]); + } else { + await fs.mkdirp(target); + const git = simpleGit(target); + await git.mirror(authRemote, target); + await git.remote(['set-url', 'origin', `https://github.com/${repo}`]); + } +}; + +const shouldRun = (repoUser: string, repoName: string) => { + const isIncluded = included.reduce((result, current) => { + if (result) return result; + const [user = '*', repo = '*'] = current.split('/'); + return (user === '*' || user === repoUser) && (repo === '*' || repo === repoName); + }, false) + + const isExcluded = excluded.reduce((result, current) => { + if (result) return result; + const [user = '', repo = ''] = current.split('/'); + return (user === '*' || user === repoUser) && (repo === '*' || repo === repoName); + }, false) + + return isIncluded && !isExcluded; +} + +const run = async () => { + const github = new Octokit({ + auth: process.env.GITHUB_TOKEN, + }); + + const action = github.repos.listForAuthenticatedUser; + const errors: any[] = []; + for await (const repos of github.paginate.iterator(action, { visibility: 'all' })) { + for (const repo of repos.data) { + if (!shouldRun(repo.owner.login, repo.name)) { + console.log(`skipping ${repo.full_name}`) + continue; + } + const loader = ora('preparing'); + loader.prefixText = repo.full_name; + loader.start(); + try { + const repoBackupLocation = path.join(backupLocation, repo.full_name); + const infoLocation = path.join(repoBackupLocation, 'info.json'); + const gitLocation = path.join(repoBackupLocation, 'git'); + await fs.mkdirp(repoBackupLocation); + loader.text = 'fething info'; + await fs.writeFile(infoLocation, JSON.stringify(repo, null, ' '), 'utf-8'); + loader.text = 'mirroring'; + await mirror(gitLocation, repo.full_name); + loader.text = ''; + loader.succeed(); + } catch (err: any) { + loader.fail(err.toString()); + errors.push(err); + } + } + } + if (errors.length > 0) { + process.exit(-1); + } +}; + +export default run; + diff --git a/yarn.lock b/yarn.lock index 74c5a1f..c2772dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,6 +256,13 @@ __metadata: languageName: node linkType: hard +"@types/node-cron@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/node-cron@npm:3.0.0" + checksum: 24cf0cdb5aa93092c71e92474d69082ea392f28b2814876a5b6ef60c1a904da635e0d70158b622113dd2b013c909fe41951b5d8f699258b7950afb121256e3df + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:^16.4.0": version: 16.11.12 resolution: "@types/node@npm:16.11.12" @@ -527,6 +534,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^8.3.0": + version: 8.3.0 + resolution: "commander@npm:8.3.0" + checksum: 0f82321821fc27b83bd409510bb9deeebcfa799ff0bf5d102128b500b7af22872c0c92cb6a0ebc5a4cf19c6b550fba9cedfa7329d18c6442a625f851377bacf0 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -685,11 +699,14 @@ __metadata: "@octokit/rest": ^18.7.0 "@types/fs-extra": ^9.0.12 "@types/node": ^16.4.0 + "@types/node-cron": ^3.0.0 "@types/rimraf": ^3.0.1 "@vercel/ncc": ^0.29.0 + commander: ^8.3.0 dotenv: ^10.0.0 fs-extra: ^10.0.0 nanoid: ^3.1.23 + node-cron: ^3.0.0 ora: ^5.4.1 rimraf: ^3.0.2 simple-git: ^2.41.1 @@ -1031,6 +1048,22 @@ __metadata: languageName: node linkType: hard +"moment-timezone@npm:^0.5.31": + version: 0.5.34 + resolution: "moment-timezone@npm:0.5.34" + dependencies: + moment: ">= 2.9.0" + checksum: 12a1d3d52e4ba509cf1fa36bbda59d898a08fa80ab35f6c358747e93aec1f07e617cec647eaf2e8acf5f9132e581d4704d34a9edffa9a80c5cd04bf23b277595 + languageName: node + linkType: hard + +"moment@npm:>= 2.9.0": + version: 2.29.1 + resolution: "moment@npm:2.29.1" + checksum: 1e14d5f422a2687996be11dd2d50c8de3bd577c4a4ca79ba5d02c397242a933e5b941655de6c8cb90ac18f01cc4127e55b4a12ae3c527a6c0a274e455979345e + languageName: node + linkType: hard + "ms@npm:2.1.2": version: 2.1.2 resolution: "ms@npm:2.1.2" @@ -1061,6 +1094,15 @@ __metadata: languageName: node linkType: hard +"node-cron@npm:^3.0.0": + version: 3.0.0 + resolution: "node-cron@npm:3.0.0" + dependencies: + moment-timezone: ^0.5.31 + checksum: acae07d835f74636ec2bf598ffadf6b89aa2eae036671bffad336371b28fb1eefb11870912c965bb351cd958d4b62f9fa9b28cf922b5f681459ea4878837ffcf + languageName: node + linkType: hard + "node-fetch@npm:^2.6.1": version: 2.6.6 resolution: "node-fetch@npm:2.6.6"