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 cleanup-closed-pull-requests action #1136

Merged
merged 12 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from 11 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
103 changes: 103 additions & 0 deletions .github/workflows/cleanup-closed-pull-requests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: cleanup-closed-pull-requests

on:
pull_request:
paths:
- cleanup-closed-pull-requests/**
- '*.json'
- .github/workflows/cleanup-closed-pull-requests.yaml
push:
branches:
- main
paths:
- cleanup-closed-pull-requests/**
- '*.json'
- .github/workflows/cleanup-closed-pull-requests.yaml

concurrency:
group: ${{ github.workflow }}--${{ github.ref }}
cancel-in-progress: true

defaults:
run:
working-directory: cleanup-closed-pull-requests

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0
- uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1
with:
node-version: 16
cache: yarn
- run: yarn
- run: yarn test

e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0
- uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1
with:
node-version: 16
cache: yarn
- run: yarn
- run: yarn build
- run: yarn package

- run: |
git config --global user.email '[email protected]'
git config --global user.name 'github-actions'

- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0
with:
ref: ${{ github.head_ref }} # avoid "shallow update not allowed" error
path: overlay-branch
- name: Set up an overlay branch
working-directory: overlay-branch
run: |
mkdir -vp monorepo-deploy-actions/pr
cd monorepo-deploy-actions/pr
touch pr-${{ github.event.pull_request.number }}.yaml # this should be kept
touch pr-${{ github.event.pull_request.number }}0.yaml # this should be deleted
git add .
git commit -m "Add overlay branch for e2e-test of ${GITHUB_REF}"
git push origin "HEAD:refs/heads/cleanup-closed-pull-requests-${{ github.run_number }}"

- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4.0.0
with:
ref: ${{ github.head_ref }} # avoid "shallow update not allowed" error
path: namespace-branch
- name: Set up namespace branches
working-directory: namespace-branch
run: |
touch dummy
git add .
# set the past commit date to be deleted by action
GIT_COMMITTER_DATE=2020-03-31T00:00:00Z git commit -m "Add namespace branch for e2e-test of ${GITHUB_REF}"
git push origin "HEAD:refs/heads/ns/monorepo-deploy-actions/pr/pr-${{ github.event.pull_request.number }}" # this should be kept
git push origin "HEAD:refs/heads/ns/monorepo-deploy-actions/pr/pr-${{ github.event.pull_request.number }}0" # this should be deleted

- uses: ./cleanup-closed-pull-requests
id: cleanup-closed-pull-requests
with:
overlay: pr
namespace-prefix: pr-
destination-repository: ${{ github.repository }}
destination-branch: cleanup-closed-pull-requests-${{ github.run_number }}

- name: Clean up the overlay branch
continue-on-error: true
if: always()
run: |
git push origin --delete "refs/heads/cleanup-closed-pull-requests-${{ github.run_number }}"
- name: Clean up the namespace branches
continue-on-error: true
if: always()
run: |
git push origin --delete \
"refs/heads/ns/monorepo-deploy-actions/pr/pr-${{ github.event.pull_request.number }}" \
"refs/heads/ns/monorepo-deploy-actions/pr/pr-${{ github.event.pull_request.number }}0"
93 changes: 93 additions & 0 deletions cleanup-closed-pull-requests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# cleanup-closed-pull-requests [![cleanup-closed-pull-requests](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/cleanup-closed-pull-requests.yaml/badge.svg)](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/cleanup-closed-pull-requests.yaml)

This is an action to delete the namespaces of closed pull requests.

For cost saving and reliability of the Kubernetes cluster,
it would be nice to delete the namespaces priodically.
int128 marked this conversation as resolved.
Show resolved Hide resolved

## Getting Started

To clean up the namespaces of closed pull requests,

```yaml
name: pr-namespace / cleanup

on:
schedule:
- cron: '*/30 * * * *' # every 30 minutes
pull_request:
paths:
# to test this workflow
- .github/workflows/pr-namespace--cleanup.yaml

