mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
Compare commits
333 Commits
Author | SHA1 | Date | |
---|---|---|---|
9cafa90b4f | |||
4892822343 | |||
9fe6b32572 | |||
ff2c6a6e11 | |||
3e90b7c71e | |||
c15991458c | |||
7a2d3c1337 | |||
141c18cf29 | |||
b677064733 | |||
3782a875e2 | |||
1241579222 | |||
ac865804dd | |||
cf475cb98b | |||
086aa29fda | |||
6d40c7797a | |||
052409f945 | |||
5462a1f664 | |||
b4e9ea5ea6 | |||
a19cc5cb76 | |||
6b3be0d550 | |||
d4b2b5ef96 | |||
f7bbaf97f0 | |||
e6f49f622d | |||
985fc18a48 | |||
5512f2d8bb | |||
d585b531cb | |||
31b64ff3e9 | |||
98d1a5bf6e | |||
09db299d37 | |||
676c65e66a | |||
f6fe108380 | |||
506b4d6ab5 | |||
9f552a7bec | |||
b2eb35627b | |||
4309bede7c | |||
cf929abebd | |||
70847336ff | |||
add7da3fe1 | |||
abd647e975 | |||
c851aad4f2 | |||
0c7e766f93 | |||
4d9db28c78 | |||
1c6c35c1db | |||
ed8303a1a6 | |||
db7969d4bc | |||
0b0b0c2bca | |||
5b23d767c7 | |||
8065125526 | |||
ba5faed23c | |||
5c9ffba5de | |||
5697f2c1a2 | |||
b477db7af0 | |||
cc0b862c5a | |||
46a8b0002e | |||
544ae0c7ab | |||
8dfc68dd6f | |||
7beea7b5a8 | |||
b848ea7525 | |||
e9b7884daf | |||
b8b58fc129 | |||
f49da87a1f | |||
bff018f66a | |||
b8f9608992 | |||
f22855fee6 | |||
b2231cb97c | |||
759f70d1f0 | |||
5b74eaa3de | |||
3eb8343ade | |||
71b50da264 | |||
1354854ba9 | |||
1c91c2b2fa | |||
c289f2cb97 | |||
29771b9188 | |||
5deada7d6b | |||
99694d43e1 | |||
0d2418f8aa | |||
afdf0543b7 | |||
605d019a3f | |||
3c1f5d9a99 | |||
8b18d02666 | |||
8e7570eee3 | |||
2c8ba9c0f3 | |||
1ab0973011 | |||
540139db3a | |||
ff3d16d294 | |||
ebbded51bf | |||
384e45ce7f | |||
cdac8665de | |||
fac512bd74 | |||
ea85191d9e | |||
38ce1f9cf3 | |||
9e4a1451f5 | |||
9feca1f509 | |||
38bf4abc1e | |||
ffc2af1c27 | |||
763c4ad109 | |||
dcfd8ab3a7 | |||
62f309cefd | |||
b5504de6d8 | |||
4d91e17273 | |||
86e182c25d | |||
8006cce4b0 | |||
caf4e33193 | |||
7ab0fa6f23 | |||
81570b42c0 | |||
4c3ebc055d | |||
61c1958717 | |||
3ab1a23ac5 | |||
a81c780e1e | |||
2baeb2eb5b | |||
e6b011b41b | |||
308467006b | |||
ecf1359336 | |||
a12dc4882e | |||
980d0a5810 | |||
eb3d84ee9d | |||
6b43057333 | |||
071ded0eac | |||
47c4338c9e | |||
dfdeea4b6c | |||
cbb7936149 | |||
c08e1307e8 | |||
25b86a057f | |||
ef72fa405d | |||
1eb773fb57 | |||
439b6d0326 | |||
ef0081f711 | |||
8c7a24b3d7 | |||
c76026c9f1 | |||
37e4ecbf47 | |||
58dc38f72e | |||
36c5472987 | |||
717542e045 | |||
940bd0405f | |||
d40e8879d1 | |||
6a67510bdc | |||
a79521a37d | |||
4667194271 | |||
a20852b03a | |||
ec0f1cc0d3 | |||
49d9650d35 | |||
1b876c45cc | |||
23f52f25e2 | |||
16d7d80183 | |||
5389fe00dd | |||
cd67674976 | |||
36673a49a4 | |||
b84b3ba9f4 | |||
5343d8c934 | |||
80891b0b46 | |||
48313685ec | |||
7dc47c00ac | |||
830b5bb934 | |||
8d43984644 | |||
1b152c1a80 | |||
175b9f0cfb | |||
16180be7e2 | |||
11b270ee68 | |||
97ab8a4475 | |||
da3b864e02 | |||
0bd989e493 | |||
59c4d2022c | |||
1ec17caa1d | |||
252618a975 | |||
315c7660d1 | |||
6a6809b07f | |||
a960b5928e | |||
46d96d7bb4 | |||
69063a9cf7 | |||
7c8d2f54c7 | |||
49aed23f8c | |||
3418f85292 | |||
361afd87aa | |||
7f6815c274 | |||
4ecd70330a | |||
ae13905512 | |||
e3377d0f28 | |||
b15061a110 | |||
6d6945bf44 | |||
cf7c526d4f | |||
8e21be0340 | |||
37265ccb4f | |||
169fbe3b93 | |||
617b378c36 | |||
438e954dd6 | |||
df1a0e20ee | |||
7b3c286790 | |||
fb7d7c75cf | |||
1eafe58d16 | |||
e7a909dc4e | |||
9deee9ddba | |||
e769c2103f | |||
ca9cdf0379 | |||
7ef471381c | |||
48e9b20836 | |||
7329d7697c | |||
83f38cdd16 | |||
057006366d | |||
f08539962e | |||
2cc8549cef | |||
8f8e5abd3d | |||
1889a57c05 | |||
c42ff85dd6 | |||
00d3c10e5e | |||
7153c260d8 | |||
48bd1a44e6 | |||
bde35e2c79 | |||
d649e371a5 | |||
01c2a37055 | |||
a932f94ec0 | |||
3ac2c74249 | |||
18af96bada | |||
445aad8b41 | |||
b61cc7460f | |||
c9586fe79a | |||
405afec38a | |||
5e0be010b7 | |||
9bc00fa4ca | |||
6742381562 | |||
6d3ced480d | |||
e8fa8896c9 | |||
c9bd47618d | |||
3ffb952cdd | |||
b5892f44d9 | |||
769b0cebb6 | |||
8f49ff7204 | |||
f89bdfbd51 | |||
1b2e899201 | |||
6c0c85544e | |||
d3d1c62411 | |||
8019d07cab | |||
d21a6f2797 | |||
a3389aaafa | |||
c6a7e30609 | |||
01abc48a01 | |||
d6374d7edf | |||
7ebfac9dc2 | |||
64d4798156 | |||
4adad3df10 | |||
30a7b2793f | |||
2d9deb7370 | |||
873944f90f | |||
cd5abe458b | |||
481e11d527 | |||
fac7394ae2 | |||
4a918b1899 | |||
05be3841d7 | |||
02cadcd907 | |||
94dff2421c | |||
51f8824659 | |||
e82c02c768 | |||
dc51e3192c | |||
45ca69432b | |||
f5f572a4d3 | |||
2d418ecffa | |||
c6b3342361 | |||
3614f0503f | |||
a2ee92acc4 | |||
93dddd75d9 | |||
c17587ca6b | |||
d7d5245d07 | |||
6c0885a5d3 | |||
4b83ce7d09 | |||
d6cebc27ab | |||
2bcbd4ba41 | |||
edc05ec6d2 | |||
94b6af6d53 | |||
8d24125eee | |||
97fc3b8693 | |||
ac1c19df28 | |||
b7f82e4635 | |||
62493411b4 | |||
0e20e39cd2 | |||
4bdbb882b5 | |||
4b2a792148 | |||
1d41cf190b | |||
e857c0309b | |||
06aff4ecb3 | |||
3145bdfa00 | |||
178e6b2d9b | |||
7a3dc917a2 | |||
8a14836bd1 | |||
ae2bf27463 | |||
c53a87b816 | |||
8463ecf00d | |||
1d7596b3df | |||
251e0a01cf | |||
22d3e3485e | |||
375272b561 | |||
7e7f956fae | |||
d672112cc2 | |||
32b92611a7 | |||
cc6cbc7d47 | |||
690a405994 | |||
f72c2b85fd | |||
42523ae9d9 | |||
da2dc5c63a | |||
a0a1d3caca | |||
915e810088 | |||
00c139b525 | |||
eca99eb569 | |||
dfaeea7483 | |||
f71b07f647 | |||
be0a3c4820 | |||
04bf2231bc | |||
ba98272530 | |||
fab38255eb | |||
88278d37a7 | |||
32daf80b76 | |||
13e7a72790 | |||
0efbccbb18 | |||
bed576f26c | |||
f75bf15552 | |||
9915e321f4 | |||
83720999d8 | |||
79bbf3d71e | |||
1dec69d726 | |||
ef2db68430 | |||
e6f3250286 | |||
ca56f1dd07 | |||
6bd973a803 | |||
49dd703e12 | |||
0f27441225 | |||
97d76ddca8 | |||
891d491cdb | |||
2a1fb0eddf | |||
5c25fbe2c4 | |||
e3e618d3cf | |||
e2489a2487 | |||
bb17027cc9 | |||
6b17d3eb7d | |||
c5f11eff8b | |||
b36bc5e47d |
26
.codecov.yml
26
.codecov.yml
@ -1,26 +0,0 @@
|
||||
# Docs: <https://docs.codecov.io/docs/commit-status>
|
||||
|
||||
coverage:
|
||||
# coverage lower than 50 is red, higher than 90 green
|
||||
range: 30..80
|
||||
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
# Choose a minimum coverage ratio that the commit must meet to be considered a success.
|
||||
#
|
||||
# `auto` will use the coverage from the base commit (pull request base or parent commit) coverage to compare
|
||||
# against.
|
||||
target: auto
|
||||
|
||||
# Allow the coverage to drop by X%, and posting a success status.
|
||||
threshold: 5%
|
||||
|
||||
# Resulting status will pass no matter what the coverage is or what other settings are specified.
|
||||
informational: true
|
||||
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 5%
|
||||
informational: true
|
18
.devcontainer/devcontainer.json
Normal file
18
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json",
|
||||
"name": "default",
|
||||
"image": "golang:1.22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/guiyomh/features/golangci-lint:0": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "go mod download"
|
||||
}
|
@ -1,14 +1,9 @@
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.github
|
||||
.git
|
||||
.gitignore
|
||||
.editorconfig
|
||||
.idea
|
||||
.vscode
|
||||
test
|
||||
temp
|
||||
tmp
|
||||
LICENSE
|
||||
Makefile
|
||||
error-pages
|
||||
## Ignore everything
|
||||
*
|
||||
|
||||
## Except the following files and directories
|
||||
!/cmd
|
||||
!/internal
|
||||
!/l10n
|
||||
!/templates
|
||||
!/go.*
|
||||
|
@ -7,11 +7,12 @@ charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{yml, yaml, sh, conf}]
|
||||
indent_size = 2
|
||||
[{*.yml,*.yaml}]
|
||||
ij_any_spaces_within_braces = false
|
||||
ij_any_spaces_within_brackets = false
|
||||
|
||||
[{Makefile, go.mod, *.go}]
|
||||
[{Makefile,go.mod,*.go}]
|
||||
indent_style = tab
|
||||
|
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# @link <https://help.github.com/en/articles/about-code-owners>
|
||||
|
||||
* @tarampampam
|
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json
|
||||
# docs: https://git.io/JR5E4
|
||||
|
||||
name: 🐞 Bug report
|
||||
description: File a bug/issue
|
||||
labels: ['type:bug']
|
||||
assignees: [tarampampam]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- label: And it has nothing to do with Traefik
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Start the container using command ...
|
||||
2. Send an HTTP request using this curl command ...
|
||||
3. See error
|
||||
|
||||
- type: textarea
|
||||
id: configs
|
||||
attributes:
|
||||
label: Configuration files
|
||||
description: |
|
||||
Please copy and paste any relevant configuration files. This will be automatically formatted
|
||||
into code (yaml), so no need for backticks.
|
||||
render: yaml
|
||||
placeholder: Traefik, docker-compose, helm, etc.
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: |
|
||||
Please copy and paste any relevant log output. This will be automatically formatted into code
|
||||
(shell), so no need for backticks.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: Links? References? Anything that will give us more context about the issue you are encountering!
|
13
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
13
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
|
||||
# docs: https://git.io/JP3tm
|
||||
|
||||
blank_issues_enabled: false
|
||||
|
||||
contact_links:
|
||||
- name: 🗣 Ask a Question, Discuss
|
||||
url: https://github.com/tarampampam/error-pages/discussions
|
||||
about: Feel free to ask anything
|
||||
|
||||
- name: 🌀 I have a question about Traefik..
|
||||
url: https://community.traefik.io/
|
||||
about: In this case - ask in the Traefik community
|
34
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
34
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json
|
||||
# docs: https://git.io/JR5E4
|
||||
|
||||
name: 💡 Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: ['type:feature_request']
|
||||
assignees: [tarampampam]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the problem to be solved
|
||||
description: Please present a concise description of the problem to be addressed by this feature request
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Suggest a solution
|
||||
description: A concise description of your preferred solution
|
||||
placeholder: If there are multiple solutions, please present each one separately
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the feature request
|
||||
placeholder: You can attach images or log files by clicking this area to highlight it and then dragging files in
|
23
.github/dependabot.yml
vendored
Normal file
23
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json
|
||||
# docs: https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/customizing-dependency-updates
|
||||
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
groups: {gomod: {patterns: ['*']}}
|
||||
schedule: {interval: monthly}
|
||||
assignees: [tarampampam]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
groups: {github-actions: {patterns: ['*']}}
|
||||
schedule: {interval: monthly}
|
||||
assignees: [tarampampam]
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
groups: {docker: {patterns: ['*']}}
|
||||
schedule: {interval: monthly}
|
||||
assignees: [tarampampam]
|
13
.github/release.yml
vendored
Normal file
13
.github/release.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-release-config.json
|
||||
# docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
||||
|
||||
changelog:
|
||||
categories:
|
||||
- title: 🛠 Fixes
|
||||
labels: [type:fix, type:bug]
|
||||
- title: 🚀 Features
|
||||
labels: [type:feature, type:feature_request]
|
||||
- title: 📦 Dependency updates
|
||||
labels: [dependencies]
|
||||
- title: Other Changes
|
||||
labels: ['*']
|
7
.github/renovate.json
vendored
Normal file
7
.github/renovate.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"github>tarampampam/.github//renovate/default",
|
||||
":rebaseStalePrs"
|
||||
]
|
||||
}
|
27
.github/workflows/dependabot.yml
vendored
Normal file
27
.github/workflows/dependabot.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
||||
|
||||
name: 🤖 Dependabot
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot: # https://tinyurl.com/e69djmen
|
||||
name: Enable auto-merge for Dependabot PRs
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
steps:
|
||||
- uses: dependabot/fetch-metadata@v2
|
||||
id: metadata
|
||||
with: {github-token: "${{ secrets.GITHUB_TOKEN }}"}
|
||||
|
||||
- if: ${{ contains(fromJSON('["version-update:semver-minor", "version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
22
.github/workflows/documentation.yml
vendored
Normal file
22
.github/workflows/documentation.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
||||
|
||||
name: 📚 Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
paths: ['README.md']
|
||||
|
||||
jobs:
|
||||
docker-hub-description:
|
||||
name: Docker Hub Description
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_USER_PASSWORD }}
|
||||
repository: tarampampam/error-pages
|
128
.github/workflows/release.yml
vendored
128
.github/workflows/release.yml
vendored
@ -1,4 +1,7 @@
|
||||
name: release
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
||||
|
||||
name: 🚀 Release
|
||||
|
||||
on:
|
||||
release: # Docs: <https://git.io/JeBz1#release-event-release>
|
||||
@ -7,94 +10,95 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build for ${{ matrix.os }} (${{ matrix.arch }})
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [linux, darwin] # linux, freebsd, darwin, windows
|
||||
arch: [amd64] # amd64, 386
|
||||
os: [linux, darwin, windows] # freebsd
|
||||
arch: [amd64, arm64] # 386
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with: {go-version: 1.17.1}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- name: Generate builder values
|
||||
id: values
|
||||
run: echo "::set-output name=binary-name::error-pages-${{ matrix.os }}-${{ matrix.arch }}"
|
||||
|
||||
- name: Build application
|
||||
env:
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
|
||||
- {uses: gacts/github-slug@v1, id: slug}
|
||||
- id: values
|
||||
run: echo "binary-name=error-pages-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT
|
||||
- env:
|
||||
GOOS: ${{ matrix.os }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
CGO_ENABLED: 0
|
||||
LDFLAGS: -s -w -X github.com/tarampampam/error-pages/internal/version.version=${{ steps.slug.outputs.version }}
|
||||
LDFLAGS: -s -w -X gh.tarampamp.am/error-pages/internal/version.version=${{ steps.slug.outputs.version }}
|
||||
run: go build -trimpath -ldflags "$LDFLAGS" -o "./${{ steps.values.outputs.binary-name }}" ./cmd/error-pages/
|
||||
|
||||
- name: Upload binary file to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
- uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.values.outputs.binary-name }}
|
||||
asset_name: ${{ steps.values.outputs.binary-name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
docker-image:
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-20.04
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
run: mkdir ./out && ./${{ steps.values.outputs.binary-name }} build --index --disable-minification --target-dir ./out
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: error-pages-static
|
||||
path: out/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
working-directory: ./out
|
||||
run: zip -r ./../templates.zip .
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: templates.zip
|
||||
asset_name: error-pages-static.zip
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
demo:
|
||||
name: Update the demo (GitHub Pages)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: error-pages-static
|
||||
path: .artifact
|
||||
- uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./.artifact
|
||||
|
||||
- uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- uses: docker/setup-qemu-action@v1 # Action page: <https://github.com/docker/setup-qemu-action>
|
||||
|
||||
- uses: docker/setup-buildx-action@v1 # Action page: <https://github.com/docker/setup-buildx-action>
|
||||
|
||||
- uses: docker/login-action@v1 # Action page: <https://github.com/docker/login-action>
|
||||
docker-image:
|
||||
name: Build the docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: gacts/github-slug@v1, id: slug}
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- uses: docker/login-action@v1 # Action page: <https://github.com/docker/login-action>
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- uses: docker/build-push-action@v2 # Action page: <https://github.com/docker/build-push-action>
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
build-args: "APP_VERSION=${{ steps.slug.outputs.version }}"
|
||||
tags: |
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version }}
|
||||
tarampampam/error-pages:latest
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version }}
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version-major }}
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest
|
||||
|
||||
demo:
|
||||
name: Update the demonstration
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [docker-image]
|
||||
steps:
|
||||
- uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- name: Take rendered templates from the built docker image
|
||||
run: |
|
||||
docker create --name img ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
|
||||
docker cp img:/opt/html ./out
|
||||
docker rm -f img
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./out
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}
|
||||
|
214
.github/workflows/tests.yml
vendored
214
.github/workflows/tests.yml
vendored
@ -1,4 +1,7 @@
|
||||
name: tests
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
||||
|
||||
name: 🧪 Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@ -8,203 +11,82 @@ on:
|
||||
pull_request:
|
||||
paths-ignore: ['**.md']
|
||||
|
||||
jobs: # Docs: <https://git.io/JvxXE>
|
||||
gitleaks:
|
||||
name: Gitleaks
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with: {fetch-depth: 0}
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
- uses: zricethezav/gitleaks-action@v1 # Action page: <https://github.com/zricethezav/gitleaks-action>
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Check for GitLeaks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- {uses: actions/checkout@v4, with: {fetch-depth: 0}}
|
||||
- uses: gacts/gitleaks@v1
|
||||
|
||||
golangci-lint:
|
||||
name: Golang-CI (lint)
|
||||
runs-on: ubuntu-20.04
|
||||
name: Run golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Run linter
|
||||
uses: golangci/golangci-lint-action@v2 # Action page: <https://github.com/golangci/golangci-lint-action>
|
||||
with:
|
||||
version: v1.42 # without patch version
|
||||
only-new-issues: false # show only new issues if it's a pull request
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
|
||||
go-test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with: {go-version: 1.17}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
with: {fetch-depth: 2} # Fixes codecov error 'Issue detecting commit SHA'
|
||||
|
||||
- name: Go modules Cache # Docs: <https://git.io/JfAKn#go---modules>
|
||||
uses: actions/cache@v2
|
||||
id: go-cache
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: ${{ runner.os }}-go-
|
||||
|
||||
- if: steps.go-cache.outputs.cache-hit != 'true'
|
||||
run: go mod download
|
||||
|
||||
- name: Run Unit tests
|
||||
run: go test -race -covermode=atomic -coverprofile /tmp/coverage.txt ./...
|
||||
|
||||
- uses: codecov/codecov-action@v2 # https://github.com/codecov/codecov-action
|
||||
continue-on-error: true
|
||||
with:
|
||||
file: /tmp/coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
|
||||
- run: go test -race ./...
|
||||
|
||||
build:
|
||||
name: Build for ${{ matrix.os }} (${{ matrix.arch }})
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [linux, darwin] # linux, freebsd, darwin, windows
|
||||
arch: [amd64] # amd64, 386
|
||||
os: [linux, darwin, windows] # freebsd
|
||||
arch: [amd64, arm64] # 386
|
||||
needs: [golangci-lint, go-test]
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with: {go-version: 1.17}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- name: Go modules Cache # Docs: <https://git.io/JfAKn#go---modules>
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: ${{ runner.os }}-go-
|
||||
|
||||
- run: go mod download
|
||||
|
||||
- name: Build application
|
||||
env:
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
|
||||
- {uses: gacts/github-slug@v1, id: slug}
|
||||
- env:
|
||||
GOOS: ${{ matrix.os }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
CGO_ENABLED: 0
|
||||
LDFLAGS: -s -w -X github.com/tarampampam/error-pages/internal/version.version=${{ steps.slug.outputs.branch-name-slug }}@${{ steps.slug.outputs.commit-hash-short }}
|
||||
LDFLAGS: -s -w -X gh.tarampamp.am/error-pages/internal/appmeta.version=${{ steps.slug.outputs.commit-hash-short }}
|
||||
run: go build -trimpath -ldflags "$LDFLAGS" -o ./error-pages ./cmd/error-pages/
|
||||
|
||||
- name: Try to execute
|
||||
if: matrix.os == 'linux'
|
||||
run: ./error-pages version && ./error-pages -h
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: error-pages-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: error-pages
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
generate:
|
||||
name: Run templates generator
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: error-pages-linux-amd64
|
||||
path: .artifact
|
||||
|
||||
- name: Prepare binary file to run
|
||||
working-directory: .artifact
|
||||
run: mv ./error-pages ./../error-pages && chmod +x ./../error-pages
|
||||
|
||||
- name: Run generator
|
||||
run: ./error-pages build ./out --verbose --index
|
||||
|
||||
- name: Test files creation
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
run: ./error-pages --version && ./error-pages -h
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
run: mkdir ./out && ./error-pages --log-level=debug build --index --target-dir ./out
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
run: |
|
||||
test -f ./out/index.html
|
||||
test -f ./out/ghost/404.html
|
||||
test -f ./out/l7-dark/404.html
|
||||
test -f ./out/l7-light/404.html
|
||||
test -f ./out/l7/404.html
|
||||
test -f ./out/shuffle/404.html
|
||||
test -f ./out/noise/404.html
|
||||
test -f ./out/hacker-terminal/404.html
|
||||
test -f ./out/cats/404.html
|
||||
test -f ./out/lost-in-space/404.html
|
||||
test -f ./out/app-down/404.html
|
||||
test -f ./out/connection/404.html
|
||||
test -f ./out/orient/404.html
|
||||
|
||||
docker-image:
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build the docker image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [golangci-lint, go-test]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- uses: docker/build-push-action@v2 # Action page: <https://github.com/docker/build-push-action>
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: gacts/github-slug@v1, id: slug}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
build-args: "APP_VERSION=${{ steps.slug.outputs.branch-name-slug }}@${{ steps.slug.outputs.commit-hash-short }}"
|
||||
build-args: "APP_VERSION=${{ steps.slug.outputs.commit-hash-short }}"
|
||||
tags: app:ci
|
||||
|
||||
- run: docker save app:ci > ./docker-image.tar
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: docker-image
|
||||
path: ./docker-image.tar
|
||||
retention-days: 1
|
||||
|
||||
scan-docker-image:
|
||||
name: Scan the docker image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [docker-image]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-image
|
||||
path: .artifact
|
||||
|
||||
- working-directory: .artifact
|
||||
run: docker load < docker-image.tar
|
||||
|
||||
- uses: anchore/scan-action@v3 # action page: <https://github.com/anchore/scan-action>
|
||||
with:
|
||||
image: app:ci
|
||||
fail-build: true
|
||||
severity-cutoff: low # negligible, low, medium, high or critical
|
||||
|
||||
poke-docker-image:
|
||||
name: Run the docker image
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [docker-image]
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: docker-image
|
||||
path: .artifact
|
||||
|
||||
- working-directory: .artifact
|
||||
run: docker load < docker-image.tar
|
||||
|
||||
- name: Run container with the app
|
||||
run: docker run --rm -d -p "8080:8080/tcp" --name app app:ci
|
||||
|
||||
- name: Wait for container "healthy" state
|
||||
run: until [[ "`docker inspect -f {{.State.Health.Status}} app`" == "healthy" ]]; do echo "wait 1 sec.."; sleep 1; done
|
||||
|
||||
- run: curl --fail http://127.0.0.1:8080/500.html
|
||||
- run: curl --fail http://127.0.0.1:8080/400.html
|
||||
- run: curl --fail http://127.0.0.1:8080/health/live
|
||||
- run: test $(curl --write-out %{http_code} --silent --output /dev/null http://127.0.0.1:8080/) -eq 404
|
||||
|
||||
- name: Stop the container
|
||||
if: always()
|
||||
run: docker kill app
|
||||
|
9
.gitignore
vendored
9
.gitignore
vendored
@ -3,14 +3,19 @@
|
||||
/.vscode
|
||||
|
||||
## Binaries
|
||||
error-pages
|
||||
/error-pages
|
||||
|
||||
## Temp dirs & trash
|
||||
/temp
|
||||
/tmp
|
||||
*.env
|
||||
/*-old
|
||||
/cmd/test*
|
||||
.DS_Store
|
||||
/go.work*
|
||||
*.cache
|
||||
*.out
|
||||
*.env
|
||||
/out
|
||||
/gen
|
||||
/cover*.*
|
||||
/report.xml
|
||||
|
@ -1,24 +1,32 @@
|
||||
# Documentation: <https://github.com/golangci/golangci-lint#config-file>
|
||||
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
||||
# docs: https://github.com/golangci/golangci-lint#config-file
|
||||
|
||||
run:
|
||||
timeout: 1m
|
||||
skip-dirs:
|
||||
- .github
|
||||
- .git
|
||||
timeout: 2m
|
||||
modules-download-mode: readonly
|
||||
allow-parallel-runners: true
|
||||
|
||||
output:
|
||||
format: colored-line-number # colored-line-number|line-number|json|tab|checkstyle|code-climate
|
||||
formats: [{format: colored-line-number}] # colored-line-number|line-number|json|tab|checkstyle|code-climate
|
||||
|
||||
linters-settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(gh.tarampamp.am/error-pages)
|
||||
gofmt:
|
||||
simplify: false
|
||||
rewrite-rules:
|
||||
- { pattern: 'interface{}', replacement: 'any' }
|
||||
govet:
|
||||
check-shadowing: true
|
||||
enable:
|
||||
- shadow
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
godot:
|
||||
scope: declarations
|
||||
capital: true
|
||||
capital: false
|
||||
dupl:
|
||||
threshold: 100
|
||||
goconst:
|
||||
@ -26,71 +34,94 @@ linters-settings:
|
||||
min-occurrences: 3
|
||||
misspell:
|
||||
locale: US
|
||||
ignore-words: [cancelled]
|
||||
lll:
|
||||
line-length: 120
|
||||
forbidigo:
|
||||
forbid:
|
||||
- '^(fmt\.Print(|f|ln)|print(|ln))(# it looks like a forgotten debugging printing call)?$'
|
||||
prealloc:
|
||||
simple: true
|
||||
range-loops: true
|
||||
for-loops: true
|
||||
nolintlint:
|
||||
allow-leading-space: false
|
||||
require-specific: true
|
||||
nakedret:
|
||||
# Make an issue if func has more lines of code than this setting, and it has naked returns.
|
||||
# Default: 30
|
||||
max-func-lines: 100
|
||||
|
||||
linters: # All available linters list: <https://golangci-lint.run/usage/linters/>
|
||||
disable-all: true
|
||||
enable:
|
||||
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
|
||||
- bodyclose # Checks whether HTTP response body is closed successfully
|
||||
- deadcode # Finds unused code
|
||||
- depguard # Go linter that checks if package imports are in a list of acceptable packages
|
||||
- bidichk # Checks for dangerous unicode character sequences
|
||||
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
|
||||
- dupl # Tool for code clone detection
|
||||
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
|
||||
- errorlint # find code that will cause problems with the error wrapping scheme introduced in Go 1.13
|
||||
- exhaustive # check exhaustiveness of enum switch statements
|
||||
- exportloopref # checks for pointers to enclosing loop variables
|
||||
- copyloopvar # detects places where loop variables are copied
|
||||
- funlen # Tool for detection of long functions
|
||||
- gci # Gci control golang package import order and make it always deterministic
|
||||
- godot # Check if comments end in a period
|
||||
- gochecknoglobals # Checks that no globals are present in Go code
|
||||
- gochecknoinits # Checks that no init functions are present in Go code
|
||||
- gocognit # Computes and checks the cognitive complexity of functions
|
||||
- goconst # Finds repeated strings that could be replaced by a constant
|
||||
- gocritic # The most opinionated Go source code linter
|
||||
- gocyclo # Computes and checks the cyclomatic complexity of functions
|
||||
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification
|
||||
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports
|
||||
- gomnd # An analyzer to detect magic numbers
|
||||
- gofmt # Gofmt checks whether code was gofmt-ed. By default, this tool runs with -s option to check for code simplification
|
||||
- goimports # Goimports does everything that gofmt does. Additionally, it checks unused imports
|
||||
- mnd # An analyzer to detect magic numbers
|
||||
- goprintffuncname # Checks that printf-like functions are named with `f` at the end
|
||||
- gosec # Inspects source code for security problems
|
||||
- gosimple # Linter for Go source code that specializes in simplifying a code
|
||||
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
|
||||
- ineffassign # Detects when assignments to existing variables are not used
|
||||
- lll # Reports long lines
|
||||
- forbidigo # Forbids identifiers
|
||||
- misspell # Finds commonly misspelled English words in comments
|
||||
- nakedret # Finds naked returns in functions greater than a specified function length
|
||||
- nestif # Reports deeply nested if statements
|
||||
- nlreturn # checks for a new line before return and branch statements to increase code clarity
|
||||
- noctx # finds sending http request without context.Context
|
||||
- nolintlint # Reports ill-formed or insufficient nolint directives
|
||||
- prealloc # Finds slice declarations that could potentially be preallocated
|
||||
- rowserrcheck # Checks whether Err of rows is checked successfully
|
||||
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks
|
||||
- structcheck # Finds unused struct fields
|
||||
- stylecheck # Stylecheck is a replacement for golint
|
||||
- tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes
|
||||
- promlinter # Check Prometheus metrics naming via promlint.
|
||||
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code
|
||||
- unconvert # Remove unnecessary type conversions
|
||||
- unparam # Reports unused function parameters
|
||||
- unused # Checks Go code for unused constants, variables, functions and types
|
||||
- varcheck # Finds unused global variables and constants
|
||||
- whitespace # Tool for detection of leading and trailing whitespace
|
||||
- wsl # Whitespace Linter - Forces you to use empty lines!
|
||||
- unused # Checks Go code for unused constants, variables, functions and types
|
||||
- gosimple # Linter for Go source code that specializes in simplifying code
|
||||
- staticcheck # It's a set of rules from staticcheck
|
||||
- asasalint # Check for pass []any as any in variadic func(...any)
|
||||
- bodyclose # Checks whether HTTP response body is closed successfully
|
||||
- contextcheck # Check whether the function uses a non-inherited context
|
||||
- decorder # Check declaration order and count of types, constants, variables and functions
|
||||
- dupword # Checks for duplicate words in the source code
|
||||
- durationcheck # Check for two durations multiplied together
|
||||
- errchkjson # Checks types passed to the json encoding functions
|
||||
- errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- .github
|
||||
- .git
|
||||
- tmp
|
||||
- temp
|
||||
- testdata
|
||||
exclude-rules:
|
||||
- {path: flags\.go, linters: [gochecknoglobals, lll, mnd, dupl]}
|
||||
- {path: env\.go, linters: [lll, gosec]}
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- dupl
|
||||
- dupword
|
||||
- lll
|
||||
- nolintlint
|
||||
- funlen
|
||||
- scopelint
|
||||
- gocognit
|
||||
- noctx
|
||||
- goconst
|
||||
- nlreturn
|
||||
- gochecknoglobals
|
||||
|
143
CHANGELOG.md
143
CHANGELOG.md
@ -1,143 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog][keepachangelog] and this project adheres to [Semantic Versioning][semver].
|
||||
|
||||
## v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Application rewritten in Go
|
||||
|
||||
## v1.8.0
|
||||
|
||||
### Added
|
||||
|
||||
- Nginx health-check endpoint (`/health/live`) and dockerfile `HEALTHCHECK` to utilise (thx [@modem7](https://github.com/modem7)) [#22], [#23]
|
||||
|
||||
[#22]:https://github.com/tarampampam/error-pages/pull/22
|
||||
[#23]:https://github.com/tarampampam/error-pages/pull/23
|
||||
|
||||
## v1.7.2
|
||||
|
||||
### Changed
|
||||
|
||||
- Nginx updated up to `1.21` (from `1.19`)
|
||||
|
||||
## v1.7.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- Random template selecting (thx [@xpliz](https://github.com/xpliz)) [#12]
|
||||
|
||||
[#12]:https://github.com/tarampampam/error-pages/pull/12
|
||||
|
||||
## v1.7.0
|
||||
|
||||
### Added
|
||||
|
||||
- Template `hacker-terminal` [#13]
|
||||
- HTML comments with error code and description into each template (header and footer, it seems more readable for curl usage)
|
||||
|
||||
[#10]:https://github.com/tarampampam/error-pages/pull/13
|
||||
|
||||
## v1.6.0
|
||||
|
||||
### Added
|
||||
|
||||
- Template `noise` [#10]
|
||||
|
||||
### Fixed
|
||||
|
||||
- File permissions in docker image
|
||||
|
||||
[#10]:https://github.com/tarampampam/error-pages/issues/10
|
||||
|
||||
## v1.5.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Repository files structure
|
||||
- Nginx updated from `1.18` up to `1.19` in docker image
|
||||
- Docker image now uses default `nginx` entrypoint scripts and command
|
||||
|
||||
### Added
|
||||
|
||||
- Support for `linux/arm64/v8`, `linux/arm/v6` and `linux/arm/v7` platforms for docker image
|
||||
- Random template selecting (use `random` as a template name) for docker image
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Added
|
||||
|
||||
- Template `shuffle` [#4]
|
||||
|
||||
[#4]:https://github.com/tarampampam/error-pages/issues/4
|
||||
|
||||
## v1.3.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- `can't create directory '/opt/html/nginx-error-pages'` error [#3]
|
||||
|
||||
[#3]:https://github.com/tarampampam/error-pages/issues/3
|
||||
|
||||
## v1.3.0
|
||||
|
||||
### Added
|
||||
|
||||
- `418` status code error page
|
||||
- Set `server_tokens off;` in `nginx` server configuration
|
||||
|
||||
## v1.2.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- By default `nginx` in docker container returns 404 http code instead 200 when `/` requested
|
||||
|
||||
### Changed
|
||||
|
||||
- Default value for `TEMPLATE_NAME` is `ghost` now
|
||||
|
||||
### Removed
|
||||
|
||||
- Environment variable `DEFAULT_ERROR_CODE` support in docker image
|
||||
|
||||
### Added
|
||||
|
||||
- Templates `l7-light` and `l7-dark`
|
||||
|
||||
## v1.1.0
|
||||
|
||||
### Added
|
||||
|
||||
- Environment variable `DEFAULT_ERROR_CODE` support in docker image
|
||||
|
||||
## v1.0.1
|
||||
|
||||
### Changed
|
||||
|
||||
- Repository (not docker image) renamed from `error-pages-docker` to `error-pages`
|
||||
- `configuration.json` renamed to `config.json`
|
||||
- Makefile contains new targets (`install`, `gen`, `preview`)
|
||||
- Generator logging messages
|
||||
|
||||
### Added
|
||||
|
||||
- `docker-compose` for development
|
||||
|
||||
### Fixed
|
||||
|
||||
- Readme file content [#1]
|
||||
|
||||
[#1]:https://github.com/tarampampam/error-pages/issues/1
|
||||
|
||||
## v1.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- First project release
|
||||
|
||||
[keepachangelog]:https://keepachangelog.com/en/1.0.0/
|
||||
[semver]:https://semver.org/spec/v2.0.0.html
|
98
Dockerfile
98
Dockerfile
@ -1,76 +1,102 @@
|
||||
# syntax=docker/dockerfile:1.2
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Image page: <https://hub.docker.com/_/golang>
|
||||
FROM golang:1.17.1-alpine as builder
|
||||
# -✂- this stage is used to develop and build the application locally -------------------------------------------------
|
||||
FROM docker.io/library/golang:1.22-bookworm AS develop
|
||||
|
||||
# can be passed with any prefix (like `v1.2.3@GITHASH`), e.g.: `docker build --build-arg "APP_VERSION=v1.2.3@GITHASH" .`
|
||||
ARG APP_VERSION="undefined@docker"
|
||||
# use the /var/tmp/go as the GOPATH to reuse the modules cache
|
||||
ENV GOPATH="/var/tmp/go"
|
||||
|
||||
RUN set -x \
|
||||
# renovate: source=github-releases name=golangci/golangci-lint
|
||||
&& GOLANGCI_LINT_VERSION="1.60.3" \
|
||||
&& wget -O- -nv "https://cdn.jsdelivr.net/gh/golangci/golangci-lint@v${GOLANGCI_LINT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /bin "v${GOLANGCI_LINT_VERSION}"
|
||||
|
||||
RUN set -x \
|
||||
# customize the shell prompt (for the bash)
|
||||
&& echo "PS1='\[\033[1;36m\][go] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" >> /etc/bash.bashrc
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . .
|
||||
# burn the modules cache
|
||||
RUN \
|
||||
--mount=type=bind,source=go.mod,target=/src/go.mod \
|
||||
--mount=type=bind,source=go.sum,target=/src/go.sum \
|
||||
go mod download -x \
|
||||
&& find "${GOPATH}" -type d -exec chmod 0777 {} \; \
|
||||
&& find "${GOPATH}" -type f -exec chmod 0666 {} \;
|
||||
|
||||
# arguments to pass on each go tool link invocation
|
||||
ENV LDFLAGS="-s -w -X github.com/tarampampam/error-pages/internal/version.version=$APP_VERSION"
|
||||
# -✂- this stage is used to compile the application -------------------------------------------------------------------
|
||||
FROM develop AS compile
|
||||
|
||||
# can be passed with any prefix (like `v1.2.3@GITHASH`), e.g.: `docker build --build-arg "APP_VERSION=v1.2.3" .`
|
||||
ARG APP_VERSION="undefined@docker"
|
||||
|
||||
# copy the source code
|
||||
COPY . /src
|
||||
|
||||
RUN set -x \
|
||||
&& go version \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o ./error-pages ./cmd/error-pages/ \
|
||||
&& ./error-pages version \
|
||||
&& ./error-pages -h
|
||||
&& go generate ./... \
|
||||
&& CGO_ENABLED=0 LDFLAGS="-s -w -X gh.tarampamp.am/error-pages/internal/appmeta.version=${APP_VERSION}" \
|
||||
go build -trimpath -ldflags "${LDFLAGS}" -o /tmp/error-pages ./cmd/error-pages/ \
|
||||
&& /tmp/error-pages --version \
|
||||
&& /tmp/error-pages -h
|
||||
|
||||
# -✂- this stage is used to prepare the runtime fs --------------------------------------------------------------------
|
||||
FROM docker.io/library/alpine:3.20 AS rootfs
|
||||
|
||||
WORKDIR /tmp/rootfs
|
||||
|
||||
# prepare rootfs for runtime
|
||||
RUN set -x \
|
||||
&& mkdir -p \
|
||||
./etc \
|
||||
./bin \
|
||||
./opt/html \
|
||||
&& mkdir -p ./etc/ssl/certs ./bin \
|
||||
&& echo 'appuser:x:10001:10001::/nonexistent:/sbin/nologin' > ./etc/passwd \
|
||||
&& echo 'appuser:x:10001:' > ./etc/group \
|
||||
&& mv /src/error-pages ./bin/error-pages \
|
||||
&& mv /src/templates ./opt/templates \
|
||||
&& mv /src/error-pages.yml ./opt/error-pages.yml
|
||||
&& cp /etc/ssl/certs/ca-certificates.crt ./etc/ssl/certs/
|
||||
|
||||
# take the binary from the compile stage
|
||||
COPY --from=compile /tmp/error-pages ./bin/error-pages
|
||||
|
||||
WORKDIR /tmp/rootfs/opt
|
||||
|
||||
# generate static error pages (for usage inside another docker images, for example)
|
||||
# generate static error pages (for use inside other Docker images, for example)
|
||||
RUN set -x \
|
||||
&& ./../bin/error-pages --config-file ./error-pages.yml build ./html --verbose --index \
|
||||
&& mkdir ./html \
|
||||
&& ./../bin/error-pages build --index --target-dir ./html \
|
||||
&& ls -l ./html
|
||||
|
||||
# use empty filesystem
|
||||
FROM scratch as runtime
|
||||
# -✂- and this is the final stage (an empty filesystem is used) -------------------------------------------------------
|
||||
FROM scratch AS runtime
|
||||
|
||||
ARG APP_VERSION="undefined@docker"
|
||||
|
||||
LABEL \
|
||||
# Docs: <https://github.com/opencontainers/image-spec/blob/master/annotations.md>
|
||||
# docs: https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
||||
org.opencontainers.image.title="error-pages" \
|
||||
org.opencontainers.image.description="Static server error pages in the docker image" \
|
||||
org.opencontainers.image.description="Pretty server's error pages" \
|
||||
org.opencontainers.image.url="https://github.com/tarampampam/error-pages" \
|
||||
org.opencontainers.image.source="https://github.com/tarampampam/error-pages" \
|
||||
org.opencontainers.image.vendor="tarampampam" \
|
||||
org.opencontainers.version="$APP_VERSION" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Import from builder
|
||||
COPY --from=builder /tmp/rootfs /
|
||||
# import from builder
|
||||
COPY --from=rootfs /tmp/rootfs /
|
||||
|
||||
# Use an unprivileged user
|
||||
USER appuser:appuser
|
||||
# use an unprivileged user
|
||||
USER 10001:10001
|
||||
|
||||
WORKDIR /opt
|
||||
|
||||
ENV LISTEN_PORT="8080" \
|
||||
TEMPLATE_NAME="ghost"
|
||||
# to find out which environment variables and CLI arguments are supported by the application, run the app
|
||||
# with the `--help` flag or refer to the documentation at https://github.com/tarampampam/error-pages#readme
|
||||
|
||||
# Docs: <https://docs.docker.com/engine/reference/builder/#healthcheck>
|
||||
HEALTHCHECK --interval=7s --timeout=2s CMD [ \
|
||||
"/bin/error-pages", "healthcheck", "--log-json" \
|
||||
]
|
||||
ENV LOG_LEVEL="warn" \
|
||||
LOG_FORMAT="json"
|
||||
|
||||
# docs: https://docs.docker.com/reference/dockerfile/#healthcheck
|
||||
HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD ["/bin/error-pages", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/bin/error-pages"]
|
||||
|
||||
CMD ["serve", "--log-json"]
|
||||
CMD ["serve"]
|
||||
|
69
Makefile
69
Makefile
@ -1,61 +1,38 @@
|
||||
#!/usr/bin/make
|
||||
# Makefile readme (ru): <http://linux.yaroslavl.ru/docs/prog/gnu_make_3-79_russian_manual.html>
|
||||
# Makefile readme (en): <https://www.gnu.org/software/make/manual/html_node/index.html#SEC_Contents>
|
||||
|
||||
SHELL = /bin/sh
|
||||
LDFLAGS = "-s -w -X github.com/tarampampam/error-pages/internal/version.version=$(shell git rev-parse HEAD)"
|
||||
|
||||
DC_RUN_ARGS = --rm --user "$(shell id -u):$(shell id -g)"
|
||||
APP_NAME = $(notdir $(CURDIR))
|
||||
|
||||
.PHONY : help \
|
||||
image dive build fmt lint gotest test shell \
|
||||
up down restart \
|
||||
clean
|
||||
.DEFAULT_GOAL : help
|
||||
.SILENT : lint gotest
|
||||
|
||||
# This will output the help for each task. thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||
help: ## Show this help
|
||||
@printf "\033[33m%s:\033[0m\n" 'Available commands'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[32m%-11s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
image: ## Build docker image with app
|
||||
docker build -f ./Dockerfile -t $(APP_NAME):local .
|
||||
docker run --rm $(APP_NAME):local version
|
||||
@printf "\n \e[30;42m %s \033[0m\n\n" 'Now you can use image like `docker run --rm -p "8080:8080/tcp" $(APP_NAME):local ...`';
|
||||
.PHONY: up
|
||||
up: ## Start the application in watch mode
|
||||
docker compose kill web --remove-orphans 2>/dev/null || true
|
||||
docker compose up --detach --wait web
|
||||
$$SHELL -c "\
|
||||
trap 'docker compose down --remove-orphans --timeout 30' EXIT; \
|
||||
docker compose watch --no-up web \
|
||||
"
|
||||
|
||||
dive: image ## Explore the docker image
|
||||
docker run --rm -it -v "/var/run/docker.sock:/var/run/docker.sock:ro" wagoodman/dive:latest $(APP_NAME):local
|
||||
.PHONY: down
|
||||
down: ## Stop the application
|
||||
docker compose down --remove-orphans
|
||||
|
||||
build: ## Build app binary file
|
||||
docker-compose run $(DC_RUN_ARGS) -e "CGO_ENABLED=0" --no-deps app go build -trimpath -ldflags $(LDFLAGS) -o ./error-pages ./cmd/error-pages/
|
||||
.PHONY: shell
|
||||
shell: ## Start shell into development environment
|
||||
docker compose run -ti $(DC_RUN_ARGS) develop bash
|
||||
|
||||
fmt: ## Run source code formatter tools
|
||||
docker-compose run $(DC_RUN_ARGS) -e "GO111MODULE=off" --no-deps app sh -c 'go get golang.org/x/tools/cmd/goimports && $$GOPATH/bin/goimports -d -w .'
|
||||
docker-compose run $(DC_RUN_ARGS) --no-deps app gofmt -s -w -d .
|
||||
docker-compose run $(DC_RUN_ARGS) --no-deps app go mod tidy
|
||||
.PHONY: test
|
||||
test: ## Run tests
|
||||
docker compose run $(DC_RUN_ARGS) develop gotestsum --format pkgname -- -race -timeout 2m ./...
|
||||
|
||||
lint: ## Run app linters
|
||||
docker-compose run --rm --no-deps golint golangci-lint run
|
||||
.PHONY: lint
|
||||
lint: ## Run linters
|
||||
docker compose run $(DC_RUN_ARGS) develop golangci-lint run
|
||||
|
||||
gotest: ## Run app tests
|
||||
docker-compose run $(DC_RUN_ARGS) --no-deps app go test -v -race -timeout 10s ./...
|
||||
|
||||
test: lint gotest ## Run app tests and linters
|
||||
|
||||
shell: ## Start shell into container with golang
|
||||
docker-compose run $(DC_RUN_ARGS) app bash
|
||||
|
||||
up: ## Create and start containers
|
||||
docker-compose up --detach web
|
||||
@printf "\n \e[30;42m %s \033[0m\n\n" 'Navigate your browser to ⇒ http://127.0.0.1:8080';
|
||||
|
||||
down: ## Stop all services
|
||||
docker-compose down -t 5
|
||||
|
||||
restart: down up ## Restart all containers
|
||||
|
||||
clean: ## Make clean
|
||||
docker-compose down -v -t 1
|
||||
-docker rmi $(APP_NAME):local -f
|
||||
.PHONY: gen
|
||||
gen: ## Generate code
|
||||
docker compose run $(DC_RUN_ARGS) develop go generate ./...
|
||||
|
919
README.md
919
README.md
@ -1,198 +1,433 @@
|
||||
<p align="center">
|
||||
<img src="https://hsto.org/webt/rm/9y/ww/rm9ywwx3gjv9agwkcmllhsuyo7k.png" width="94" alt="" />
|
||||
<a href="https://github.com/tarampampam/error-pages#readme">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Dark">
|
||||
<img align="center" src="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Light">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# HTTP's error pages
|
||||
|
||||
[![Release version][badge_release_version]][link_releases]
|
||||
![Project language][badge_language]
|
||||
[![Build Status][badge_build]][link_build]
|
||||
[![Release Status][badge_release]][link_build]
|
||||
[![Coverage][badge_coverage]][link_coverage]
|
||||
[![Image size][badge_size_latest]][link_docker_hub]
|
||||
[![License][badge_license]][link_license]
|
||||
|
||||
One day you may want to replace the standard error pages of your HTTP server with something more original and pretty. That's what this repository was created for :) It contains:
|
||||
|
||||
- Simple error pages generator, written on Go
|
||||
- Single-page error page templates with different designs (located in the [templates](templates) directory)
|
||||
- Fast and lightweight HTTP server (written on Go also, with the [FastHTTP][fasthttp] under the hood)
|
||||
- Already generated error pages (sources can be [found here][preview-sources], the **demonstration** is always accessible [here][preview-demo])
|
||||
- Lightweight docker image _(~3.5Mb compressed size)_ with all the things described above
|
||||
|
||||
Also, this project can be used for the [**Traefik** error pages customization](https://doc.traefik.io/traefik/middlewares/http/errorpages/).
|
||||
|
||||
<p align="center">
|
||||
<img src="https://hsto.org/webt/bc/bt/9i/bcbt9i3jyvozequr1e4maz7i2q8.png" alt="" />
|
||||
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true&label=&logo=go&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/tests.yml?branch=master&maxAge=30&label=tests&logo=github&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/release.yml?maxAge=30&label=release&logo=github&style=flat-square" alt="" /></a>
|
||||
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/pulls/tarampampam/error-pages.svg?maxAge=30&label=pulls&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30&label=size&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/blob/master/LICENSE"><img src="https://img.shields.io/github/license/tarampampam/error-pages.svg?maxAge=30&style=flat-square" alt="" /></a>
|
||||
</p>
|
||||
|
||||
## Installing
|
||||
One day, you might want to replace the standard error pages of your HTTP server or K8S cluster with something more
|
||||
original and attractive. That's why this repository was created :) It contains:
|
||||
|
||||
Download the latest binary file for your os/arch from the [releases page][link_releases] or use our docker image:
|
||||
- A simple error page generator written in Go
|
||||
- Single-page error templates (themes) with various designs (located in the [templates][templates-dir] directory) that
|
||||
you can customize as you wish
|
||||
- A fast and lightweight HTTP server is available as a single binary file and Docker image. It includes built-in error
|
||||
page templates from this repository. You don't need anything except the compiled binary file or Docker image
|
||||
- Pre-generated error pages (sources can be [found here][preview-sources], and the [**demo** is always
|
||||
accessible here][preview-demo])
|
||||
|
||||
[][link_docker_hub]
|
||||
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
|
||||
[preview-demo]:https://tarampampam.github.io/error-pages/
|
||||
[templates-dir]:https://github.com/tarampampam/error-pages/tree/master/templates
|
||||
|
||||
Registry | Image
|
||||
-------------------------------------- | -----
|
||||
[Docker Hub][link_docker_hub] | `tarampampam/error-pages`
|
||||
[GitHub Container Registry][link_ghcr] | `ghcr.io/tarampampam/error-pages`
|
||||
## 🔥 Features List
|
||||
|
||||
> Using the `latest` tag for the docker image is highly discouraged because of possible backward-incompatible changes during **major** upgrades. Please, use tags in `X.Y.Z` format
|
||||
- HTTP server written in Go, utilizing the extremely fast [FastHTTP][fasthttp] and in-memory caching
|
||||
- Respects the `Content-Type` HTTP header (and `X-Format`) value, responding with the corresponding format
|
||||
(supported formats: `json`, `xml`, and `plaintext`)
|
||||
- Error pages are configured to be excluded from search engine indexing (using meta tags and HTTP headers) to
|
||||
prevent SEO issues on your website
|
||||
- HTML content (including CSS, SVG, and JS) is minified on the fly
|
||||
- Logs written in `json` format
|
||||
- Contains a health check endpoint (`/healthz`)
|
||||
- Consumes very few resources and is suitable for use in resource-constrained environments
|
||||
- Lightweight Docker image, distroless, and uses an unprivileged user by default
|
||||
- [Go-template](https://pkg.go.dev/text/template) tags are allowed in the templates
|
||||
- Ready for integration with [Traefik][traefik], [Ingress-nginx][ingress-nginx], and more
|
||||
- Error pages can be embedded into your own Docker image with `nginx` in a few simple steps
|
||||
- Fully configurable
|
||||
- Distributed as a Docker image and compiled binary files
|
||||
- Localized HTML error pages (🇺🇸, 🇫🇷, 🇺🇦, 🇷🇺, 🇵🇹, 🇳🇱, 🇩🇪, 🇪🇸, 🇨🇳, 🇮🇩, 🇵🇱, 🇰🇷) - translation process
|
||||
[described here][l10n-dir] - other translations are welcome!
|
||||
|
||||
To watch the docker image content you can use the [dive](https://github.com/wagoodman/dive):
|
||||
[fasthttp]:https://github.com/valyala/fasthttp
|
||||
[traefik]:https://github.com/traefik/traefik
|
||||
[l10n-dir]:https://github.com/tarampampam/error-pages/tree/master/l10n
|
||||
|
||||
## 🧩 Install
|
||||
|
||||
Download the latest binary file for your OS/architecture from the [releases page][latest-release] or use our Docker image:
|
||||
|
||||
| Registry | Image |
|
||||
|-----------------------------------|-----------------------------------|
|
||||
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
|
||||
| [Docker Hub][docker-hub] (mirror) | `tarampampam/error-pages` |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Using the `latest` tag for the Docker image is highly discouraged due to potential backward-incompatible changes
|
||||
> during **major** upgrades. Please use tags in the `X.Y.Z` format.
|
||||
|
||||
💣 **Or** you can also download the **already rendered** error pages pack as a [zip][pages-pack-zip] or
|
||||
[tar.gz][pages-pack-tar-gz] archive.
|
||||
|
||||
[latest-release]:https://github.com/tarampampam/error-pages/releases/latest
|
||||
[docker-hub]:https://hub.docker.com/r/tarampampam/error-pages
|
||||
[ghcr]:https://github.com/tarampampam/error-pages/pkgs/container/error-pages
|
||||
[pages-pack-zip]:https://github.com/tarampampam/error-pages/zipball/gh-pages/
|
||||
[pages-pack-tar-gz]:https://github.com/tarampampam/error-pages/tarball/gh-pages/
|
||||
|
||||
## 🪂 Templates (themes)
|
||||
|
||||
The following templates are built-in and available for use without any additional setup:
|
||||
|
||||
> [!NOTE]
|
||||
> The `cats` template is the only one of those that fetches resources (the actual cat pictures) from external
|
||||
> servers - all other templates are self-contained.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>app-down</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fapp-down.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/app-down/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/4e668a56-a4c4-47cd-ac4d-b6b45db54ab8">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/ad4b4fd7-7c7b-4bdc-a6b6-44f9ba7f77ca">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>cats</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fcats.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/cats/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/5689880b-f770-406c-81dd-2d28629e6f2e">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/056cd00e-bc9a-4120-8325-310d7b0ebd1b">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>connection</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fconnection.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/connection/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/3f03dc1b-c1ee-4a91-b3d7-e3b93c79020e">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/099ecc2d-e724-4d9c-b5ed-66ddabd71139">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>ghost</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fghost.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/ghost/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/714482ab-f8c1-4455-8ae8-b2ae78f7a2c6">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/f253dfe7-96a0-4e96-915b-d4c544d4a237">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>hacker-terminal</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fhacker-terminal.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/hacker-terminal/404.html">
|
||||
<picture>
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c197fc35-0844-43d0-9830-82440cee4559">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>l7</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fl7.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/l7/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/18e43ea3-6389-4459-be41-0fc6566a073f">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/05f26669-94ec-40ce-8d67-a199cde54202">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>lost-in-space</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Flost-in-space.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/lost-in-space/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/debf87c0-6f27-41a8-b141-ee3464cbd6cc">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c347e63d-13a7-46d4-81b9-b25266819a1d">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>noise</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fnoise.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/noise/404.html">
|
||||
<picture>
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/4cc5c3bd-6ebb-4e96-bee8-02d4ad4e7266">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>orient</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Forient.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/orient/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/bc2b0dad-c32c-4628-98f6-e3eab61dd1f2">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/8fc0a7ea-694d-49ce-bb50-3ea032d52d1e">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>shuffle</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fshuffle.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/shuffle/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/7504b7c3-b0cb-4991-9ac2-759cd6c50fc0">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/d2a73fc8-cf5f-4f42-bff8-cce33d8ae47e">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
> [!NOTE]
|
||||
> The "used times" counter increments when someone start the server with the specified template. Stats service does
|
||||
> not collect any information about location, IP addresses, and so on. Moreover, the stats are open and available for
|
||||
> everyone at [error-pages.goatcounter.com](https://error-pages.goatcounter.com/). This is simply a counter to display
|
||||
> how often a particular template is used, nothing more.
|
||||
|
||||
## 🛠 Usage scenarios
|
||||
|
||||
### HTTP server starting, utilizing either a binary file or Docker image
|
||||
|
||||
First, ensure you have a precompiled binary file on your machine or have Docker/Podman installed. Next, start the
|
||||
server with the following command:
|
||||
|
||||
```bash
|
||||
$ docker run --rm -it \
|
||||
-v "/var/run/docker.sock:/var/run/docker.sock:ro" \
|
||||
wagoodman/dive:latest \
|
||||
tarampampam/error-pages:latest
|
||||
$ ./error-pages serve
|
||||
# --- or ---
|
||||
$ docker run --rm -p '8080:8080/tcp' tarampampam/error-pages serve
|
||||
```
|
||||
|
||||
That's it! The server will begin running and listen on address `0.0.0.0` and port `8080`. Access error pages using
|
||||
URLs like `http://127.0.0.1:8080/{page_code}.html`.
|
||||
|
||||
To retrieve different error page codes using a static URL, use the `X-Code` HTTP header:
|
||||
|
||||
```bash
|
||||
$ curl -H 'X-Code: 500' http://127.0.0.1:8080/
|
||||
```
|
||||
|
||||
The server respects the `Content-Type` HTTP header (and `X-Format`), delivering responses in requested formats
|
||||
such as HTML, XML, JSON, and PlainText. Customization of these formats is possible via CLI flags or environment
|
||||
variables.
|
||||
|
||||
For integration with [ingress-nginx][ingress-nginx] or debugging purposes, start the server with `--show-details`
|
||||
(or set the environment variable `SHOW_DETAILS=true`) to enrich error pages (including JSON and XML responses)
|
||||
with upstream proxy information.
|
||||
|
||||
Switch themes using the `TEMPLATE_NAME` environment variable or the `--template-name` flag; available templates
|
||||
are detailed in the readme file below.
|
||||
|
||||
> [!TIP]
|
||||
> Use the `--rotation-mode` flag or the `TEMPLATES_ROTATION_MODE` environment variable to automate theme
|
||||
> rotation. Available modes include `random-on-startup`, `random-on-each-request`, `random-hourly`,
|
||||
> and `random-daily`.
|
||||
|
||||
To proxy HTTP headers from requests to responses, utilize the `--proxy-headers` flag or environment variable
|
||||
(comma-separated list of headers).
|
||||
|
||||
<details>
|
||||
<summary>Dive screenshot</summary>
|
||||
<summary><strong>🚀 Start the HTTP server with my custom template (theme)</strong></summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://hsto.org/webt/mi/ak/uf/miakufsh2ibxtsa1nomfhyqombi.png" alt="" />
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
All of the examples below will use a docker image with the application, but you can also use a binary. By the way, our docker image uses the **unleveled user** by default and **distroless**.
|
||||
|
||||
### HTTP server
|
||||
|
||||
As mentioned above - our application can be run as an HTTP server. It only needs to specify the path to the configuration file (it does not need statically generated error pages). The server uses [FastHTTP][fasthttp] and stores all necessary data in memory - so it does not use the file system and very fast. Oh yes, the image with the app also contains a configured **healthcheck** and **logs in JSON** format :)
|
||||
|
||||
For the HTTP server running execute in your terminal:
|
||||
|
||||
```bash
|
||||
$ docker run --rm \
|
||||
-p "8080:8080/tcp" \
|
||||
-e "TEMPLATE_NAME=random" \
|
||||
tarampampam/error-pages
|
||||
```
|
||||
|
||||
And open [`http://127.0.0.1:8080/404.html`](http://127.0.0.1:8080/404.html) in your favorite browser. Error pages are accessible by the following URLs: `http://127.0.0.1:8080/{page_code}.html`.
|
||||
|
||||
Environment variable `TEMPLATE_NAME` should be used for the theme switching (supported templates are described below).
|
||||
|
||||
> **Cheat**: you can use `random` (to use the randomized theme on server start) or `i-said-random` (to use the randomized template on **each request**)
|
||||
|
||||
To see the help run the following command:
|
||||
|
||||
```bash
|
||||
$ docker run --rm tarampampam/error-pages serve --help
|
||||
```
|
||||
|
||||
### Generator
|
||||
|
||||
Create a config file (`error-pages.yml`) with the following content:
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
- path: ./foo.html # Template name "foo" (same as file name),
|
||||
# content located in the file "foo.html"
|
||||
- name: bar # Template name "bar", its content is described below:
|
||||
content: "Error {{ code }}: {{ message }} ({{ description }})"
|
||||
|
||||
pages:
|
||||
400:
|
||||
message: Bad Request
|
||||
description: The server did not understand the request
|
||||
|
||||
401:
|
||||
message: Unauthorized
|
||||
description: The requested page needs a username and a password
|
||||
```
|
||||
|
||||
Template file `foo.html`:
|
||||
First, create your own template file, for example `my-super-theme.html`:
|
||||
|
||||
```html
|
||||
<html>
|
||||
<title>{{ code }}</title>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ code }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ message }}: {{ description }}</h1>
|
||||
<h1>YEAH! {{ message }}: {{ description }}</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
And run the generator:
|
||||
And simply start the server with the following command:
|
||||
|
||||
```bash
|
||||
$ docker run --rm \
|
||||
-v "$(pwd):/opt:rw" \
|
||||
-u "$(id -u):$(id -g)" \
|
||||
tarampampam/error-pages build --config-file ./error-pages.yml ./out
|
||||
-v "$(pwd)/my-super-theme.html:/opt/my-template.html:ro" \
|
||||
-p '8080:8080/tcp' ghcr.io/tarampampam/error-pages:3 serve \
|
||||
--add-template /opt/my-template.html \
|
||||
--template-name my-template
|
||||
# --- or ---
|
||||
$ ./error-pages serve \
|
||||
--add-template /opt/my-template.html \
|
||||
--template-name my-template
|
||||
```
|
||||
|
||||
$ tree
|
||||
.
|
||||
├── error-pages.yml
|
||||
├── foo.html
|
||||
└── out
|
||||
├── bar
|
||||
│ ├── 400.html
|
||||
│ └── 401.html
|
||||
└── foo
|
||||
├── 400.html
|
||||
└── 401.html
|
||||
And test it:
|
||||
|
||||
3 directories, 6 files
|
||||
```bash
|
||||
$ curl -H "Accept: text/html" http://127.0.0.1:8080/503
|
||||
|
||||
$ cat ./out/foo/400.html
|
||||
<html>
|
||||
<title>400</title>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>503</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bad Request: The server did not understand the request</h1>
|
||||
<h1>YEAH! Service Unavailable: The server is temporarily overloading or down</h1>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
$ cat ./out/bar/400.html
|
||||
Error 400: Bad Request (The server did not understand the request)
|
||||
```
|
||||
|
||||
To see the usage help run the following command:
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🚀 Generate a set of error pages using built-in or my own template</strong></summary>
|
||||
|
||||
Generating a set of error pages is straightforward. If you prefer to use your own template, start by crafting it.
|
||||
Create a file like this:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ code }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ message }}: {{ description }}</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Save it as `my-template.html` and use it as your custom template. Then, generate your error pages using the command:
|
||||
|
||||
```bash
|
||||
$ docker run --rm tarampampam/error-pages build --help
|
||||
$ mkdir -p /path/to/output
|
||||
$ ./error-pages build --add-template /path/to/your/my-template.html --target-dir /path/to/output
|
||||
```
|
||||
|
||||
### Static error pages
|
||||
|
||||
You may want to use the generated error pages somewhere else, and you can simply extract them from the docker image to your local directory for this purpose:
|
||||
This will create error pages based on your template in the specified output directory:
|
||||
|
||||
```bash
|
||||
$ docker create --name error-pages tarampampam/error-pages:2.0.0-rc2
|
||||
$ docker cp error-pages:/opt/html ./out
|
||||
$ docker rm -f error-pages
|
||||
$ ls ./out
|
||||
ghost hacker-terminal index.html l7-dark l7-light noise shuffle
|
||||
$ tree
|
||||
.
|
||||
└── out
|
||||
├── ghost
|
||||
│ ├── 400.html
|
||||
│ ├── ...
|
||||
│ └── 505.html
|
||||
├── hacker-terminal
|
||||
│ ├── 400.html
|
||||
│ ├── ...
|
||||
│ └── 505.html
|
||||
├── index.html
|
||||
├── l7-dark
|
||||
│ ├── 400.html
|
||||
│ ├── ...
|
||||
...
|
||||
$ cd /path/to/output && tree .
|
||||
├── my-template
|
||||
│ ├── 400.html
|
||||
│ ├── 401.html
|
||||
│ ├── 403.html
|
||||
│ ├── 404.html
|
||||
│ ├── 405.html
|
||||
│ ├── 407.html
|
||||
│ ├── 408.html
|
||||
│ ├── 409.html
|
||||
│ ├── 410.html
|
||||
│ ├── 411.html
|
||||
│ ├── 412.html
|
||||
│ ├── 413.html
|
||||
│ ├── 416.html
|
||||
│ ├── 418.html
|
||||
│ ├── 429.html
|
||||
│ ├── 500.html
|
||||
│ ├── 502.html
|
||||
│ ├── 503.html
|
||||
│ ├── 504.html
|
||||
│ └── 505.html
|
||||
…
|
||||
|
||||
$ cat my-template/403.html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>403</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Forbidden: Access is forbidden to the requested page</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Custom error pages for your image with [nginx][link_nginx]
|
||||
</details>
|
||||
|
||||
You can build your own docker image with `nginx` and our error pages:
|
||||
<details>
|
||||
<summary><strong>🚀 Customize error pages within your own Nginx Docker image</strong></summary>
|
||||
|
||||
To create this cocktail, we need two components:
|
||||
|
||||
- Nginx configuration file
|
||||
- A Dockerfile to build the image
|
||||
|
||||
Let's start with the Nginx configuration file:
|
||||
|
||||
```nginx
|
||||
# File `nginx.conf`
|
||||
# File: nginx.conf
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
@ -217,142 +452,300 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
And the Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
# File `Dockerfile`
|
||||
FROM docker.io/library/nginx:1.27-alpine
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
# override default Nginx configuration
|
||||
COPY --chown=nginx ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# copy statically built error pages from the error-pages image
|
||||
# (instead of `ghost` you may use any other template)
|
||||
COPY --chown=nginx \
|
||||
./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --chown=nginx \
|
||||
--from=tarampampam/error-pages:2.0.0 \
|
||||
--from=ghcr.io/tarampampam/error-pages:3 \
|
||||
/opt/html/ghost /usr/share/nginx/errorpages/_error-pages
|
||||
```
|
||||
|
||||
```shell
|
||||
Now, we can build the image:
|
||||
|
||||
```bash
|
||||
$ docker build --tag your-nginx:local -f ./Dockerfile .
|
||||
```
|
||||
|
||||
> More info about `error_page` directive can be [found here](http://nginx.org/en/docs/http/ngx_http_core_module.html#error_page).
|
||||
And voilà! Let's start the image and test if everything is working as expected:
|
||||
|
||||
## Templates
|
||||
```bash
|
||||
$ docker run --rm -p '8081:80/tcp' your-nginx:local
|
||||
|
||||
Name | Preview
|
||||
:---------------: | :-----:
|
||||
`ghost` | [](https://tarampampam.github.io/error-pages/ghost/404.html)
|
||||
`l7-light` | [](https://tarampampam.github.io/error-pages/l7-light/404.html)
|
||||
`l7-dark` | [](https://tarampampam.github.io/error-pages/l7-dark/404.html)
|
||||
`shuffle` | [](https://tarampampam.github.io/error-pages/shuffle/404.html)
|
||||
`noise` | [](https://tarampampam.github.io/error-pages/noise/404.html)
|
||||
`hacker-terminal` | [](https://tarampampam.github.io/error-pages/hacker-terminal/404.html)
|
||||
|
||||
> Note: `noise` template highly uses the CPU, be careful
|
||||
|
||||
## Custom error pages for [Traefik][link_traefik]
|
||||
|
||||
Simple traefik (tested on `v2.5.3`) service configuration for usage in [docker swarm][link_swarm] (**change with your needs**):
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
error-pages:
|
||||
image: tarampampam/error-pages:2.0.0
|
||||
environment:
|
||||
TEMPLATE_NAME: l7-dark
|
||||
networks:
|
||||
- traefik-public
|
||||
deploy:
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == worker
|
||||
labels:
|
||||
traefik.enable: 'true'
|
||||
traefik.docker.network: traefik-public
|
||||
# use as "fallback" for any non-registered services (with priority below normal)
|
||||
traefik.http.routers.error-pages-router.rule: HostRegexp(`{host:.+}`)
|
||||
traefik.http.routers.error-pages-router.priority: 10
|
||||
# should say that all of your services work on https
|
||||
traefik.http.routers.error-pages-router.tls: 'true'
|
||||
traefik.http.routers.error-pages-router.entrypoints: https
|
||||
traefik.http.routers.error-pages-router.middlewares: error-pages-middleware@docker
|
||||
traefik.http.services.error-pages-service.loadbalancer.server.port: 8080
|
||||
# "errors" middleware settings
|
||||
traefik.http.middlewares.error-pages-middleware.errors.status: 400-599
|
||||
traefik.http.middlewares.error-pages-middleware.errors.service: error-pages-service@docker
|
||||
traefik.http.middlewares.error-pages-middleware.errors.query: /{status}.html
|
||||
|
||||
any-another-http-service:
|
||||
image: nginx:alpine
|
||||
networks:
|
||||
- traefik-public
|
||||
deploy:
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == worker
|
||||
labels:
|
||||
traefik.enable: 'true'
|
||||
traefik.docker.network: traefik-public
|
||||
traefik.http.routers.another-service.rule: Host(`subdomain.example.com`)
|
||||
traefik.http.routers.another-service.tls: 'true'
|
||||
traefik.http.routers.another-service.entrypoints: https
|
||||
# next line is important
|
||||
traefik.http.routers.another-service.middlewares: error-pages-middleware@docker
|
||||
traefik.http.services.another-service.loadbalancer.server.port: 80
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
$ curl http://127.0.0.1:8081/foobar | head -n 15 # in another terminal
|
||||
```
|
||||
|
||||
## Changes log
|
||||
</details>
|
||||
|
||||
[![Release date][badge_release_date]][link_releases]
|
||||
[![Commits since latest release][badge_commits_since_release]][link_commits]
|
||||
<details>
|
||||
<summary><strong>🚀 Usage with Traefik and local Docker Compose</strong></summary>
|
||||
|
||||
Changes log can be [found here][link_changes_log].
|
||||
Instead of thousands of words, let's take a look at one compose file:
|
||||
|
||||
## Support
|
||||
```yaml
|
||||
# file: compose.yml (or docker-compose.yml)
|
||||
|
||||
[![Issues][badge_issues]][link_issues]
|
||||
[![Issues][badge_pulls]][link_pulls]
|
||||
services:
|
||||
traefik:
|
||||
image: docker.io/library/traefik:v3.1
|
||||
command:
|
||||
#- --log.level=DEBUG
|
||||
- --api.dashboard=true # activate dashboard
|
||||
- --api.insecure=true # enable the API in insecure mode
|
||||
- --providers.docker=true # enable Docker backend with default settings
|
||||
- --providers.docker.exposedbydefault=false # do not expose containers by default
|
||||
- --entrypoints.web.address=:80 # --entrypoints.<name>.address for ports, 80 (i.e., name = web)
|
||||
ports:
|
||||
- "80:80/tcp" # HTTP (web)
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# dashboard
|
||||
traefik.http.routers.traefik.rule: Host(`traefik.localtest.me`)
|
||||
traefik.http.routers.traefik.service: api@internal
|
||||
traefik.http.routers.traefik.entrypoints: web
|
||||
traefik.http.routers.traefik.middlewares: error-pages-middleware
|
||||
depends_on:
|
||||
error-pages: {condition: service_healthy}
|
||||
|
||||
If you will find any package errors, please, [make an issue][link_create_issue] in current repository.
|
||||
error-pages:
|
||||
image: ghcr.io/tarampampam/error-pages:3 # using the latest tag is highly discouraged
|
||||
environment:
|
||||
TEMPLATE_NAME: l7 # set the error pages template
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# use as "fallback" for any NON-registered services (with priority below normal)
|
||||
traefik.http.routers.error-pages-router.rule: HostRegexp(`.+`)
|
||||
traefik.http.routers.error-pages-router.priority: 10
|
||||
# should say that all of your services work on https
|
||||
traefik.http.routers.error-pages-router.entrypoints: web
|
||||
traefik.http.routers.error-pages-router.middlewares: error-pages-middleware
|
||||
# "errors" middleware settings
|
||||
traefik.http.middlewares.error-pages-middleware.errors.status: 400-599
|
||||
traefik.http.middlewares.error-pages-middleware.errors.service: error-pages-service
|
||||
traefik.http.middlewares.error-pages-middleware.errors.query: /{status}.html
|
||||
# define service properties
|
||||
traefik.http.services.error-pages-service.loadbalancer.server.port: 8080
|
||||
|
||||
## License
|
||||
nginx-or-any-another-service:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.routers.test-service.rule: Host(`test.localtest.me`)
|
||||
traefik.http.routers.test-service.entrypoints: web
|
||||
traefik.http.routers.test-service.middlewares: error-pages-middleware
|
||||
```
|
||||
|
||||
This is open-sourced software licensed under the [MIT License][link_license].
|
||||
After executing `docker compose up` in the same directory as the `compose.yml` file, you can:
|
||||
|
||||
[badge_build]:https://img.shields.io/github/workflow/status/tarampampam/error-pages/tests?maxAge=30&label=tests&logo=github
|
||||
[badge_release]:https://img.shields.io/github/workflow/status/tarampampam/error-pages/release?maxAge=30&label=release&logo=github
|
||||
[badge_coverage]:https://img.shields.io/codecov/c/github/tarampampam/error-pages/master.svg?maxAge=30
|
||||
[badge_release_version]:https://img.shields.io/github/release/tarampampam/error-pages.svg?maxAge=30
|
||||
[badge_size_latest]:https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30
|
||||
[badge_language]:https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true
|
||||
[badge_license]:https://img.shields.io/github/license/tarampampam/error-pages.svg?longCache=true
|
||||
[badge_release_date]:https://img.shields.io/github/release-date/tarampampam/error-pages.svg?maxAge=180
|
||||
[badge_commits_since_release]:https://img.shields.io/github/commits-since/tarampampam/error-pages/latest.svg?maxAge=45
|
||||
[badge_issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
|
||||
[badge_pulls]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
|
||||
- Open the Traefik dashboard [at `traefik.localtest.me`](http://traefik.localtest.me/dashboard/#/)
|
||||
- [View customized error pages on the Traefik dashboard](http://traefik.localtest.me/foobar404)
|
||||
- Open the nginx index page [at `test.localtest.me`](http://test.localtest.me/)
|
||||
- View customized error pages for non-existent [pages](http://test.localtest.me/404) and [domains](http://404.localtest.me/)
|
||||
|
||||
[link_coverage]:https://codecov.io/gh/tarampampam/error-pages
|
||||
[link_build]:https://github.com/tarampampam/error-pages/actions
|
||||
[link_docker_hub]:https://hub.docker.com/r/tarampampam/error-pages
|
||||
[link_docker_tags]:https://hub.docker.com/r/tarampampam/error-pages/tags
|
||||
[link_license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
|
||||
[link_releases]:https://github.com/tarampampam/error-pages/releases
|
||||
[link_commits]:https://github.com/tarampampam/error-pages/commits
|
||||
[link_changes_log]:https://github.com/tarampampam/error-pages/blob/master/CHANGELOG.md
|
||||
[link_issues]:https://github.com/tarampampam/error-pages/issues
|
||||
[link_create_issue]:https://github.com/tarampampam/error-pages/issues/new/choose
|
||||
[link_pulls]:https://github.com/tarampampam/error-pages/pulls
|
||||
[link_ghcr]:https://github.com/users/tarampampam/packages/container/package/error-pages
|
||||
Isn't this kind of magic? 😀
|
||||
|
||||
[fasthttp]:https://github.com/valyala/fasthttp
|
||||
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
|
||||
[preview-demo]:https://tarampampam.github.io/error-pages/
|
||||
</details>
|
||||
|
||||
[link_nginx]:http://nginx.org/
|
||||
[link_traefik]:https://docs.traefik.io/
|
||||
[link_swarm]:https://docs.docker.com/engine/swarm/
|
||||
[link_gh_pages]:https://tarampampam.github.io/error-pages/
|
||||
<details>
|
||||
<summary><strong>🚀 Kubernetes (K8s) & Ingress Nginx</strong></summary>
|
||||
|
||||
Error-pages can be configured to work with the [ingress-nginx][ingress-nginx] helm chart in Kubernetes.
|
||||
|
||||
- Set the `custom-http-errors` config value
|
||||
- Enable default backend
|
||||
- Set the default backend image
|
||||
|
||||
```yaml
|
||||
controller:
|
||||
config:
|
||||
custom-http-errors: >-
|
||||
401,403,404,500,501,502,503
|
||||
|
||||
defaultBackend:
|
||||
enabled: true
|
||||
image:
|
||||
repository: ghcr.io/tarampampam/error-pages
|
||||
tag: '3' # using the latest tag is highly discouraged
|
||||
extraEnvs:
|
||||
- name: TEMPLATE_NAME # Optional: change the default theme
|
||||
value: l7
|
||||
- name: SHOW_DETAILS # Optional: enables the output of additional information on error pages
|
||||
value: 'true'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🦾 Performance
|
||||
|
||||
Hardware used:
|
||||
|
||||
- 12th Gen Intel® Core™ i7-1260P (16 cores)
|
||||
- 32 GiB RAM
|
||||
|
||||
RPS: **~180k** 🔥 requests served without any errors, with peak memory usage ~60 MiB under the default configuration
|
||||
|
||||
<details>
|
||||
<summary>Performance test details (click to expand)</summary>
|
||||
|
||||
```shell
|
||||
$ ulimit -aH | grep file
|
||||
core file size (blocks, -c) unlimited
|
||||
file size (blocks, -f) unlimited
|
||||
open files (-n) 1048576
|
||||
file locks (-x) unlimited
|
||||
|
||||
$ go build ./cmd/error-pages/ && ./error-pages --log-level warn serve
|
||||
|
||||
$ ./error-pages perftest # in separate terminal
|
||||
Starting the test to bomb ONE PAGE (code). Please, be patient...
|
||||
Test completed successfully. Here is the output:
|
||||
|
||||
Running 15s test @ http://127.0.0.1:8080/
|
||||
12 threads and 400 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 4.52ms 6.43ms 94.34ms 85.44%
|
||||
Req/Sec 15.76k 2.83k 29.64k 69.20%
|
||||
2839632 requests in 15.09s, 32.90GB read
|
||||
Requests/sec: 188185.61
|
||||
Transfer/sec: 2.18GB
|
||||
|
||||
Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...
|
||||
Test completed successfully. Here is the output:
|
||||
|
||||
Running 15s test @ http://127.0.0.1:8080/
|
||||
12 threads and 400 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 6.75ms 13.71ms 252.66ms 91.94%
|
||||
Req/Sec 14.06k 3.25k 26.39k 71.98%
|
||||
2534473 requests in 15.10s, 29.22GB read
|
||||
Requests/sec: 167899.78
|
||||
Transfer/sec: 1.94GB
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<!--GENERATED:CLI_DOCS-->
|
||||
<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->
|
||||
## CLI interface
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
$ error-pages [GLOBAL FLAGS] [COMMAND] [COMMAND FLAGS] [ARGUMENTS...]
|
||||
```
|
||||
|
||||
Global flags:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|--------------------|---------------------------------------|:-------------:|:---------------------:|
|
||||
| `--log-level="…"` | Logging level (debug/info/warn/error) | `info` | `LOG_LEVEL` |
|
||||
| `--log-format="…"` | Logging format (console/json) | `console` | `LOG_FORMAT` |
|
||||
|
||||
### `serve` command (aliases: `s`, `server`, `http`)
|
||||
|
||||
Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
$ error-pages [GLOBAL FLAGS] serve [COMMAND FLAGS] [ARGUMENTS...]
|
||||
```
|
||||
|
||||
The following flags are supported:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:---------------------------:|
|
||||
| `--listen="…"` (`-l`) | The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, 0.0.0.0 to listen on all interfaces, or specify a custom IP) | `0.0.0.0` | `LISTEN_ADDR` |
|
||||
| `--port="…"` (`-p`) | The TCP port number for the HTTP server to listen on (0-65535) | `8080` | `LISTEN_PORT` |
|
||||
| `--add-template="…"` | To add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | `ADD_TEMPLATE` |
|
||||
| `--disable-template="…"` | Disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
|
||||
| `--add-code="…"` | To add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
|
||||
| `--json-format="…"` | Override the default error page response in JSON format (Go templates are supported; the error page will use this template if the client requests JSON content type) | | `RESPONSE_JSON_FORMAT` |
|
||||
| `--xml-format="…"` | Override the default error page response in XML format (Go templates are supported; the error page will use this template if the client requests XML content type) | | `RESPONSE_XML_FORMAT` |
|
||||
| `--plaintext-format="…"` | Override the default error page response in plain text format (Go templates are supported; the error page will use this template if the client requests plain text content type or does not specify any) | | `RESPONSE_PLAINTEXT_FORMAT` |
|
||||
| `--template-name="…"` (`-t`) | Name of the template to use for rendering error pages (built-in templates: app-down, cats, connection, ghost, hacker-terminal, l7, lost-in-space, noise, orient, shuffle) | `app-down` | `TEMPLATE_NAME` |
|
||||
| `--disable-l10n` | Disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
|
||||
| `--default-error-page="…"` | The code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` |
|
||||
| `--send-same-http-code` | The HTTP response should have the same status code as the requested error page (by default, every response with an error page will have a status code of 200) | `false` | `SEND_SAME_HTTP_CODE` |
|
||||
| `--show-details` | Show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` |
|
||||
| `--proxy-headers="…"` | HTTP headers listed here will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` |
|
||||
| `--rotation-mode="…"` | Templates automatic rotation mode (disabled/random-on-startup/random-on-each-request/random-hourly/random-daily) | `disabled` | `TEMPLATES_ROTATION_MODE` |
|
||||
| `--read-buffer-size="…"` | Per-connection buffer size in bytes for reading requests, this also limits the maximum header size (increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., large cookies), note that increasing this value will increase memory consumption) | `5120` | `READ_BUFFER_SIZE` |
|
||||
| `--disable-minification` | Disable the minification of HTML pages, including CSS, SVG, and JS (may be useful for debugging) | `false` | `DISABLE_MINIFICATION` |
|
||||
|
||||
### `build` command (aliases: `b`)
|
||||
|
||||
Build the static error pages and put them into a specified directory.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
$ error-pages [GLOBAL FLAGS] build [COMMAND FLAGS] [ARGUMENTS...]
|
||||
```
|
||||
|
||||
The following flags are supported:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------:|:----------------------:|
|
||||
| `--add-template="…"` | To add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | `ADD_TEMPLATE` |
|
||||
| `--disable-template="…"` | Disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
|
||||
| `--add-code="…"` | To add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
|
||||
| `--disable-l10n` | Disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
|
||||
| `--index` (`-i`) | Generate index.html file with links to all error pages | `false` | *none* |
|
||||
| `--target-dir="…"` (`--out`, `--dir`, `-o`) | Directory to put the built error pages into | `.` | *none* |
|
||||
| `--disable-minification` | Disable the minification of HTML pages, including CSS, SVG, and JS (may be useful for debugging) | `false` | `DISABLE_MINIFICATION` |
|
||||
|
||||
### `healthcheck` command (aliases: `chk`, `health`, `check`)
|
||||
|
||||
Health checker for the HTTP server. The use case - docker health check.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
$ error-pages [GLOBAL FLAGS] healthcheck [COMMAND FLAGS] [ARGUMENTS...]
|
||||
```
|
||||
|
||||
The following flags are supported:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|---------------------|-----------------------------------------------|:-------------:|:---------------------:|
|
||||
| `--port="…"` (`-p`) | TCP port number with the HTTP server to check | `8080` | `LISTEN_PORT` |
|
||||
|
||||
<!--/GENERATED:CLI_DOCS-->
|
||||
|
||||
## 🦾 Contributors
|
||||
|
||||
I want to say a big thank you to everyone who contributed to this project:
|
||||
|
||||
[][contributors]
|
||||
|
||||
[contributors]:https://github.com/tarampampam/error-pages/graphs/contributors
|
||||
|
||||
## 👾 Support
|
||||
|
||||
[![Issues][badge-issues]][issues]
|
||||
[![Issues][badge-prs]][prs]
|
||||
|
||||
If you encounter any bugs in the project, please [create an issue][new-issue] in this repository.
|
||||
|
||||
[badge-issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
|
||||
[badge-prs]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
|
||||
[issues]:https://github.com/tarampampam/error-pages/issues
|
||||
[prs]:https://github.com/tarampampam/error-pages/pulls
|
||||
[new-issue]:https://github.com/tarampampam/error-pages/issues/new/choose
|
||||
|
||||
## 📖 License
|
||||
|
||||
This is open-sourced software licensed under the [MIT License][license].
|
||||
|
||||
[license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
|
||||
|
||||
[ingress-nginx]:https://github.com/kubernetes/ingress-nginx/tree/main/charts/ingress-nginx
|
||||
|
@ -1,29 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/tarampampam/error-pages/internal/cli"
|
||||
"go.uber.org/automaxprocs/maxprocs"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli"
|
||||
)
|
||||
|
||||
// exitFn is a function for application exiting.
|
||||
var exitFn = os.Exit //nolint:gochecknoglobals
|
||||
|
||||
// main CLI application entrypoint.
|
||||
func main() { exitFn(run()) }
|
||||
func main() {
|
||||
// automatically set GOMAXPROCS to match Linux container CPU quota
|
||||
_, _ = maxprocs.Set(maxprocs.Min(1), maxprocs.Logger(func(_ string, _ ...any) {}))
|
||||
|
||||
if err := run(); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err.Error())
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run this CLI application.
|
||||
// Exit codes documentation: <https://tldp.org/LDP/abs/html/exitcodes.html>
|
||||
func run() int {
|
||||
cmd := cli.NewCommand(filepath.Base(os.Args[0]))
|
||||
func run() error {
|
||||
// create a context that is canceled when the user interrupts the program
|
||||
var ctx, cancel = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
_, _ = color.New(color.FgHiRed, color.Bold).Fprintln(os.Stderr, err.Error())
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
return (cli.NewApp(filepath.Base(os.Args[0]))).Run(ctx, os.Args)
|
||||
}
|
||||
|
19
compose.yml
Normal file
19
compose.yml
Normal file
@ -0,0 +1,19 @@
|
||||
# yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/compose-spec/compose-spec@master/schema/compose-spec.json
|
||||
|
||||
services:
|
||||
develop:
|
||||
build: {target: develop}
|
||||
environment: {HOME: /tmp}
|
||||
volumes: [.:/src:rw, tmp-data:/tmp:rw]
|
||||
security_opt: [no-new-privileges:true]
|
||||
|
||||
web:
|
||||
build: {target: runtime}
|
||||
ports: ['8080:8080/tcp'] # open http://127.0.0.1:8080
|
||||
command: --log-level debug serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah
|
||||
develop: # available since docker compose v2.22, https://docs.docker.com/compose/file-watch/
|
||||
watch: [{action: rebuild, path: .}]
|
||||
security_opt: [no-new-privileges:true]
|
||||
|
||||
volumes:
|
||||
tmp-data: {}
|
@ -1,46 +0,0 @@
|
||||
# Docker-compose file is used only for local development. This is not production-ready example.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
volumes:
|
||||
tmp-data: {}
|
||||
golint-cache: {}
|
||||
|
||||
services:
|
||||
app: &app-service
|
||||
image: golang:1.17.1-buster # Image page: <https://hub.docker.com/_/golang>
|
||||
working_dir: /src
|
||||
environment:
|
||||
HOME: /tmp
|
||||
GOPATH: /tmp
|
||||
volumes:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
- /etc/group:/etc/group:ro
|
||||
- .:/src:rw
|
||||
- tmp-data:/tmp:rw
|
||||
|
||||
web:
|
||||
<<: *app-service
|
||||
ports:
|
||||
- "8080:8080/tcp" # Open <http://127.0.0.1:8080>
|
||||
command:
|
||||
- go
|
||||
- run
|
||||
- ./cmd/error-pages
|
||||
- serve
|
||||
- --verbose
|
||||
- --port=8080
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--spider', '-q', 'http://127.0.0.1:8080/health/live']
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
|
||||
golint:
|
||||
image: golangci/golangci-lint:v1.42-alpine # Image page: <https://hub.docker.com/r/golangci/golangci-lint>
|
||||
environment:
|
||||
GOLANGCI_LINT_CACHE: /tmp/golint # <https://github.com/golangci/golangci-lint/blob/v1.42.0/internal/cache/default.go#L68>
|
||||
volumes:
|
||||
- .:/src:ro
|
||||
- golint-cache:/tmp/golint:rw
|
||||
working_dir: /src
|
||||
command: /bin/true
|
@ -1,93 +0,0 @@
|
||||
templates:
|
||||
# - name: {string} Template name (optional, if path is defined)
|
||||
# path: {string} Path to the template file
|
||||
# content: {string} Template content, if path is not defined
|
||||
- path: ./templates/ghost.html
|
||||
name: ghost # name is optional, if path is defined
|
||||
content: ${GHOST_TEMPLATE_CONTENT}
|
||||
- path: ./templates/l7-light.html
|
||||
- path: ./templates/l7-dark.html
|
||||
- path: ./templates/shuffle.html
|
||||
- path: ./templates/noise.html
|
||||
- path: ./templates/hacker-terminal.html
|
||||
|
||||
pages:
|
||||
400:
|
||||
message: Bad Request
|
||||
description: The server did not understand the request
|
||||
|
||||
401:
|
||||
message: Unauthorized
|
||||
description: The requested page needs a username and a password
|
||||
|
||||
403:
|
||||
message: Forbidden
|
||||
description: Access is forbidden to the requested page
|
||||
|
||||
404:
|
||||
message: Not Found
|
||||
description: The server can not find the requested page
|
||||
|
||||
405:
|
||||
message: Method Not Allowed
|
||||
description: The method specified in the request is not allowed
|
||||
|
||||
407:
|
||||
message: Proxy Authentication Required
|
||||
description: You must authenticate with a proxy server before this request can be served
|
||||
|
||||
408:
|
||||
message: Request Timeout
|
||||
description: The request took longer than the server was prepared to wait
|
||||
|
||||
409:
|
||||
message: Conflict
|
||||
description: The request could not be completed because of a conflict
|
||||
|
||||
410:
|
||||
message: Gone
|
||||
description: The requested page is no longer available
|
||||
|
||||
411:
|
||||
message: Length Required
|
||||
description: The "Content-Length" is not defined. The server will not accept the request without it
|
||||
|
||||
412:
|
||||
message: Precondition Failed
|
||||
description: The pre condition given in the request evaluated to false by the server
|
||||
|
||||
413:
|
||||
message: Payload Too Large
|
||||
description: The server will not accept the request, because the request entity is too large
|
||||
|
||||
416:
|
||||
message: Requested Range Not Satisfiable
|
||||
description: The requested byte range is not available and is out of bounds
|
||||
|
||||
418:
|
||||
message: I'm a teapot
|
||||
description: Attempt to brew coffee with a teapot is not supported
|
||||
|
||||
429:
|
||||
message: Too Many Requests
|
||||
description: Too many requests in a given amount of time
|
||||
|
||||
500:
|
||||
message: Internal Server Error
|
||||
description: The server met an unexpected condition
|
||||
|
||||
502:
|
||||
message: Bad Gateway
|
||||
description: The server received an invalid response from the upstream server
|
||||
|
||||
503:
|
||||
message: Service Unavailable
|
||||
description: The server is temporarily overloading or down
|
||||
|
||||
504:
|
||||
message: Gateway Timeout
|
||||
description: The gateway has timed out
|
||||
|
||||
505:
|
||||
message: HTTP Version Not Supported
|
||||
description: The server does not support the "http protocol" version
|
40
go.mod
40
go.mod
@ -1,32 +1,28 @@
|
||||
module github.com/tarampampam/error-pages
|
||||
module gh.tarampamp.am/error-pages
|
||||
|
||||
go 1.17
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/a8m/envsubst v1.2.0
|
||||
github.com/fasthttp/router v1.4.3
|
||||
github.com/fatih/color v1.7.0
|
||||
github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d
|
||||
github.com/pkg/errors v0.8.1
|
||||
github.com/spf13/cobra v1.2.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/valyala/fasthttp v1.30.0
|
||||
go.uber.org/zap v1.19.1
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/tdewolff/minify/v2 v2.20.35
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha5
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha9
|
||||
github.com/valyala/fasthttp v1.55.0
|
||||
go.uber.org/automaxprocs v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.3 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.13.6 // indirect
|
||||
github.com/mattn/go-colorable v0.0.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.3 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
649
go.sum
649
go.sum
@ -1,615 +1,52 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/a8m/envsubst v1.2.0 h1:yvzAhJD2QKdo35Ut03wIfXQmg+ta3wC/1bskfZynz+Q=
|
||||
github.com/a8m/envsubst v1.2.0/go.mod h1:PpvLvNWa+Rvu/10qXmFbFiGICIU5hZvFJNPCCkUaObg=
|
||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
|
||||
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fasthttp/router v1.4.3 h1:spS+LUnRryQ/+hbmYzs/xWGJlQCkeQI3hxGZdlVYhLU=
|
||||
github.com/fasthttp/router v1.4.3/go.mod h1:9ytWCfZ5LcCcbD3S7pEXyBX9vZnOZmN918WiiaYUzr8=
|
||||
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d h1:cVtBfNW5XTHiKQe7jDaDBSh/EVM4XLPutLAGboIXuM0=
|
||||
github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d/go.mod h1:P2viExyCEfeWGU259JnaQ34Inuec4R38JCyBx2edgD0=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/savsgio/gotils v0.0.0-20210907153846-c06938798b52/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
|
||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 h1:ocK/D6lCgLji37Z2so4xhMl46se1ntReQQCUIU4BWI8=
|
||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
|
||||
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.20.35 h1:/Vq/oivpkFyi2PViD25XHZZbJz+eO4OmPSgePex1kBU=
|
||||
github.com/tdewolff/minify/v2 v2.20.35/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
|
||||
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
|
||||
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha5 h1:H1oWnR2/GN0dNm2PVylws+GxSOD6YOwW/jI5l78YfPk=
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha5/go.mod h1:AIqom6Q60U4tiqHp41i7+/AB2XHgi1WvQ7jOFlccmZ4=
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo=
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.30.0 h1:nBNzWrgZUUHohyLPU/jTvXdhrcaf2m5k3bWk+3Q049g=
|
||||
github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
|
||||
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
2
internal/appmeta/doc.go
Normal file
2
internal/appmeta/doc.go
Normal file
@ -0,0 +1,2 @@
|
||||
// Package appmeta provides the application metadata, such as version.
|
||||
package appmeta
|
@ -1,5 +1,4 @@
|
||||
// Package version is used as a place, where application version defined.
|
||||
package version
|
||||
package appmeta
|
||||
|
||||
import "strings"
|
||||
|
||||
@ -8,7 +7,7 @@ var version = "v0.0.0@undefined"
|
||||
|
||||
// Version returns version value (without `v` prefix).
|
||||
func Version() string {
|
||||
v := strings.TrimSpace(version)
|
||||
var v = strings.TrimSpace(version)
|
||||
|
||||
if len(v) > 1 && ((v[0] == 'v' || v[0] == 'V') && (v[1] >= '0' && v[1] <= '9')) {
|
||||
return v[1:]
|
@ -1,10 +1,10 @@
|
||||
package version
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for give, want := range map[string]string{
|
||||
// without changes
|
||||
"vvv": "vvv",
|
@ -1,54 +0,0 @@
|
||||
// Package breaker provides OSSignals struct for OS signals handling (with context).
|
||||
package breaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// OSSignals allows to subscribe for system signals.
|
||||
type OSSignals struct {
|
||||
ctx context.Context
|
||||
ch chan os.Signal
|
||||
}
|
||||
|
||||
// NewOSSignals creates new subscriber for system signals.
|
||||
func NewOSSignals(ctx context.Context) OSSignals {
|
||||
return OSSignals{
|
||||
ctx: ctx,
|
||||
ch: make(chan os.Signal, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe for some of system signals (call Stop for stopping).
|
||||
func (oss *OSSignals) Subscribe(onSignal func(os.Signal), signals ...os.Signal) {
|
||||
if len(signals) == 0 {
|
||||
signals = []os.Signal{os.Interrupt, syscall.SIGINT, syscall.SIGTERM} // default signals
|
||||
}
|
||||
|
||||
signal.Notify(oss.ch, signals...)
|
||||
|
||||
go func(ch <-chan os.Signal) {
|
||||
select {
|
||||
case <-oss.ctx.Done():
|
||||
break
|
||||
|
||||
case sig, opened := <-ch:
|
||||
if oss.ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if opened && sig != nil {
|
||||
onSignal(sig)
|
||||
}
|
||||
}
|
||||
}(oss.ch)
|
||||
}
|
||||
|
||||
// Stop system signals listening.
|
||||
func (oss *OSSignals) Stop() {
|
||||
signal.Stop(oss.ch)
|
||||
close(oss.ch)
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
package breaker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/breaker"
|
||||
)
|
||||
|
||||
func TestNewOSSignals(t *testing.T) {
|
||||
oss := breaker.NewOSSignals(context.Background())
|
||||
|
||||
gotSignal := make(chan os.Signal, 1)
|
||||
|
||||
oss.Subscribe(func(signal os.Signal) {
|
||||
gotSignal <- signal
|
||||
}, syscall.SIGUSR2)
|
||||
|
||||
defer oss.Stop()
|
||||
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, proc.Signal(syscall.SIGUSR2)) // send the signal
|
||||
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
assert.Equal(t, syscall.SIGUSR2, <-gotSignal)
|
||||
}
|
||||
|
||||
func TestNewOSSignalCtxCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
oss := breaker.NewOSSignals(ctx)
|
||||
|
||||
gotSignal := make(chan os.Signal, 1)
|
||||
|
||||
oss.Subscribe(func(signal os.Signal) {
|
||||
gotSignal <- signal
|
||||
}, syscall.SIGUSR2)
|
||||
|
||||
defer oss.Stop()
|
||||
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
assert.NoError(t, err)
|
||||
|
||||
cancel()
|
||||
|
||||
assert.NoError(t, proc.Signal(syscall.SIGUSR2)) // send the signal
|
||||
|
||||
assert.Empty(t, gotSignal)
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// HealthChecker is a heals checker.
|
||||
type HealthChecker struct {
|
||||
ctx context.Context
|
||||
httpClient httpClient
|
||||
}
|
||||
|
||||
const defaultHTTPClientTimeout = time.Second * 3
|
||||
|
||||
// NewHealthChecker creates heals checker.
|
||||
func NewHealthChecker(ctx context.Context, client ...httpClient) *HealthChecker {
|
||||
var c httpClient
|
||||
|
||||
if len(client) == 1 {
|
||||
c = client[0]
|
||||
} else {
|
||||
c = &http.Client{Timeout: defaultHTTPClientTimeout} // default
|
||||
}
|
||||
|
||||
return &HealthChecker{ctx: ctx, httpClient: c}
|
||||
}
|
||||
|
||||
// Check application using liveness probe.
|
||||
func (c *HealthChecker) Check(port uint16) error {
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health/live", port), nil) //nolint:lll
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "HealthChecker/internal")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if code := resp.StatusCode; code != http.StatusOK {
|
||||
return fmt.Errorf("wrong status code [%d] from live endpoint", code)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
package checkers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/checkers"
|
||||
)
|
||||
|
||||
type httpClientFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestHealthChecker_CheckSuccess(t *testing.T) {
|
||||
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, http.MethodGet, req.Method)
|
||||
assert.Equal(t, "http://127.0.0.1:123/health/live", req.URL.String())
|
||||
assert.Equal(t, "HealthChecker/internal", req.Header.Get("User-Agent"))
|
||||
|
||||
return &http.Response{
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte{})),
|
||||
StatusCode: http.StatusOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
checker := checkers.NewHealthChecker(context.Background(), httpMock)
|
||||
|
||||
assert.NoError(t, checker.Check(123))
|
||||
}
|
||||
|
||||
func TestHealthChecker_CheckFail(t *testing.T) {
|
||||
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte{})),
|
||||
StatusCode: http.StatusBadGateway,
|
||||
}, nil
|
||||
}
|
||||
|
||||
checker := checkers.NewHealthChecker(context.Background(), httpMock)
|
||||
|
||||
err := checker.Check(123)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "wrong status code")
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
package checkers
|
||||
|
||||
// LiveChecker is a liveness checker.
|
||||
type LiveChecker struct{}
|
||||
|
||||
// NewLiveChecker creates liveness checker.
|
||||
func NewLiveChecker() *LiveChecker { return &LiveChecker{} }
|
||||
|
||||
// Check application is alive?
|
||||
func (*LiveChecker) Check() error { return nil }
|
@ -1,12 +0,0 @@
|
||||
package checkers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/checkers"
|
||||
)
|
||||
|
||||
func TestLiveChecker_Check(t *testing.T) {
|
||||
assert.NoError(t, checkers.NewLiveChecker().Check())
|
||||
}
|
90
internal/cli/app.go
Normal file
90
internal/cli/app.go
Normal file
@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/build"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/perftest"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/serve"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
//go:generate go run app_generate.go
|
||||
|
||||
// NewApp creates a new console application.
|
||||
func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
var (
|
||||
logLevelFlag = cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Value: logger.InfoLevel.String(),
|
||||
Usage: "Logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
|
||||
Sources: cli.EnvVars("LOG_LEVEL"),
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(s string) error {
|
||||
if _, err := logger.ParseLevel(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
logFormatFlag = cli.StringFlag{
|
||||
Name: "log-format",
|
||||
Value: logger.ConsoleFormat.String(),
|
||||
Usage: "Logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
|
||||
Sources: cli.EnvVars("LOG_FORMAT"),
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(s string) error {
|
||||
if _, err := logger.ParseFormat(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// create a "default" logger (will be swapped later with customized)
|
||||
var log, _ = logger.New(logger.InfoLevel, logger.ConsoleFormat) // error will never occur
|
||||
|
||||
return &cli.Command{
|
||||
Usage: appName,
|
||||
Suggest: true,
|
||||
Before: func(ctx context.Context, c *cli.Command) error {
|
||||
var (
|
||||
logLevel, _ = logger.ParseLevel(c.String(logLevelFlag.Name)) // error ignored because the flag validates itself
|
||||
logFormat, _ = logger.ParseFormat(c.String(logFormatFlag.Name)) // --//--
|
||||
)
|
||||
|
||||
configured, err := logger.New(logLevel, logFormat) // create a new logger instance
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*log = *configured // swap the "default" logger with customized
|
||||
|
||||
return nil
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
serve.NewCommand(log),
|
||||
build.NewCommand(log),
|
||||
healthcheck.NewCommand(log, healthcheck.NewHTTPHealthChecker()),
|
||||
perftest.NewCommand(),
|
||||
},
|
||||
Version: fmt.Sprintf("%s (%s)", appmeta.Version(), runtime.Version()),
|
||||
Flags: []cli.Flag{ // global flags
|
||||
&logLevelFlag,
|
||||
&logFormatFlag,
|
||||
},
|
||||
}
|
||||
}
|
25
internal/cli/app_generate.go
Normal file
25
internal/cli/app_generate.go
Normal file
@ -0,0 +1,25 @@
|
||||
//go:build generate
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
cliDocs "github.com/urfave/cli-docs/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const readmePath = "../../README.md"
|
||||
|
||||
if stat, err := os.Stat(readmePath); err == nil && stat.Mode().IsRegular() {
|
||||
if err = cliDocs.ToTabularToFileBetweenTags(cli.NewApp(""), "error-pages", readmePath); err != nil {
|
||||
panic(err)
|
||||
} else {
|
||||
println("✔ cli docs updated successfully")
|
||||
}
|
||||
} else if err != nil {
|
||||
println("⚠ readme file not found, cli docs not updated:", err.Error())
|
||||
}
|
||||
}
|
18
internal/cli/app_test.go
Normal file
18
internal/cli/app_test.go
Normal file
@ -0,0 +1,18 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli"
|
||||
)
|
||||
|
||||
func TestNewApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := cli.NewApp("appName")
|
||||
|
||||
assert.NoError(t, app.Run(context.Background(), []string{""}))
|
||||
}
|
@ -1,141 +1,245 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path"
|
||||
"text/template"
|
||||
"time"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tarampampam/error-pages/internal/config"
|
||||
"github.com/tarampampam/error-pages/internal/tpl"
|
||||
"go.uber.org/zap"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
appTemplate "gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
type historyItem struct {
|
||||
Code, Message, Path string
|
||||
//go:embed index.html
|
||||
var indexHtml string
|
||||
|
||||
type command struct {
|
||||
c *cli.Command
|
||||
|
||||
opt struct {
|
||||
createIndex bool
|
||||
targetDirAbsPath string
|
||||
}
|
||||
}
|
||||
|
||||
// NewCommand creates `build` command.
|
||||
func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:funlen,gocognit
|
||||
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
|
||||
var (
|
||||
generateIndex bool
|
||||
cfg *config.Config
|
||||
cmd command
|
||||
cfg = config.New()
|
||||
|
||||
addTplFlag = shared.AddTemplatesFlag
|
||||
disableTplFlag = shared.DisableTemplateNamesFlag
|
||||
addCodeFlag = shared.AddHTTPCodesFlag
|
||||
disableL10nFlag = shared.DisableL10nFlag
|
||||
disableMinificationFlag = shared.DisableMinificationFlag
|
||||
createIndexFlag = cli.BoolFlag{
|
||||
Name: "index",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Generate index.html file with links to all error pages",
|
||||
Category: shared.CategoryBuild,
|
||||
}
|
||||
targetDirFlag = cli.StringFlag{
|
||||
Name: "target-dir",
|
||||
Aliases: []string{"out", "dir", "o"},
|
||||
Usage: "Directory to put the built error pages into",
|
||||
Value: ".", // current directory by default
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Category: shared.CategoryBuild,
|
||||
OnlyOnce: true,
|
||||
Validator: func(dir string) error {
|
||||
if dir == "" {
|
||||
return errors.New("missing target directory")
|
||||
}
|
||||
|
||||
if stat, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("cannot access the target directory '%s': %w", dir, err)
|
||||
} else if !stat.IsDir() {
|
||||
return fmt.Errorf("'%s' is not a directory", dir)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "build <output-directory>",
|
||||
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
|
||||
|
||||
cmd.c = &cli.Command{
|
||||
Name: "build",
|
||||
Aliases: []string{"b"},
|
||||
Short: "Build the error pages",
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRunE: func(*cobra.Command, []string) error {
|
||||
if configFile == nil {
|
||||
return errors.New("path to the config file is required for this command")
|
||||
}
|
||||
Usage: "Build the static error pages and put them into a specified directory",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
|
||||
cfg.DisableMinification = c.Bool(disableMinificationFlag.Name)
|
||||
cmd.opt.createIndex = c.Bool(createIndexFlag.Name)
|
||||
cmd.opt.targetDirAbsPath, _ = filepath.Abs(c.String(targetDirFlag.Name)) // an error checked by [os.Stat] validator
|
||||
|
||||
if c, err := config.FromYamlFile(*configFile); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err = c.Validate(); err != nil {
|
||||
return err
|
||||
// add templates from files to the configuration
|
||||
if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
|
||||
for _, templatePath := range add {
|
||||
if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
|
||||
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
|
||||
} else {
|
||||
log.Info("Template added",
|
||||
logger.String("name", addedName),
|
||||
logger.String("path", templatePath),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cfg = c
|
||||
}
|
||||
|
||||
return nil
|
||||
// disable templates specified by the user
|
||||
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
|
||||
for _, templateName := range disable {
|
||||
if ok := cfg.Templates.Remove(templateName); ok {
|
||||
log.Info("Template disabled", logger.String("name", templateName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add custom HTTP codes to the configuration
|
||||
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
|
||||
for code, desc := range shared.ParseHTTPCodes(add) {
|
||||
cfg.Codes[code] = desc
|
||||
|
||||
log.Info("HTTP code added",
|
||||
logger.String("code", code),
|
||||
logger.String("message", desc.Message),
|
||||
logger.String("description", desc.Description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Templates) == 0 {
|
||||
return errors.New("no templates specified")
|
||||
}
|
||||
|
||||
log.Info("Building error pages",
|
||||
logger.String("targetDir", cmd.opt.targetDirAbsPath),
|
||||
logger.Strings("templates", cfg.Templates.Names()...),
|
||||
logger.Bool("index", cmd.opt.createIndex),
|
||||
logger.Bool("l10n", !cfg.L10n.Disable),
|
||||
)
|
||||
|
||||
return cmd.Run(ctx, log, &cfg)
|
||||
},
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("wrong arguments count")
|
||||
}
|
||||
|
||||
log.Info("loading templates")
|
||||
|
||||
templates, err := cfg.LoadTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(templates) == 0 {
|
||||
return errors.New("no loaded templates")
|
||||
}
|
||||
|
||||
log.Debug("the output directory preparing", zap.String("Path", args[0]))
|
||||
|
||||
if err = createDirectory(args[0]); err != nil {
|
||||
return errors.Wrap(err, "cannot prepare output directory")
|
||||
}
|
||||
|
||||
codes := make(map[string]tpl.Annotator)
|
||||
|
||||
for code, desc := range cfg.Pages {
|
||||
codes[code] = tpl.Annotator{Message: desc.Message, Description: desc.Description}
|
||||
}
|
||||
|
||||
history := make(map[string][]historyItem, len(templates))
|
||||
|
||||
log.Info("saving the error pages")
|
||||
startedAt := time.Now()
|
||||
|
||||
if err = tpl.NewErrors(templates, codes).VisitAll(func(template, code string, content []byte) error {
|
||||
if e := createDirectory(path.Join(args[0], template)); e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
fileName := code + ".html"
|
||||
|
||||
if e := os.WriteFile(path.Join(args[0], template, fileName), content, 0664); e != nil { //nolint:gosec,gomnd
|
||||
return e
|
||||
}
|
||||
|
||||
if _, ok := history[template]; !ok {
|
||||
history[template] = make([]historyItem, 0, len(codes))
|
||||
}
|
||||
|
||||
history[template] = append(history[template], historyItem{
|
||||
Code: code,
|
||||
Message: codes[code].Message,
|
||||
Path: path.Join(template, fileName),
|
||||
})
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("saved", zap.Duration("duration", time.Since(startedAt)))
|
||||
|
||||
if generateIndex {
|
||||
log.Info("index file generation")
|
||||
startedAt = time.Now()
|
||||
|
||||
if err = writeIndexFile(path.Join(args[0], "index.html"), history); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug("index file generated", zap.Duration("duration", time.Since(startedAt)))
|
||||
}
|
||||
|
||||
return nil
|
||||
Flags: []cli.Flag{
|
||||
&addTplFlag,
|
||||
&disableTplFlag,
|
||||
&addCodeFlag,
|
||||
&disableL10nFlag,
|
||||
&createIndexFlag,
|
||||
&targetDirFlag,
|
||||
&disableMinificationFlag,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(
|
||||
&generateIndex,
|
||||
"index", "i",
|
||||
false,
|
||||
"generate index page",
|
||||
)
|
||||
return cmd.c
|
||||
}
|
||||
|
||||
return cmd
|
||||
func (cmd *command) Run( //nolint:funlen,gocognit
|
||||
_ context.Context,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
) error {
|
||||
type historyItem struct{ Code, Message, RelativePath string }
|
||||
|
||||
var history = make(map[string][]historyItem, len(cfg.Codes)*len(cfg.Templates)) // map[template_name]codes
|
||||
|
||||
for templateName, templateContent := range cfg.Templates {
|
||||
log.Debug("Processing template", logger.String("name", templateName))
|
||||
|
||||
for code, codeDescription := range cfg.Codes {
|
||||
if err := createDirectory(filepath.Join(cmd.opt.targetDirAbsPath, templateName)); err != nil {
|
||||
return fmt.Errorf("cannot create directory for template '%s': %w", templateName, err)
|
||||
}
|
||||
|
||||
var codeAsUint, codeParsingErr = strconv.ParseUint(code, 10, 32)
|
||||
if codeParsingErr != nil {
|
||||
log.Warn("Cannot parse code", logger.String("code", code))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var outFilePath = path.Join(cmd.opt.targetDirAbsPath, templateName, code+".html")
|
||||
|
||||
if content, renderErr := appTemplate.Render(templateContent, appTemplate.Props{ //nolint:nestif
|
||||
Code: uint16(codeAsUint), //nolint:gosec
|
||||
Message: codeDescription.Message,
|
||||
Description: codeDescription.Description,
|
||||
L10nDisabled: cfg.L10n.Disable,
|
||||
ShowRequestDetails: false,
|
||||
}); renderErr == nil {
|
||||
if !cfg.DisableMinification {
|
||||
if mini, minErr := appTemplate.MiniHTML(content); minErr != nil {
|
||||
log.Warn("Cannot minify the content", logger.Error(minErr))
|
||||
} else {
|
||||
content = mini
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outFilePath, []byte(content), os.FileMode(0664)); err != nil { //nolint:mnd
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("cannot render template '%s': %w", templateName, renderErr)
|
||||
}
|
||||
|
||||
log.Debug("Page built", logger.String("template", templateName), logger.String("code", code))
|
||||
|
||||
history[templateName] = append(history[templateName], historyItem{
|
||||
Code: code,
|
||||
Message: codeDescription.Message,
|
||||
RelativePath: "." + strings.TrimPrefix(outFilePath, cmd.opt.targetDirAbsPath), // to make it relative
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.opt.createIndex {
|
||||
log.Debug("Creating the index file")
|
||||
|
||||
for name := range history {
|
||||
slices.SortFunc(history[name], func(a, b historyItem) int { return strings.Compare(a.Code, b.Code) })
|
||||
}
|
||||
|
||||
indexTpl, tplErr := template.New("index").Parse(indexHtml)
|
||||
if tplErr != nil {
|
||||
return tplErr
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
if err := indexTpl.Execute(&buf, history); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(
|
||||
filepath.Join(cmd.opt.targetDirAbsPath, "index.html"),
|
||||
[]byte(buf.String()),
|
||||
os.FileMode(0664), //nolint:mnd
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDirectory(path string) error {
|
||||
stat, err := os.Stat(path)
|
||||
var stat, err = os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(path, 0775) //nolint:gomnd
|
||||
return os.MkdirAll(path, os.FileMode(0775)) //nolint:mnd
|
||||
}
|
||||
|
||||
return err
|
||||
@ -147,52 +251,3 @@ func createDirectory(path string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeIndexFile(path string, history map[string][]historyItem) error {
|
||||
t, err := template.New("index").Parse(`<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<title>Error pages list</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.1.1/css/bootstrap.min.css"
|
||||
integrity="sha512-6KY5s6UI5J7SVYuZB4S/CZMyPylqyyNZco376NM2Z8Sb8OxEdp02e1jkKk/wZxIEmjQ6DRCEBhni+gpr9c4tvA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container">
|
||||
<main>
|
||||
<div class="py-5 text-center">
|
||||
<img class="d-block mx-auto mb-4" src="https://hsto.org/webt/rm/9y/ww/rm9ywwx3gjv9agwkcmllhsuyo7k.png"
|
||||
alt="" width="94">
|
||||
<h2>Error pages index</h2>
|
||||
</div>
|
||||
{{- range $template, $item := . -}}
|
||||
<h2 class="mb-3">Template name: <Code>{{ $template }}</Code></h2>
|
||||
<ul class="mb-5">
|
||||
{{ range $item -}}
|
||||
<li><a href="{{ .Path }}"><strong>{{ .Code }}</strong>: {{ .Message }}</a></li>
|
||||
{{ end -}}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="container text-center text-muted mt-3 mb-3">
|
||||
For online documentation and support please refer to the
|
||||
<a href="https://github.com/tarampampam/error-pages">project repository</a>.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err = t.Execute(&buf, history); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, buf.Bytes(), 0664) //nolint:gosec,gomnd
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
package build_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNothing(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
}
|
122
internal/cli/build/index.html
Normal file
122
internal/cli/build/index.html
Normal file
@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="robots" content="follow,index">
|
||||
<title>Error pages list</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
:root {
|
||||
--color-primary: #fff;
|
||||
--color-inverted: #202020;
|
||||
--color-link: #395364;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-primary: #1a1a1a;
|
||||
--color-inverted: #fff;
|
||||
--color-link: #5cb0d3;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-inverted);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
html, body {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 1200px;
|
||||
height: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 3em 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 3em;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
article h2 {
|
||||
font-size: 1.7em;
|
||||
}
|
||||
|
||||
article h2 code {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
article ul {
|
||||
list-style: none;
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
}
|
||||
|
||||
article ul li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 3em 0;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Error pages index</h1>
|
||||
</header>
|
||||
|
||||
<article>
|
||||
<!-- {{- range $templateName, $details := . -}} -->
|
||||
<h2>Template name: <Code>{{ $templateName }}</Code></h2>
|
||||
<ul class="mb-5">
|
||||
<!-- {{ range $details -}}-->
|
||||
<li><a href="{{ .RelativePath }}"><strong>{{ .Code }}</strong>: {{ .Message }}</a></li>
|
||||
<!-- {{ end -}} -->
|
||||
</ul>
|
||||
<!-- {{ end }} -->
|
||||
</article>
|
||||
|
||||
<footer>
|
||||
For online documentation and support please refer to the
|
||||
<a href="https://gh.tarampamp.am/error-pages">project repository</a>.
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
89
internal/cli/healthcheck/checker.go
Normal file
89
internal/cli/healthcheck/checker.go
Normal file
@ -0,0 +1,89 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
)
|
||||
|
||||
type (
|
||||
httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// HealthCheckerOption allows you to change some settings of the checker.
|
||||
HealthCheckerOption func(*HTTPHealthChecker)
|
||||
)
|
||||
|
||||
// WithHttpClient allows to set http client.
|
||||
func WithHttpClient(c httpClient) HealthCheckerOption {
|
||||
return func(hc *HTTPHealthChecker) { hc.httpClient = c }
|
||||
}
|
||||
|
||||
// WithLiveEndpoint set the endpoint to check.
|
||||
func WithLiveEndpoint(endpoint string) HealthCheckerOption {
|
||||
if len(endpoint) > 0 && endpoint[0] != '/' {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
|
||||
return func(hc *HTTPHealthChecker) { hc.liveEndpoint = endpoint }
|
||||
}
|
||||
|
||||
// HTTPHealthChecker is HTTP probe checker.
|
||||
type HTTPHealthChecker struct {
|
||||
httpClient httpClient
|
||||
liveEndpoint string
|
||||
}
|
||||
|
||||
var _ checker = (*HTTPHealthChecker)(nil) // ensure that HTTPHealthChecker implements checker interface
|
||||
|
||||
func NewHTTPHealthChecker(opts ...HealthCheckerOption) *HTTPHealthChecker {
|
||||
const (
|
||||
httpClientTimeout = 3 * time.Second
|
||||
liveRoute = "/healthz"
|
||||
)
|
||||
|
||||
var c = HTTPHealthChecker{
|
||||
httpClient: &http.Client{
|
||||
Timeout: httpClientTimeout,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec
|
||||
},
|
||||
liveEndpoint: liveRoute,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&c)
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
// Check performs HTTP get request.
|
||||
func (c *HTTPHealthChecker) Check(ctx context.Context, baseURL string) error {
|
||||
var endpoint = strings.TrimRight(strings.TrimSpace(baseURL), "/") + c.liveEndpoint
|
||||
|
||||
var req, err = http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ErrorPages/%s (HealthCheck)", appmeta.Version()))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if code := resp.StatusCode; code != http.StatusOK && code != http.StatusNoContent {
|
||||
return fmt.Errorf("wrong status code [%d] from the live endpoint (%s)", code, endpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
130
internal/cli/healthcheck/checker_test.go
Normal file
130
internal/cli/healthcheck/checker_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package healthcheck_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
|
||||
)
|
||||
|
||||
type httpClientFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestHealthChecker_CheckSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, http.MethodGet, req.Method)
|
||||
assert.Equal(t, "foobar:123/healthz", req.URL.String())
|
||||
assert.Equal(t, fmt.Sprintf("ErrorPages/%s (HealthCheck)", appmeta.Version()), req.Header.Get("User-Agent"))
|
||||
|
||||
return &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader([]byte("ok"))),
|
||||
StatusCode: http.StatusOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
assert.NoError(t, healthcheck.NewHTTPHealthChecker(
|
||||
healthcheck.WithHttpClient(httpMock),
|
||||
).Check(context.Background(), "foobar:123"))
|
||||
}
|
||||
|
||||
func TestHealthChecker_CheckFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, "foobar:123/foo", req.URL.String())
|
||||
|
||||
return &http.Response{
|
||||
Body: http.NoBody,
|
||||
StatusCode: http.StatusBadGateway,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var err = healthcheck.NewHTTPHealthChecker(
|
||||
healthcheck.WithHttpClient(httpMock),
|
||||
healthcheck.WithLiveEndpoint("foo"),
|
||||
).Check(context.Background(), "foobar:123")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "wrong status code [502]")
|
||||
}
|
||||
|
||||
func TestHealthChecker_ClientDoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
var err = healthcheck.NewHTTPHealthChecker(
|
||||
healthcheck.WithHttpClient(httpMock),
|
||||
healthcheck.WithLiveEndpoint("foo"),
|
||||
).Check(context.Background(), "foobar:123")
|
||||
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestHTTPHealthChecker_CheckNormalize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, _tc := range map[string]struct {
|
||||
giveBaseURL string
|
||||
giveLive string
|
||||
wantURL string
|
||||
}{
|
||||
"no-live": {
|
||||
giveBaseURL: "foobar:123",
|
||||
wantURL: "foobar:123",
|
||||
},
|
||||
"live with slash": {
|
||||
giveBaseURL: "foobar:123",
|
||||
giveLive: "/foo",
|
||||
wantURL: "foobar:123/foo",
|
||||
},
|
||||
"live without slash": {
|
||||
giveBaseURL: "foobar:123",
|
||||
giveLive: "foo",
|
||||
wantURL: "foobar:123/foo",
|
||||
},
|
||||
"base with slash": {
|
||||
giveBaseURL: "foobar:123/",
|
||||
giveLive: "foo",
|
||||
wantURL: "foobar:123/foo",
|
||||
},
|
||||
"all of slashes": {
|
||||
giveBaseURL: "foobar:123/",
|
||||
giveLive: "/foo",
|
||||
wantURL: "foobar:123/foo",
|
||||
},
|
||||
} {
|
||||
tc := _tc
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, tc.wantURL, req.URL.String())
|
||||
|
||||
return &http.Response{
|
||||
Body: http.NoBody,
|
||||
StatusCode: http.StatusOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
require.NoError(t, healthcheck.NewHTTPHealthChecker(
|
||||
healthcheck.WithHttpClient(httpMock),
|
||||
healthcheck.WithLiveEndpoint(tc.giveLive),
|
||||
).Check(context.Background(), tc.giveBaseURL))
|
||||
})
|
||||
}
|
||||
}
|
@ -1,57 +1,34 @@
|
||||
// Package healthcheck contains CLI `healthcheck` command implementation.
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/tarampampam/error-pages/internal/env"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
type checker interface {
|
||||
Check(port uint16) error
|
||||
Check(ctx context.Context, baseURL string) error
|
||||
}
|
||||
|
||||
const portFlagName = "port"
|
||||
|
||||
// NewCommand creates `healthcheck` command.
|
||||
func NewCommand(checker checker) *cobra.Command {
|
||||
var port uint16
|
||||
func NewCommand(_ *logger.Logger, checker checker) *cli.Command {
|
||||
var portFlag = shared.ListenPortFlag
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "healthcheck",
|
||||
portFlag.Usage = "TCP port number with the HTTP server to check"
|
||||
|
||||
return &cli.Command{
|
||||
Name: "healthcheck",
|
||||
Aliases: []string{"chk", "health", "check"},
|
||||
Short: "Health checker for the HTTP server. Use case - docker healthcheck",
|
||||
PreRunE: func(c *cobra.Command, _ []string) (lastErr error) {
|
||||
c.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
// flag was NOT defined using CLI (flags should have maximal priority)
|
||||
if !flag.Changed && flag.Name == portFlagName {
|
||||
if envPort, exists := env.ListenPort.Lookup(); exists && envPort != "" {
|
||||
if p, err := strconv.ParseUint(envPort, 10, 16); err == nil { //nolint:gomnd
|
||||
port = uint16(p)
|
||||
} else {
|
||||
lastErr = fmt.Errorf("wrong TCP port environment variable [%s] value", envPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return lastErr
|
||||
Usage: "Health checker for the HTTP server. The use case - docker health check",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return checker.Check(ctx, fmt.Sprintf("http://127.0.0.1:%d", c.Uint(portFlag.Name)))
|
||||
},
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
return checker.Check(port)
|
||||
Flags: []cli.Flag{
|
||||
&portFlag,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Uint16VarP(
|
||||
&port,
|
||||
portFlagName,
|
||||
"p",
|
||||
8080, //nolint:gomnd // must be same as default serve `--port` flag value
|
||||
fmt.Sprintf("TCP port number [$%s]", env.ListenPort),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
@ -1,94 +1,55 @@
|
||||
package healthcheck_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami-zh/go-capturer"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/cli/healthcheck"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
type fakeChecker struct{ err error }
|
||||
func TestNewCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func (c *fakeChecker) Check(port uint16) error { return c.err }
|
||||
var cmd = healthcheck.NewCommand(logger.NewNop(), nil)
|
||||
|
||||
func TestProperties(t *testing.T) {
|
||||
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
|
||||
|
||||
assert.Equal(t, "healthcheck", cmd.Use)
|
||||
assert.ElementsMatch(t, []string{"chk", "health", "check"}, cmd.Aliases)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.Equal(t, "healthcheck", cmd.Name)
|
||||
assert.Equal(t, []string{"chk", "health", "check"}, cmd.Aliases)
|
||||
}
|
||||
|
||||
func TestCommandRun(t *testing.T) {
|
||||
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
|
||||
cmd.SetArgs([]string{})
|
||||
type fakeHealthChecker struct {
|
||||
t *testing.T
|
||||
wantAddress string
|
||||
giveErr error
|
||||
}
|
||||
|
||||
output := capturer.CaptureOutput(func() {
|
||||
assert.NoError(t, cmd.Execute())
|
||||
func (m *fakeHealthChecker) Check(_ context.Context, addr string) error {
|
||||
assert.Equal(m.t, m.wantAddress, addr)
|
||||
|
||||
return m.giveErr
|
||||
}
|
||||
|
||||
func TestCommand_RunSuccess(t *testing.T) {
|
||||
var cmd = healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
|
||||
t: t,
|
||||
wantAddress: "http://127.0.0.1:1234",
|
||||
})
|
||||
|
||||
assert.Empty(t, output)
|
||||
require.NoError(t, cmd.Run(context.Background(), []string{"", "--port", "1234"}))
|
||||
}
|
||||
|
||||
func TestCommandRunFailed(t *testing.T) {
|
||||
cmd := healthcheck.NewCommand(&fakeChecker{err: errors.New("foo err")})
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
output := capturer.CaptureStderr(func() {
|
||||
assert.Error(t, cmd.Execute())
|
||||
func TestCommand_RunFail(t *testing.T) {
|
||||
cmd := healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
|
||||
t: t,
|
||||
wantAddress: "http://127.0.0.1:4321",
|
||||
giveErr: assert.AnError,
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "foo err")
|
||||
}
|
||||
|
||||
func TestPortFlagWrongArgument(t *testing.T) {
|
||||
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
|
||||
cmd.SetArgs([]string{"-p", "65536"}) // 65535 is max
|
||||
|
||||
var executed bool
|
||||
|
||||
cmd.RunE = func(*cobra.Command, []string) error {
|
||||
executed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
output := capturer.CaptureStderr(func() {
|
||||
assert.Error(t, cmd.Execute())
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "invalid argument")
|
||||
assert.Contains(t, output, "65536")
|
||||
assert.Contains(t, output, "value out of range")
|
||||
assert.False(t, executed)
|
||||
}
|
||||
|
||||
func TestPortFlagWrongEnvValue(t *testing.T) {
|
||||
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
assert.NoError(t, os.Setenv("LISTEN_PORT", "65536")) // 65535 is max
|
||||
|
||||
defer func() { assert.NoError(t, os.Unsetenv("LISTEN_PORT")) }()
|
||||
|
||||
var executed bool
|
||||
|
||||
cmd.RunE = func(*cobra.Command, []string) error {
|
||||
executed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
output := capturer.CaptureStderr(func() {
|
||||
assert.Error(t, cmd.Execute())
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "wrong TCP port")
|
||||
assert.Contains(t, output, "environment variable")
|
||||
assert.Contains(t, output, "65536")
|
||||
assert.False(t, executed)
|
||||
assert.ErrorIs(t,
|
||||
cmd.Run(context.Background(), []string{"", "--port", "4321"}),
|
||||
assert.AnError,
|
||||
)
|
||||
}
|
||||
|
194
internal/cli/perftest/command.go
Normal file
194
internal/cli/perftest/command.go
Normal file
@ -0,0 +1,194 @@
|
||||
package perftest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
)
|
||||
|
||||
const wrkOneCodeTestLua = `
|
||||
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
|
||||
|
||||
request = function()
|
||||
wrk.headers["User-Agent"] = "wrk"
|
||||
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
|
||||
|
||||
return wrk.format("GET", "/500.html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
|
||||
end
|
||||
`
|
||||
|
||||
//nolint:lll
|
||||
const bombDifferentCodes = `
|
||||
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
|
||||
|
||||
request = function()
|
||||
wrk.headers["User-Agent"] = "wrk"
|
||||
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
|
||||
|
||||
return wrk.format("GET", "/" .. tostring(math.random(400, 599)) .. ".html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
|
||||
end
|
||||
`
|
||||
|
||||
// NewCommand creates `perftest` command.
|
||||
func NewCommand() *cli.Command { //nolint:funlen
|
||||
var (
|
||||
portFlag = shared.ListenPortFlag
|
||||
durationFlag = cli.DurationFlag{
|
||||
Name: "duration",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Duration of test",
|
||||
Value: 15 * time.Second, //nolint:mnd
|
||||
Validator: func(d time.Duration) error {
|
||||
if d <= time.Second {
|
||||
return errors.New("duration can't be less than 1 second")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
threadsFlag = cli.UintFlag{
|
||||
Name: "threads",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Number of threads to use",
|
||||
Value: max(2, uint64(math.Round(float64(runtime.NumCPU())/1.3))), //nolint:mnd
|
||||
Validator: func(u uint64) error {
|
||||
if u == 0 {
|
||||
return errors.New("threads number can't be zero")
|
||||
} else if u > math.MaxUint16 {
|
||||
return errors.New("threads number can't be greater than 65535")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
connectionsFlag = cli.UintFlag{
|
||||
Name: "connections",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "Number of connections to keep open",
|
||||
Value: max(16, uint64(runtime.NumCPU()*25)), //nolint:mnd
|
||||
Validator: func(u uint64) error {
|
||||
if u == 0 {
|
||||
return errors.New("threads number can't be zero")
|
||||
} else if u > math.MaxUint16 {
|
||||
return errors.New("threads number can't be greater than 65535")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return &cli.Command{
|
||||
Name: "perftest",
|
||||
Aliases: []string{"perf", "benchmark", "bench"},
|
||||
Hidden: true,
|
||||
Usage: "Performance (load) test for the HTTP server (locally installed wrk is required)",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
var wrkBinPath, lErr = exec.LookPath("wrk")
|
||||
if lErr != nil {
|
||||
return fmt.Errorf("seems like wrk (https://github.com/wg/wrk) is not installed: %w", lErr)
|
||||
}
|
||||
|
||||
var runTest = func(scriptContent string) error {
|
||||
if stdOut, stdErr, err := wrkRunTest(ctx,
|
||||
wrkBinPath,
|
||||
uint16(c.Uint(threadsFlag.Name)), //nolint:gosec
|
||||
uint16(c.Uint(connectionsFlag.Name)), //nolint:gosec
|
||||
c.Duration(durationFlag.Name),
|
||||
uint16(c.Uint(portFlag.Name)), //nolint:gosec
|
||||
scriptContent,
|
||||
); err != nil {
|
||||
var errData, _ = io.ReadAll(stdErr)
|
||||
|
||||
return fmt.Errorf("failed to execute the test: %w (%s)", err, string(errData))
|
||||
} else {
|
||||
var outData, _ = io.ReadAll(stdOut)
|
||||
|
||||
printf("Test completed successfully. Here is the output:\n\n%s\n", string(outData))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
printf("Starting the test to bomb ONE PAGE (code). Please, be patient...\n")
|
||||
|
||||
if err := runTest(wrkOneCodeTestLua); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printf("Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...\n")
|
||||
|
||||
if err := runTest(bombDifferentCodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&portFlag,
|
||||
&durationFlag,
|
||||
&threadsFlag,
|
||||
&connectionsFlag,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func printf(format string, args ...any) { fmt.Printf(format, args...) } //nolint:forbidigo
|
||||
|
||||
func wrkRunTest(
|
||||
ctx context.Context,
|
||||
wrkBinPath string,
|
||||
threadsCount, connectionsCount uint16,
|
||||
duration time.Duration,
|
||||
port uint16,
|
||||
scriptContent string,
|
||||
) (io.Reader, io.Reader, error) {
|
||||
var tmpFile, tErr = os.CreateTemp("", "ep-perf-one-page")
|
||||
if tErr != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create a temporary file: %w", tErr)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.WriteString(scriptContent); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to write to a temporary file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
var cmd = exec.CommandContext(ctx, wrkBinPath, //nolint:gosec
|
||||
"--timeout", "1s",
|
||||
"--threads", strconv.FormatUint(uint64(threadsCount), 10),
|
||||
"--connections", strconv.FormatUint(uint64(connectionsCount), 10),
|
||||
"--duration", duration.String(),
|
||||
"--script", tmpFile.Name(),
|
||||
fmt.Sprintf("http://127.0.0.1:%d/", port),
|
||||
)
|
||||
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
|
||||
return &stdout, &stderr, cmd.Run() // execute
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
// Package cli contains CLI command handlers.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/tarampampam/error-pages/internal/checkers"
|
||||
buildCmd "github.com/tarampampam/error-pages/internal/cli/build"
|
||||
healthcheckCmd "github.com/tarampampam/error-pages/internal/cli/healthcheck"
|
||||
serveCmd "github.com/tarampampam/error-pages/internal/cli/serve"
|
||||
versionCmd "github.com/tarampampam/error-pages/internal/cli/version"
|
||||
"github.com/tarampampam/error-pages/internal/env"
|
||||
"github.com/tarampampam/error-pages/internal/logger"
|
||||
"github.com/tarampampam/error-pages/internal/version"
|
||||
)
|
||||
|
||||
const configFileFlagName = "config-file"
|
||||
|
||||
// NewCommand creates root command.
|
||||
func NewCommand(appName string) *cobra.Command { //nolint:funlen
|
||||
var (
|
||||
configFile string
|
||||
verbose bool
|
||||
debug bool
|
||||
logJSON bool
|
||||
)
|
||||
|
||||
ctx := context.Background() // main CLI context
|
||||
|
||||
// create "default" logger (will be overwritten later with customized)
|
||||
log, err := logger.New(false, false, false)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: appName,
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
_ = log.Sync() // sync previous logger instance
|
||||
|
||||
customizedLog, e := logger.New(verbose, debug, logJSON)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
*log = *customizedLog // override "default" logger with customized
|
||||
|
||||
c.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
// flag was NOT defined using CLI (flags should have maximal priority)
|
||||
if !flag.Changed && flag.Name == configFileFlagName {
|
||||
if envConfigFile, exists := env.ConfigFilePath.Lookup(); exists && envConfigFile != "" {
|
||||
configFile = envConfigFile
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
PersistentPostRun: func(*cobra.Command, []string) {
|
||||
// error ignoring reasons:
|
||||
// - <https://github.com/uber-go/zap/issues/772>
|
||||
// - <https://github.com/uber-go/zap/issues/328>
|
||||
_ = log.Sync()
|
||||
},
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
CompletionOptions: cobra.CompletionOptions{
|
||||
DisableDefaultCmd: true,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
cmd.PersistentFlags().BoolVarP(&debug, "debug", "", false, "debug output")
|
||||
cmd.PersistentFlags().BoolVarP(&logJSON, "log-json", "", false, "logs in JSON format")
|
||||
cmd.PersistentFlags().StringVarP(
|
||||
&configFile,
|
||||
configFileFlagName, "c",
|
||||
"./error-pages.yml",
|
||||
fmt.Sprintf("path to the config file [$%s]", env.ConfigFilePath),
|
||||
)
|
||||
|
||||
cmd.AddCommand(
|
||||
versionCmd.NewCommand(version.Version()),
|
||||
healthcheckCmd.NewCommand(checkers.NewHealthChecker(ctx)),
|
||||
buildCmd.NewCommand(log, &configFile),
|
||||
serveCmd.NewCommand(ctx, log, &configFile),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/cli"
|
||||
)
|
||||
|
||||
func TestSubcommands(t *testing.T) {
|
||||
cmd := cli.NewCommand("unit test")
|
||||
|
||||
cases := []struct {
|
||||
giveName string
|
||||
}{
|
||||
{giveName: "build"},
|
||||
{giveName: "version"},
|
||||
{giveName: "healthcheck"},
|
||||
{giveName: "serve"},
|
||||
}
|
||||
|
||||
// get all existing subcommands and put into the map
|
||||
subcommands := make(map[string]*cobra.Command)
|
||||
for _, sub := range cmd.Commands() {
|
||||
subcommands[sub.Name()] = sub
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.giveName, func(t *testing.T) {
|
||||
if _, exists := subcommands[tt.giveName]; !exists {
|
||||
assert.Failf(t, "command not found", "command [%s] was not found", tt.giveName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlags(t *testing.T) {
|
||||
cmd := cli.NewCommand("unit test")
|
||||
|
||||
cases := []struct {
|
||||
giveName string
|
||||
wantShorthand string
|
||||
wantDefault string
|
||||
}{
|
||||
{giveName: "verbose", wantShorthand: "v", wantDefault: "false"},
|
||||
{giveName: "debug", wantShorthand: "", wantDefault: "false"},
|
||||
{giveName: "log-json", wantShorthand: "", wantDefault: "false"},
|
||||
{giveName: "config-file", wantShorthand: "c", wantDefault: "./error-pages.yml"},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.giveName, func(t *testing.T) {
|
||||
flag := cmd.Flag(tt.giveName)
|
||||
|
||||
if flag == nil {
|
||||
assert.Failf(t, "flag not found", "flag [%s] was not found", tt.giveName)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantShorthand, flag.Shorthand)
|
||||
assert.Equal(t, tt.wantDefault, flag.DefValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuting(t *testing.T) {
|
||||
cmd := cli.NewCommand("unit test")
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
var executed bool
|
||||
|
||||
if cmd.Run == nil { // override "Run" property for test (if it was not set)
|
||||
cmd.Run = func(cmd *cobra.Command, args []string) {
|
||||
executed = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.NoError(t, cmd.Execute())
|
||||
assert.True(t, executed)
|
||||
}
|
@ -3,121 +3,374 @@ package serve
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tarampampam/error-pages/internal/http/handlers/errorpage"
|
||||
"github.com/tarampampam/error-pages/internal/tpl"
|
||||
"go.uber.org/zap"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tarampampam/error-pages/internal/breaker"
|
||||
"github.com/tarampampam/error-pages/internal/config"
|
||||
appHttp "github.com/tarampampam/error-pages/internal/http"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
appHttp "gh.tarampamp.am/error-pages/internal/http"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
c *cli.Command
|
||||
|
||||
opt struct {
|
||||
http struct { // our HTTP server
|
||||
addr string
|
||||
port uint16
|
||||
readBufferSize uint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewCommand creates `serve` command.
|
||||
func NewCommand(ctx context.Context, log *zap.Logger, configFile *string) *cobra.Command {
|
||||
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
|
||||
var (
|
||||
f flags
|
||||
cfg *config.Config
|
||||
cmd command
|
||||
cfg = config.New()
|
||||
env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true}
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Aliases: []string{"s", "server"},
|
||||
Short: "Start HTTP server",
|
||||
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if configFile == nil {
|
||||
return errors.New("path to the config file is required for this command")
|
||||
}
|
||||
var (
|
||||
addrFlag = shared.ListenAddrFlag
|
||||
portFlag = shared.ListenPortFlag
|
||||
addTplFlag = shared.AddTemplatesFlag
|
||||
disableTplFlag = shared.DisableTemplateNamesFlag
|
||||
addCodeFlag = shared.AddHTTPCodesFlag
|
||||
disableL10nFlag = shared.DisableL10nFlag
|
||||
disableMinificationFlag = shared.DisableMinificationFlag
|
||||
jsonFormatFlag = cli.StringFlag{
|
||||
Name: "json-format",
|
||||
Usage: "Override the default error page response in JSON format (Go templates are supported; the error " +
|
||||
"page will use this template if the client requests JSON content type)",
|
||||
Sources: env("RESPONSE_JSON_FORMAT"),
|
||||
Category: shared.CategoryFormats,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
xmlFormatFlag = cli.StringFlag{
|
||||
Name: "xml-format",
|
||||
Usage: "Override the default error page response in XML format (Go templates are supported; the error " +
|
||||
"page will use this template if the client requests XML content type)",
|
||||
Sources: env("RESPONSE_XML_FORMAT"),
|
||||
Category: shared.CategoryFormats,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
plainTextFormatFlag = cli.StringFlag{
|
||||
Name: "plaintext-format",
|
||||
Usage: "Override the default error page response in plain text format (Go templates are supported; the " +
|
||||
"error page will use this template if the client requests plain text content type or does not specify any)",
|
||||
Sources: env("RESPONSE_PLAINTEXT_FORMAT"),
|
||||
Category: shared.CategoryFormats,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
templateNameFlag = cli.StringFlag{
|
||||
Name: "template-name",
|
||||
Aliases: []string{"t"},
|
||||
Value: cfg.TemplateName,
|
||||
Usage: "Name of the template to use for rendering error pages (built-in templates: " +
|
||||
strings.Join(cfg.Templates.Names(), ", ") + ")",
|
||||
Sources: env("TEMPLATE_NAME"),
|
||||
Category: shared.CategoryTemplates,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
defaultCodeToRenderFlag = cli.UintFlag{
|
||||
Name: "default-error-page",
|
||||
Usage: "The code of the default (index page, when a code is not specified) error page to render",
|
||||
Value: uint64(cfg.DefaultCodeToRender),
|
||||
Sources: env("DEFAULT_ERROR_PAGE"),
|
||||
Category: shared.CategoryCodes,
|
||||
Validator: func(code uint64) error {
|
||||
if code > 999 { //nolint:mnd
|
||||
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
|
||||
}
|
||||
|
||||
if err := f.overrideUsingEnv(cmd.Flags()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnlyOnce: true,
|
||||
}
|
||||
sendSameHTTPCodeFlag = cli.BoolFlag{
|
||||
Name: "send-same-http-code",
|
||||
Usage: "The HTTP response should have the same status code as the requested error page (by default, " +
|
||||
"every response with an error page will have a status code of 200)",
|
||||
Value: cfg.RespondWithSameHTTPCode,
|
||||
Sources: env("SEND_SAME_HTTP_CODE"),
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
showDetailsFlag = cli.BoolFlag{
|
||||
Name: "show-details",
|
||||
Usage: "Show request details in the error page response (if supported by the template)",
|
||||
Value: cfg.ShowDetails,
|
||||
Sources: env("SHOW_DETAILS"),
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
proxyHeadersListFlag = cli.StringFlag{
|
||||
Name: "proxy-headers",
|
||||
Usage: "HTTP headers listed here will be proxied from the original request to the error page response " +
|
||||
"(comma-separated list)",
|
||||
Value: strings.Join(cfg.ProxyHeaders, ","),
|
||||
Sources: env("PROXY_HTTP_HEADERS"),
|
||||
Validator: func(s string) error {
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
|
||||
return fmt.Errorf("whitespaces in the HTTP headers are not allowed: %s", clean)
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := config.FromYamlFile(*configFile); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err = c.Validate(); err != nil {
|
||||
return nil
|
||||
},
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
rotationModeFlag = cli.StringFlag{
|
||||
Name: "rotation-mode",
|
||||
Value: config.RotationModeDisabled.String(),
|
||||
Usage: "Templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
|
||||
Sources: env("TEMPLATES_ROTATION_MODE"),
|
||||
Category: shared.CategoryTemplates,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
Validator: func(s string) error {
|
||||
if _, err := config.ParseRotationMode(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg = c
|
||||
}
|
||||
|
||||
return f.validate()
|
||||
},
|
||||
RunE: func(*cobra.Command, []string) error { return run(ctx, log, f, cfg) },
|
||||
}
|
||||
|
||||
f.init(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
const serverShutdownTimeout = 15 * time.Second
|
||||
|
||||
// run current command.
|
||||
func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config) error { //nolint:funlen
|
||||
var (
|
||||
ctx, cancel = context.WithCancel(parentCtx) // serve context creation
|
||||
oss = breaker.NewOSSignals(ctx) // OS signals listener
|
||||
return nil
|
||||
},
|
||||
}
|
||||
readBufferSizeFlag = cli.UintFlag{
|
||||
Name: "read-buffer-size",
|
||||
Usage: "Per-connection buffer size in bytes for reading requests, this also limits the maximum header size " +
|
||||
"(increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., " +
|
||||
"large cookies), note that increasing this value will increase memory consumption)",
|
||||
Value: 1024 * 5, //nolint:mnd // 5 KB
|
||||
Sources: env("READ_BUFFER_SIZE"),
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
)
|
||||
|
||||
// subscribe for system signals
|
||||
oss.Subscribe(func(sig os.Signal) {
|
||||
log.Warn("Stopping by OS signal..", zap.String("signal", sig.String()))
|
||||
// override some flag usage messages
|
||||
addrFlag.Usage = "The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, " +
|
||||
"0.0.0.0 to listen on all interfaces, or specify a custom IP)"
|
||||
portFlag.Usage = "The TCP port number for the HTTP server to listen on (0-65535)"
|
||||
|
||||
cancel()
|
||||
})
|
||||
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
|
||||
|
||||
defer func() {
|
||||
cancel() // call the cancellation function after all
|
||||
oss.Stop() // stop system signals listening
|
||||
}()
|
||||
cmd.c = &cli.Command{
|
||||
Name: "serve",
|
||||
Aliases: []string{"s", "server", "http"},
|
||||
Usage: "Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D",
|
||||
Suggest: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
cmd.opt.http.addr = c.String(addrFlag.Name)
|
||||
cmd.opt.http.port = uint16(c.Uint(portFlag.Name)) //nolint:gosec
|
||||
cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
|
||||
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
|
||||
cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) //nolint:gosec
|
||||
cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name)
|
||||
cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name))
|
||||
cfg.ShowDetails = c.Bool(showDetailsFlag.Name)
|
||||
cfg.DisableMinification = c.Bool(disableMinificationFlag.Name)
|
||||
|
||||
// load templates content
|
||||
templates, loadingErr := cfg.LoadTemplates()
|
||||
if loadingErr != nil {
|
||||
return loadingErr
|
||||
} else if len(templates) == 0 {
|
||||
return errors.New("no loaded templates")
|
||||
{ // override default JSON, XML, and PlainText formats
|
||||
if c.IsSet(jsonFormatFlag.Name) {
|
||||
cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name))
|
||||
}
|
||||
|
||||
if c.IsSet(xmlFormatFlag.Name) {
|
||||
cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name))
|
||||
}
|
||||
|
||||
if c.IsSet(plainTextFormatFlag.Name) {
|
||||
cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// add templates from files to the configuration
|
||||
if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
|
||||
for _, templatePath := range add {
|
||||
if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
|
||||
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
|
||||
} else {
|
||||
log.Info("Template added",
|
||||
logger.String("name", addedName),
|
||||
logger.String("path", templatePath),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set the list of HTTP headers we need to proxy from the incoming request to the error page response
|
||||
if c.IsSet(proxyHeadersListFlag.Name) {
|
||||
var m = make(map[string]struct{}) // map is used to avoid duplicates
|
||||
|
||||
for _, header := range strings.Split(c.String(proxyHeadersListFlag.Name), ",") {
|
||||
m[http.CanonicalHeaderKey(strings.TrimSpace(header))] = struct{}{}
|
||||
}
|
||||
|
||||
clear(cfg.ProxyHeaders) // clear the list before adding new headers
|
||||
|
||||
for header := range m {
|
||||
cfg.ProxyHeaders = append(cfg.ProxyHeaders, header)
|
||||
}
|
||||
}
|
||||
|
||||
// add custom HTTP codes to the configuration
|
||||
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
|
||||
for code, desc := range shared.ParseHTTPCodes(add) {
|
||||
cfg.Codes[code] = desc
|
||||
|
||||
log.Info("HTTP code added",
|
||||
logger.String("code", code),
|
||||
logger.String("message", desc.Message),
|
||||
logger.String("description", desc.Description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// disable templates specified by the user
|
||||
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
|
||||
for _, templateName := range disable {
|
||||
if ok := cfg.Templates.Remove(templateName); ok {
|
||||
log.Info("Template disabled", logger.String("name", templateName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if there are any templates available to render error pages
|
||||
if len(cfg.Templates.Names()) == 0 {
|
||||
return errors.New("no templates available to render error pages")
|
||||
}
|
||||
|
||||
// if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided
|
||||
// template name)
|
||||
if cfg.RotationMode == config.RotationModeRandomOnStartup {
|
||||
cfg.TemplateName = cfg.Templates.RandomName()
|
||||
} else { // otherwise, use the user-provided template name
|
||||
cfg.TemplateName = c.String(templateNameFlag.Name)
|
||||
|
||||
if !cfg.Templates.Has(cfg.TemplateName) {
|
||||
return fmt.Errorf(
|
||||
"template '%s' not found and cannot be used (available templates: %s)",
|
||||
cfg.TemplateName,
|
||||
cfg.Templates.Names(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("Configuration",
|
||||
logger.Strings("loaded templates", cfg.Templates.Names()...),
|
||||
logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
|
||||
logger.String("JSON format", cfg.Formats.JSON),
|
||||
logger.String("XML format", cfg.Formats.XML),
|
||||
logger.String("plain text format", cfg.Formats.PlainText),
|
||||
logger.String("template name", cfg.TemplateName),
|
||||
logger.Bool("disable localization", cfg.L10n.Disable),
|
||||
logger.Uint16("default code to render", cfg.DefaultCodeToRender),
|
||||
logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
|
||||
logger.String("rotation mode", cfg.RotationMode.String()),
|
||||
logger.Bool("show details", cfg.ShowDetails),
|
||||
logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
|
||||
)
|
||||
|
||||
return cmd.Run(ctx, log, &cfg)
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&addrFlag,
|
||||
&portFlag,
|
||||
&addTplFlag,
|
||||
&disableTplFlag,
|
||||
&addCodeFlag,
|
||||
&jsonFormatFlag,
|
||||
&xmlFormatFlag,
|
||||
&plainTextFormatFlag,
|
||||
&templateNameFlag,
|
||||
&disableL10nFlag,
|
||||
&defaultCodeToRenderFlag,
|
||||
&sendSameHTTPCodeFlag,
|
||||
&showDetailsFlag,
|
||||
&proxyHeadersListFlag,
|
||||
&rotationModeFlag,
|
||||
&readBufferSizeFlag,
|
||||
&disableMinificationFlag,
|
||||
},
|
||||
}
|
||||
|
||||
if f.template.name != "" && f.template.name != errorpage.UseRandom && f.template.name != errorpage.UseRandomOnEachRequest { //nolint:lll
|
||||
if _, found := templates[f.template.name]; !found {
|
||||
return errors.New("requested nonexistent template: " + f.template.name) // requested unknown template
|
||||
}
|
||||
}
|
||||
return cmd.c
|
||||
}
|
||||
|
||||
// burn the error codes map
|
||||
codes := make(map[string]tpl.Annotator)
|
||||
for code, desc := range cfg.Pages {
|
||||
codes[code] = tpl.Annotator{Message: desc.Message, Description: desc.Description}
|
||||
}
|
||||
// Run current command.
|
||||
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error { //nolint:funlen
|
||||
var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize)
|
||||
|
||||
// create HTTP server
|
||||
server := appHttp.NewServer(log)
|
||||
|
||||
// register server routes, middlewares, etc.
|
||||
if err := server.Register(f.template.name, templates, codes); err != nil {
|
||||
if err := srv.Register(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
startingErrCh := make(chan error, 1) // channel for server starting error
|
||||
var startingErrCh = make(chan error, 1) // channel for server starting error
|
||||
defer close(startingErrCh)
|
||||
|
||||
// to track the frequency of each template's use, we send a simple GET request to the GoatCounter API
|
||||
// (https://goatcounter.com, https://github.com/arp242/goatcounter) to increment the counter. this service is
|
||||
// free and does not require an API key. no private data is sent, as shown in the URL below. this is necessary
|
||||
// to render a badge displaying the number of template usages on the error-pages repository README file :D
|
||||
//
|
||||
// badge code example:
|
||||
// 
|
||||
//
|
||||
// if you wish, you may view the collected statistics at any time here - https://error-pages.goatcounter.com/
|
||||
go func() {
|
||||
var tpl = url.QueryEscape(cfg.TemplateName)
|
||||
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(
|
||||
// https://www.goatcounter.com/help/pixel
|
||||
"https://error-pages.goatcounter.com/count?p=/use-template/%s&t=%s", tpl, tpl,
|
||||
), http.NoBody)
|
||||
if reqErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("Mozilla/5.0 (error-pages, rnd:%d)", time.Now().UnixNano()))
|
||||
|
||||
resp, respErr := (&http.Client{Timeout: 10 * time.Second}).Do(req) //nolint:mnd // don't care about the response
|
||||
if respErr != nil {
|
||||
log.Debug("Cannot send a request to increment the template usage counter", logger.Error(respErr))
|
||||
|
||||
return
|
||||
} else if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// start HTTP server in separate goroutine
|
||||
go func(errCh chan<- error) {
|
||||
defer close(errCh)
|
||||
var now = time.Now()
|
||||
|
||||
log.Info("Server starting",
|
||||
zap.String("addr", f.listen.ip),
|
||||
zap.Uint16("port", f.listen.port),
|
||||
zap.String("template name", f.template.name),
|
||||
defer func() {
|
||||
log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
|
||||
}()
|
||||
|
||||
log.Info("HTTP server starting",
|
||||
logger.String("addr", cmd.opt.http.addr),
|
||||
logger.Uint16("port", cmd.opt.http.port),
|
||||
)
|
||||
|
||||
if err := server.Start(f.listen.ip, f.listen.port); err != nil {
|
||||
if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}(startingErrCh)
|
||||
@ -128,20 +381,13 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config
|
||||
return err
|
||||
|
||||
case <-ctx.Done(): // ..or context cancellation
|
||||
log.Info("Gracefully server stopping")
|
||||
const shutdownTimeout = 5 * time.Second
|
||||
|
||||
stoppedAt := time.Now()
|
||||
|
||||
// stop the server using created context above
|
||||
if err := server.Stop(serverShutdownTimeout); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Error("Server stopping timeout exceeded", zap.Duration("timeout", serverShutdownTimeout))
|
||||
}
|
||||
log.Info("HTTP server stopping", logger.Duration("with timeout", shutdownTimeout))
|
||||
|
||||
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug("Server stopped", zap.Duration("stopping duration", time.Since(stoppedAt)))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -1,7 +1,101 @@
|
||||
package serve_test
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
func TestNothing(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/serve"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
func TestCommand_Run(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
port = getFreeTcpPort(t)
|
||||
cmd = serve.NewCommand(logger.NewNop())
|
||||
)
|
||||
|
||||
var ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var ch = make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
ch <- cmd.Run(ctx, []string{
|
||||
"serve",
|
||||
"--port", strconv.Itoa(int(port)),
|
||||
"--add-template", "./testdata/foo-template.html",
|
||||
"--disable-template", "ghost",
|
||||
"--disable-template", "<unknown>",
|
||||
"--add-code", "200=Code/Description",
|
||||
"--json-format", "json format",
|
||||
"--xml-format", "xml format",
|
||||
"--plaintext-format", "plaintext format",
|
||||
"--template-name", "foo-template",
|
||||
"--disable-l10n",
|
||||
"--default-error-page", "503",
|
||||
"--send-same-http-code",
|
||||
"--show-details",
|
||||
"--proxy-headers", "X-Forwarded-For,X-Forwarded-Proto",
|
||||
"--rotation-mode", "random-on-each-request",
|
||||
})
|
||||
}()
|
||||
|
||||
var connected bool
|
||||
|
||||
for {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
|
||||
if err == nil {
|
||||
connected = true
|
||||
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
break
|
||||
} else {
|
||||
t.Log(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout")
|
||||
case chErr := <-ch:
|
||||
require.NoError(t, chErr)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, connected, "server is not running")
|
||||
}
|
||||
|
||||
// getFreeTcpPort is a helper function to get a free TCP port number.
|
||||
func getFreeTcpPort(t *testing.T) uint16 {
|
||||
t.Helper()
|
||||
|
||||
l, lErr := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, lErr)
|
||||
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, l.Close())
|
||||
|
||||
// make sure port is closed
|
||||
for {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
require.NoError(t, conn.Close())
|
||||
<-time.After(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
return uint16(port) //nolint:gosec
|
||||
}
|
||||
|
@ -1,91 +0,0 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tarampampam/error-pages/internal/http/handlers/errorpage"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/tarampampam/error-pages/internal/env"
|
||||
)
|
||||
|
||||
type flags struct {
|
||||
listen struct {
|
||||
ip string
|
||||
port uint16
|
||||
}
|
||||
template struct {
|
||||
name string
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
listenFlagName = "listen"
|
||||
portFlagName = "port"
|
||||
templateNameFlagName = "template-name"
|
||||
)
|
||||
|
||||
func (f *flags) init(flagSet *pflag.FlagSet) {
|
||||
flagSet.StringVarP(
|
||||
&f.listen.ip,
|
||||
listenFlagName, "l",
|
||||
"0.0.0.0",
|
||||
fmt.Sprintf("IP address to listen on [$%s]", env.ListenAddr),
|
||||
)
|
||||
flagSet.Uint16VarP(
|
||||
&f.listen.port,
|
||||
portFlagName, "p",
|
||||
8080, //nolint:gomnd // must be same as default healthcheck `--port` flag value
|
||||
fmt.Sprintf("TCP port number [$%s]", env.ListenPort),
|
||||
)
|
||||
flagSet.StringVarP(
|
||||
&f.template.name,
|
||||
templateNameFlagName, "t",
|
||||
"",
|
||||
fmt.Sprintf(
|
||||
"template name (set \"%s\" to use the randomized or \"%s\" to use the randomized template on each request) [$%s]", //nolint:lll
|
||||
errorpage.UseRandom, errorpage.UseRandomOnEachRequest, env.TemplateName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) {
|
||||
flagSet.VisitAll(func(flag *pflag.Flag) {
|
||||
// flag was NOT defined using CLI (flags should have maximal priority)
|
||||
if !flag.Changed { //nolint:nestif
|
||||
switch flag.Name {
|
||||
case listenFlagName:
|
||||
if envVar, exists := env.ListenAddr.Lookup(); exists {
|
||||
f.listen.ip = strings.TrimSpace(envVar)
|
||||
}
|
||||
|
||||
case portFlagName:
|
||||
if envVar, exists := env.ListenPort.Lookup(); exists {
|
||||
if p, err := strconv.ParseUint(envVar, 10, 16); err == nil { //nolint:gomnd
|
||||
f.listen.port = uint16(p)
|
||||
} else {
|
||||
lastErr = fmt.Errorf("wrong TCP port environment variable [%s] value", envVar)
|
||||
}
|
||||
}
|
||||
|
||||
case templateNameFlagName:
|
||||
if envVar, exists := env.TemplateName.Lookup(); exists {
|
||||
f.template.name = strings.TrimSpace(envVar)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (f *flags) validate() error {
|
||||
if net.ParseIP(f.listen.ip) == nil {
|
||||
return fmt.Errorf("wrong IP address [%s] for listening", f.listen.ip)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
10
internal/cli/serve/testdata/foo-template.html
vendored
Normal file
10
internal/cli/serve/testdata/foo-template.html
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
157
internal/cli/shared/flags.go
Normal file
157
internal/cli/shared/flags.go
Normal file
@ -0,0 +1,157 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
CategoryHTTP = "HTTP:"
|
||||
CategoryTemplates = "TEMPLATES:"
|
||||
CategoryCodes = "HTTP CODES:"
|
||||
CategoryFormats = "FORMATS:"
|
||||
CategoryBuild = "BUILD:"
|
||||
CategoryOther = "OTHER:"
|
||||
)
|
||||
|
||||
// Note: Don't use pointers for flags, because they have own state which is not thread-safe.
|
||||
// https://github.com/urfave/cli/issues/1926
|
||||
|
||||
var ListenAddrFlag = cli.StringFlag{
|
||||
Name: "listen",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "IP (v4 or v6) address to listen on",
|
||||
Value: "0.0.0.0", // bind to all interfaces by default
|
||||
Sources: cli.EnvVars("LISTEN_ADDR"),
|
||||
Category: CategoryHTTP,
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(ip string) error {
|
||||
if ip == "" {
|
||||
return fmt.Errorf("missing IP address")
|
||||
}
|
||||
|
||||
if net.ParseIP(ip) == nil {
|
||||
return fmt.Errorf("wrong IP address [%s] for listening", ip)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var ListenPortFlag = cli.UintFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "TCP port number",
|
||||
Value: 8080, // default port number
|
||||
Sources: cli.EnvVars("LISTEN_PORT"),
|
||||
Category: CategoryHTTP,
|
||||
OnlyOnce: true,
|
||||
Validator: func(port uint64) error {
|
||||
if port == 0 || port > 65535 {
|
||||
return fmt.Errorf("wrong TCP port number [%d]", port)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var AddTemplatesFlag = cli.StringSliceFlag{
|
||||
Name: "add-template",
|
||||
Usage: "To add a new template, provide the path to the file using this flag (the filename without the extension " +
|
||||
"will be used as the template name)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Sources: cli.EnvVars("ADD_TEMPLATE"),
|
||||
Category: CategoryTemplates,
|
||||
Validator: func(paths []string) error {
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
return fmt.Errorf("missing template path")
|
||||
}
|
||||
|
||||
if stat, err := os.Stat(path); err != nil || stat.IsDir() {
|
||||
return fmt.Errorf("wrong template path [%s]", path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DisableTemplateNamesFlag = cli.StringSliceFlag{
|
||||
Name: "disable-template",
|
||||
Usage: "Disable the specified template by its name (useful to disable the built-in templates and use only custom ones)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Category: CategoryTemplates,
|
||||
}
|
||||
|
||||
var AddHTTPCodesFlag = cli.StringMapFlag{
|
||||
Name: "add-code",
|
||||
Usage: "To add a new HTTP status code, provide the code and its message/description using this flag (the format " +
|
||||
"should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at " +
|
||||
"once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Category: CategoryCodes,
|
||||
Validator: func(codes map[string]string) error {
|
||||
for code, msgAndDesc := range codes {
|
||||
if code == "" {
|
||||
return fmt.Errorf("missing HTTP code")
|
||||
} else if len(code) != 3 {
|
||||
return fmt.Errorf("wrong HTTP code [%s]: it should be 3 characters long", code)
|
||||
}
|
||||
|
||||
if parts := strings.SplitN(msgAndDesc, "/", 3); len(parts) < 1 || len(parts) > 2 {
|
||||
return fmt.Errorf("wrong message/description format for HTTP code [%s]: %s", code, msgAndDesc)
|
||||
} else if parts[0] == "" {
|
||||
return fmt.Errorf("missing message for HTTP code [%s]", code)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ParseHTTPCodes converts a map of HTTP status codes and their messages/descriptions into a map of codes and
|
||||
// descriptions. Should be used together with [AddHTTPCodesFlag].
|
||||
func ParseHTTPCodes(codes map[string]string) map[string]config.CodeDescription {
|
||||
var result = make(map[string]config.CodeDescription, len(codes))
|
||||
|
||||
for code, msgAndDesc := range codes {
|
||||
var (
|
||||
parts = strings.SplitN(msgAndDesc, "/", 2)
|
||||
desc config.CodeDescription
|
||||
)
|
||||
|
||||
desc.Message = strings.TrimSpace(parts[0])
|
||||
|
||||
if len(parts) > 1 {
|
||||
desc.Description = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
result[code] = desc
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var DisableL10nFlag = cli.BoolFlag{
|
||||
Name: "disable-l10n",
|
||||
Usage: "Disable localization of error pages (if the template supports localization)",
|
||||
Sources: cli.EnvVars("DISABLE_L10N"),
|
||||
Category: CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
|
||||
var DisableMinificationFlag = cli.BoolFlag{
|
||||
Name: "disable-minification",
|
||||
Usage: "Disable the minification of HTML pages, including CSS, SVG, and JS (may be useful for debugging)",
|
||||
Sources: cli.EnvVars("DISABLE_MINIFICATION"),
|
||||
Category: CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
228
internal/cli/shared/flags_test.go
Normal file
228
internal/cli/shared/flags_test.go
Normal file
@ -0,0 +1,228 @@
|
||||
package shared_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
)
|
||||
|
||||
func TestListenAddrFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.ListenAddrFlag
|
||||
|
||||
assert.Equal(t, "listen", flag.Name)
|
||||
assert.Equal(t, "0.0.0.0", flag.Value)
|
||||
assert.Contains(t, flag.Sources.String(), "LISTEN_ADDR")
|
||||
|
||||
for giveValue, wantErrMsg := range map[string]string{
|
||||
flag.Value: "", // default value
|
||||
|
||||
// ipv4
|
||||
"0.0.0.0": "",
|
||||
"127.0.0.1": "",
|
||||
"255.255.255.255": "",
|
||||
|
||||
// ipv6
|
||||
"::": "",
|
||||
"::1": "",
|
||||
"2001:0db8:85a3:0000:0000:8a2e:0370:7334": "",
|
||||
"2001:db8:85a3:0:0:8a2e:370:7334": "",
|
||||
"2001:db8:85a3::8a2e:370:7334": "",
|
||||
"2001:db8::8a2e:370:7334": "",
|
||||
"2001:db8::7334": "",
|
||||
"2001:db8::": "",
|
||||
"2001:db8:0:0:1::1": "",
|
||||
"2001:db8:0:0:1::": "",
|
||||
|
||||
// invalid
|
||||
"": "missing IP address",
|
||||
"255.255.255.256": "wrong IP address [255.255.255.256] for listening",
|
||||
"example.com": "wrong IP address [example.com] for listening",
|
||||
"123.123.abc.123": "wrong IP address [123.123.abc.123] for listening",
|
||||
"foo:123:321": "wrong IP address [foo:123:321] for listening",
|
||||
"2001:db8:0:0:1:": "wrong IP address [2001:db8:0:0:1:] for listening",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s: %s", giveValue, wantErrMsg), func(t *testing.T) {
|
||||
if err := flag.Validator(giveValue); wantErrMsg != "" {
|
||||
assert.ErrorContains(t, err, wantErrMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenPortFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.ListenPortFlag
|
||||
|
||||
assert.Equal(t, "port", flag.Name)
|
||||
assert.Equal(t, uint64(8080), flag.Value)
|
||||
assert.Contains(t, flag.Sources.String(), "LISTEN_PORT")
|
||||
|
||||
for giveValue, wantErrMsg := range map[uint64]string{
|
||||
flag.Value: "", // default value
|
||||
1: "",
|
||||
8080: "",
|
||||
65535: "",
|
||||
|
||||
0: "wrong TCP port number [0]",
|
||||
65536: "wrong TCP port number [65536]",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%d: %s", giveValue, wantErrMsg), func(t *testing.T) {
|
||||
if err := flag.Validator(giveValue); wantErrMsg != "" {
|
||||
assert.ErrorContains(t, err, wantErrMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTemplatesFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.AddTemplatesFlag
|
||||
|
||||
assert.Equal(t, "add-template", flag.Name)
|
||||
assert.Contains(t, flag.Sources.String(), "ADD_TEMPLATE")
|
||||
|
||||
for wantErrMsg, giveValue := range map[string][]string{
|
||||
"missing template path": {""},
|
||||
"wrong template path [.]": {".", "./"},
|
||||
"wrong template path [..]": {"..", "../"},
|
||||
"wrong template path [foo]": {"foo"},
|
||||
"": {"./flags.go"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s: %s", giveValue, wantErrMsg), func(t *testing.T) {
|
||||
if err := flag.Validator(giveValue); wantErrMsg != "" {
|
||||
assert.ErrorContains(t, err, wantErrMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableTemplateNamesFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.DisableTemplateNamesFlag
|
||||
|
||||
assert.Equal(t, "disable-template", flag.Name)
|
||||
}
|
||||
|
||||
func TestAddHTTPCodesFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.AddHTTPCodesFlag
|
||||
|
||||
assert.Equal(t, "add-code", flag.Name)
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
giveValue map[string]string
|
||||
wantErrMsg string
|
||||
}{
|
||||
"common": {
|
||||
giveValue: map[string]string{
|
||||
"200": "foo/bar",
|
||||
"404": "foo",
|
||||
"2**": "baz",
|
||||
},
|
||||
},
|
||||
|
||||
"missing HTTP code": {
|
||||
giveValue: map[string]string{"": "foo/bar"},
|
||||
wantErrMsg: "missing HTTP code",
|
||||
},
|
||||
"wrong HTTP code [6]": {
|
||||
giveValue: map[string]string{"6": "foo"},
|
||||
wantErrMsg: "wrong HTTP code [6]: it should be 3 characters long",
|
||||
},
|
||||
"wrong HTTP code [66]": {
|
||||
giveValue: map[string]string{"66": "foo"},
|
||||
wantErrMsg: "wrong HTTP code [66]: it should be 3 characters long",
|
||||
},
|
||||
"wrong HTTP code [1000]": {
|
||||
giveValue: map[string]string{"1000": "foo"},
|
||||
wantErrMsg: "wrong HTTP code [1000]: it should be 3 characters long",
|
||||
},
|
||||
"missing message and description": {
|
||||
giveValue: map[string]string{"200": "//"},
|
||||
wantErrMsg: "wrong message/description format for HTTP code [200]: //",
|
||||
},
|
||||
"missing message": {
|
||||
giveValue: map[string]string{"200": "/bar"},
|
||||
wantErrMsg: "missing message for HTTP code [200]",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := flag.Validator(tt.giveValue); tt.wantErrMsg != "" {
|
||||
assert.ErrorContains(t, err, tt.wantErrMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTTPCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, shared.ParseHTTPCodes(nil), map[string]config.CodeDescription{})
|
||||
|
||||
assert.Equal(t,
|
||||
shared.ParseHTTPCodes(map[string]string{"200": "msg"}),
|
||||
map[string]config.CodeDescription{"200": {Message: "msg", Description: ""}},
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
shared.ParseHTTPCodes(map[string]string{"200": "/aaa"}),
|
||||
map[string]config.CodeDescription{"200": {Message: "", Description: "aaa"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, // not sure here
|
||||
shared.ParseHTTPCodes(map[string]string{"aa": "////aaa"}),
|
||||
map[string]config.CodeDescription{"aa": {Message: "", Description: "///aaa"}},
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
shared.ParseHTTPCodes(map[string]string{"200": "msg/desc"}),
|
||||
map[string]config.CodeDescription{"200": {Message: "msg", Description: "desc"}},
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
shared.ParseHTTPCodes(map[string]string{
|
||||
"200": "msg/desc",
|
||||
"foo": "Word word/Desc desc // adsadas",
|
||||
}),
|
||||
map[string]config.CodeDescription{
|
||||
"200": {Message: "msg", Description: "desc"},
|
||||
"foo": {Message: "Word word", Description: "Desc desc // adsadas"},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestDisableL10nFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.DisableL10nFlag
|
||||
|
||||
assert.Equal(t, "disable-l10n", flag.Name)
|
||||
assert.Contains(t, flag.Sources.String(), "DISABLE_L10N")
|
||||
}
|
||||
|
||||
func TestDisableMinificationFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.DisableMinificationFlag
|
||||
|
||||
assert.Equal(t, "disable-minification", flag.Name)
|
||||
assert.Contains(t, flag.Sources.String(), "DISABLE_MINIFICATION")
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
// Package version contains CLI `version` command implementation.
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCommand creates `version` command.
|
||||
func NewCommand(ver string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Aliases: []string{"v", "ver"},
|
||||
Short: "Display application version",
|
||||
RunE: func(*cobra.Command, []string) (err error) {
|
||||
_, err = fmt.Fprintf(os.Stdout, "app version:\t%s (%s)\n", ver, runtime.Version())
|
||||
|
||||
return
|
||||
},
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
package version_test
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/kami-zh/go-capturer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/cli/version"
|
||||
)
|
||||
|
||||
func TestProperties(t *testing.T) {
|
||||
cmd := version.NewCommand("")
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
assert.ElementsMatch(t, []string{"v", "ver"}, cmd.Aliases)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
}
|
||||
|
||||
func TestCommandRun(t *testing.T) {
|
||||
cmd := version.NewCommand("1.2.3@foobar")
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
output := capturer.CaptureStdout(func() {
|
||||
assert.NoError(t, cmd.Execute())
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "1.2.3@foobar")
|
||||
assert.Contains(t, output, runtime.Version())
|
||||
}
|
124
internal/config/codes.go
Normal file
124
internal/config/codes.go
Normal file
@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type (
|
||||
CodeDescription struct {
|
||||
// Message is a short description of the HTTP error.
|
||||
Message string
|
||||
|
||||
// Description is a longer description of the HTTP error.
|
||||
Description string
|
||||
}
|
||||
|
||||
// Codes is a map of HTTP codes to their descriptions.
|
||||
//
|
||||
// The codes may be written in a non-strict manner. For example, they may be "4xx", "4XX", or "4**".
|
||||
// If the map contains both "404" and "4xx" keys, and we search for "404", the "404" key will be returned.
|
||||
// However, if we search for "405", "400", or any non-existing code that starts with "4" and its length is 3,
|
||||
// the value under the key "4xx" will be retrieved.
|
||||
//
|
||||
// The length of the code (in string format) is matter.
|
||||
Codes map[string]CodeDescription // map[http_code]description
|
||||
)
|
||||
|
||||
// Find searches the closest match for the given HTTP code, written in a non-strict manner. Read [Codes] for more
|
||||
// information.
|
||||
func (c Codes) Find(httpCode uint16) (CodeDescription, bool) { //nolint:funlen,gocyclo
|
||||
if len(c) == 0 { // empty map, fast return
|
||||
return CodeDescription{}, false
|
||||
}
|
||||
|
||||
var code = strconv.FormatUint(uint64(httpCode), 10)
|
||||
|
||||
if desc, ok := c[code]; ok { // search for the exact match
|
||||
return desc, true
|
||||
}
|
||||
|
||||
var (
|
||||
keysMap = make(map[string][]rune, len(c))
|
||||
codeRunes = []rune(code)
|
||||
)
|
||||
|
||||
for key := range c { // take only the keys that are the same length and start with the same character or a wildcard
|
||||
if kr := []rune(key); len(kr) > 0 && len(kr) == len(codeRunes) && isWildcardOr(kr[0], codeRunes[0]) {
|
||||
keysMap[key] = kr
|
||||
}
|
||||
}
|
||||
|
||||
if len(keysMap) == 0 { // no matches found using the first rune comparison
|
||||
return CodeDescription{}, false
|
||||
}
|
||||
|
||||
var matchedMap = make(map[string]uint16, len(keysMap)) // map[mapKey]wildcardMatchedCount
|
||||
|
||||
for mapKey, keyRunes := range keysMap { // search for the closest match
|
||||
var wildcardMatchedCount uint16 = 0
|
||||
|
||||
for i := 0; i < len(codeRunes); i++ { // loop through each httpCode rune
|
||||
var keyRune, codeRune = keyRunes[i], codeRunes[i]
|
||||
|
||||
if wm := isWildcard(keyRune); wm || keyRune == codeRune {
|
||||
if wm {
|
||||
wildcardMatchedCount++
|
||||
}
|
||||
|
||||
if i == len(codeRunes)-1 { // is the last rune?
|
||||
matchedMap[mapKey] = wildcardMatchedCount
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchedMap) == 0 { // no matches found
|
||||
return CodeDescription{}, false
|
||||
} else if len(matchedMap) == 1 { // only one match found
|
||||
for mapKey := range matchedMap {
|
||||
return c[mapKey], true
|
||||
}
|
||||
}
|
||||
|
||||
// multiple matches found, find the most specific one based on the wildcard matched count (pick the one with the
|
||||
// least wildcards)
|
||||
var (
|
||||
minCount uint16
|
||||
key string
|
||||
)
|
||||
|
||||
for mapKey, count := range matchedMap {
|
||||
if minCount == 0 || count < minCount {
|
||||
minCount, key = count, mapKey
|
||||
}
|
||||
}
|
||||
|
||||
return c[key], true
|
||||
}
|
||||
|
||||
func isWildcard(r rune) bool { return r == '*' || r == 'x' || r == 'X' }
|
||||
func isWildcardOr(r, or rune) bool { return isWildcard(r) || r == or }
|
||||
|
||||
// Codes returns all HTTP codes sorted alphabetically.
|
||||
func (c Codes) Codes() []string {
|
||||
var codes = make([]string, 0, len(c))
|
||||
|
||||
for code := range c {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
|
||||
slices.Sort(codes)
|
||||
|
||||
return codes
|
||||
}
|
||||
|
||||
// Has checks if the HTTP code exists.
|
||||
func (c Codes) Has(code string) (found bool) { _, found = c[code]; return } //nolint:nlreturn
|
||||
|
||||
// Get returns the HTTP code description by the specified code, if it exists.
|
||||
func (c Codes) Get(code string) (data CodeDescription, ok bool) { data, ok = c[code]; return } //nolint:nlreturn
|
131
internal/config/codes_test.go
Normal file
131
internal/config/codes_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
)
|
||||
|
||||
func TestCodes_Common(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var codes = make(config.Codes)
|
||||
|
||||
t.Run("initial state", func(t *testing.T) {
|
||||
require.Empty(t, codes.Codes())
|
||||
require.Empty(t, codes.Has("404"))
|
||||
|
||||
var got, ok = codes.Get("404")
|
||||
|
||||
require.Empty(t, got)
|
||||
require.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("add a code", func(t *testing.T) {
|
||||
codes["404"] = config.CodeDescription{Message: "Not Found"}
|
||||
|
||||
assert.True(t, codes.Has("404"))
|
||||
assert.Equal(t, []string{"404"}, codes.Codes())
|
||||
|
||||
var got, ok = codes.Get("404")
|
||||
|
||||
assert.Equal(t, got.Message, "Not Found")
|
||||
assert.True(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodes_Find(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:typecheck
|
||||
var common = config.Codes{
|
||||
"101": {Message: "Upgrade"}, // 101
|
||||
"1xx": {Message: "Informational"}, // 102-199
|
||||
"200": {Message: "OK"}, // 200
|
||||
"20*": {Message: "Success"}, // 201-209
|
||||
"2**": {Message: "Success, but..."}, // 210-299
|
||||
"3**": {Message: "Redirection"}, // 300-399
|
||||
"404": {Message: "Not Found"}, // 404
|
||||
"405": {Message: "Method Not Allowed"}, // 405
|
||||
"500": {Message: "Internal Server Error"}, // 500
|
||||
"501": {Message: "Not Implemented"}, // 501
|
||||
"502": {Message: "Bad Gateway"}, // 502
|
||||
"503": {Message: "Service Unavailable"}, // 503
|
||||
"5XX": {Message: "Server Error"}, // 504-599
|
||||
}
|
||||
|
||||
var ladder = config.Codes{
|
||||
"123": {Message: "Full triple"},
|
||||
"***": {Message: "Triple"},
|
||||
"12": {Message: "Full double"},
|
||||
"**": {Message: "Double"},
|
||||
"1": {Message: "Full single"},
|
||||
"*": {Message: "Single"},
|
||||
}
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
giveCodes config.Codes
|
||||
giveCode uint16
|
||||
|
||||
wantMessage string
|
||||
wantNotFound bool
|
||||
}{
|
||||
"101 - exact match": {giveCodes: common, giveCode: 101, wantMessage: "Upgrade"},
|
||||
"102 - multi-wildcard match": {giveCodes: common, giveCode: 102, wantMessage: "Informational"},
|
||||
"110 - multi-wildcard match": {giveCodes: common, giveCode: 110, wantMessage: "Informational"},
|
||||
"111 - multi-wildcard match": {giveCodes: common, giveCode: 111, wantMessage: "Informational"},
|
||||
"199 - multi-wildcard match": {giveCodes: common, giveCode: 199, wantMessage: "Informational"},
|
||||
"200 - exact match": {giveCodes: common, giveCode: 200, wantMessage: "OK"},
|
||||
"201 - single-wildcard match": {giveCodes: common, giveCode: 201, wantMessage: "Success"},
|
||||
"209 - single-wildcard match": {giveCodes: common, giveCode: 209, wantMessage: "Success"},
|
||||
"210 - multi-wildcard match": {giveCodes: common, giveCode: 210, wantMessage: "Success, but..."},
|
||||
"234 - multi-wildcard match": {giveCodes: common, giveCode: 234, wantMessage: "Success, but..."},
|
||||
"299 - multi-wildcard match": {giveCodes: common, giveCode: 299, wantMessage: "Success, but..."},
|
||||
"300 - multi-wildcard match": {giveCodes: common, giveCode: 300, wantMessage: "Redirection"},
|
||||
"301 - multi-wildcard match": {giveCodes: common, giveCode: 301, wantMessage: "Redirection"},
|
||||
"311 - multi-wildcard match": {giveCodes: common, giveCode: 311, wantMessage: "Redirection"},
|
||||
"399 - multi-wildcard match": {giveCodes: common, giveCode: 399, wantMessage: "Redirection"},
|
||||
"400 - not found": {giveCodes: common, giveCode: 400, wantNotFound: true},
|
||||
"403 - not found": {giveCodes: common, giveCode: 403, wantNotFound: true},
|
||||
"404 - exact match": {giveCodes: common, giveCode: 404, wantMessage: "Not Found"},
|
||||
"405 - exact match": {giveCodes: common, giveCode: 405, wantMessage: "Method Not Allowed"},
|
||||
"410 - not found": {giveCodes: common, giveCode: 410, wantNotFound: true},
|
||||
"450 - not found": {giveCodes: common, giveCode: 450, wantNotFound: true},
|
||||
"499 - not found": {giveCodes: common, giveCode: 499, wantNotFound: true},
|
||||
"500 - exact match": {giveCodes: common, giveCode: 500, wantMessage: "Internal Server Error"},
|
||||
"501 - exact match": {giveCodes: common, giveCode: 501, wantMessage: "Not Implemented"},
|
||||
"502 - exact match": {giveCodes: common, giveCode: 502, wantMessage: "Bad Gateway"},
|
||||
"503 - exact match": {giveCodes: common, giveCode: 503, wantMessage: "Service Unavailable"},
|
||||
"504 - multi-wildcard match": {giveCodes: common, giveCode: 504, wantMessage: "Server Error"},
|
||||
"505 - multi-wildcard match": {giveCodes: common, giveCode: 505, wantMessage: "Server Error"},
|
||||
"599 - multi-wildcard match": {giveCodes: common, giveCode: 599, wantMessage: "Server Error"},
|
||||
"600 - not found": {giveCodes: common, giveCode: 600, wantNotFound: true},
|
||||
|
||||
"ladder - strict triple match": {giveCodes: ladder, giveCode: 123, wantMessage: "Full triple"},
|
||||
"ladder - triple wildcard": {giveCodes: ladder, giveCode: 321, wantMessage: "Triple"},
|
||||
"ladder - strict double match": {giveCodes: ladder, giveCode: 12, wantMessage: "Full double"},
|
||||
"ladder - double wildcard": {giveCodes: ladder, giveCode: 21, wantMessage: "Double"},
|
||||
"ladder - strict single match": {giveCodes: ladder, giveCode: 1, wantMessage: "Full single"},
|
||||
"ladder - single wildcard": {giveCodes: ladder, giveCode: 2, wantMessage: "Single"},
|
||||
|
||||
"empty map": {giveCodes: config.Codes{}, giveCode: 404, wantNotFound: true},
|
||||
"zero code": {giveCodes: common, giveCode: 0, wantNotFound: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for i := 0; i < 100; i++ { // repeat the test to ensure the function is idempotent
|
||||
var desc, found = tt.giveCodes.Find(tt.giveCode)
|
||||
|
||||
if !tt.wantNotFound {
|
||||
require.Truef(t, found, "should have found something")
|
||||
require.Equal(t, tt.wantMessage, desc.Message)
|
||||
} else {
|
||||
require.Falsef(t, found, "should not have found anything, but got: %v", desc)
|
||||
require.Empty(t, desc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,116 +1,178 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/a8m/envsubst"
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
builtinTemplates "gh.tarampamp.am/error-pages/templates"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Templates []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
} `yaml:"templates"`
|
||||
Pages map[string]struct {
|
||||
Message string `yaml:"message"`
|
||||
Description string `yaml:"description"`
|
||||
} `yaml:"pages"`
|
||||
// Templates hold all templates, with the key being the template name and the value being the template content
|
||||
// in HTML format (Go templates are supported here).
|
||||
Templates templates
|
||||
|
||||
// Formats contain alternative response formats (e.g., if a client requests a response in one of these formats,
|
||||
// we will render the response using the specified format instead of HTML; Go templates are supported).
|
||||
Formats struct {
|
||||
JSON string
|
||||
XML string
|
||||
PlainText string
|
||||
}
|
||||
|
||||
// Codes hold descriptions for HTTP codes (e.g., 404: "Not Found / The server can not find the requested page").
|
||||
Codes Codes
|
||||
|
||||
// TemplateName is the name of the template to use for rendering error pages. The template must be present in the
|
||||
// Templates map.
|
||||
TemplateName string
|
||||
|
||||
// ProxyHeaders contains a list of HTTP headers that will be proxied from the incoming request to the
|
||||
// error page response.
|
||||
ProxyHeaders []string
|
||||
|
||||
// L10n contains localization settings.
|
||||
L10n struct {
|
||||
// Disable the localization of error pages.
|
||||
Disable bool
|
||||
}
|
||||
|
||||
// DefaultCodeToRender is the code for the default error page to be displayed. It is used when the requested
|
||||
// code is not defined in the incoming request (i.e., the code to render as the index page).
|
||||
DefaultCodeToRender uint16
|
||||
|
||||
// RespondWithSameHTTPCode determines whether the response should have the same HTTP status code as the requested
|
||||
// error page.
|
||||
// In other words, if set to true and the requested error page has a code of 404, the HTTP response will also have
|
||||
// a status code of 404. If set to false, the HTTP response will have a status code of 200 regardless of the
|
||||
// requested error page's status code.
|
||||
RespondWithSameHTTPCode bool
|
||||
|
||||
// RotationMode allows to set the rotation mode for templates to switch between them automatically on startup,
|
||||
// on each request, daily, hourly and so on.
|
||||
RotationMode RotationMode
|
||||
|
||||
// ShowDetails determines whether to show additional details in the error response, extracted from the
|
||||
// incoming request (if supported by the template).
|
||||
ShowDetails bool
|
||||
|
||||
// DisableMinification determines whether to disable minification of the rendered content (e.g., HTML, CSS) or not.
|
||||
DisableMinification bool
|
||||
}
|
||||
|
||||
// Validate the config and return an error if something is wrong.
|
||||
func (c Config) Validate() error {
|
||||
if len(c.Templates) == 0 {
|
||||
return errors.New("empty templates list")
|
||||
} else {
|
||||
for i := 0; i < len(c.Templates); i++ {
|
||||
if c.Templates[i].Name == "" && c.Templates[i].Path == "" {
|
||||
return errors.New("empty path and name with index " + strconv.Itoa(i))
|
||||
}
|
||||
const defaultJSONFormat string = `{
|
||||
"error": true,
|
||||
"code": {{ code | json }},
|
||||
"message": {{ message | json }},
|
||||
"description": {{ description | json }}{{ if show_details }},
|
||||
"details": {
|
||||
"host": {{ host | json }},
|
||||
"original_uri": {{ original_uri | json }},
|
||||
"forwarded_for": {{ forwarded_for | json }},
|
||||
"namespace": {{ namespace | json }},
|
||||
"ingress_name": {{ ingress_name | json }},
|
||||
"service_name": {{ service_name | json }},
|
||||
"service_port": {{ service_port | json }},
|
||||
"request_id": {{ request_id | json }},
|
||||
"timestamp": {{ nowUnix }}
|
||||
}{{ end }}
|
||||
}
|
||||
` // an empty line at the end is important for better UX
|
||||
|
||||
if c.Templates[i].Path == "" && c.Templates[i].Content == "" {
|
||||
return errors.New("empty path and template content with index " + strconv.Itoa(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<error>
|
||||
<code>{{ code }}</code>
|
||||
<message>{{ message }}</message>
|
||||
<description>{{ description }}</description>{{ if show_details }}
|
||||
<details>
|
||||
<host>{{ host }}</host>
|
||||
<originalURI>{{ original_uri }}</originalURI>
|
||||
<forwardedFor>{{ forwarded_for }}</forwardedFor>
|
||||
<namespace>{{ namespace }}</namespace>
|
||||
<ingressName>{{ ingress_name }}</ingressName>
|
||||
<serviceName>{{ service_name }}</serviceName>
|
||||
<servicePort>{{ service_port }}</servicePort>
|
||||
<requestID>{{ request_id }}</requestID>
|
||||
<timestamp>{{ nowUnix }}</timestamp>
|
||||
</details>{{ end }}
|
||||
</error>
|
||||
` // an empty line at the end is important for better UX
|
||||
|
||||
if len(c.Pages) == 0 {
|
||||
return errors.New("empty pages list")
|
||||
} else {
|
||||
for code := range c.Pages {
|
||||
if code == "" {
|
||||
return errors.New("empty page code")
|
||||
}
|
||||
const defaultPlainTextFormat string = `Error {{ code }}: {{ message }}{{ if description }}
|
||||
{{ description }}{{ end }}{{ if show_details }}
|
||||
|
||||
if strings.ContainsRune(code, ' ') {
|
||||
return errors.New("code should not contain whitespaces")
|
||||
}
|
||||
}
|
||||
}
|
||||
Host: {{ host }}
|
||||
Original URI: {{ original_uri }}
|
||||
Forwarded For: {{ forwarded_for }}
|
||||
Namespace: {{ namespace }}
|
||||
Ingress Name: {{ ingress_name }}
|
||||
Service Name: {{ service_name }}
|
||||
Service Port: {{ service_port }}
|
||||
Request ID: {{ request_id }}
|
||||
Timestamp: {{ nowUnix }}{{ end }}
|
||||
` // an empty line at the end is important for better UX
|
||||
|
||||
return nil
|
||||
//nolint:lll
|
||||
var defaultCodes = Codes{ //nolint:gochecknoglobals
|
||||
"400": {"Bad Request", "The server did not understand the request"},
|
||||
"401": {"Unauthorized", "The requested page needs a username and a password"},
|
||||
"403": {"Forbidden", "Access is forbidden to the requested page"},
|
||||
"404": {"Not Found", "The server can not find the requested page"},
|
||||
"405": {"Method Not Allowed", "The method specified in the request is not allowed"},
|
||||
"407": {"Proxy Authentication Required", "You must authenticate with a proxy server before this request can be served"},
|
||||
"408": {"Request Timeout", "The request took longer than the server was prepared to wait"},
|
||||
"409": {"Conflict", "The request could not be completed because of a conflict"},
|
||||
"410": {"Gone", "The requested page is no longer available"},
|
||||
"411": {"Length Required", "The \"Content-Length\" is not defined. The server will not accept the request without it"},
|
||||
"412": {"Precondition Failed", "The pre condition given in the request evaluated to false by the server"},
|
||||
"413": {"Payload Too Large", "The server will not accept the request, because the request entity is too large"},
|
||||
"416": {"Requested Range Not Satisfiable", "The requested byte range is not available and is out of bounds"},
|
||||
"418": {"I'm a teapot", "Attempt to brew coffee with a teapot is not supported"},
|
||||
"429": {"Too Many Requests", "Too many requests in a given amount of time"},
|
||||
"500": {"Internal Server Error", "The server met an unexpected condition"},
|
||||
"502": {"Bad Gateway", "The server received an invalid response from the upstream server"},
|
||||
"503": {"Service Unavailable", "The server is temporarily overloading or down"},
|
||||
"504": {"Gateway Timeout", "The gateway has timed out"},
|
||||
"505": {"HTTP Version Not Supported", "The server does not support the \"http protocol\" version"},
|
||||
}
|
||||
|
||||
// LoadTemplates loading templates content from the local files and return it.
|
||||
func (c Config) LoadTemplates() (map[string][]byte, error) {
|
||||
var templates = make(map[string][]byte)
|
||||
|
||||
for i := 0; i < len(c.Templates); i++ {
|
||||
var name string
|
||||
|
||||
if c.Templates[i].Name == "" {
|
||||
basename := filepath.Base(c.Templates[i].Path)
|
||||
name = strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||
} else {
|
||||
name = c.Templates[i].Name
|
||||
}
|
||||
|
||||
var content []byte
|
||||
|
||||
if c.Templates[i].Content == "" {
|
||||
b, err := ioutil.ReadFile(c.Templates[i].Path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot load content for the template "+name)
|
||||
}
|
||||
|
||||
content = b
|
||||
} else {
|
||||
content = []byte(c.Templates[i].Content)
|
||||
}
|
||||
|
||||
templates[name] = content
|
||||
}
|
||||
|
||||
return templates, nil
|
||||
var defaultProxyHeaders = []string{ //nolint:gochecknoglobals
|
||||
// "Traceparent", // W3C Trace Context
|
||||
// "Tracestate", // W3C Trace Context
|
||||
"X-Request-Id", // unofficial HTTP header, used to trace individual HTTP requests
|
||||
"X-Trace-Id", // same as above
|
||||
"X-Amzn-Trace-Id", // to track HTTP requests from clients to targets or other AWS services
|
||||
}
|
||||
|
||||
// FromYaml creates new config instance using YAML-structured content.
|
||||
func FromYaml(in []byte) (cfg *Config, err error) {
|
||||
cfg = &Config{}
|
||||
|
||||
in, err = envsubst.Bytes(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// New creates a new configuration with default values.
|
||||
func New() Config {
|
||||
var cfg = Config{
|
||||
Templates: make(templates), // allocate memory for templates
|
||||
Codes: maps.Clone(defaultCodes), // copy default codes
|
||||
}
|
||||
|
||||
if err = yaml.Unmarshal(in, cfg); err != nil {
|
||||
return nil, errors.Wrap(err, "cannot parse configuration file")
|
||||
cfg.Formats.JSON = defaultJSONFormat
|
||||
cfg.Formats.XML = defaultXMLFormat
|
||||
cfg.Formats.PlainText = defaultPlainTextFormat
|
||||
|
||||
// add built-in templates
|
||||
for name, content := range builtinTemplates.BuiltIn() {
|
||||
cfg.Templates[name] = content
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// FromYamlFile creates new config instance using YAML file.
|
||||
func FromYamlFile(filepath string) (*Config, error) {
|
||||
bytes, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot read configuration file")
|
||||
}
|
||||
|
||||
return FromYaml(bytes)
|
||||
// set first template as default
|
||||
for _, name := range cfg.Templates.Names() {
|
||||
cfg.TemplateName = name
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// set default HTTP headers to proxy
|
||||
cfg.ProxyHeaders = slices.Clone(defaultProxyHeaders)
|
||||
|
||||
// set defaults
|
||||
cfg.DefaultCodeToRender = http.StatusNotFound
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
@ -1,315 +1,58 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tarampampam/error-pages/internal/config"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
for name, tt := range map[string]struct {
|
||||
giveConfig func() config.Config
|
||||
wantError error
|
||||
}{
|
||||
"valid": {
|
||||
giveConfig: func() config.Config {
|
||||
c := config.Config{}
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c.Templates = []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
}{
|
||||
{"foo", "bar", "baz"},
|
||||
}
|
||||
t.Run("default config", func(t *testing.T) {
|
||||
var cfg = config.New()
|
||||
|
||||
c.Pages = map[string]struct {
|
||||
Message string `yaml:"message"`
|
||||
Description string `yaml:"description"`
|
||||
}{
|
||||
"400": {"Bad Request", "The server did not understand the request"},
|
||||
}
|
||||
assert.NotEmpty(t, cfg.Formats.XML)
|
||||
assert.NotEmpty(t, cfg.Formats.JSON)
|
||||
assert.NotEmpty(t, cfg.Formats.PlainText)
|
||||
assert.True(t, len(cfg.Codes) >= 19)
|
||||
assert.True(t, len(cfg.Templates) >= 1)
|
||||
assert.NotEmpty(t, cfg.TemplateName)
|
||||
assert.True(t, cfg.Templates.Has(cfg.TemplateName))
|
||||
assert.Equal(t, uint16(http.StatusNotFound), cfg.DefaultCodeToRender)
|
||||
assert.False(t, cfg.DisableMinification)
|
||||
})
|
||||
|
||||
return c
|
||||
},
|
||||
wantError: nil,
|
||||
},
|
||||
"empty templates list": {
|
||||
giveConfig: func() config.Config {
|
||||
return config.Config{}
|
||||
},
|
||||
wantError: errors.New("empty templates list"),
|
||||
},
|
||||
"empty path and name": {
|
||||
giveConfig: func() config.Config {
|
||||
c := config.Config{}
|
||||
t.Run("changing cfg1 should not affect cfg2", func(t *testing.T) {
|
||||
var cfg1, cfg2 = config.New(), config.New()
|
||||
|
||||
c.Templates = []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
}{
|
||||
{
|
||||
Path: "foo",
|
||||
Name: "bar",
|
||||
Content: "baz",
|
||||
},
|
||||
{
|
||||
Path: "",
|
||||
Name: "",
|
||||
Content: "blah",
|
||||
},
|
||||
}
|
||||
cfg1.Codes["400"] = config.CodeDescription{Message: "foo", Description: "bar"}
|
||||
|
||||
return c
|
||||
},
|
||||
wantError: errors.New("empty path and name with index 1"),
|
||||
},
|
||||
"empty path and template content": {
|
||||
giveConfig: func() config.Config {
|
||||
c := config.Config{}
|
||||
assert.NotEqual(t, cfg1.Codes["400"], cfg2.Codes["400"])
|
||||
|
||||
c.Templates = []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
}{
|
||||
{
|
||||
Path: "foo",
|
||||
Name: "bar",
|
||||
Content: "baz",
|
||||
},
|
||||
{
|
||||
Path: "",
|
||||
Name: "blah",
|
||||
Content: "",
|
||||
},
|
||||
}
|
||||
cfg1.ProxyHeaders = append(cfg1.ProxyHeaders, "foo")
|
||||
|
||||
return c
|
||||
},
|
||||
wantError: errors.New("empty path and template content with index 1"),
|
||||
},
|
||||
"empty pages list": {
|
||||
giveConfig: func() config.Config {
|
||||
c := config.Config{}
|
||||
assert.NotEqual(t, cfg1.ProxyHeaders, cfg2.ProxyHeaders)
|
||||
})
|
||||
|
||||
c.Templates = []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
}{
|
||||
{"foo", "bar", "baz"},
|
||||
}
|
||||
t.Run("render default format templates", func(t *testing.T) {
|
||||
var cfg = config.New()
|
||||
|
||||
c.Pages = map[string]struct {
|
||||
Message string `yaml:"message"`
|
||||
Description string `yaml:"description"`
|
||||
}{}
|
||||
for _, content := range []string{cfg.Formats.JSON, cfg.Formats.XML, cfg.Formats.PlainText} {
|
||||
var result, err = template.Render(content, template.Props{
|
||||
ShowRequestDetails: true,
|
||||
Code: 404,
|
||||
Message: "Not Found",
|
||||
})
|
||||
|
||||
return c
|
||||
},
|
||||
wantError: errors.New("empty pages list"),
|
||||
},
|
||||
"empty page code": {
|
||||
giveConfig: func() config.Config {
|
||||
c := config.Config{}
|
||||
assert.NotEmpty(t, result)
|
||||
assert.NoError(t, err)
|
||||
|
||||
c.Templates = []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
}{
|
||||
{"foo", "bar", "baz"},
|
||||
}
|
||||
|
||||
c.Pages = map[string]struct {
|
||||
Message string `yaml:"message"`
|
||||
Description string `yaml:"description"`
|
||||
}{
|
||||
"": {"foo", "bar"},
|
||||
}
|
||||
|
||||
return c
|
||||
},
|
||||
wantError: errors.New("empty page code"),
|
||||
},
|
||||
"code should not contain whitespaces": {
|
||||
giveConfig: func() config.Config {
|
||||
c := config.Config{}
|
||||
|
||||
c.Templates = []struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Content string `yaml:"content"`
|
||||
}{
|
||||
{"foo", "bar", "baz"},
|
||||
}
|
||||
|
||||
c.Pages = map[string]struct {
|
||||
Message string `yaml:"message"`
|
||||
Description string `yaml:"description"`
|
||||
}{
|
||||
" 123": {"foo", "bar"},
|
||||
}
|
||||
|
||||
return c
|
||||
},
|
||||
wantError: errors.New("code should not contain whitespaces"),
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := tt.giveConfig().Validate()
|
||||
|
||||
if tt.wantError != nil {
|
||||
assert.EqualError(t, err, tt.wantError.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromYaml(t *testing.T) {
|
||||
var cases = []struct { //nolint:maligned
|
||||
name string
|
||||
giveYaml []byte
|
||||
giveEnv map[string]string
|
||||
wantErr bool
|
||||
checkResultFn func(*testing.T, *config.Config)
|
||||
}{
|
||||
{
|
||||
name: "with all possible values",
|
||||
giveEnv: map[string]string{
|
||||
"__GHOST_PATH": "./templates/ghost.html",
|
||||
"__GHOST_NAME": "ghost",
|
||||
},
|
||||
giveYaml: []byte(`
|
||||
templates:
|
||||
- path: ${__GHOST_PATH}
|
||||
name: ${__GHOST_NAME:-default_value} # name is optional
|
||||
- path: ./templates/l7-light.html
|
||||
- name: Foo
|
||||
content: |
|
||||
Some content
|
||||
New line
|
||||
|
||||
pages:
|
||||
400:
|
||||
message: Bad Request
|
||||
description: The server did not understand the request
|
||||
|
||||
401:
|
||||
message: Unauthorized
|
||||
description: The requested page needs a username and a password
|
||||
`),
|
||||
wantErr: false,
|
||||
checkResultFn: func(t *testing.T, cfg *config.Config) {
|
||||
assert.Len(t, cfg.Templates, 3)
|
||||
assert.Equal(t, "./templates/ghost.html", cfg.Templates[0].Path)
|
||||
assert.Equal(t, "ghost", cfg.Templates[0].Name)
|
||||
assert.Equal(t, "", cfg.Templates[0].Content)
|
||||
assert.Equal(t, "./templates/l7-light.html", cfg.Templates[1].Path)
|
||||
assert.Equal(t, "", cfg.Templates[1].Name)
|
||||
assert.Equal(t, "", cfg.Templates[1].Content)
|
||||
assert.Equal(t, "", cfg.Templates[2].Path)
|
||||
assert.Equal(t, "Foo", cfg.Templates[2].Name)
|
||||
assert.Equal(t, "Some content\nNew line\n", cfg.Templates[2].Content)
|
||||
|
||||
assert.Len(t, cfg.Pages, 2)
|
||||
assert.Equal(t, "Bad Request", cfg.Pages["400"].Message)
|
||||
assert.Equal(t, "The server did not understand the request", cfg.Pages["400"].Description)
|
||||
assert.Equal(t, "Unauthorized", cfg.Pages["401"].Message)
|
||||
assert.Equal(t, "The requested page needs a username and a password", cfg.Pages["401"].Description)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "broken yaml",
|
||||
giveYaml: []byte(`foo bar`),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.giveEnv != nil {
|
||||
for key, value := range tt.giveEnv {
|
||||
assert.NoError(t, os.Setenv(key, value))
|
||||
}
|
||||
}
|
||||
|
||||
conf, err := config.FromYaml(tt.giveYaml)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
tt.checkResultFn(t, conf)
|
||||
}
|
||||
|
||||
if tt.giveEnv != nil {
|
||||
for key := range tt.giveEnv {
|
||||
assert.NoError(t, os.Unsetenv(key))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromYamlFile(t *testing.T) {
|
||||
var cases = []struct { //nolint:maligned
|
||||
name string
|
||||
giveYamlFilePath string
|
||||
wantErr bool
|
||||
checkResultFn func(*testing.T, *config.Config)
|
||||
}{
|
||||
{
|
||||
name: "with all possible values",
|
||||
giveYamlFilePath: "./testdata/simple.yml",
|
||||
wantErr: false,
|
||||
checkResultFn: func(t *testing.T, cfg *config.Config) {
|
||||
assert.Len(t, cfg.Templates, 2)
|
||||
assert.Equal(t, "./templates/ghost.html", cfg.Templates[0].Path)
|
||||
assert.Equal(t, "ghost", cfg.Templates[0].Name)
|
||||
assert.Equal(t, "./templates/l7-light.html", cfg.Templates[1].Path)
|
||||
assert.Equal(t, "", cfg.Templates[1].Name)
|
||||
|
||||
assert.Len(t, cfg.Pages, 2)
|
||||
assert.Equal(t, "Bad Request", cfg.Pages["400"].Message)
|
||||
assert.Equal(t, "The server did not understand the request", cfg.Pages["400"].Description)
|
||||
assert.Equal(t, "Unauthorized", cfg.Pages["401"].Message)
|
||||
assert.Equal(t, "The requested page needs a username and a password", cfg.Pages["401"].Description)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "broken yaml",
|
||||
giveYamlFilePath: "./testdata/broken.yml",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong file path",
|
||||
giveYamlFilePath: "foo bar",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
conf, err := config.FromYamlFile(tt.giveYamlFilePath)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
tt.checkResultFn(t, conf)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Log(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
87
internal/config/rotation_mode.go
Normal file
87
internal/config/rotation_mode.go
Normal file
@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RotationMode represents the rotation mode for templates.
|
||||
type RotationMode byte
|
||||
|
||||
const (
|
||||
RotationModeDisabled RotationMode = iota // do not rotate templates, default
|
||||
RotationModeRandomOnStartup // pick a random template on startup
|
||||
RotationModeRandomOnEachRequest // pick a random template on each request
|
||||
RotationModeRandomHourly // once an hour switch to a random template
|
||||
RotationModeRandomDaily // once a day switch to a random template
|
||||
)
|
||||
|
||||
// String returns a human-readable representation of the rotation mode.
|
||||
func (rm RotationMode) String() string {
|
||||
switch rm {
|
||||
case RotationModeDisabled:
|
||||
return "disabled"
|
||||
case RotationModeRandomOnStartup:
|
||||
return "random-on-startup"
|
||||
case RotationModeRandomOnEachRequest:
|
||||
return "random-on-each-request"
|
||||
case RotationModeRandomHourly:
|
||||
return "random-hourly"
|
||||
case RotationModeRandomDaily:
|
||||
return "random-daily"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("RotationMode(%d)", rm)
|
||||
}
|
||||
|
||||
// RotationModes returns a slice of all rotation modes.
|
||||
func RotationModes() []RotationMode {
|
||||
return []RotationMode{
|
||||
RotationModeDisabled,
|
||||
RotationModeRandomOnStartup,
|
||||
RotationModeRandomOnEachRequest,
|
||||
RotationModeRandomHourly,
|
||||
RotationModeRandomDaily,
|
||||
}
|
||||
}
|
||||
|
||||
// RotationModeStrings returns a slice of all rotation modes as strings.
|
||||
func RotationModeStrings() []string {
|
||||
var (
|
||||
modes = RotationModes()
|
||||
result = make([]string, len(modes))
|
||||
)
|
||||
|
||||
for i := range modes {
|
||||
result[i] = modes[i].String()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseRotationMode parses a rotation mode (case is ignored) based on the ASCII representation of the rotation mode.
|
||||
// If the provided ASCII representation is invalid an error is returned.
|
||||
func ParseRotationMode[T string | []byte](text T) (RotationMode, error) {
|
||||
var mode string
|
||||
|
||||
if s, ok := any(text).(string); ok {
|
||||
mode = s
|
||||
} else {
|
||||
mode = string(any(text).([]byte))
|
||||
}
|
||||
|
||||
switch strings.ToLower(mode) {
|
||||
case RotationModeDisabled.String(), "":
|
||||
return RotationModeDisabled, nil // the empty string makes sense
|
||||
case RotationModeRandomOnStartup.String():
|
||||
return RotationModeRandomOnStartup, nil
|
||||
case RotationModeRandomOnEachRequest.String():
|
||||
return RotationModeRandomOnEachRequest, nil
|
||||
case RotationModeRandomHourly.String():
|
||||
return RotationModeRandomHourly, nil
|
||||
case RotationModeRandomDaily.String():
|
||||
return RotationModeRandomDaily, nil
|
||||
}
|
||||
|
||||
return RotationModeDisabled, fmt.Errorf("unrecognized rotation mode: %q", mode)
|
||||
}
|
90
internal/config/rotation_mode_test.go
Normal file
90
internal/config/rotation_mode_test.go
Normal file
@ -0,0 +1,90 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
)
|
||||
|
||||
func TestRotationMode_String(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "disabled", config.RotationModeDisabled.String())
|
||||
assert.Equal(t, "random-on-startup", config.RotationModeRandomOnStartup.String())
|
||||
assert.Equal(t, "random-on-each-request", config.RotationModeRandomOnEachRequest.String())
|
||||
assert.Equal(t, "random-daily", config.RotationModeRandomDaily.String())
|
||||
assert.Equal(t, "random-hourly", config.RotationModeRandomHourly.String())
|
||||
|
||||
assert.Equal(t, "RotationMode(255)", config.RotationMode(255).String())
|
||||
}
|
||||
|
||||
func TestRotationModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []config.RotationMode{
|
||||
config.RotationModeDisabled,
|
||||
config.RotationModeRandomOnStartup,
|
||||
config.RotationModeRandomOnEachRequest,
|
||||
config.RotationModeRandomHourly,
|
||||
config.RotationModeRandomDaily,
|
||||
}, config.RotationModes())
|
||||
}
|
||||
|
||||
func TestRotationModeStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"disabled",
|
||||
"random-on-startup",
|
||||
"random-on-each-request",
|
||||
"random-hourly",
|
||||
"random-daily",
|
||||
}, config.RotationModeStrings())
|
||||
}
|
||||
|
||||
func TestParseRotationMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, _tt := range map[string]struct {
|
||||
giveBytes []byte
|
||||
giveString string
|
||||
wantMode config.RotationMode
|
||||
wantErrorMsg string
|
||||
}{
|
||||
"<empty string>": {giveString: "", wantMode: config.RotationModeDisabled},
|
||||
"<empty bytes>": {giveBytes: []byte(""), wantMode: config.RotationModeDisabled},
|
||||
"disabled": {giveString: "disabled", wantMode: config.RotationModeDisabled},
|
||||
"disabled (bytes)": {giveBytes: []byte("disabled"), wantMode: config.RotationModeDisabled},
|
||||
"random-on-startup": {giveString: "random-on-startup", wantMode: config.RotationModeRandomOnStartup},
|
||||
"random-on-startup (bytes)": {giveBytes: []byte("random-on-startup"), wantMode: config.RotationModeRandomOnStartup},
|
||||
"on-each-request": {giveString: "random-on-each-request", wantMode: config.RotationModeRandomOnEachRequest},
|
||||
"daily": {giveString: "random-daily", wantMode: config.RotationModeRandomDaily},
|
||||
"hourly": {giveString: "random-hourly", wantMode: config.RotationModeRandomHourly},
|
||||
|
||||
"foobar": {giveString: "foobar", wantErrorMsg: "unrecognized rotation mode: \"foobar\""},
|
||||
} {
|
||||
tt := _tt
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var (
|
||||
mode config.RotationMode
|
||||
err error
|
||||
)
|
||||
|
||||
if tt.giveString != "" || tt.giveBytes == nil {
|
||||
mode, err = config.ParseRotationMode(tt.giveString)
|
||||
} else {
|
||||
mode, err = config.ParseRotationMode(tt.giveBytes)
|
||||
}
|
||||
|
||||
if tt.wantErrorMsg == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMode, mode)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tt.wantErrorMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
105
internal/config/templates.go
Normal file
105
internal/config/templates.go
Normal file
@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type templates map[string]string // map[name]content
|
||||
|
||||
// Add adds a new template.
|
||||
func (tpl templates) Add(name, content string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("template name cannot be empty")
|
||||
}
|
||||
|
||||
tpl[name] = content
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddFromFile reads the file content and adds it as a new template.
|
||||
func (tpl templates) AddFromFile(path string, name ...string) (addedTemplateName string, _ error) {
|
||||
// check if the file exists and is not a directory
|
||||
if stat, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("file %s not found", path)
|
||||
}
|
||||
|
||||
return "", err
|
||||
} else if stat.IsDir() {
|
||||
return "", fmt.Errorf("%s is not a file", path)
|
||||
}
|
||||
|
||||
// read the file content
|
||||
var content, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
var templateName string
|
||||
|
||||
if len(name) > 0 && name[0] != "" { // if the name is provided, use it
|
||||
templateName = name[0]
|
||||
} else { // otherwise, use the file name without the extension
|
||||
var (
|
||||
fileName = filepath.Base(path)
|
||||
ext = filepath.Ext(fileName)
|
||||
)
|
||||
|
||||
if ext != "" && fileName != ext {
|
||||
templateName = strings.TrimSuffix(fileName, ext)
|
||||
} else {
|
||||
templateName = fileName
|
||||
}
|
||||
}
|
||||
|
||||
// add the template to the config
|
||||
tpl[templateName] = string(content)
|
||||
|
||||
return templateName, nil
|
||||
}
|
||||
|
||||
// Names returns all template names sorted alphabetically.
|
||||
func (tpl templates) Names() []string {
|
||||
var names = make([]string, 0, len(tpl))
|
||||
|
||||
for name := range tpl {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
slices.Sort(names)
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// Has checks if the template with the specified name exists.
|
||||
func (tpl templates) Has(name string) (found bool) { _, found = tpl[name]; return } //nolint:nlreturn
|
||||
|
||||
// Get returns the template content by the specified name, if it exists.
|
||||
func (tpl templates) Get(name string) (data string, ok bool) { data, ok = tpl[name]; return } //nolint:nlreturn
|
||||
|
||||
// Remove deletes the template by the specified name.
|
||||
func (tpl templates) Remove(name string) (ok bool) {
|
||||
if _, ok = tpl[name]; ok {
|
||||
delete(tpl, name)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RandomName picks a random template name. It returns an empty string if there are no templates.
|
||||
func (tpl templates) RandomName() string {
|
||||
if len(tpl) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for name := range tpl { // map iteration order is unpredictable (random) by design
|
||||
return name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
164
internal/config/templates_test.go
Normal file
164
internal/config/templates_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTemplates_Common(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var tpl = make(templates)
|
||||
|
||||
t.Run("initial state", func(t *testing.T) {
|
||||
assert.Empty(t, tpl.Names())
|
||||
assert.False(t, tpl.Has("test"))
|
||||
|
||||
var got, ok = tpl.Get("test")
|
||||
|
||||
assert.Empty(t, got)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("add a template from variable", func(t *testing.T) {
|
||||
const testContent = "content"
|
||||
|
||||
assert.NoError(t, tpl.Add("test", testContent))
|
||||
assert.True(t, tpl.Has("test"))
|
||||
|
||||
var got, ok = tpl.Get("test")
|
||||
|
||||
assert.Equal(t, got, testContent)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"test"}, tpl.Names())
|
||||
assert.False(t, tpl.Has("_test99"))
|
||||
|
||||
assert.NoError(t, tpl.Add("_test99", ""))
|
||||
assert.NoError(t, tpl.Add("_test11", ""))
|
||||
|
||||
assert.Equal(t, []string{"_test11", "_test99", "test"}, tpl.Names()) // sorted
|
||||
assert.True(t, tpl.Has("_test99"))
|
||||
|
||||
assert.True(t, tpl.Remove("_test99"))
|
||||
assert.False(t, tpl.Has("_test99"))
|
||||
assert.False(t, tpl.Remove("_test99"))
|
||||
})
|
||||
|
||||
t.Run("adding template without a name should fail", func(t *testing.T) {
|
||||
assert.ErrorContains(t, tpl.Add("", "content"), "template name cannot be empty")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTemplates_AddFromFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, _tt := range map[string]struct {
|
||||
givePath string
|
||||
giveName func() []string
|
||||
|
||||
wantError string
|
||||
wantThisName string
|
||||
wantThisContent string
|
||||
}{
|
||||
"dotfile": {
|
||||
givePath: "./testdata/.dotfile",
|
||||
wantThisName: ".dotfile",
|
||||
},
|
||||
"dotfile with extension": {
|
||||
givePath: "./testdata/.dotfile_with.ext",
|
||||
wantThisName: ".dotfile_with",
|
||||
},
|
||||
"empty file": {
|
||||
givePath: "./testdata/empty.html",
|
||||
wantThisName: "empty",
|
||||
},
|
||||
"file with multiple dots but without a name": {
|
||||
givePath: "./testdata/file.with.multiple.dots",
|
||||
wantThisName: "file.with.multiple",
|
||||
},
|
||||
"name with spaces": {
|
||||
givePath: "./testdata/name with spaces.txt",
|
||||
wantThisName: "name with spaces",
|
||||
},
|
||||
"with content and a name": {
|
||||
givePath: "./testdata/with-content.htm",
|
||||
giveName: func() []string { return []string{"test name"} },
|
||||
wantThisName: "test name",
|
||||
wantThisContent: "<!DOCTYPE html><html lang=\"en\"></html>\n",
|
||||
},
|
||||
"with content but without a name": {
|
||||
givePath: "./testdata/with-content.htm",
|
||||
wantThisName: "with-content",
|
||||
wantThisContent: "<!DOCTYPE html><html lang=\"en\"></html>\n",
|
||||
},
|
||||
"filename with no extension": {
|
||||
givePath: "./testdata/without_extension",
|
||||
wantThisName: "without_extension",
|
||||
},
|
||||
|
||||
"file not found": {
|
||||
givePath: "./testdata/not-found",
|
||||
wantError: "file ./testdata/not-found not found",
|
||||
},
|
||||
"directory": {
|
||||
givePath: "./testdata",
|
||||
wantError: "./testdata is not a file",
|
||||
},
|
||||
} {
|
||||
var tt = _tt
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
tpl = make(templates)
|
||||
giveName []string
|
||||
)
|
||||
|
||||
if tt.giveName != nil {
|
||||
giveName = tt.giveName()
|
||||
}
|
||||
|
||||
var addedName, err = tpl.AddFromFile(tt.givePath, giveName...)
|
||||
|
||||
if tt.wantError == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, tpl.Has(tt.wantThisName))
|
||||
assert.Equal(t, addedName, tt.wantThisName)
|
||||
|
||||
var content, _ = tpl.Get(tt.wantThisName)
|
||||
|
||||
assert.Equal(t, content, tt.wantThisContent)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tt.wantError)
|
||||
|
||||
assert.False(t, tpl.Has(tt.wantThisName))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplates_RandomName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
tpl = templates{"test": "content", "test2": "content", "test3": "content"}
|
||||
|
||||
lastName = tpl.RandomName()
|
||||
changedCount int
|
||||
)
|
||||
|
||||
for range 1_000 {
|
||||
var name = tpl.RandomName()
|
||||
|
||||
if name != lastName {
|
||||
changedCount++
|
||||
}
|
||||
|
||||
lastName = name
|
||||
}
|
||||
|
||||
// I expect at least 100 different names in 1000 iterations
|
||||
assert.True(t, changedCount > 200)
|
||||
}
|
0
internal/config/testdata/.dotfile
vendored
Normal file
0
internal/config/testdata/.dotfile
vendored
Normal file
0
internal/config/testdata/.dotfile_with.ext
vendored
Normal file
0
internal/config/testdata/.dotfile_with.ext
vendored
Normal file
1
internal/config/testdata/broken.yml
vendored
1
internal/config/testdata/broken.yml
vendored
@ -1 +0,0 @@
|
||||
foo bar
|
0
internal/config/testdata/empty.html
vendored
Normal file
0
internal/config/testdata/empty.html
vendored
Normal file
0
internal/config/testdata/file.with.multiple.dots
vendored
Normal file
0
internal/config/testdata/file.with.multiple.dots
vendored
Normal file
0
internal/config/testdata/name with spaces.txt
vendored
Normal file
0
internal/config/testdata/name with spaces.txt
vendored
Normal file
13
internal/config/testdata/simple.yml
vendored
13
internal/config/testdata/simple.yml
vendored
@ -1,13 +0,0 @@
|
||||
templates:
|
||||
- path: ./templates/ghost.html
|
||||
name: ghost # name is optional
|
||||
- path: ./templates/l7-light.html
|
||||
|
||||
pages:
|
||||
400:
|
||||
message: Bad Request
|
||||
description: The server did not understand the request
|
||||
|
||||
401:
|
||||
message: Unauthorized
|
||||
description: The requested page needs a username and a password
|
1
internal/config/testdata/with-content.htm
vendored
Normal file
1
internal/config/testdata/with-content.htm
vendored
Normal file
@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html lang="en"></html>
|
0
internal/config/testdata/without_extension
vendored
Normal file
0
internal/config/testdata/without_extension
vendored
Normal file
21
internal/env/env.go
vendored
21
internal/env/env.go
vendored
@ -1,21 +0,0 @@
|
||||
// Package env contains all about environment variables, that can be used by current application.
|
||||
package env
|
||||
|
||||
import "os"
|
||||
|
||||
type envVariable string
|
||||
|
||||
const (
|
||||
ListenAddr envVariable = "LISTEN_ADDR" // IP address for listening
|
||||
ListenPort envVariable = "LISTEN_PORT" // port number for listening
|
||||
TemplateName envVariable = "TEMPLATE_NAME" // template name
|
||||
ConfigFilePath envVariable = "CONFIG_FILE" // path to the config file
|
||||
)
|
||||
|
||||
// String returns environment variable name in the string representation.
|
||||
func (e envVariable) String() string { return string(e) }
|
||||
|
||||
// Lookup retrieves the value of the environment variable. If the variable is present in the environment the value
|
||||
// (which may be empty) is returned and the boolean is true. Otherwise the returned value will be empty and the
|
||||
// boolean will be false.
|
||||
func (e envVariable) Lookup() (string, bool) { return os.LookupEnv(string(e)) }
|
45
internal/env/env_test.go
vendored
45
internal/env/env_test.go
vendored
@ -1,45 +0,0 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
assert.Equal(t, "LISTEN_ADDR", string(ListenAddr))
|
||||
assert.Equal(t, "LISTEN_PORT", string(ListenPort))
|
||||
assert.Equal(t, "TEMPLATE_NAME", string(TemplateName))
|
||||
assert.Equal(t, "CONFIG_FILE", string(ConfigFilePath))
|
||||
}
|
||||
|
||||
func TestEnvVariable_Lookup(t *testing.T) {
|
||||
cases := []struct {
|
||||
giveEnv envVariable
|
||||
}{
|
||||
{giveEnv: ListenAddr},
|
||||
{giveEnv: ListenPort},
|
||||
{giveEnv: TemplateName},
|
||||
{giveEnv: ConfigFilePath},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.giveEnv.String(), func(t *testing.T) {
|
||||
assert.NoError(t, os.Unsetenv(tt.giveEnv.String())) // make sure that env is unset for test
|
||||
|
||||
defer func() { assert.NoError(t, os.Unsetenv(tt.giveEnv.String())) }()
|
||||
|
||||
value, exists := tt.giveEnv.Lookup()
|
||||
assert.False(t, exists)
|
||||
assert.Empty(t, value)
|
||||
|
||||
assert.NoError(t, os.Setenv(tt.giveEnv.String(), "foo"))
|
||||
|
||||
value, exists = tt.giveEnv.Lookup()
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "foo", value)
|
||||
})
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
const internalErrorPattern = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Internal error occurred</title>
|
||||
<style>
|
||||
html,body {background-color: #0e0e0e;color:#fff;font-family:'Nunito',sans-serif;height:100%;margin:0}
|
||||
.message {height:100%;align-items:center;display:flex;justify-content:center;position:relative;font-size:1.4em}
|
||||
img {padding-right: .4em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message">
|
||||
<img src="https://hsto.org/webt/fs/sx/gt/fssxgtssfg689qxboqvjil5yz8g.png" alt="logo" height="32">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func HandleInternalHTTPError(ctx *fasthttp.RequestCtx, statusCode int, message string) {
|
||||
ctx.SetStatusCode(statusCode)
|
||||
ctx.SetContentType("text/html; charset=UTF-8")
|
||||
|
||||
_, _ = ctx.WriteString(strings.ReplaceAll(internalErrorPattern, "{{ message }}", message))
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package common_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNothing(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func LogRequest(h fasthttp.RequestHandler, log *zap.Logger) fasthttp.RequestHandler {
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
var (
|
||||
startedAt = time.Now()
|
||||
ua = string(ctx.UserAgent())
|
||||
)
|
||||
|
||||
h(ctx)
|
||||
|
||||
if strings.Contains(strings.ToLower(ua), "healthcheck") { // skip healthcheck requests logging
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("HTTP request processed",
|
||||
zap.String("useragent", ua),
|
||||
zap.String("method", string(ctx.Method())),
|
||||
zap.String("url", string(ctx.RequestURI())),
|
||||
zap.Int("status_code", ctx.Response.StatusCode()),
|
||||
zap.Bool("connection_close", ctx.Response.ConnectionClose()),
|
||||
zap.Duration("duration", time.Since(startedAt)),
|
||||
)
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package common_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNothing2(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
}
|
111
internal/http/handlers/error_page/cache.go
Normal file
111
internal/http/handlers/error_page/cache.go
Normal file
@ -0,0 +1,111 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5" //nolint:gosec
|
||||
"encoding/gob"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
type (
|
||||
// RenderedCache is a cache for rendered error pages. It's safe for concurrent use.
|
||||
// It uses a hash of the template and props as a key.
|
||||
//
|
||||
// To remove expired items, call ClearExpired method periodically (a bit more often than the ttl).
|
||||
RenderedCache struct {
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
items map[[32]byte]cacheItem // map[template_hash[0:15];props_hash[16:32]]cache_item
|
||||
}
|
||||
|
||||
cacheItem struct {
|
||||
content []byte
|
||||
addedAtNano int64
|
||||
}
|
||||
)
|
||||
|
||||
// NewRenderedCache creates a new RenderedCache with the specified ttl.
|
||||
func NewRenderedCache(ttl time.Duration) *RenderedCache {
|
||||
return &RenderedCache{ttl: ttl, items: make(map[[32]byte]cacheItem)}
|
||||
}
|
||||
|
||||
// genKey generates a key for the cache item by hashing the template and props.
|
||||
func (rc *RenderedCache) genKey(template string, props template.Props) [32]byte {
|
||||
var (
|
||||
key [32]byte
|
||||
th, ph = hash(template), hash(props) // template hash, props hash
|
||||
)
|
||||
|
||||
copy(key[:16], th[:]) // first 16 bytes for the template hash
|
||||
copy(key[16:], ph[:]) // last 16 bytes for the props hash
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// Has checks if the cache has an item with the specified template and props.
|
||||
func (rc *RenderedCache) Has(template string, props template.Props) bool {
|
||||
var key = rc.genKey(template, props)
|
||||
|
||||
rc.mu.RLock()
|
||||
_, ok := rc.items[key]
|
||||
rc.mu.RUnlock()
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// Put adds a new item to the cache with the specified template, props, and content.
|
||||
func (rc *RenderedCache) Put(template string, props template.Props, content []byte) {
|
||||
var key = rc.genKey(template, props)
|
||||
|
||||
rc.mu.Lock()
|
||||
rc.items[key] = cacheItem{content: content, addedAtNano: time.Now().UnixNano()}
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the content of the item with the specified template and props.
|
||||
func (rc *RenderedCache) Get(template string, props template.Props) ([]byte, bool) {
|
||||
var key = rc.genKey(template, props)
|
||||
|
||||
rc.mu.RLock()
|
||||
item, ok := rc.items[key]
|
||||
rc.mu.RUnlock()
|
||||
|
||||
return item.content, ok
|
||||
}
|
||||
|
||||
// ClearExpired removes all expired items from the cache.
|
||||
func (rc *RenderedCache) ClearExpired() {
|
||||
rc.mu.Lock()
|
||||
|
||||
var now = time.Now().UnixNano()
|
||||
|
||||
for key, item := range rc.items {
|
||||
if now-item.addedAtNano > rc.ttl.Nanoseconds() {
|
||||
delete(rc.items, key)
|
||||
}
|
||||
}
|
||||
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clear removes all items from the cache.
|
||||
func (rc *RenderedCache) Clear() {
|
||||
rc.mu.Lock()
|
||||
clear(rc.items)
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// hash returns an MD5 hash of the provided value (it may be any built-in type).
|
||||
func hash(in any) [16]byte {
|
||||
var b bytes.Buffer
|
||||
|
||||
if err := gob.NewEncoder(&b).Encode(in); err != nil {
|
||||
return [16]byte{} // never happens because we encode only built-in types
|
||||
}
|
||||
|
||||
return md5.Sum(b.Bytes()) //nolint:gosec
|
||||
}
|
86
internal/http/handlers/error_page/cache_test.go
Normal file
86
internal/http/handlers/error_page/cache_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package error_page_test
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
func TestRenderedCache_CRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cache = error_page.NewRenderedCache(time.Millisecond)
|
||||
|
||||
t.Run("has", func(t *testing.T) {
|
||||
assert.False(t, cache.Has("template", template.Props{}))
|
||||
cache.Put("template", template.Props{}, []byte("content"))
|
||||
assert.True(t, cache.Has("template", template.Props{}))
|
||||
|
||||
assert.False(t, cache.Has("template", template.Props{Code: 1}))
|
||||
assert.False(t, cache.Has("foo", template.Props{Code: 1}))
|
||||
})
|
||||
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
var got, ok = cache.Get("template", template.Props{})
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []byte("content"), got)
|
||||
|
||||
cache.Clear()
|
||||
|
||||
assert.False(t, cache.Has("template", template.Props{}))
|
||||
})
|
||||
|
||||
t.Run("not exists", func(t *testing.T) {
|
||||
var got, ok = cache.Get("template", template.Props{Code: 2})
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
|
||||
t.Run("race condition provocation", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
cache.Get("template", template.Props{})
|
||||
cache.Put("template"+strconv.Itoa(i), template.Props{}, []byte("content"))
|
||||
cache.Has("template", template.Props{})
|
||||
}(i)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
cache.ClearExpired()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderedCache_Expiring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cache = error_page.NewRenderedCache(10 * time.Millisecond)
|
||||
|
||||
cache.Put("template", template.Props{}, []byte("content"))
|
||||
cache.ClearExpired()
|
||||
assert.True(t, cache.Has("template", template.Props{}))
|
||||
|
||||
<-time.After(10 * time.Millisecond)
|
||||
|
||||
assert.True(t, cache.Has("template", template.Props{})) // expired, but not cleared yet
|
||||
cache.ClearExpired()
|
||||
assert.False(t, cache.Has("template", template.Props{})) // cleared
|
||||
}
|
62
internal/http/handlers/error_page/code.go
Normal file
62
internal/http/handlers/error_page/code.go
Normal file
@ -0,0 +1,62 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// extractCodeFromURL extracts the error code from the given URL.
|
||||
func extractCodeFromURL(url string) (uint16, bool) {
|
||||
var parts = strings.SplitN(strings.TrimLeft(url, "/"), "/", 1)
|
||||
|
||||
if len(parts) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = strings.ToLower(parts[0])
|
||||
ext = filepath.Ext(fileName) // ".html", ".htm", ".%something%" or an empty string
|
||||
)
|
||||
|
||||
if ext != "" && ext != ".html" && ext != ".htm" {
|
||||
return 0, false
|
||||
} else if ext != "" {
|
||||
fileName = strings.TrimSuffix(fileName, ext)
|
||||
}
|
||||
|
||||
if code, err := strconv.ParseUint(fileName, 10, 16); err == nil && code > 0 && code < 999 {
|
||||
return uint16(code), true //nolint:gosec
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// URLContainsCode checks if the given URL contains an error code.
|
||||
func URLContainsCode(url string) (ok bool) { _, ok = extractCodeFromURL(url); return } //nolint:nlreturn
|
||||
|
||||
// extractCodeFromHeaders extracts the error code from the given headers.
|
||||
func extractCodeFromHeaders(headers *fasthttp.RequestHeader) (uint16, bool) {
|
||||
if headers == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
|
||||
// HTTP status code returned by the request
|
||||
if value := headers.Peek("X-Code"); len(value) > 0 && len(value) <= 3 {
|
||||
if code, err := strconv.ParseUint(string(value), 10, 16); err == nil && code > 0 && code < 999 {
|
||||
return uint16(code), true //nolint:gosec
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// HeadersContainCode checks if the given headers contain an error code.
|
||||
func HeadersContainCode(headers *fasthttp.RequestHeader) (ok bool) {
|
||||
_, ok = extractCodeFromHeaders(headers)
|
||||
|
||||
return
|
||||
}
|
66
internal/http/handlers/error_page/code_test.go
Normal file
66
internal/http/handlers/error_page/code_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package error_page_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
)
|
||||
|
||||
func TestURLContainsCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for giveUrl, wantOk := range map[string]bool{
|
||||
"/404": true,
|
||||
"/404.htm": true,
|
||||
"/404.HTM": true,
|
||||
"/404.html": true,
|
||||
"/404.HtmL": true,
|
||||
"/404.css": false,
|
||||
"/foo/404": false,
|
||||
"/foo/404.html": false,
|
||||
"/error": false,
|
||||
"/": false,
|
||||
"/////": false,
|
||||
"///404//": false,
|
||||
"": false,
|
||||
} {
|
||||
t.Run(giveUrl, func(t *testing.T) {
|
||||
assert.Equal(t, wantOk, error_page.URLContainsCode(giveUrl))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadersContainCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mkHeaders = func(key, value string) *fasthttp.RequestHeader {
|
||||
var out = new(fasthttp.RequestHeader)
|
||||
|
||||
out.Set(key, value)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
for name, _tt := range map[string]struct {
|
||||
giveHeaders *fasthttp.RequestHeader
|
||||
wantOk bool
|
||||
}{
|
||||
"with code": {giveHeaders: mkHeaders("X-Code", "404"), wantOk: true},
|
||||
|
||||
"empty": {giveHeaders: nil},
|
||||
"no code": {giveHeaders: mkHeaders("X-Code", "")},
|
||||
"wrong": {giveHeaders: mkHeaders("X-Code", "foo")},
|
||||
"too big": {giveHeaders: mkHeaders("X-Code", "1000")},
|
||||
"too small": {giveHeaders: mkHeaders("X-Code", "0")},
|
||||
"negative": {giveHeaders: mkHeaders("X-Code", "-1")},
|
||||
} {
|
||||
tt := _tt
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.wantOk, error_page.HeadersContainCode(tt.giveHeaders))
|
||||
})
|
||||
}
|
||||
}
|
135
internal/http/handlers/error_page/format.go
Normal file
135
internal/http/handlers/error_page/format.go
Normal file
@ -0,0 +1,135 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
type preferredFormat = byte
|
||||
|
||||
const (
|
||||
unknownFormat preferredFormat = iota // should be first, no format detected
|
||||
jsonFormat // json
|
||||
xmlFormat // xml
|
||||
htmlFormat // html
|
||||
plainTextFormat // plain text
|
||||
)
|
||||
|
||||
// detectPreferredFormatForClient detects the preferred format for the client based on the headers.
|
||||
// It supports the following headers: Content-Type, Accept, X-Format.
|
||||
// If the headers are not set or the format is not recognized, it returns unknownFormat.
|
||||
func detectPreferredFormatForClient(headers *fasthttp.RequestHeader) preferredFormat { //nolint:funlen,gocognit
|
||||
var contentType, accept string
|
||||
|
||||
if contentTypeHeader := strings.TrimSpace(string(headers.Peek("Content-Type"))); contentTypeHeader != "" { //nolint:nestif,lll
|
||||
// https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Type
|
||||
// text/html; charset=utf-8
|
||||
// multipart/form-data; boundary=something
|
||||
// application/json
|
||||
if parts := strings.SplitN(contentTypeHeader, ";", 2); len(parts) > 1 { //nolint:mnd
|
||||
// take only the first part of the content type:
|
||||
// text/html; charset=utf-8
|
||||
// ^^^^^^^^^ - will be taken
|
||||
contentType = strings.TrimSpace(parts[0])
|
||||
} else {
|
||||
// take the whole value
|
||||
contentType = contentTypeHeader
|
||||
}
|
||||
} else if xFormatHeader := strings.TrimSpace(string(headers.Peek("X-Format"))); xFormatHeader != "" {
|
||||
// https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
|
||||
// Value of the `Accept` header sent by the client
|
||||
accept = xFormatHeader
|
||||
} else if acceptHeader := strings.TrimSpace(string(headers.Peek("Accept"))); acceptHeader != "" {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept
|
||||
// text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8
|
||||
// text/html
|
||||
// image/*
|
||||
// */*
|
||||
// text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
|
||||
accept = acceptHeader
|
||||
} else {
|
||||
return unknownFormat
|
||||
}
|
||||
|
||||
switch {
|
||||
case contentType != "":
|
||||
return mimeTypeToPreferredFormat(contentType)
|
||||
|
||||
case accept != "":
|
||||
type piece struct {
|
||||
mimeType string
|
||||
weight int // to avoid float32 comparison (weight 1.0 = 1_0, 0.9 = 0_9, 0.8 = 0_8, etc.)
|
||||
}
|
||||
|
||||
var pieces = make([]piece, 0, strings.Count(accept, ",")+1)
|
||||
|
||||
// split application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 into parts:
|
||||
// ^^^^^^^^^ - segment #3
|
||||
// ^^^^^^^^^^^^^^^^^^^^^ - segment #2
|
||||
// ^^^^^^^^^^^^^^^^^^^^^ - segment #1
|
||||
for _, segment := range strings.FieldsFunc(accept, func(r rune) bool { return r == ',' }) {
|
||||
// split segment into parts:
|
||||
//
|
||||
// application/xhtml+xml
|
||||
// ^^^^^^^^^^^^^^^^^^^^^ - part #1
|
||||
//
|
||||
// application/xml;q=0.9
|
||||
// ^^^^^ - part #2
|
||||
// ^^^^^^^^^^^^^^^ - part #1
|
||||
//
|
||||
// */*;q=0.8
|
||||
// ^^^^^ - part #2
|
||||
// ^^^ - part #1
|
||||
if parts := strings.SplitN(strings.TrimSpace(segment), ";", 2); len(parts) > 0 { //nolint:mnd,nestif
|
||||
if parts[0] == "*/*" {
|
||||
continue // skip the wildcard
|
||||
}
|
||||
|
||||
var p = piece{mimeType: parts[0], weight: 1_0} //nolint:mnd // by default the weight is 10 (1.0 in float)
|
||||
|
||||
if len(parts) > 1 { // we need to extract the weight
|
||||
// trim the `q=` prefix and try to parse the weight value
|
||||
if weight, err := strconv.ParseFloat(strings.TrimPrefix(strings.ToLower(parts[1]), "q="), 32); err == nil {
|
||||
if weight = math.Round(weight*100) / 100; weight <= 1 && weight >= 0 { //nolint:mnd
|
||||
p.weight = int(weight * 10) //nolint:mnd
|
||||
} else {
|
||||
p.weight = 0 // invalid weight, set it to 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pieces = append(pieces, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pieces) > 0 {
|
||||
slices.SortStableFunc(pieces, func(a, b piece) int { return b.weight - a.weight })
|
||||
|
||||
return mimeTypeToPreferredFormat(pieces[0].mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
return unknownFormat
|
||||
}
|
||||
|
||||
// mimeTypeToPreferredFormat converts a MIME type to a preferred format, using non-string comparison.
|
||||
func mimeTypeToPreferredFormat(mimeType string) preferredFormat {
|
||||
switch value := strings.ToLower(mimeType); {
|
||||
case strings.Contains(value, "/json"): // application/json text/json
|
||||
return jsonFormat
|
||||
case strings.Contains(value, "/xml"): // application/xml text/xml
|
||||
return xmlFormat
|
||||
case strings.Contains(value, "+xml"): // application/xhtml+xml
|
||||
return xmlFormat
|
||||
case strings.Contains(value, "/html"): // text/html
|
||||
return htmlFormat
|
||||
case strings.Contains(value, "/plain"): // text/plain
|
||||
return plainTextFormat
|
||||
}
|
||||
|
||||
return unknownFormat
|
||||
}
|
114
internal/http/handlers/error_page/format_test.go
Normal file
114
internal/http/handlers/error_page/format_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func Test_detectPreferredFormatForClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, _tt := range map[string]struct {
|
||||
giveHeaders map[string][]string
|
||||
wantFormat preferredFormat
|
||||
}{
|
||||
"content type json": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"application/jSoN"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"content type xml": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"application/xml; charset=UTF-8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
"content type html": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"text/hTmL; charset=utf-8"}},
|
||||
wantFormat: htmlFormat,
|
||||
},
|
||||
"content type plain": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"text/plaIN"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
|
||||
"accept json": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/jsoN,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"accept xml, depends on weight": {
|
||||
giveHeaders: map[string][]string{"Accept": {"text/html;q=0.5,application/xhtml+xml;q=0.9,application/xml;q=1,*/*;q=0.8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
"accept json, depends on weight": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/jsoN,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"accept xml": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
"accept html": {
|
||||
giveHeaders: map[string][]string{"Accept": {"text/html, application/xhtml+xml, application/xml;q=0.9, image/avif, image/webp, */*;q=0.8"}},
|
||||
wantFormat: htmlFormat,
|
||||
},
|
||||
"accept plain": {
|
||||
giveHeaders: map[string][]string{"Accept": {"text/plaiN,text/html,application/xml;q=0.9,,,*/*;q=0.8"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
"accept json, weighted values only": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/jsoN;Q=0.1,text/html;q=1.1,application/xml;q=-1,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
|
||||
"x-format json, depends on weight": {
|
||||
giveHeaders: map[string][]string{"X-Format": {"application/jsoN,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"x-format xml": {
|
||||
giveHeaders: map[string][]string{"X-Format": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
|
||||
"content type has priority over accept": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"text/plain"}, "Accept": {"application/xml"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
"accept has priority over x-format": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/xml"}, "X-Format": {"text/plain"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
|
||||
"empty headers": {
|
||||
giveHeaders: nil,
|
||||
},
|
||||
"empty content type": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {" "}},
|
||||
},
|
||||
"wrong content type": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"multipart/form-data; boundary=something"}},
|
||||
},
|
||||
"wrong accept": {
|
||||
giveHeaders: map[string][]string{"Accept": {";q=foobar,bar/baz;;;;;application/xml"}},
|
||||
},
|
||||
"none on invalid input": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"foo/bar; charset=utf-8"}, "Accept": {"foo/bar; charset=utf-8"}},
|
||||
},
|
||||
"completely unknown": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"😀"}, "Accept": {"😄"}, "X-Format": {"😍"}},
|
||||
},
|
||||
} {
|
||||
tt := _tt
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var headers = new(fasthttp.RequestHeader)
|
||||
|
||||
for key, values := range tt.giveHeaders {
|
||||
for _, value := range values {
|
||||
headers.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantFormat, detectPreferredFormatForClient(headers))
|
||||
})
|
||||
}
|
||||
}
|
284
internal/http/handlers/error_page/handler.go
Normal file
284
internal/http/handlers/error_page/handler.go
Normal file
@ -0,0 +1,284 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
// New creates a new handler that returns an error page with the specified status code and format.
|
||||
func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, closeCache func()) { //nolint:funlen,gocognit,gocyclo,lll
|
||||
// if the ttl will be bigger than 1 second, the template functions like `nowUnix` will not work as expected
|
||||
const cacheTtl = 900 * time.Millisecond // the cache TTL
|
||||
|
||||
var (
|
||||
cache, stopCh = NewRenderedCache(cacheTtl), make(chan struct{})
|
||||
stopOnce sync.Once
|
||||
)
|
||||
|
||||
// run a goroutine that will clear the cache from expired items. to stop the goroutine - close the stop channel
|
||||
// or call the closeCache
|
||||
go func() {
|
||||
var timer = time.NewTimer(cacheTtl)
|
||||
defer func() { timer.Stop(); cache.Clear() }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
cache.ClearExpired()
|
||||
timer.Reset(cacheTtl)
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
var (
|
||||
reqHeaders = &ctx.Request.Header
|
||||
code uint16
|
||||
)
|
||||
|
||||
if fromUrl, okUrl := extractCodeFromURL(string(ctx.Path())); okUrl {
|
||||
code = fromUrl
|
||||
} else if fromHeader, okHeaders := extractCodeFromHeaders(reqHeaders); okHeaders {
|
||||
code = fromHeader
|
||||
} else {
|
||||
code = cfg.DefaultCodeToRender
|
||||
}
|
||||
|
||||
var httpCode int
|
||||
|
||||
if cfg.RespondWithSameHTTPCode {
|
||||
httpCode = int(code)
|
||||
} else {
|
||||
httpCode = http.StatusOK
|
||||
}
|
||||
|
||||
var format = detectPreferredFormatForClient(reqHeaders)
|
||||
|
||||
{ // deal with the headers
|
||||
switch format {
|
||||
case jsonFormat:
|
||||
ctx.SetContentType("application/json; charset=utf-8")
|
||||
case xmlFormat:
|
||||
ctx.SetContentType("application/xml; charset=utf-8")
|
||||
case htmlFormat:
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
default:
|
||||
ctx.SetContentType("text/plain; charset=utf-8") // plainTextFormat as default
|
||||
}
|
||||
|
||||
// https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag
|
||||
// disallow indexing of the error pages
|
||||
ctx.Response.Header.Set("X-Robots-Tag", "noindex")
|
||||
|
||||
switch code {
|
||||
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
||||
// tell the client (search crawler) to retry the request after 120 seconds
|
||||
ctx.Response.Header.Set("Retry-After", "120")
|
||||
}
|
||||
|
||||
// proxy the headers from the incoming request to the error page response if they are defined in the config
|
||||
for _, proxyHeader := range cfg.ProxyHeaders {
|
||||
if value := reqHeaders.Peek(proxyHeader); len(value) > 0 {
|
||||
ctx.Response.Header.SetBytesV(proxyHeader, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetStatusCode(httpCode)
|
||||
|
||||
// prepare the template properties for rendering
|
||||
var tplProps = template.Props{
|
||||
Code: code, // http status code
|
||||
ShowRequestDetails: cfg.ShowDetails, // status message
|
||||
L10nDisabled: cfg.L10n.Disable, // status description
|
||||
}
|
||||
|
||||
//nolint:lll
|
||||
if cfg.ShowDetails { // https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
|
||||
tplProps.OriginalURI = string(reqHeaders.Peek("X-Original-URI")) // (ingress-nginx) URI that caused the error
|
||||
tplProps.Namespace = string(reqHeaders.Peek("X-Namespace")) // (ingress-nginx) namespace where the backend Service is located
|
||||
tplProps.IngressName = string(reqHeaders.Peek("X-Ingress-Name")) // (ingress-nginx) name of the Ingress where the backend is defined
|
||||
tplProps.ServiceName = string(reqHeaders.Peek("X-Service-Name")) // (ingress-nginx) name of the Service backing the backend
|
||||
tplProps.ServicePort = string(reqHeaders.Peek("X-Service-Port")) // (ingress-nginx) port number of the Service backing the backend
|
||||
tplProps.RequestID = string(reqHeaders.Peek("X-Request-Id")) // (ingress-nginx) unique ID that identifies the request - same as for backend service
|
||||
tplProps.ForwardedFor = string(reqHeaders.Peek("X-Forwarded-For")) // the value of the `X-Forwarded-For` header
|
||||
tplProps.Host = string(reqHeaders.Peek("Host")) // the value of the `Host` header
|
||||
}
|
||||
|
||||
// try to find the code message and description in the config and if not - use the standard status text or fallback
|
||||
if desc, found := cfg.Codes.Find(code); found {
|
||||
tplProps.Message = desc.Message
|
||||
tplProps.Description = desc.Description
|
||||
} else if stdlibStatusText := http.StatusText(int(code)); stdlibStatusText != "" {
|
||||
tplProps.Message = stdlibStatusText
|
||||
} else {
|
||||
tplProps.Message = "Unknown Status Code" // fallback
|
||||
}
|
||||
|
||||
switch {
|
||||
case format == jsonFormat && cfg.Formats.JSON != "":
|
||||
if cached, ok := cache.Get(cfg.Formats.JSON, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
|
||||
errAsJson, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
|
||||
write(ctx, log, errAsJson) // error during rendering
|
||||
} else {
|
||||
cache.Put(cfg.Formats.JSON, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content) // rendered successfully
|
||||
}
|
||||
}
|
||||
|
||||
case format == xmlFormat && cfg.Formats.XML != "":
|
||||
if cached, ok := cache.Get(cfg.Formats.XML, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
|
||||
write(ctx, log, fmt.Sprintf(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>\n", err.Error(),
|
||||
))
|
||||
} else {
|
||||
cache.Put(cfg.Formats.XML, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content)
|
||||
}
|
||||
}
|
||||
|
||||
case format == htmlFormat:
|
||||
var templateName = templateToUse(cfg)
|
||||
|
||||
if tpl, found := cfg.Templates.Get(templateName); found { //nolint:nestif
|
||||
if cached, ok := cache.Get(tpl, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(tpl, tplProps); err != nil {
|
||||
// TODO: add GZIP compression for the HTML content support
|
||||
write(ctx, log, fmt.Sprintf(
|
||||
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>\n",
|
||||
templateName,
|
||||
err.Error(),
|
||||
))
|
||||
} else {
|
||||
if !cfg.DisableMinification {
|
||||
if mini, minErr := template.MiniHTML(content); minErr != nil {
|
||||
log.Warn("HTML minification failed", logger.Error(minErr))
|
||||
} else {
|
||||
content = mini
|
||||
}
|
||||
}
|
||||
|
||||
cache.Put(tpl, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write(ctx, log, fmt.Sprintf(
|
||||
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>\n", templateName,
|
||||
))
|
||||
}
|
||||
|
||||
default: // plainTextFormat as default
|
||||
if cfg.Formats.PlainText != "" { //nolint:nestif
|
||||
if cached, ok := cache.Get(cfg.Formats.PlainText, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
|
||||
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
|
||||
} else {
|
||||
cache.Put(cfg.Formats.PlainText, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write(ctx, log, `The requested content format is not supported.
|
||||
Please create an issue on the project's GitHub page to request support for this format.
|
||||
|
||||
Supported formats: JSON, XML, HTML, Plain Text
|
||||
`)
|
||||
}
|
||||
}
|
||||
}, func() { stopOnce.Do(func() { close(stopCh) }) }
|
||||
}
|
||||
|
||||
var (
|
||||
templateChangedAt atomic.Pointer[time.Time] //nolint:gochecknoglobals // the time when the theme was changed last time
|
||||
pickedTemplate atomic.Pointer[string] //nolint:gochecknoglobals // the name of the randomly picked template
|
||||
)
|
||||
|
||||
// templateToUse decides which template to use based on the rotation mode and the last time the template was changed.
|
||||
func templateToUse(cfg *config.Config) string {
|
||||
switch rotationMode := cfg.RotationMode; rotationMode {
|
||||
case config.RotationModeDisabled:
|
||||
return cfg.TemplateName // not needed to do anything
|
||||
case config.RotationModeRandomOnStartup:
|
||||
return cfg.TemplateName // do nothing, the scope of this rotation mode is not here
|
||||
case config.RotationModeRandomOnEachRequest:
|
||||
return cfg.Templates.RandomName() // pick a random template on each request
|
||||
case config.RotationModeRandomHourly, config.RotationModeRandomDaily:
|
||||
var now, rndTemplate = time.Now(), cfg.Templates.RandomName()
|
||||
|
||||
if changedAt := templateChangedAt.Load(); changedAt == nil {
|
||||
// the template was not changed yet (first request)
|
||||
templateChangedAt.Store(&now)
|
||||
pickedTemplate.Store(&rndTemplate)
|
||||
|
||||
return rndTemplate
|
||||
} else {
|
||||
// is it time to change the template?
|
||||
if (rotationMode == config.RotationModeRandomHourly && changedAt.Hour() != now.Hour()) ||
|
||||
(rotationMode == config.RotationModeRandomDaily && changedAt.Day() != now.Day()) {
|
||||
templateChangedAt.Store(&now)
|
||||
pickedTemplate.Store(&rndTemplate)
|
||||
|
||||
return rndTemplate
|
||||
} else if lastUsed := pickedTemplate.Load(); lastUsed != nil {
|
||||
// time to change the template has not come yet, so use the last picked template
|
||||
return *lastUsed
|
||||
} else {
|
||||
// in case if the last picked template is not set, pick a random one and store it
|
||||
templateChangedAt.Store(&now)
|
||||
pickedTemplate.Store(&rndTemplate)
|
||||
|
||||
return rndTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg.TemplateName // the fallback of the fallback :D
|
||||
}
|
||||
|
||||
// write the content to the response writer and log the error if any.
|
||||
func write[T string | []byte](ctx *fasthttp.RequestCtx, log *logger.Logger, content T) {
|
||||
var data []byte
|
||||
|
||||
if s, ok := any(content).(string); ok {
|
||||
data = []byte(s)
|
||||
} else {
|
||||
data = any(content).([]byte)
|
||||
}
|
||||
|
||||
if _, err := ctx.Write(data); err != nil && log != nil {
|
||||
log.Error("failed to write the response body",
|
||||
logger.String("content", string(data)),
|
||||
logger.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
226
internal/http/handlers/error_page/handler_test.go
Normal file
226
internal/http/handlers/error_page/handler_test.go
Normal file
@ -0,0 +1,226 @@
|
||||
package error_page_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
giveConfig func() *config.Config
|
||||
giveUrl string
|
||||
giveHeaders map[string]string
|
||||
|
||||
wantStatusCode int
|
||||
wantHeaders map[string]string
|
||||
wantBodyIncludes []string
|
||||
}{
|
||||
"common, plain text": {
|
||||
giveConfig: func() *config.Config { cfg := config.New(); return &cfg },
|
||||
giveUrl: "http://testing/",
|
||||
giveHeaders: map[string]string{"Content-Type": "text/plain"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{"Content-Type": "text/plain; charset=utf-8"},
|
||||
wantBodyIncludes: []string{"Error 404", "Not Found"},
|
||||
},
|
||||
"common, html": {
|
||||
giveConfig: func() *config.Config {
|
||||
cfg := config.New()
|
||||
|
||||
cfg.TemplateName = "ghost"
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/",
|
||||
giveHeaders: map[string]string{"X-Format": "text/html", "X-Code": "407"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{"Content-Type": "text/html; charset=utf-8"},
|
||||
wantBodyIncludes: []string{
|
||||
"<!doctype html>",
|
||||
"<title>407: Proxy Authentication Required",
|
||||
"Proxy Authentication Required",
|
||||
},
|
||||
},
|
||||
"common, json": {
|
||||
giveConfig: func() *config.Config {
|
||||
cfg := config.New()
|
||||
|
||||
cfg.RespondWithSameHTTPCode = true
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/503.html?rnd=123",
|
||||
giveHeaders: map[string]string{"Accept": "application/json", "X-FooBar": "baz"},
|
||||
|
||||
wantStatusCode: http.StatusServiceUnavailable,
|
||||
wantHeaders: map[string]string{
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-FooBar": "", // is not in the list of proxy headers
|
||||
},
|
||||
wantBodyIncludes: []string{"503", "Service Unavailable"},
|
||||
},
|
||||
"common, xml": {
|
||||
giveConfig: func() *config.Config {
|
||||
cfg := config.New()
|
||||
|
||||
cfg.ProxyHeaders = append(cfg.ProxyHeaders, "X-FooBar")
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/500",
|
||||
giveHeaders: map[string]string{"Accept": "application/xml", "X-FooBar": "baz"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"X-FooBar": "baz",
|
||||
},
|
||||
wantBodyIncludes: []string{"500", "Internal Server Error"},
|
||||
},
|
||||
"show details": {
|
||||
giveConfig: func() *config.Config {
|
||||
cfg := config.New()
|
||||
|
||||
cfg.ShowDetails = true
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://example.com/503",
|
||||
giveHeaders: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"X-Original-URI": "/foo/bar",
|
||||
"X-Namespace": "some-Namespace",
|
||||
"X-Ingress-Name": "ingress-name",
|
||||
"X-Service-Name": "service-name",
|
||||
"X-Service-Port": "666",
|
||||
"X-Request-ID": "req-id-777",
|
||||
"X-Forwarded-For": "123.123.123.123:12312",
|
||||
},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{"Content-Type": "application/json; charset=utf-8"},
|
||||
wantBodyIncludes: []string{
|
||||
"503",
|
||||
"Service Unavailable",
|
||||
"details",
|
||||
"/foo/bar",
|
||||
"some-Namespace",
|
||||
"ingress-name",
|
||||
"service-name",
|
||||
"666",
|
||||
"req-id-777",
|
||||
"123.123.123.123:12312",
|
||||
"example.com",
|
||||
},
|
||||
},
|
||||
"fallback to StatusText if code is not found": {
|
||||
giveConfig: func() *config.Config {
|
||||
cfg := config.New()
|
||||
|
||||
cfg.Codes = config.Codes{}
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/100",
|
||||
giveHeaders: map[string]string{"Accept": "application/json"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{"Content-Type": "application/json; charset=utf-8"},
|
||||
wantBodyIncludes: []string{"100", "Continue"},
|
||||
},
|
||||
"unknown code": {
|
||||
giveConfig: func() *config.Config {
|
||||
cfg := config.New()
|
||||
|
||||
cfg.Codes = config.Codes{}
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/1",
|
||||
giveHeaders: map[string]string{"Accept": "application/json"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{"Content-Type": "application/json; charset=utf-8"},
|
||||
wantBodyIncludes: []string{"1", "Unknown Status Code"},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var handler, closeCache = error_page.New(tt.giveConfig(), logger.NewNop())
|
||||
defer closeCache()
|
||||
|
||||
req, reqErr := http.NewRequest(http.MethodGet, tt.giveUrl, http.NoBody)
|
||||
require.NoError(t, reqErr)
|
||||
|
||||
for k, v := range tt.giveHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
httptest.HandleFastRequest(t, handler, req, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, tt.wantStatusCode, status)
|
||||
|
||||
for hName, hWant := range tt.wantHeaders {
|
||||
for hGot := range headers {
|
||||
if hGot == hName {
|
||||
assert.Contains(t, hWant, headers.Get(hGot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, wantBodyInclude := range tt.wantBodyIncludes {
|
||||
assert.Contains(t, body, wantBodyInclude)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotationModeOnEachRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cfg = config.New()
|
||||
|
||||
cfg.RotationMode = config.RotationModeRandomOnEachRequest
|
||||
cfg.Templates = map[string]string{
|
||||
"foo": "foo",
|
||||
"bar": "bar",
|
||||
}
|
||||
|
||||
var (
|
||||
lastResponseBody string
|
||||
changedTimes int
|
||||
|
||||
handler, closeCache = error_page.New(&cfg, logger.NewNop())
|
||||
)
|
||||
|
||||
defer func() { closeCache(); closeCache(); closeCache() }() // multiple calls should not panic
|
||||
|
||||
for range 300 {
|
||||
req, reqErr := http.NewRequest(http.MethodGet, "http://testing/", http.NoBody)
|
||||
require.NoError(t, reqErr)
|
||||
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
httptest.HandleFastRequest(t, handler, req, func(status int, body string, headers http.Header) {
|
||||
if lastResponseBody != body {
|
||||
changedTimes++
|
||||
lastResponseBody = body
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
assert.True(t, changedTimes > 30, "the template should be changed at least 30 times")
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
package errorpage
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tarampampam/error-pages/internal/http/common"
|
||||
"github.com/tarampampam/error-pages/internal/tpl"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
const (
|
||||
UseRandom = "random"
|
||||
UseRandomOnEachRequest = "i-said-random"
|
||||
)
|
||||
|
||||
// NewHandler creates handler for error pages serving.
|
||||
func NewHandler(
|
||||
templateName string,
|
||||
templates map[string][]byte,
|
||||
codes map[string]tpl.Annotator,
|
||||
) (fasthttp.RequestHandler, error) {
|
||||
if len(templates) == 0 {
|
||||
return nil, errors.New("empty templates map")
|
||||
}
|
||||
|
||||
var (
|
||||
rnd = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec
|
||||
templateNames = templateTames(templates)
|
||||
)
|
||||
|
||||
if templateName == "" { // on empty template name
|
||||
templateName = templateNames[0] // pick the first
|
||||
} else if templateName == UseRandom { // on "random" template name
|
||||
templateName = templateNames[rnd.Intn(len(templateNames))] // pick the randomized
|
||||
}
|
||||
|
||||
if _, found := templates[templateName]; !found && templateName != UseRandomOnEachRequest {
|
||||
return nil, errors.New("wrong template name passed")
|
||||
}
|
||||
|
||||
var pages = tpl.NewErrors(templates, codes)
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
var useTemplate = templateName // default
|
||||
|
||||
if templateName == UseRandomOnEachRequest {
|
||||
useTemplate = templateNames[rnd.Intn(len(templateNames))] // pick the randomized
|
||||
}
|
||||
|
||||
if code, ok := ctx.UserValue("code").(string); ok {
|
||||
if content, err := pages.Get(useTemplate, code); err == nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
_, _ = ctx.Write(content)
|
||||
} else {
|
||||
common.HandleInternalHTTPError(
|
||||
ctx,
|
||||
fasthttp.StatusNotFound,
|
||||
"requested code not available: "+err.Error(),
|
||||
)
|
||||
}
|
||||
} else { // will never happen
|
||||
common.HandleInternalHTTPError(
|
||||
ctx,
|
||||
fasthttp.StatusInternalServerError,
|
||||
"cannot extract requested code from the request",
|
||||
)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func templateTames(templates map[string][]byte) []string {
|
||||
var templateNames = make([]string, 0, len(templates))
|
||||
|
||||
for name := range templates {
|
||||
templateNames = append(templateNames, name)
|
||||
}
|
||||
|
||||
sort.Strings(templateNames)
|
||||
|
||||
return templateNames
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package errorpage_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNothing(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
package healthz
|
||||
|
||||
import "github.com/valyala/fasthttp"
|
||||
|
||||
// checker allows to check some service part.
|
||||
type checker interface {
|
||||
// Check makes a check and return error only if something is wrong.
|
||||
Check() error
|
||||
}
|
||||
|
||||
// NewHandler creates healthcheck handler.
|
||||
func NewHandler(checker checker) fasthttp.RequestHandler {
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
if err := checker.Check(); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusServiceUnavailable)
|
||||
_, _ = ctx.WriteString(err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package healthz_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNothing(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
}
|
30
internal/http/handlers/live/handler.go
Normal file
30
internal/http/handlers/live/handler.go
Normal file
@ -0,0 +1,30 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// New creates a new handler that returns "OK" for GET and HEAD requests.
|
||||
func New() fasthttp.RequestHandler {
|
||||
var (
|
||||
body = []byte("OK\n")
|
||||
notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
)
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
switch string(ctx.Method()) {
|
||||
case fasthttp.MethodGet:
|
||||
ctx.SetContentType("text/plain; charset=utf-8")
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
_, _ = ctx.Write(body)
|
||||
|
||||
case fasthttp.MethodHead:
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
default:
|
||||
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
52
internal/http/handlers/live/handler_test.go
Normal file
52
internal/http/handlers/live/handler_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package live_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/live"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
handler = live.New()
|
||||
url = "http://testing"
|
||||
body = http.NoBody
|
||||
)
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "OK\n", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, method := range []string{
|
||||
http.MethodDelete,
|
||||
http.MethodPatch,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "Method Not Allowed\n", body)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
BIN
internal/http/handlers/static/favicon.ico
Normal file
BIN
internal/http/handlers/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
31
internal/http/handlers/static/handler.go
Normal file
31
internal/http/handlers/static/handler.go
Normal file
@ -0,0 +1,31 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
//go:embed favicon.ico
|
||||
var Favicon []byte
|
||||
|
||||
// New creates a new handler that returns the provided content for GET and HEAD requests.
|
||||
func New(content []byte) fasthttp.RequestHandler {
|
||||
var notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
switch string(ctx.Method()) {
|
||||
case fasthttp.MethodGet:
|
||||
ctx.SetContentType(http.DetectContentType(content))
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
_, _ = ctx.Write(content)
|
||||
|
||||
case fasthttp.MethodHead:
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
default:
|
||||
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
68
internal/http/handlers/static/handler_test.go
Normal file
68
internal/http/handlers/static/handler_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/static"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
handler = static.New([]byte{1, 2, 3})
|
||||
url = "http://testing"
|
||||
body = http.NoBody
|
||||
)
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "application/octet-stream", headers.Get("Content-Type"))
|
||||
assert.Equal(t, []byte{1, 2, 3}, []byte(body))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, method := range []string{
|
||||
http.MethodDelete,
|
||||
http.MethodPatch,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "Method Not Allowed\n", body)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeHTTP_Favicon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
httptest.HandleFast(t,
|
||||
static.New(static.Favicon),
|
||||
http.MethodGet,
|
||||
"http://testing",
|
||||
http.NoBody,
|
||||
func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "image/x-icon", headers.Get("Content-Type"))
|
||||
assert.Equal(t, static.Favicon, []byte(body))
|
||||
},
|
||||
)
|
||||
}
|
@ -2,25 +2,34 @@ package version
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// NewHandler creates version handler.
|
||||
func NewHandler(ver string) fasthttp.RequestHandler {
|
||||
var cache []byte
|
||||
// New creates a handler that returns the version of the service in JSON format.
|
||||
func New(ver string) fasthttp.RequestHandler {
|
||||
var body, _ = json.Marshal(struct { //nolint:errchkjson
|
||||
Version string `json:"version"`
|
||||
}{
|
||||
Version: strings.TrimSpace(ver),
|
||||
})
|
||||
|
||||
var notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
if cache == nil {
|
||||
cache, _ = json.Marshal(struct {
|
||||
Version string `json:"version"`
|
||||
}{
|
||||
Version: ver,
|
||||
})
|
||||
}
|
||||
switch string(ctx.Method()) {
|
||||
case fasthttp.MethodGet:
|
||||
ctx.SetContentType("application/json; charset=utf-8")
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
_, _ = ctx.Write(body)
|
||||
|
||||
ctx.SetContentType("application/json")
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
_, _ = ctx.Write(cache)
|
||||
case fasthttp.MethodHead:
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
default:
|
||||
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,52 @@
|
||||
package version_test
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
func TestNothing(t *testing.T) {
|
||||
t.Skip("tests for this package have not been implemented yet")
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/version"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
handler = version.New("\t\n foo@bar ")
|
||||
url = "http://testing"
|
||||
body = http.NoBody
|
||||
)
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "application/json; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, `{"version":"foo@bar"}`, body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, method := range []string{
|
||||
http.MethodDelete,
|
||||
http.MethodPatch,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "Method Not Allowed\n", body)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
69
internal/http/httptest/httptest.go
Normal file
69
internal/http/httptest/httptest.go
Normal file
@ -0,0 +1,69 @@
|
||||
// Package httptest provides utilities for (fast-)HTTP testing.
|
||||
package httptest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttputil"
|
||||
)
|
||||
|
||||
// HandleFastRequest serves http request using provided fasthttp handler and HTTP request.
|
||||
func HandleFastRequest(
|
||||
t *testing.T,
|
||||
handler fasthttp.RequestHandler,
|
||||
req *http.Request,
|
||||
check func(status int, body string, _ http.Header),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// create in-memory listener
|
||||
var ln = fasthttputil.NewInmemoryListener()
|
||||
defer func() { require.NoError(t, ln.Close()) }()
|
||||
|
||||
// start fasthttp server
|
||||
go func() { require.NoError(t, fasthttp.Serve(ln, handler)) }()
|
||||
|
||||
// send http request
|
||||
resp, respErr := (&http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return ln.Dial() },
|
||||
},
|
||||
}).Do(req)
|
||||
require.NoError(t, respErr)
|
||||
|
||||
// close response body after the test
|
||||
defer func() { assert.NoError(t, resp.Body.Close()) }()
|
||||
|
||||
// read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// check the response
|
||||
check(resp.StatusCode, string(respBody), resp.Header)
|
||||
}
|
||||
|
||||
// HandleFast serves http request using provided fasthttp handler.
|
||||
func HandleFast(
|
||||
t *testing.T,
|
||||
handler fasthttp.RequestHandler,
|
||||
method string,
|
||||
url string,
|
||||
body io.Reader,
|
||||
check func(status int, body string, _ http.Header),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// create http request
|
||||
req, reqErr := http.NewRequest(method, url, body)
|
||||
require.NoError(t, reqErr)
|
||||
|
||||
// serve http request
|
||||
HandleFastRequest(t, handler, req, check)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user