jobs:
cleanup-closed-pull-requests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: quipper/monorepo-deploy-actions/cleanup-closed-pull-requests@v1
with:
dry-run: ${{ github.event_name == 'pull_request' }}
overlay: pr
namespace-prefix: pr-
destination-repository: octocat/generated-manifests
destination-branch: main
destination-repository-token: ${{ steps.destination-repository-github-app.outputs.token }}
```

This action deletes both namespace applications and branches.

### Namespace applications

It assumes that the destination branch has Argo CD application files as below structure.

```
.
└── ${source-repository}
└── ${overlay}
└── ${namespace-prefix}${pull-request-number}.yaml
```

For example,

```
.
└── monorepo
└── pr
├── .keep
├── pr-100.yaml
├── pr-101.yaml
└── pr-102.yaml
```

It deletes applications by the following rules:

- If a pull request is open, this action does not delete it.
- If a pull request was recently updated, this action does not delete it.
Argo CD Application becomes stuck on deletion if the PreSync hook is running.
- Otherwise, delete it.

### Namespace branches

It assumes that the destination repository has the following branches:

```
ns/${source-repository}/${overlay}/${namespace-prefix}${pull-request-number}
```

For example,

```
ns/monorepo/pr/pr-100
ns/monorepo/pr/pr-101
ns/monorepo/pr/pr-102
```

It deletes branches by the following rules:

- If both namespace application and namespace branch exist, this action does not delete it.
- Otherwise, delete it.

## Specification

See [action.yaml](action.yaml).
40 changes: 40 additions & 0 deletions cleanup-closed-pull-requests/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: cleanup-closed-pull-requests
description: Delete namespaces of closed pull requests

inputs:
overlay:
description: Name of overlay
required: true
namespace-prefix:
description: Prefix of namespace
required: true
source-repository:
description: Source repository
required: true
default: ${{ github.repository }}
source-repository-token:
description: GitHub token for source repository
required: true
default: ${{ github.token }}
destination-repository:
description: Destination repository
required: true
destination-branch:
description: Destination branch
required: true
destination-repository-token:
description: GitHub token for destination repository
required: true
default: ${{ github.token }}
exclude-updated-within-minutes:
description: Exclude pull requests updated recently (exclude nothing if 0 is given)
required: false
default: '15'
dry-run:
description: Do not delete manifest(s) actually
required: true
default: 'false'

runs:
using: 'node16'
main: 'dist/index.js'
7 changes: 7 additions & 0 deletions cleanup-closed-pull-requests/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
clearMocks: true,
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
verbose: true,
}
12 changes: 12 additions & 0 deletions cleanup-closed-pull-requests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "cleanup-closed-pull-requests",
"version": "0.0.0",
"private": true,
"main": "lib/src/main.js",
"scripts": {
"build": "tsc",
"package": "ncc build --source-map --license licenses.txt",
"test": "jest",
"ci:package": "yarn build && yarn package"
}
}
139 changes: 139 additions & 0 deletions cleanup-closed-pull-requests/src/applications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as core from '@actions/core'
import * as git from './git'
import * as io from '@actions/io'
import * as path from 'path'
import { promises as fs } from 'fs'
import { retryExponential } from './retry'

type DeleteNamespaceApplicationsOptions = {
overlay: string
namespacePrefix: string
sourceRepositoryName: string
destinationRepository: string
destinationBranch: string
destinationRepositoryToken: string
openPullRequestNumbers: number[]
excludeUpdatedWithinMinutes: number
commitMessage: string
dryRun: boolean
}

export const deleteNamespaceApplicationsWithRetry = async (opts: DeleteNamespaceApplicationsOptions) =>
await retryExponential(() => deleteNamespaceApplications(opts), {
maxAttempts: 5,
waitMs: 10000,
})

const deleteNamespaceApplications = async (opts: DeleteNamespaceApplicationsOptions) => {
const cwd = await git.checkout({
repository: opts.destinationRepository,
branch: opts.destinationBranch,
token: opts.destinationRepositoryToken,
})
const applications = await findApplications(cwd, opts)
const deletedApplications = []
const deployedApplications = []
for (const application of applications) {
if (await shouldDeleteNamespace(application, cwd, opts)) {
core.info(`Removing ${application.filepath}`)
await io.rmRF(application.filepath)
deletedApplications.push(application)
} else {
deployedApplications.push(application)
}
}

core.summary.addHeading(`Deleting namespace applications`)
core.summary.addList(deletedApplications.map((app) => app.filepath))
const result = {
deployedPullRequestNumbers: deployedApplications.map((app) => app.pullRequestNumber),
}
if (deletedApplications.length === 0) {
core.info(`Nothing to delete`)
return result
}
const deletedNamespaces = deletedApplications.map((app) => app.namespace).join('\n')
const commitMessage = `${opts.commitMessage}\nDeleted:\n${deletedNamespaces}`
await git.commit(cwd, commitMessage)

if (opts.dryRun) {
core.info(`(dry-run) git-push`)
return result
}
const pushCode = await git.pushByFastForward(cwd)
if (pushCode > 0) {
// Retry from checkout if fast-forward was failed
return new Error(`git-push returned code ${pushCode}`)
}
return result
}

const shouldDeleteNamespace = async (
application: NamespaceApplication,
cwd: string,
opts: DeleteNamespaceApplicationsOptions,
) => {
if (opts.openPullRequestNumbers.includes(application.pullRequestNumber)) {
core.info(`Skip deletion of namespace ${application.namespace}, because it is open`)
return false
}

const lastCommitDate = await git.getLastCommitDate(cwd, application.namespaceBranch)
if (lastCommitDate === undefined) {
core.info(`Namespace branch ${application.namespaceBranch} does not exist`)
return true
}

// Do not delete an application updated recently.
// Argo CD would be stuck on deletion if PreSync hook is in progress.
// If excludeUpdatedWithinMinutes is zero, exclude nothing.
const agoMinutes = Math.floor((Date.now() - lastCommitDate.getTime()) / (60 * 1000))
core.info(`Branch ${application.namespaceBranch} was updated ${agoMinutes} minutes ago`)
if (agoMinutes < opts.excludeUpdatedWithinMinutes) {
core.info(`Skip deletion of namespace ${application.namespace}, because the namespace branch was updated recently`)
return false
}
return true
}

type NamespaceApplication = {
filepath: string
namespace: string
namespaceBranch: string
pullRequestNumber: number
}

const findApplications = async (cwd: string, opts: DeleteNamespaceApplicationsOptions) => {
const baseDirectory = path.join(cwd, opts.sourceRepositoryName, opts.overlay)
const entries = await fs.readdir(baseDirectory, { withFileTypes: true })
const filenames = entries.filter((e) => e.isFile()).map((e) => e.name)

core.info(`Finding applications in ${baseDirectory}`)
const applications: NamespaceApplication[] = []
for (const filename of filenames) {
const pullRequestNumber = extractPullRequestNumber(filename, opts.namespacePrefix)
if (pullRequestNumber === undefined) {
continue
}
core.info(`Found namespace application: ${filename}`)
applications.push({
filepath: path.join(baseDirectory, filename),
namespace: `${opts.namespacePrefix}${pullRequestNumber}`,
namespaceBranch: `ns/${opts.sourceRepositoryName}/${opts.overlay}/${opts.namespacePrefix}${pullRequestNumber}`,
pullRequestNumber,
})
}
return applications
}

const extractPullRequestNumber = (filename: string, prefix: string, suffix = '.yaml'): number | undefined => {
if (!filename.startsWith(prefix) || !filename.endsWith(suffix)) {
return
}
const withoutPrefix = filename.substring(prefix.length)
const withoutSuffix = withoutPrefix.substring(0, suffix.length)
const n = Number.parseInt(withoutSuffix)
if (Number.isSafeInteger(n)) {
return n
}
}
Loading