From d12db38a7313dabdae3c8a5e9f728d23dc19168b Mon Sep 17 00:00:00 2001 From: Varakh Date: Thu, 21 Dec 2023 17:04:04 +0100 Subject: [PATCH] Initial commit --- .editorconfig | 100 ++ .forgejo/workflows/build.yaml | 44 + .forgejo/workflows/release.yaml | 77 ++ .gitignore | 4 + CHANGELOG.md | 7 + Dockerfile | 49 + LICENSE.txt | 674 +++++++++++ Makefile | 62 + README.md | 334 ++++++ _doc/DEPLOYMENT.md | 162 +++ _doc/api.yaml | 1308 ++++++++++++++++++++++ _doc/updaserver.postman_collection.json | 952 ++++++++++++++++ api/constants.go | 77 ++ api/dto.go | 292 +++++ cmd/cli.go | 7 + cmd/server.go | 7 + go.mod | 77 ++ go.sum | 359 ++++++ renovate.json5 | 44 + server/api_handler_auth.go | 18 + server/api_handler_error.go | 77 ++ server/api_handler_event.go | 62 + server/api_handler_health.go | 20 + server/api_handler_info.go | 23 + server/api_handler_update.go | 106 ++ server/api_handler_webhook.go | 120 ++ server/api_handler_webhook_invocation.go | 62 + server/api_middleware.go | 55 + server/app.go | 150 +++ server/constants_api.go | 11 + server/constants_app.go | 6 + server/constants_env.go | 77 ++ server/constants_prometheus.go | 24 + server/datatype_json_map.go | 87 ++ server/entity.go | 56 + server/environment.go | 324 ++++++ server/errors.go | 48 + server/repository_event.go | 180 +++ server/repository_update.go | 302 +++++ server/repository_webhook.go | 166 +++ server/service_event.go | 214 ++++ server/service_prometheus.go | 139 +++ server/service_task.go | 206 ++++ server/service_update.go | 137 +++ server/service_webhook.go | 130 +++ server/service_webhook_invocation.go | 105 ++ terminal/app.go | 337 ++++++ util/secure_string.go | 62 + util/string.go | 53 + util/string_test.go | 37 + 50 files changed, 8030 insertions(+) create mode 100644 .editorconfig create mode 100644 .forgejo/workflows/build.yaml create mode 100644 .forgejo/workflows/release.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 _doc/DEPLOYMENT.md create mode 100644 _doc/api.yaml create mode 100644 _doc/updaserver.postman_collection.json create mode 100644 api/constants.go create mode 100644 api/dto.go create mode 100644 cmd/cli.go create mode 100644 cmd/server.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 renovate.json5 create mode 100644 server/api_handler_auth.go create mode 100644 server/api_handler_error.go create mode 100644 server/api_handler_event.go create mode 100644 server/api_handler_health.go create mode 100644 server/api_handler_info.go create mode 100644 server/api_handler_update.go create mode 100644 server/api_handler_webhook.go create mode 100644 server/api_handler_webhook_invocation.go create mode 100644 server/api_middleware.go create mode 100644 server/app.go create mode 100644 server/constants_api.go create mode 100644 server/constants_app.go create mode 100644 server/constants_env.go create mode 100644 server/constants_prometheus.go create mode 100644 server/datatype_json_map.go create mode 100644 server/entity.go create mode 100644 server/environment.go create mode 100644 server/errors.go create mode 100644 server/repository_event.go create mode 100644 server/repository_update.go create mode 100644 server/repository_webhook.go create mode 100644 server/service_event.go create mode 100644 server/service_prometheus.go create mode 100644 server/service_task.go create mode 100644 server/service_update.go create mode 100644 server/service_webhook.go create mode 100644 server/service_webhook_invocation.go create mode 100644 terminal/app.go create mode 100644 util/secure_string.go create mode 100644 util/string.go create mode 100644 util/string_test.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d3dd5c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,100 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = +ij_wrap_on_typing = false + +[{*.go,*.go2}] +ij_continuation_indent_size = 4 +ij_go_GROUP_CURRENT_PROJECT_IMPORTS = false +ij_go_add_leading_space_to_comments = false +ij_go_add_parentheses_for_single_import = false +ij_go_call_parameters_new_line_after_left_paren = true +ij_go_call_parameters_right_paren_on_new_line = true +ij_go_call_parameters_wrap = off +ij_go_fill_paragraph_width = 80 +ij_go_group_stdlib_imports = false +ij_go_import_sorting = gofmt +ij_go_keep_indents_on_empty_lines = false +ij_go_local_group_mode = project +ij_go_local_package_prefixes = +ij_go_move_all_imports_in_one_declaration = false +ij_go_move_all_stdlib_imports_in_one_group = false +ij_go_remove_redundant_import_aliases = false +ij_go_run_go_fmt_on_reformat = true +ij_go_use_back_quotes_for_imports = false +ij_go_wrap_comp_lit = off +ij_go_wrap_comp_lit_newline_after_lbrace = true +ij_go_wrap_comp_lit_newline_before_rbrace = true +ij_go_wrap_func_params = off +ij_go_wrap_func_params_newline_after_lparen = true +ij_go_wrap_func_params_newline_before_rparen = true +ij_go_wrap_func_result = off +ij_go_wrap_func_result_newline_after_lparen = true +ij_go_wrap_func_result_newline_before_rparen = true + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true + +[{*.markdown,*.md}] +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_format_tables = true +ij_markdown_insert_quote_arrows_on_wrap = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_keep_line_breaks_inside_text_blocks = true +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 +ij_markdown_wrap_text_if_long = true +ij_markdown_wrap_text_inside_blockquotes = true + +[{*.json,*.json5,*.jsonc}] +indent_size = 2 +ij_json_array_wrapping = split_into_lines +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_keep_trailing_comma = false +ij_json_object_wrapping = split_into_lines +ij_json_property_alignment = do_not_align +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = false +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true \ No newline at end of file diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml new file mode 100644 index 0000000..caf0645 --- /dev/null +++ b/.forgejo/workflows/build.yaml @@ -0,0 +1,44 @@ +on: + pull_request: + types: [ opened, synchronize, reopened ] + push: + branches: + - master +env: + IMAGE_TAG: varakh/upda + REVISION: ${{ github.sha }} +jobs: + build: + runs-on: docker + container: + image: node:20-bookworm + steps: + - name: Prepare requirements + run: | + apt-get update + apt-get install -y curl wget bash apt-transport-https ca-certificates gnupg zstd clang + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update + apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '^1.21' + - name: Checkout + uses: actions/checkout@v3 + - name: Test native build + run: | + make ci + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Test docker image build + uses: docker/build-push-action@v4 + with: + push: false + tags: | + ${{ env.IMAGE_TAG }}:${{ github.sha }} diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..5d3c0ef --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,77 @@ +on: + push: + tags: + - '*' +env: + VERSION_MAJOR: 1 + VERSION_MINOR: 0 + VERSION_PATCH: 0 + IMAGE_TAG: varakh/upda + IMAGE_TAG_PRIVATE: git.myservermanager.com/varakh/upda + FORGEJO_URL: https://git.myservermanager.com + FORGEJO_FQDN: git.myservermanager.com + FORGEJO_REPO: varakh/upda + REVISION: ${{ github.sha }} +jobs: + release: + runs-on: docker + container: + image: node:20-bookworm + steps: + - name: Prepare requirements + run: | + apt-get update + apt-get install -y curl wget bash apt-transport-https ca-certificates gnupg zstd clang + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update + apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '^1.21' + - name: Checkout + uses: actions/checkout@v3 + - name: Test native build + run: | + make ci + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Forgejo + uses: docker/login-action@v2 + with: + registry: ${{ env.FORGEJO_FQDN }} + username: ${{ secrets.FORGEJO_USER }} + password: ${{ secrets.FORGEJO_TOKEN }} + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_KEY }} + - name: Build and push docker image + uses: docker/build-push-action@v4 + with: + push: true + tags: | + ${{ env.IMAGE_TAG_PRIVATE }}:${{ env.VERSION_MAJOR }} + ${{ env.IMAGE_TAG_PRIVATE }}:${{ env.VERSION_MAJOR }}.${{ env.VERSION_MINOR }} + ${{ env.IMAGE_TAG_PRIVATE }}:${{ env.VERSION_MAJOR }}.${{ env.VERSION_MINOR }}.${{ env.VERSION_PATCH }} + ${{ env.IMAGE_TAG_PRIVATE }}:latest + ${{ env.IMAGE_TAG }}:${{ env.VERSION_MAJOR }} + ${{ env.IMAGE_TAG }}:${{ env.VERSION_MAJOR }}.${{ env.VERSION_MINOR }} + ${{ env.IMAGE_TAG }}:${{ env.VERSION_MAJOR }}.${{ env.VERSION_MINOR }}.${{ env.VERSION_PATCH }} + ${{ env.IMAGE_TAG }}:latest + - name: Upload artifacts + uses: actions/forgejo-release@v1 + with: + url: ${{ env.FORGEJO_URL }} + repo: ${{ env.FORGEJO_REPO }} + token: ${{ secrets.FORGEJO_TOKEN }} + direction: upload + tag: ${{ github.ref_name }} + release-dir: bin + release-notes: "Release of version '${{ env.VERSION_MAJOR }}.${{ env.VERSION_MINOR }}.${{ env.VERSION_PATCH }}'. For a complete changelog, look into ${{ env.FORGEJO_URL }}/${{ env.FORGEJO_REPO }}/src/tag/${{ github.ref_name }}/CHANGELOG.md" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d3b806 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +.idea/ +*.iml +.run/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f8699a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# CHANGELOG + +Changes adhere to [semantic versioning](https://semver.org). + +## [1.0.0] - UNRELEASED + +* Initial release diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e7b365 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# +# Build image +# +FROM alpine:3.18 AS builder +LABEL maintainer="Varakh " + +RUN apk --update upgrade && \ + apk add go gcc make sqlite && \ + # See https://stackoverflow.com/questions/34729748/installed-go-binary-not-found-in-path-on-alpine-linux-docker + mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 && \ + rm -rf /var/cache/apk/* + +WORKDIR /app +COPY . . +RUN rm -rf bin/ && \ + CC=gcc make ci + +# +# Actual image +# +FROM alpine:3.18 +LABEL maintainer="Varakh " \ + description="upda" \ + org.opencontainers.image.authors="Varakh" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.vendor="Varakh" \ + org.opencontainers.image.title="upda" \ + org.opencontainers.image.description="upda" \ + org.opencontainers.image.base.name="alpine:3.18" + +ENV USER=appuser \ + GROUP=appuser \ + UID=2033 \ + GID=2033 + +RUN apk --update upgrade && \ + apk add sqlite tzdata && \ + rm -rf /var/cache/apk/* && \ + addgroup -S ${GROUP} -g ${GID} && \ + adduser -S ${USER} -G ${GROUP} -u ${UID} + +COPY --from=builder /app/bin/upda-cli-linux-amd64 /usr/bin/upda-cli +COPY --from=builder /app/bin/upda-server-linux-amd64 /usr/bin/upda-server + +USER ${USER} + +ENV SERVER_PORT 8080 +EXPOSE ${SERVER_PORT} +CMD ["/usr/bin/upda-server"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e62ec04 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e62095 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +BIN_DIR = $(shell pwd)/bin + +clean: + rm -rf ${BIN_DIR} + +dependencies: + GO111MODULE=on go mod download + +ci: clean dependencies test-ci build-server-ci build-cli-ci + +build-server-ci: build-server-linux-amd64 + +# server requires CGO_ENABLED=1 for go-sqlite +build-server-freebsd-amd64: + CGO_ENABLED=1 GO111MODULE=on GOOS=freebsd GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-freebsd-amd64 cmd/server.go +build-server-freebsd-arm64: + CGO_ENABLED=1 GO111MODULE=on GOOS=freebsd GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-freebsd-arm64 cmd/server.go +build-server-darwin-amd64: + CGO_ENABLED=1 GO111MODULE=on GOOS=darwin GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-darwin-amd64 cmd/server.go +build-server-darwin-arm64: + CGO_ENABLED=1 GO111MODULE=on GOOS=darwin GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-darwin-arm64 cmd/server.go +build-server-linux-amd64: + CGO_ENABLED=1 GO111MODULE=on GOOS=linux GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-linux-amd64 cmd/server.go +build-server-linux-arm64: + CGO_ENABLED=1 GO111MODULE=on GOOS=linux GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-linux-arm64 cmd/server.go +build-server-windows-amd64: + CGO_ENABLED=1 GO111MODULE=on GOOS=windows GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-windows-amd64 cmd/server.go +build-server-windows-arm64: + CGO_ENABLED=1 GO111MODULE=on GOOS=windows GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-windows-arm64 cmd/server.go + +# cli does not require CGO_ENABLED=1, cross-platform build possible +build-cli-ci: build-cli-linux-amd64 + +build-cli-all: build-cli-freebsd-amd64 build-cli-freebsd-arm64 build-cli-darwin-amd64 build-cli-darwin-arm64 build-cli-linux-amd64 build-cli-linux-arm64 build-cli-windows-amd64 build-cli-windows-arm64 + +build-cli-freebsd-amd64: + CGO_ENABLED=0 GO111MODULE=on GOOS=freebsd GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-freebsd-amd64 cmd/cli.go +build-cli-freebsd-arm64: + CGO_ENABLED=0 GO111MODULE=on GOOS=freebsd GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-freebsd-arm64 cmd/cli.go +build-cli-darwin-amd64: + CGO_ENABLED=0 GO111MODULE=on GOOS=darwin GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-darwin-amd64 cmd/cli.go +build-cli-darwin-arm64: + CGO_ENABLED=0 GO111MODULE=on GOOS=darwin GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-darwin-arm64 cmd/cli.go +build-cli-linux-amd64: + CGO_ENABLED=0 GO111MODULE=on GOOS=linux GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-linux-amd64 cmd/cli.go +build-cli-linux-arm64: + CGO_ENABLED=0 GO111MODULE=on GOOS=linux GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-linux-arm64 cmd/cli.go +build-cli-windows-amd64: + CGO_ENABLED=0 GO111MODULE=on GOOS=windows GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-windows-amd64 cmd/cli.go +build-cli-windows-arm64: + CGO_ENABLED=0 GO111MODULE=on GOOS=windows GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-windows-arm64 cmd/cli.go + +test: test-server test-cli test-util + +test-ci: test + +test-server: + GO111MODULE=on go test ./server/... +test-cli: + GO111MODULE=on go test ./terminal/... +test-util: + GO111MODULE=on go test ./util/... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf2fd39 --- /dev/null +++ b/README.md @@ -0,0 +1,334 @@ +# README + +upda - **Up**date **Da**shboard in Go. Please see [motivation](#motivation) and [concepts](#concepts) what this +application does. + +There's also a [upda web interface](https://git.myservermanager.com/varakh/upda-ui). It's recommended to take a look (at +least at the screenshots). + +In addition, there's a commandline tool called `upda-cli`. For more information, download it and run `./upda-cli help` +for further instructions. This is especially useful, if you have an `upda` (server) running and like to invoke webhooks +from CLI. `upda-cli` is also bundled in the docker images. + +**See the [deployment instructions](./_doc/DEPLOYMENT.md) for examples on how to deploy upda and upda-ui** + +The main git repository is hosted at +_[https://git.myservermanager.com/varakh/upda](https://git.myservermanager.com/varakh/upda)_. +Other repositories are mirrors and pull requests, issues, and planning are managed there. + +Contributions are very welcome! + +* [Motivation](#motivation) +* [Concepts](#concepts) +* [Configuration](#configuration) +* [3rd party integrations](#3rd-party-integrations) + * [Webhooks](#webhooks) + * [Prometheus Metrics](#prometheus-metrics) +* [Deployment](#deployment) + * [Native](#native) + * [Docker](#docker) + * [Build docker image](#build-docker-image) +* [Development & contribution](#development--contribution) + * [Getting started](#getting-started) + * [Windows hints](#windows-hints) + * [Release](#release) + +## Motivation + +> [duin](https://crazymax.dev/diun/) can determine which OCI images have updates +> available. [Argus](https://release-argus.io) can query other sources like GitHub and even invoke actions when an +> update +> has been found, but there's no _convenient_ way of having **one** dashboard or source of truth for all of them across +> different hosts without tinkering with collecting them somewhere in one place. This application is the result of that +> tinkering. :-) + +Managing various application or OCI container image updates can be a tedious task: + +* A lot of hosts to operate with a lot of different applications being deployed +* A lot of different OCI containers to watch for updated images +* No convenient dashboard to see and manage all the available updates in one place + +_upda_ manages a list of updates with attributes attached to it. For new updates to arrive, _upda_ needs to be called +via a webhook call (created within _upda_) from other applications, such as a bash script, an +application like [duin](https://crazymax.dev/diun/) or simply by using the `upda-cli`. + +After an update is being tracked, _upda_ provides a convenient way to have everything in one place. In addition, it +exposes managed _updates_ as [prometheus](https://prometheus.io) metrics, so that you can easily build a dashboard +in [Grafana](https://grafana.com), or even attach alerts to pending updates +via [alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/). + +In addition, you can use _upda_'s UI to manage updates, e.g. _approve_ them when they have been rolled out to a host. + +Important to note: + +* _upda_ is **NOT a scraper** to watch docker registries or GitHub releases, it simply collects and consolidates updates + from different sources via _webhooks_. If you like to watch GitHub releases, write a scraper and use `upda-cli` to + report back to _upda_. +* _upda_ uses basic auth for administrative tasks like viewing available updates or setting up the initial webhooks. + +## Concepts + +_upda_ retrieves new updates when webhooks of upda are invoked, e.g., [duin](https://crazymax.dev/diun/) invokes it +or any other application which can reach the instance. +Tracked updates are unique for the attributes `(application,provider,host)` which means that subsequent updates for an +identical _application_, _provider_ and _host_ simply updates the `version` and `metadata` attributes for that tracked +_update_ (regardless if the version or metadata payload _actually_ changed - reasoning behind this is to get reflected +metadata updates independent if version attribute has changed). + +State management of tracked updates: + +* On first creation, state is set to _pending_. +* When an _update_ is in _approved_ state, an invocation for it resets its state to _pending_. +* _Ignored_ updates are skipped entirely and no attribute is updated. + +##### The `application` attribute + +The _application_ attribute is an arbitrary identifier, name or label of a subject you like to track, +e.g., `docker.io/varakh/upda` for an OCI image. + +##### The `provider` attribute + +The _provider_ attribute is an arbitrary name or label. During webhook invocation the provider attribute is derived in +priority: + +For the _generic_ webhook: + +1. If the incoming payload contains a non-blank `provider` attribute, it's taken from the request. +2. If the incoming payload contains a blank or missing `provider` attribute, the issuing webhook's label is taken. + +For the _diun_ webhook: + +1. If the issuing webhook's label is blank, then `oci` is used. +2. In any other case, the webhook's label is used. + +Because the first priority is the issuing webhook's label, setting the _same_ label for all webhooks results in a +grouping. Also see the _ignore host_ setting for `host` below. + +_Remember that changing a webhook's label won't be reflected in already created/tracked updates!_ + +##### The `host` attribute + +_host_ should be set to the originating host name a webhook has been issued from. The _host_ +attribute can also be "ignored" (a setting in each webhook). If set to ignored, _upda_ sets _host_ to _global_, thus +update versions can be grouped independent of the originating host. If set for all webhooks, you'll end up with a host +independent update dashboard. + +##### The `version` attribute + +The _version_ attribute is an arbitrary name or label and subject to change across invocations of webhooks. This can be +a version number, a number of total updates, anything. + +##### The `metadata` attribute + +An update can hold any additional metadata information provided by request payload `metadata`. Metadata can be inspected +via web interface or API. + +## Configuration + +The following environment variables can be used to modify application behavior. + +| Variable | Purpose | Default/Description | +|:-----------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------| +| `TZ` | The time zone (**recommended** to set it properly, background tasks depend on it) | Defaults to `Europe/Berlin`, can be any time zone according to _tz database_ | +| `ADMIN_USER` | Admin user name for login | Not set by default, you need to explicitly set it to user name | +| `ADMIN_PASSWORD` | Admin password for login | Not set by default, you need to explicitly set it to a secure random | +| | | | +| `DB_TYPE` | The database type (Postgres is **recommended**) | Defaults to `sqlite`, possible values are `sqlite` or `postgres` | +| `DB_SQLITE_FILE` | Path to the SQLITE file | Defaults to `/upda/upda.db`, e.g. `~/.local/share/upda/upda.db` | +| `DB_POSTGRES_HOST` | The postgres host | Postgres host address, defaults to `localhost` | +| `DB_POSTGRES_PORT` | The postgres port | Postgres port, defaults to `5432` | +| `DB_POSTGRES_NAME` | The postgres database name | Postgres database name, needs to be set | +| `DB_POSTGRES_TZ` | The postgres time zone | Postgres time zone settings, defaults to `Europe/Berlin` | +| `DB_POSTGRES_USER` | The postgres user | Postgres user name, needs to be set | +| `DB_POSTGRES_PASSWORD` | The postgres password | Postgres user password, needs to be set | +| | | | +| `SERVER_PORT` | Port | Defaults to `8080` | +| `SERVER_LISTEN` | Server's listen address | Defaults to empty which equals `0.0.0.0` | +| `SERVER_TLS_ENABLED` | If server uses TLS | Defaults `false` | +| `SERVER_TLS_CERT_PATH` | When TLS enabled, provide the certificate path | | +| `SERVER_TLS_KEY_PATH` | When TLS enabled, provide the key path | | +| `SERVER_TIMEOUT` | Timeout the server waits before shutting down to end any pending tasks | Defaults to `1s` (1 second), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number | +| `CORS_ALLOW_ORIGIN` | CORS configuration | Defaults to `*` | +| `CORS_ALLOW_METHODS` | CORS configuration | Defaults to `GET, POST, PUT, PATCH, DELETE, OPTIONS` | +| `CORS_ALLOW_HEADERS` | CORS configuration | Defaults to `Authorization, Content-Type` | +| | | | +| `LOGGING_LEVEL` | Logging level. Possible are `debug`, `info`, `warn`, `error`, `dpanic`, `panic`, `fatal` | Defaults to `info` | +| | | | +| `WEBHOOKS_TOKEN_LENGTH` | The length of the token | Defaults to `16`, positive number | +| | | | +| `TASK_UPDATE_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (ignored/approved) updates from the database | Defaults to `true` | +| `TASK_UPDATE_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (ignored/approved) updates from the database | Defaults to `1h` (1 hour), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number | +| `TASK_UPDATE_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (ignored/approved) updates are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `168h` (168 hours = 1 week), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number | +| `TASK_EVENT_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (old) events from the database | Defaults to `true` | +| `TASK_EVENT_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (old) events from the database | Defaults to `8h` (8 hours), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number | +| `TASK_EVENT_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (old) events are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `2190h` (2190 hours = 3 months), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number | +| `TASK_PROMETHEUS_REFRESH_INTERVAL` | Interval at which a background task updates custom metrics | Defaults to `60s` (60 seconds), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number | +| `TASK_LOCK_REDIS_ENABLED` | If task locking (multiple instances) is enabled. Requires REDIS. | Defaults to `false` | +| `TASK_LOCK_REDIS_URL` | If task locking via REDIS is enabled, this should point to a resolvable REDIS instance, e.g. `redis://:@localhost:6379/`. | | +| | | | +| `PROMETHEUS_ENABLED` | If Prometheus metrics are exposed | Defaults to `false` | +| `PROMETHEUS_METRICS_PATH` | Defines the metrics endpoint path | Defaults to `/metrics` | +| `PROMETHEUS_SECURE_TOKEN_ENABLED` | If Prometheus metrics endpoint is protected by a token when enabled (**recommended**) | Defaults to `true` | +| `PROMETHEUS_SECURE_TOKEN` | The token securing the metrics endpoint when enabled (**recommended**) | Not set by default, you need to explicitly set it to a secure random | + +## 3rd party integrations + +### Webhooks + +This is the core mechanism of _upda_ and why it exists. Webhooks are the central piece of how _upda_ gets notified about +updates. + +In order to configure a 3rd party application like [duin](https://crazymax.dev/diun/) to send updates to _upda_ with +the [duin webhook notification configuration](https://crazymax.dev/diun/notif/webhook/), **create** a new _upda_ webhook +token via _upda_'s web interface or via API call. This gives you + +* a unique _upda_ URL to configure in the notification part of [duin](https://crazymax.dev/diun/), + e.g., `/api/v1/webhooks/` +* a corresponding token for the URL which must be sent as `X-Webhook-Token` header when calling _upda_'s URL + +Expected payload is derived from the _type_ of the webhook which has been created in _upda_. + +Example for [duin Webhook notification](https://crazymax.dev/diun/notif/webhook/) `notif`: + +```yaml +notif: + webhook: + endpoint: https://upda.domain.tld/api/v1/webhooks/ee03cd9e-04d0-4c7f-9866-efe219c2501e + method: POST + headers: + content-type: application/json + X-Webhook-Token: + timeout: 10s +``` + +### Prometheus Metrics + +When `PROMETHEUS_ENABLED` is set to `true`, default metrics about memory utilization, but also custom metrics specific +to _upda_ are exposed under the `PROMETHEUS_METRICS_PATH` endpoint. + +A Prometheus scrape configuration might look like the following if `PROMETHEUS_SECURE_TOKEN_ENABLED` is set to `true`. + +```shell +scrape_configs: + - job_name: 'upda' + static_configs: + - targets: ['upda:8080'] + bearer_token: 'VALUE_OF_PROMETHEUS_SECURE_TOKEN' +``` + +Custom exposed metrics are exposed under the `upda_` namespace. + +Examples: + +```shell +# HELP upda_updates details for all updates, 0=pending, 1=approved, 2=ignored +upda_updates{application="codeberg.org/forgejo/forgejo",host="myserver",provider="oci"} 0 +upda_updates{application="docker.io/library/mysql",host="myserver",provider="oci"} 2 +upda_updates{application="quay.io/navidys/prometheus-podman-exporter",host="myserver",provider="oci"} 1 +upda_updates{application="quay.io/navidys/prometheus-podman-exporter",host="myserver2",provider="oci"} 1 +# HELP upda_updates_all amount of all updates +upda_updates_all 4 +# HELP upda_updates_approved amount of all updates in approved state +upda_updates_approved 2 +# HELP upda_updates_ignored amount of all updates in ignored state +upda_updates_ignored 1 +# HELP upda_updates_pending amount of all updates in pending state +upda_updates_pending 1 +# HELP upda_webhooks amount of all webhooks +upda_webhooks 2 +# HELP upda_events amount of all events +upda_events 146 +``` + +## Deployment + +### Native + +Use the released binary for your platform or run `make clean build-server-{your-platform}` and the binary will be placed +into the `bin/` folder. + +### Docker + +For examples how to run, look into [deployment instructions](./_doc/DEPLOYMENT.md) which contains examples +for `docker-compose` files. + +#### Build docker image + +To build docker images, do the following + +```shell +docker build --rm --no-cache -t upda:latest . +``` + +## Development & contribution + +* Ensure to set `LOGGING_LEVEL=debug` for proper debug logs during development. +* Code guidelines + * Each entity has its own repository + * Each entity is only used in repository and service (otherwise, mapping happens) + * Presenter layer is constructed from the entity, e.g., in REST responses and mapped + * No entity is directly returned in any REST response + * All log calls should be handled by `zap.L()` + * Configuration is bootstrapped via separated `struct` types which are given to the service which need them + * Error handling + * Always throw an error with `NewServiceError` + * Always wrap the cause error with `fmt.Errorf` + * Forward/bubble up the error directly, when original error is already a `NewServiceError` (most likely internal + calls) + * Always abort handler chain with `AbortWithError` + * Utils can throw any error + +Please look into the `_doc/` folder for [OpenAPI specification](./_doc/api.yaml) and a Postman Collection. + +### Getting started + +1. Run `make clean dependencies` to fetch dependencies +2. Start `git.myservermanager.com/varakh/upda/cmd/server` (or `cli`) as Go application and ensure to have _required_ + environment variables set + +If you like to test with PSQL and/or REDIS for task locking, here are some useful docker commands to have containers +up and running quickly. Set necessary environment variables properly. + +```shell +# postgres +docker run --rm --name=upda-db \ + -p 5432:5432 \ + --restart=unless-stopped \ + -e POSTGRES_USER=upda \ + -e POSTGRES_PASSWORD=upda \ + -e POSTGRES_DB=upda \ + postgres:16-alpine + +# redis +docker run --rm --name some-redis \ + -p 6379:6379 \ + redis redis-server --save 60 1 --loglevel warning +``` + +#### Windows hints + +On Windows, you need a valid `gcc`, e.g., https://jmeubank.github.io/tdm-gcc/download/ and add the `\bin` folder to your +path. + +For any `go` command you run, ensure that your `PATH` has the `gcc` binary and that you add `CGO_ENABLED=1` as +environment. + +### Release + +Releases are handled by the SCM platform and pipeline. Creating a **new git tag**, creates a new release in the SCM +platform, uploads produced artifacts to that release and publishes docker images automatically. +**Before** doing so, please ensure that the **commit on `master`** has the **correct version settings** and has been +built successfully: + +* Adapt `constants_app.go` and change `Version` to the correct version number +* Adapt `CHANGELOG.md` to reflect changes and ensure a date is properly set in the header, also add a reference link + in footer (link to scm git tag source) +* Adapt `api.yaml`: `version` attribute must reflect the to be released version +* Adapt `env: VERSION_*` in `.forgejo/workflows/release.yaml` + +After the release has been created, ensure to change the following settings for the _next development cycle_: + +* Adapt `constants_app.go` and change `Version` to the _next_ version number +* Adapt `CHANGELOG.md` and add an _UNRELEASED_ section +* Adapt `api.yaml`: `version` attribute must reflect the _next_ version number +* Adapt `env: VERSION_*` in `.forgejo/workflows/release.yaml` to _next_ version number diff --git a/_doc/DEPLOYMENT.md b/_doc/DEPLOYMENT.md new file mode 100644 index 0000000..327f6ec --- /dev/null +++ b/_doc/DEPLOYMENT.md @@ -0,0 +1,162 @@ +# Deployment + +Use one of the provided `docker-compose` examples, edit to your needs. Then issue `docker compose up` command. + +All applications should be up and running. + +As of now, the web interface and the server comes as different container images. + +Default image user is `appuser` (`uid=2033`) and group is `appgroup` (`gid=2033`). + +The following examples are available + +## Postgres + +```yaml +version: '3.9' + +networks: + internal: + external: false + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-upda + +services: + ui: + container_name: upda_ui + image: git.myservermanager.com/varakh/upda-ui:latest + environment: + - VITE_API_URL=https://upda.domain.tld/api/v1/ + - VITE_APP_TITLE=upda + - VITE_APP_DESCRIPTION=upda + restart: unless-stopped + networks: + - internal + ports: + - "127.0.0.1:8181:80" + depends_on: + - api + + api: + container_name: upda_api + image: git.myservermanager.com/varakh/upda:latest + environment: + - TZ=Europe/Berlin + - DB_POSTGRES_TZ=Europe/Berlin + - DB_TYPE=postgres + - DB_POSTGRES_HOST=db + - DB_POSTGRES_PORT=5432 + - DB_POSTGRES_NAME=upda + - DB_POSTGRES_USER=upda + - DB_POSTGRES_PASSWORD=upda + - ADMIN_USER=admin + - ADMIN_PASSWORD=changeit + restart: unless-stopped + networks: + - internal + ports: + - "127.0.0.1:8080:8080" + depends_on: + - db + + db: + container_name: upda_db + image: postgres:16 + restart: unless-stopped + environment: + - POSTGRES_USER=upda + - POSTGRES_PASSWORD=upda + - POSTGRES_DB=upda + networks: + - internal + volumes: + - upda-db-vol:/var/lib/postgresql/data + +volumes: + upda-db-vol: + external: false +``` + +## SQLite + +```yaml +version: '3.9' + +networks: + internal: + external: false + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-upda + +services: + ui: + container_name: upda_ui + image: git.myservermanager.com/varakh/upda-ui:latest + environment: + - VITE_API_URL=https://upda.domain.tld/api/v1/ + - VITE_APP_TITLE=upda + - VITE_APP_DESCRIPTION=upda + restart: unless-stopped + networks: + - internal + ports: + - "127.0.0.1:8181:80" + depends_on: + - api + + api: + container_name: upda_api + image: git.myservermanager.com/varakh/upda:latest + environment: + - TZ=Europe/Berlin + - DB_FILE=/data/upda.db + - ADMIN_USER=admin + - ADMIN_PASSWORD=changeit + restart: unless-stopped + networks: + - internal + volumes: + - upda-app-vol:/data + ports: + - "127.0.0.1:8080:8080" + +volumes: + upda-app-vol: + external: false +``` + +### Reverse proxy + +You may want to use a proxy in front of them on your host, e.g., nginx. Here's a configuration snippet which should do +the work. + +The UI and API is reachable through the same domain, e.g., `https://upda.domain.tld`. In addition, Let's Encrypt is used +for transport encryption. + +```shell +server { + listen 443 ssl http2; + ssl_certificate /etc/letsencrypt/live/upda.domain.tld/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/upda.domain.tld/privkey.pem; + + # ui + location / { + proxy_pass http://localhost:8181; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # api + location ~* ^/(api)/ { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` diff --git a/_doc/api.yaml b/_doc/api.yaml new file mode 100644 index 0000000..a7d7c9b --- /dev/null +++ b/_doc/api.yaml @@ -0,0 +1,1308 @@ +openapi: 3.0.3 +info: + title: upda + description: API specification + license: + name: GPLv3 + url: https://www.gnu.org/licenses/gpl-3.0.en.html + version: 1.0.0 +externalDocs: + description: Find out more about the project + url: https://git.myservermanager.com/varakh/upda +servers: + - url: http://localhost:8080/api/v1 +tags: + - name: updates + description: Updates endpoints + - name: webhooks + description: Webhooks endpoints + - name: events + description: Events endpoints + - name: application + description: Application related endpoints + - name: auth + description: Application authorization related endpoints +paths: + '/info': + get: + tags: + - application + summary: Find application information + description: Find application information + operationId: findInfo + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/InfoResponse' + '/health': + get: + tags: + - application + summary: Find application health + description: Find application health + operationId: findHealth + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + /updates: + get: + tags: + - updates + security: + - basicAuth: [ ] + summary: Finds updates + description: Finds updates + operationId: findUpdates + parameters: + - name: page + in: query + description: the page + required: false + schema: + type: number + default: 1 + - name: pageSize + in: query + description: the page size + required: false + schema: + type: number + default: 5 + - name: order + in: query + description: the order + required: false + schema: + type: string + default: desc + enum: + - asc + - desc + - name: orderBy + in: query + description: the order by + required: false + schema: + type: string + default: updated_at + enum: + - id + - application + - provider + - host + - created_at + - updated_at + - name: state + in: query + description: the state (list, added with &state=...&state=... + required: false + schema: + type: array + items: + type: string + enum: + - pending + - ignored + - approved + - name: searchTerm + in: query + description: the search term + required: false + schema: + type: string + - name: searchIn + in: query + description: the search in + required: false + schema: + type: string + default: application + enum: + - application + - provider + - host + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePageResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/updates/{id}': + get: + tags: + - updates + security: + - basicAuth: [ ] + summary: Finds update by ID + description: Finds update by ID + operationId: findUpdateById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSingleResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - updates + security: + - basicAuth: [ ] + summary: Deletes an update by ID + description: 'Deletes an update by ID' + operationId: deleteUpdateById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + responses: + '204': + description: Successful operation + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/updates/{id}/state': + patch: + tags: + - updates + security: + - basicAuth: [ ] + summary: Modifies update's state by ID + description: Modifies update's state by ID + operationId: patchUpdateStateById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + requestBody: + description: Modifies an update + content: + application/json: + schema: + $ref: '#/components/schemas/ModifyUpdateStateRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSingleResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/webhooks': + post: + tags: + - webhooks + security: + - basicAuth: [ ] + summary: Creates webhook + description: Creates webhook + operationId: createWebhook + requestBody: + description: Creates an announcement + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSingleResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - webhooks + security: + - basicAuth: [ ] + summary: Finds webhooks + description: Finds webhooks + operationId: findWebhooks + parameters: + - name: page + in: query + description: the page + required: false + schema: + type: number + default: 1 + - name: pageSize + in: query + description: the page size + required: false + schema: + type: number + default: 5 + - name: order + in: query + description: the order + required: false + schema: + type: string + default: desc + enum: + - asc + - desc + - name: orderBy + in: query + description: the order by + required: false + schema: + type: string + default: updated_at + enum: + - id + - label + - created_at + - updated_at + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookPageResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/webhooks/{id}': + post: + tags: + - webhooks + summary: Invokes a webhook by ID + description: 'Invokes a webhook by ID' + operationId: invokeWebhookById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + - name: X-Webhook-Token + in: header + description: the token for the webhook id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/WebhookGenericRequest' + - $ref: '#/components/schemas/WebhookDiunRequest' + responses: + '204': + description: Successful operation + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - webhooks + security: + - basicAuth: [ ] + summary: Deletes a webhook by ID + description: 'Deletes a webhook by ID' + operationId: deleteWebhookById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + responses: + '204': + description: Successful operation + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/webhooks/{id}/label': + patch: + tags: + - webhooks + security: + - basicAuth: [ ] + summary: Modifies webhook's label by ID + description: Modifies webhook's label by ID + operationId: patchWebhookLabelById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + requestBody: + description: Modifies a webhook + content: + application/json: + schema: + $ref: '#/components/schemas/ModifyWebhookLabelRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSingleResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/webhooks/{id}/ignore-host': + patch: + tags: + - webhooks + security: + - basicAuth: [ ] + summary: Modifies webhook's ignoreHost by ID + description: Modifies webhook's ignoreHost by ID + operationId: patchWebhookIgnoreHostById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + requestBody: + description: Modifies a webhook + content: + application/json: + schema: + $ref: '#/components/schemas/ModifyWebhookIgnoreHostRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSingleResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /events: + get: + tags: + - events + security: + - basicAuth: [ ] + summary: Finds events + description: Finds events + operationId: findEvents + parameters: + - name: size + in: query + description: the size + required: false + schema: + type: number + default: 10 + - name: skip + in: query + description: the skip + required: false + schema: + type: number + default: 0 + - name: order + in: query + description: the order + required: false + schema: + type: string + default: desc + enum: + - asc + - desc + - name: orderBy + in: query + description: the order by + required: false + schema: + type: string + default: created_at + enum: + - id + - name + - created_at + - updated_at + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EventWindowResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '/events/{id}': + delete: + tags: + - events + security: + - basicAuth: [ ] + summary: Deletes a event by ID + description: 'Deletes a event by ID' + operationId: deleteEventById + parameters: + - name: id + in: path + description: the id + required: true + schema: + type: string + responses: + '204': + description: Successful operation + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /login: + get: + tags: + - auth + security: + - basicAuth: [ ] + summary: Probes login + description: Probes login + operationId: probeLogin + responses: + '204': + description: Successful operation + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + schemas: + # response + ErrorResponse: + type: object + properties: + message: + type: string + status: + type: string + enum: + - IllegalArgument + - Unauthorized + - Forbidden + - NotFound + - Conflict + - GeneralError + InfoResponse: + type: object + properties: + data: + type: object + properties: + name: + type: string + version: + type: string + timeZone: + type: string + HealthResponse: + type: object + properties: + data: + type: object + properties: + healthy: + type: boolean + UpdateResponse: + type: object + properties: + id: + type: string + application: + type: string + provider: + type: string + host: + type: string + version: + type: string + state: + type: string + enum: + - pending + - approved + - ignored + createdAt: + type: string + updatedAt: + type: string + metadata: + type: object + nullable: true + UpdateSingleResponse: + type: object + properties: + data: + type: object + allOf: + - $ref: '#/components/schemas/UpdateResponse' + UpdatePageResponse: + type: object + properties: + data: + type: object + properties: + content: + type: array + items: + $ref: '#/components/schemas/UpdateResponse' + page: + type: number + pageSize: + type: number + orderBy: + type: string + enum: + - id + - created_at + - updated_at + - provider + - host + - label + order: + type: string + enum: + - asc + - desc + totalElements: + type: number + totalPages: + type: number + WebhookSingleResponse: + type: object + properties: + data: + type: object + allOf: + - $ref: '#/components/schemas/WebhookResponse' + WebhookResponse: + type: object + properties: + id: + type: string + label: + type: string + type: + type: string + enum: + - generic + - diun + createdAt: + type: string + updatedAt: + type: string + token: + type: string + description: Only returned during creation + nullable: true + ignoreHost: + type: boolean + WebhookPageResponse: + type: object + properties: + data: + type: object + properties: + content: + type: array + items: + $ref: '#/components/schemas/WebhookResponse' + page: + type: number + pageSize: + type: number + orderBy: + type: string + enum: + - id + - label + - type + - created_at + - updated_at + order: + type: string + enum: + - asc + - desc + totalElements: + type: number + totalPages: + type: number + EventWindowResponse: + type: object + properties: + data: + type: object + properties: + content: + type: array + items: + $ref: '#/components/schemas/EventResponse' + size: + type: number + skip: + type: number + orderBy: + type: string + enum: + - id + - name + - created_at + - updated_at + order: + type: string + enum: + - asc + - desc + hasNext: + type: boolean + EventResponse: + type: object + properties: + id: + type: string + name: + type: string + enum: + - update_created + - update_updated + - update_updated_state_pending + - update_updated_state_approved + - update_updated_state_ignored + - update_deleted + - webhook_created + - webhook_updated_label + - webhook_updated_ignore_host + - webhook_deleted + state: + type: string + enum: + - created + createdAt: + type: string + updatedAt: + type: string + payload: + type: object + description: | + Depending on the event name, different payload can be returned + oneOf: + - $ref: '#/components/schemas/EventPayloadUpdateCreatedResponse' + - $ref: '#/components/schemas/EventPayloadUpdateUpdatedResponse' + - $ref: '#/components/schemas/EventPayloadUpdateDeletedResponse' + - $ref: '#/components/schemas/EventPayloadWebhookCreatedResponse' + - $ref: '#/components/schemas/EventPayloadWebhookUpdatedResponse' + - $ref: '#/components/schemas/EventPayloadWebhookDeletedResponse' + EventPayloadUpdateCreatedResponse: + type: object + properties: + id: + type: string + application: + type: string + provider: + type: string + host: + type: string + version: + type: string + state: + type: string + enum: + - pending + - approved + - ignored + EventPayloadUpdateUpdatedResponse: + type: object + properties: + id: + type: string + application: + type: string + provider: + type: string + host: + type: string + versionPrior: + type: string + version: + type: string + statePrior: + type: string + enum: + - pending + - approved + - ignored + state: + type: string + enum: + - pending + - approved + - ignored + EventPayloadUpdateDeletedResponse: + type: object + properties: + application: + type: string + provider: + type: string + host: + type: string + version: + type: string + EventPayloadWebhookCreatedResponse: + type: object + properties: + id: + type: string + label: + type: string + type: + type: string + enum: + - generic + - diun + ignoreHost: + type: boolean + EventPayloadWebhookUpdatedResponse: + type: object + properties: + id: + type: string + labelPrior: + type: string + label: + type: string + type: + type: string + enum: + - generic + - diun + ignoreHostPrior: + type: boolean + ignoreHost: + type: boolean + EventPayloadWebhookDeletedResponse: + type: object + properties: + label: + type: string + type: + type: string + enum: + - generic + - diun + ignoreHost: + type: boolean + + # requests + ModifyUpdateStateRequest: + type: object + required: + - state + properties: + state: + type: string + enum: + - pending + - approved + - ignored + ModifyWebhookLabelRequest: + type: object + required: + - label + properties: + label: + type: string + ModifyWebhookIgnoreHostRequest: + type: object + required: + - ignoreHost + properties: + ignoreHost: + type: boolean + CreateWebhookRequest: + type: object + required: + - label + - type + properties: + label: + type: string + type: + type: string + enum: + - generic + - diun + ignoreHost: + type: boolean + WebhookGenericRequest: + type: object + required: + - application + - host + - version + properties: + application: + type: string + provider: + type: string + description: The optional provider attribute. If left blank or not sent during webhook invocation, the issuing webhook's label is used. + nullable: true + host: + type: string + version: + type: string + metadata: + description: "Any JSON object" + type: object + additionalProperties: + type: object + WebhookDiunRequest: + type: object + required: + - diun_version + - hostname + - status + - provider + - image + - mime_type + - digest + - created + - platform + - metadata + properties: + diun_version: + type: string + hostname: + type: string + status: + type: string + provider: + type: string + image: + type: string + hub_link: + type: string + mime_type: + type: string + digest: + type: string + created: + type: string + platform: + type: string + metadata: + type: object + properties: + ctn_command: + type: string + ctn_createdat: + type: string + ctn_id: + type: string + ctn_names: + type: string + ctn_size: + type: string + ctn_state: + type: string + ctn_status: + type: string diff --git a/_doc/updaserver.postman_collection.json b/_doc/updaserver.postman_collection.json new file mode 100644 index 0000000..0c85732 --- /dev/null +++ b/_doc/updaserver.postman_collection.json @@ -0,0 +1,952 @@ +{ + "info": { + "_postman_id": "cf0931c9-d395-45f4-be89-a43c1b85b925", + "name": "updaserver", + "description": "API specification", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "updates", + "item": [ + { + "name": "/updates", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = JSON.parse(responseBody);", + "", + "if (jsonData.data && jsonData.data.content.length > 0) {", + " postman.setEnvironmentVariable(\"updateId\", jsonData.data.content[0].id);", + "} else {", + " postman.setEnvironmentVariable(\"updateId\", null);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/updates?page=1&pageSize=10&order=desc&orderBy=updated_at&searchTerm=docker.io&searchIn=application", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "updates" + ], + "query": [ + { + "key": "page", + "value": "1", + "description": "the page" + }, + { + "key": "pageSize", + "value": "10", + "description": "the page size" + }, + { + "key": "order", + "value": "desc", + "description": "the order" + }, + { + "key": "orderBy", + "value": "updated_at", + "description": "the order by" + }, + { + "key": "state", + "value": "ignored", + "description": "the state", + "disabled": true + }, + { + "key": "state", + "value": "approved", + "description": "the state", + "disabled": true + }, + { + "key": "state", + "value": "pending", + "description": "the state", + "disabled": true + }, + { + "key": "searchTerm", + "value": "docker.io", + "description": "the search term" + }, + { + "key": "searchIn", + "value": "application", + "description": "the search in" + } + ] + }, + "description": "Finds updates" + }, + "response": [] + }, + { + "name": "/updates/:id", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/updates/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "updates", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{updateId}}", + "description": "(Required) the id" + } + ] + }, + "description": "Deletes an update by ID" + }, + "response": [] + }, + { + "name": "/updates/:id/state", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"state\": \"pending\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/updates/:id/state", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "updates", + ":id", + "state" + ], + "variable": [ + { + "key": "id", + "value": "{{webhookId}}" + } + ] + }, + "description": "Creates webhook" + }, + "response": [] + }, + { + "name": "/updates/:id", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/updates/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "updates", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{updateId}}", + "description": "(Required) the id" + } + ] + }, + "description": "Deletes an update by ID" + }, + "response": [] + } + ] + }, + { + "name": "events", + "item": [ + { + "name": "/events", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = JSON.parse(responseBody);", + "", + "if (jsonData.data && jsonData.data.content.length > 0) {", + " postman.setEnvironmentVariable(\"eventId\", jsonData.data.content[0].id);", + "} else {", + " postman.setEnvironmentVariable(\"eventId\", null);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/events", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events" + ], + "query": [ + { + "key": "size", + "value": "2", + "description": "the size", + "disabled": true + }, + { + "key": "skip", + "value": "15", + "description": "the skip", + "disabled": true + }, + { + "key": "order", + "value": "asc", + "description": "the order", + "disabled": true + }, + { + "key": "orderBy", + "value": "updated_at", + "description": "the order by", + "disabled": true + } + ] + }, + "description": "Finds updates" + }, + "response": [] + }, + { + "name": "/events/:id", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/events/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "events", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{eventId}}", + "description": "(Required) the id" + } + ] + }, + "description": "Deletes an update by ID" + }, + "response": [] + } + ] + }, + { + "name": "webhooks", + "item": [ + { + "name": "/webhooks", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = JSON.parse(responseBody);", + "", + "if (jsonData.data && jsonData.data.content.length > 0) {", + " postman.setEnvironmentVariable(\"webhookId\", jsonData.data.content[0].id);", + " postman.setEnvironmentVariable(\"webhookToken\", jsonData.data.content[0].token);", + "} else {", + " postman.setEnvironmentVariable(\"webhookId\", null);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/webhooks?page=1&pageSize=10&order=desc&orderBy=created_at", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks" + ], + "query": [ + { + "key": "page", + "value": "1", + "description": "the page" + }, + { + "key": "pageSize", + "value": "10", + "description": "the page size" + }, + { + "key": "order", + "value": "desc", + "description": "the order" + }, + { + "key": "orderBy", + "value": "created_at", + "description": "the order by" + } + ] + }, + "description": "Finds webhooks" + }, + "response": [] + }, + { + "name": "/webhooks (generic)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"My test hook\",\n \"type\": \"generic\",\n \"ignoreHost\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/webhooks", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks" + ] + }, + "description": "Creates webhook" + }, + "response": [] + }, + { + "name": "/webhooks (diun)", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"My test hook\",\n \"type\": \"diun\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/webhooks", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks" + ] + }, + "description": "Creates webhook" + }, + "response": [] + }, + { + "name": "/webhooks/:id/label", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"a new label\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/webhooks/:id/label", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks", + ":id", + "label" + ], + "variable": [ + { + "key": "id", + "value": "{{webhookId}}" + } + ] + }, + "description": "Creates webhook" + }, + "response": [] + }, + { + "name": "/webhooks/:id/ignore-host", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"ignoreHost\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/webhooks/:id/ignore-host", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks", + ":id", + "ignore-host" + ], + "variable": [ + { + "key": "id", + "value": "{{webhookId}}" + } + ] + }, + "description": "Creates webhook" + }, + "response": [] + }, + { + "name": "/webhooks/:id", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/webhooks/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{webhookId}}", + "description": "(Required) the id" + } + ] + }, + "description": "Deletes a webhook by ID" + }, + "response": [] + }, + { + "name": "/webhooks/:id (Invoke)", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{webhookToken}}", + "type": "string" + }, + { + "key": "key", + "value": "X-Webhook-Token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "description": "(Required) the token for the webhook id", + "key": "X-Webhook-Token", + "value": "tempor labore amet" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"application\": \"2\",\n \"provider\": \"cli\",\n \"host\": \"myserver\",\n \"version\": \"1.0.1\",\n \"metadata\": {\n \"key1\": \"val1\",\n \"key2\": \"val2\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/webhooks/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "webhooks", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{webhookId}}", + "description": "(Required) the id" + } + ] + }, + "description": "Invokes a webhook by ID" + }, + "response": [] + } + ] + }, + { + "name": "/info", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/info", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "info" + ] + }, + "description": "Find application information" + }, + "response": [] + }, + { + "name": "/health", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/health", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "health" + ] + }, + "description": "Find application health" + }, + "response": [] + }, + { + "name": "/login", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "{{basicAuthPass}}", + "type": "string" + }, + { + "key": "username", + "value": "{{basicAuthUser}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/login", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "login" + ] + }, + "description": "Probes login" + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080/api/v1", + "type": "string" + }, + { + "key": "basicAuthUser", + "value": "admin", + "type": "default" + }, + { + "key": "basicAuthPass", + "value": "changeit", + "type": "default" + }, + { + "key": "webhookId", + "value": "no", + "type": "default" + }, + { + "key": "updateId", + "value": "no", + "type": "default" + } + ] +} \ No newline at end of file diff --git a/api/constants.go b/api/constants.go new file mode 100644 index 0000000..c91f2b6 --- /dev/null +++ b/api/constants.go @@ -0,0 +1,77 @@ +package api + +// UpdateState state of an update +type UpdateState string + +const ( + UpdateStatePending UpdateState = "pending" + UpdateStateApproved UpdateState = "approved" + UpdateStateIgnored UpdateState = "ignored" +) + +func (e *UpdateState) Scan(value interface{}) error { + *e = UpdateState(value.([]byte)) + return nil +} + +func (e UpdateState) Value() string { + return string(e) +} + +// WebhookType type of webhook +type WebhookType string + +const ( + WebhookTypeGeneric WebhookType = "generic" + WebhookTypeDiun WebhookType = "diun" +) + +func (e *WebhookType) Scan(value interface{}) error { + *e = WebhookType(value.([]byte)) + return nil +} + +func (e WebhookType) Value() string { + return string(e) +} + +// EventName name of event +type EventName string + +const ( + EventNameUpdateCreated EventName = "update_created" + EventNameUpdateUpdated EventName = "update_updated" + EventNameUpdateUpdatedPending EventName = "update_updated_state_pending" + EventNameUpdateUpdatedApproved EventName = "update_updated_state_approved" + EventNameUpdateUpdatedIgnored EventName = "update_updated_state_ignored" + EventNameUpdateDeleted EventName = "update_deleted" + EventNameWebhookCreated EventName = "webhook_created" + EventNameWebhookUpdatedLabel EventName = "webhook_updated_label" + EventNameWebhookUpdatedIgnoreHost EventName = "webhook_updated_ignore_host" + EventNameWebhookDeleted EventName = "webhook_deleted" +) + +func (e *EventName) Scan(value interface{}) error { + *e = EventName(value.([]byte)) + return nil +} + +func (e EventName) Value() string { + return string(e) +} + +// EventState name of event +type EventState string + +const ( + EventStateCreated EventState = "created" +) + +func (e *EventState) Scan(value interface{}) error { + *e = EventState(value.([]byte)) + return nil +} + +func (e EventState) Value() string { + return string(e) +} diff --git a/api/dto.go b/api/dto.go new file mode 100644 index 0000000..c13e815 --- /dev/null +++ b/api/dto.go @@ -0,0 +1,292 @@ +package api + +import ( + "github.com/google/uuid" + "time" +) + +// Requests + +type ModifyUpdateStateRequest struct { + State string `json:"state" binding:"required,oneof=pending approved ignored"` +} + +type ModifyWebhookLabelRequest struct { + Label string `json:"label" binding:"required,min=1,max=255"` +} + +type ModifyWebhookIgnoreHostRequest struct { + IgnoreHost bool `json:"ignoreHost"` +} + +type CreateWebhookRequest struct { + Label string `json:"label" binding:"required,min=1,max=255"` + Type string `json:"type" binding:"required,oneof=generic diun"` + IgnoreHost bool `json:"ignoreHost"` +} + +type PaginateUpdateRequest struct { + PageSize int `form:"pageSize,default=5" binding:"numeric,gte=1"` + Page int `form:"page,default=1" binding:"numeric,gte=1"` + Order string `form:"order,default=desc" binding:"oneof=asc desc"` + OrderBy string `form:"orderBy,default=updated_at" binding:"oneof=id application provider host version created_at updated_at"` + SearchTerm string `form:"searchTerm"` + SearchIn string `form:"searchIn,default=application" binding:"oneof=application provider host version"` +} + +type PaginateWebhookRequest struct { + PageSize int `form:"pageSize,default=5" binding:"numeric,gte=1"` + Page int `form:"page,default=1" binding:"numeric,gte=1"` + Order string `form:"order,default=desc" binding:"oneof=asc desc"` + OrderBy string `form:"orderBy,default=updated_at" binding:"oneof=id label type created_at updated_at"` +} + +type WebhookGenericRequest struct { + Application string `json:"application" binding:"required,min=1"` + Provider string `json:"provider"` + Host string `json:"host" binding:"required,min=1"` + Version string `json:"version" binding:"required,min=1"` + Metadata interface{} `json:"metadata"` +} + +type WebhookDiunMetadataRequest struct { + Command string `json:"ctn_command"` + CreatedAt string `json:"ctn_createdat"` + Id string `json:"ctn_id"` + Names string `json:"ctn_names"` + Size string `json:"ctn_size"` + State string `json:"ctn_state"` + Status string `json:"ctn_status"` +} + +type WebhookDiunRequest struct { + DiunVersion string `json:"diun_version" binding:"required,min=1"` + Hostname string `json:"hostname" binding:"required,min=1"` + Status string `json:"status" binding:"required,min=1"` + Provider string `json:"provider" binding:"required,min=1"` + Image string `json:"image" binding:"required,min=1"` + HubLink string `json:"hub_link"` + MimeType string `json:"mime_type" binding:"required,min=1"` + Digest string `json:"digest" binding:"required,min=1"` + Created string `json:"created" binding:"required,min=1"` + Platform string `json:"platform" binding:"required,min=1"` + Metadata WebhookDiunMetadataRequest `json:"metadata"` +} + +type EventWindowRequest struct { + Size int `form:"size,default=10" binding:"numeric,gte=1"` + Skip int `form:"skip,default=0" binding:"numeric"` + Order string `form:"order,default=desc" binding:"oneof=asc desc"` + OrderBy string `form:"orderBy,default=created_at" binding:"oneof=id name created_at updated_at"` +} + +// Responses + +type Response struct { +} + +type DataResponse struct { + Response + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type ErrorResponse struct { + Status string `json:"status,omitempty"` + DataResponse +} + +func NewDataResponseWithPayload(payload interface{}) *DataResponse { + e := new(DataResponse) + e.Data = payload + return e +} + +func NewErrorResponseWithStatusAndMessage(status string, message string) *ErrorResponse { + e := new(ErrorResponse) + e.Status = status + e.Message = message + return e +} + +type UpdateResponse struct { + ID uuid.UUID `json:"id"` + Application string `json:"application"` + Provider string `json:"provider"` + Host string `json:"host"` + Version string `json:"version"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Metadata interface{} `json:"metadata,omitempty"` +} + +type UpdateSingleResponse struct { + Data UpdateResponse `json:"data"` +} + +func NewUpdateSingleResponse(id uuid.UUID, application string, provider string, host string, version string, state string, createdAt time.Time, updatedAt time.Time, metadata interface{}) *UpdateSingleResponse { + e := new(UpdateSingleResponse) + e.Data.ID = id + e.Data.Application = application + e.Data.Provider = provider + e.Data.Host = host + e.Data.Version = version + e.Data.State = state + e.Data.CreatedAt = createdAt + e.Data.UpdatedAt = updatedAt + e.Data.Metadata = metadata + return e +} + +type UpdatePageResponse struct { + Content []*UpdateResponse `json:"content"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + OrderBy string `json:"orderBy"` + Order string `json:"order"` + TotalElements int64 `json:"totalElements"` + TotalPages int64 `json:"totalPages"` +} + +type UpdateDataPageResponse struct { + Data *UpdatePageResponse `json:"data"` +} + +func NewUpdatePageResponse(content []*UpdateResponse, page int, pageSize int, orderBy string, order string, totalElements int64, totalPages int64) *UpdatePageResponse { + e := new(UpdatePageResponse) + e.Content = content + e.Page = page + e.PageSize = pageSize + e.OrderBy = orderBy + e.Order = order + e.TotalElements = totalElements + e.TotalPages = totalPages + return e +} + +type WebhookResponse struct { + ID uuid.UUID `json:"id"` + Label string `json:"label"` + Type string `json:"type"` + IgnoreHost bool `json:"ignoreHost"` + Token string `json:"token,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type WebhookSingleResponse struct { + Data WebhookResponse `json:"data"` +} + +func NewWebhookSingleResponse(id uuid.UUID, label string, t string, ignoreHost bool, token string, createdAt time.Time, updatedAt time.Time) *WebhookSingleResponse { + e := new(WebhookSingleResponse) + e.Data.ID = id + e.Data.Label = label + e.Data.Type = t + e.Data.IgnoreHost = ignoreHost + e.Data.Token = token + e.Data.CreatedAt = createdAt + e.Data.UpdatedAt = updatedAt + return e +} + +type WebhookPageResponse struct { + Content []*WebhookResponse `json:"content"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + OrderBy string `json:"orderBy"` + Order string `json:"order"` + TotalElements int64 `json:"totalElements"` + TotalPages int64 `json:"totalPages"` +} + +func NewWebhookPageResponse(content []*WebhookResponse, page int, pageSize int, orderBy string, order string, totalElements int64, totalPages int64) *WebhookPageResponse { + e := new(WebhookPageResponse) + e.Content = content + e.Page = page + e.PageSize = pageSize + e.OrderBy = orderBy + e.Order = order + e.TotalElements = totalElements + e.TotalPages = totalPages + return e +} + +type EventResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Payload interface{} `json:"payload,omitempty"` +} + +type EventWindowResponse struct { + Content []*EventResponse `json:"content"` + Size int `json:"size"` + Skip int `json:"skip"` + HasNext bool `json:"hasNext"` + OrderBy string `json:"orderBy"` + Order string `json:"order"` +} + +type EventPayloadUpdateCreatedDto struct { + ID uuid.UUID `json:"id,omitempty"` + Application string `json:"application,omitempty"` + Provider string `json:"provider,omitempty"` + Host string `json:"host,omitempty"` + Version string `json:"version,omitempty"` + State string `json:"state,omitempty"` +} + +type EventPayloadUpdateUpdatedDto struct { + ID uuid.UUID `json:"id,omitempty"` + Application string `json:"application,omitempty"` + Provider string `json:"provider,omitempty"` + Host string `json:"host,omitempty"` + VersionPrior string `json:"versionPrior,omitempty"` + Version string `json:"version,omitempty"` + StatePrior string `json:"statePrior,omitempty"` + State string `json:"state,omitempty"` +} + +type EventPayloadUpdateDeletedDto struct { + Application string `json:"application,omitempty"` + Provider string `json:"provider,omitempty"` + Host string `json:"host,omitempty"` + Version string `json:"version,omitempty"` +} + +func NewEventWindowResponse(content []*EventResponse, size int, skip int, orderBy string, order string, hasNext bool) *EventWindowResponse { + e := new(EventWindowResponse) + e.Content = content + e.Size = size + e.Skip = skip + e.HasNext = hasNext + e.OrderBy = orderBy + e.Order = order + return e +} + +type EventPayloadWebhookCreatedDto struct { + ID uuid.UUID `json:"id,omitempty"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + IgnoreHost bool `json:"ignoreHost"` +} + +type EventPayloadWebhookUpdatedDto struct { + ID uuid.UUID `json:"id,omitempty"` + LabelPrior string `json:"labelPrior,omitempty"` + Label string `json:"label,omitempty"` + IgnoreHostPrior bool `json:"ignoreHostPrior"` + IgnoreHost bool `json:"ignoreHost"` + Type string `json:"type,omitempty"` +} + +type EventPayloadWebhookDeletedDto struct { + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + IgnoreHost bool `json:"ignoreHost"` +} diff --git a/cmd/cli.go b/cmd/cli.go new file mode 100644 index 0000000..022878f --- /dev/null +++ b/cmd/cli.go @@ -0,0 +1,7 @@ +package main + +import "git.myservermanager.com/varakh/upda/terminal" + +func main() { + terminal.Start() +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..d4d7320 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,7 @@ +package main + +import "git.myservermanager.com/varakh/upda/server" + +func main() { + server.Start() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..775643b --- /dev/null +++ b/go.mod @@ -0,0 +1,77 @@ +module git.myservermanager.com/varakh/upda + +go 1.21 + +require ( + github.com/Depado/ginprom v1.8.0 + github.com/adrg/xdg v0.4.0 + github.com/gin-contrib/cors v1.5.0 + github.com/gin-contrib/zap v0.2.0 + github.com/gin-gonic/gin v1.9.1 + github.com/go-co-op/gocron v1.37.0 + github.com/go-co-op/gocron-redis-lock v1.3.0 + github.com/go-playground/validator/v10 v10.16.0 + github.com/google/uuid v1.5.0 + github.com/redis/go-redis/v9 v9.3.1 + github.com/stretchr/testify v1.8.4 + github.com/urfave/cli/v2 v2.26.0 + go.uber.org/zap v1.26.0 + gorm.io/driver/postgres v1.5.4 + gorm.io/driver/sqlite v1.5.4 + gorm.io/gorm v1.25.5 + moul.io/zapgorm2 v1.3.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/sonic v1.10.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-redsync/redsync/v4 v4.10.0 // indirect + github.com/go-resty/resty/v2 v2.10.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.5.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..732715f --- /dev/null +++ b/go.sum @@ -0,0 +1,359 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Depado/ginprom v1.8.0 h1:zaaibRLNI1dMiiuj1MKzatm8qrcHzikMlCc1anqOdyo= +github.com/Depado/ginprom v1.8.0/go.mod h1:XBaKzeNBqPF4vxJpNLincSQZeMDnZp1tIbU0FU0UKgg= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.0 h1:7EFNIY4igHEXUdj1zXgAyU3fLc7QfOKHbkldRVTBdiM= +github.com/Microsoft/hcsshim v0.11.0/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= +github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc= +github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/containerd/containerd v1.7.6 h1:oNAVsnhPoy4BTPQivLgTzI9Oleml9l/+eYIDYXRCYo8= +github.com/containerd/containerd v1.7.6/go.mod h1:SY6lrkkuJT40BVNO37tlYTSnKJnP5AXBc0fhx0q+TJ4= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.6+incompatible h1:hceabKCtUgDqPu+qm0NgsaXf28Ljf4/pWFL7xjWWDgE= +github.com/docker/docker v24.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk= +github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/zap v0.2.0 h1:HLvt3rZXyC8XC+s2lHzMFow3UDqiEbfrBWJyHHS6L8A= +github.com/gin-contrib/zap v0.2.0/go.mod h1:eqfbe9ZmI+GgTZF6nRiC2ZwDeM4DK1Viwc8OxTCphh0= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= +github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= +github.com/go-co-op/gocron-redis-lock v1.2.0 h1:c5aGtxGxqWfln50Fdx9WpwYwtX7bK8i+pw3aIu2feao= +github.com/go-co-op/gocron-redis-lock v1.2.0/go.mod h1:En1WRsLSXsWiRul1GNyKiqniGe6UnrcEs9bOzDBya04= +github.com/go-co-op/gocron-redis-lock v1.3.0 h1:PKwtuc/BhrDll/DxJfnXoW/+D1VXubd47xcGaB9pDuM= +github.com/go-co-op/gocron-redis-lock v1.3.0/go.mod h1:9+H7ZfqVtJfx94uEAELwH+uHkn1UpM6lRM99wOBTGtg= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= +github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= +github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4= +github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg= +github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg= +github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-redsync/redsync/v4 v4.10.0 h1:hTeAak4C73mNBQSTq6KCKDFaiIlfC+z5yTTl8fCJuBs= +github.com/go-redsync/redsync/v4 v4.10.0/go.mod h1:ZfayzutkgeBmEmBlUR3j+rF6kN44UUGtEdfzhBFZTPc= +github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= +github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= +github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/moby/patternmatcher v0.5.0 h1:YCZgJOeULcxLw1Q+sVR636pmS7sPEn1Qo2iAN6M7DBo= +github.com/moby/patternmatcher v0.5.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc4 h1:oOxKUJWnFC4YGHCCMNql1x4YaDfYBTS5Y4x/Cgeo1E0= +github.com/opencontainers/image-spec v1.1.0-rc4/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/runc v1.1.5 h1:L44KXEpKmfWDcS02aeGm8QNTFXTo2D+8MYGDIJ/GDEs= +github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= +github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= +github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= +github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +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/shirou/gopsutil/v3 v3.23.8 h1:xnATPiybo6GgdRoC4YoGnxXZFRc3dqQTGi73oLvvBrE= +github.com/shirou/gopsutil/v3 v3.23.8/go.mod h1:7hmCaBn+2ZwaZOr6jmPBZDfawwMGuo1id3C6aM8EDqQ= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM= +github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8= +github.com/testcontainers/testcontainers-go v0.25.0 h1:erH6cQjsaJrH+rJDU9qIf89KFdhK0Bft0aEZHlYC3Vs= +github.com/testcontainers/testcontainers-go v0.25.0/go.mod h1:4sC9SiJyzD1XFi59q8umTQYWxnkweEc5OjVtTUlJzqQ= +github.com/testcontainers/testcontainers-go/modules/redis v0.25.0 h1:Oml2QVZtDfLB8gosT7fRdWsWYSM8vihWKVSl7XZZtv0= +github.com/testcontainers/testcontainers-go/modules/redis v0.25.0/go.mod h1:xmCaWbWOQoWzhb5isnZw4GGy4aIaDjq8lSCXYFZz1ME= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI= +github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y= +golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0/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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +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/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 h1:0nDDozoAU19Qb2HwhXadU8OcsiO/09cnTqhUtq2MEOM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +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= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= +gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +moul.io/zapgorm2 v1.3.0 h1:+CzUTMIcnafd0d/BvBce8T4uPn6DQnpIrz64cyixlkk= +moul.io/zapgorm2 v1.3.0/go.mod h1:nPVy6U9goFKHR4s+zfSo1xVFaoU7Qgd5DoCdOfzoCqs= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 0000000..005bbd0 --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,44 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":rebaseStalePrs", + ":ignoreUnstable", + "group:monorepos", + "group:recommended" + ], + "prConcurrentLimit": 0, + // skip next alpine, see https://github.com/mattn/go-sqlite3/issues/1164 + "packageRules": [ + { + "matchPackageNames": [ + "alpine" + ], + "matchUpdateTypes": [ + "minor", + "major" + ], + "enabled": false + }, + { + "matchPackagePrefixes": [ + "github.com/go-co-op/gocron" + ], + "groupName": "gocron" + }, + { + "matchUpdateTypes": [ + "minor" + ], + "groupName": "all minor dependencies", + "groupSlug": "all-minor-deps" + }, + { + "matchUpdateTypes": [ + "patch" + ], + "groupName": "all patch dependencies", + "groupSlug": "all-patch-deps" + } + ] +} diff --git a/server/api_handler_auth.go b/server/api_handler_auth.go new file mode 100644 index 0000000..dfd2018 --- /dev/null +++ b/server/api_handler_auth.go @@ -0,0 +1,18 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +type authHandler struct { +} + +func newAuthHandler() *authHandler { + return &authHandler{} +} + +func (h *authHandler) login(c *gin.Context) { + c.Header(headerContentType, headerContentTypeApplicationJson) + c.Status(http.StatusNoContent) +} diff --git a/server/api_handler_error.go b/server/api_handler_error.go new file mode 100644 index 0000000..b956ae6 --- /dev/null +++ b/server/api_handler_error.go @@ -0,0 +1,77 @@ +package server + +import ( + "errors" + "fmt" + "git.myservermanager.com/varakh/upda/util" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "net/http" +) + +func errAbortWithValidatorPayload(c *gin.Context, err error) { + var errs validator.ValidationErrors + errors.As(err, &errs) + + errorMap := make(map[string]string) + for _, v := range errs { + key, txt := validatorErrorToText(&v) + errorMap[key] = txt + } + + resErr := newServiceError(IllegalArgument, fmt.Errorf("validation error: %v (%w)", util.ValuesString(errorMap), err)) + c.Header(headerContentType, headerContentTypeApplicationJson) + _ = c.AbortWithError(http.StatusBadRequest, resErr) + return +} + +func errToHttpStatus(err error) int { + var e *serviceError + switch { + case errors.As(err, &e): + if e.Status == IllegalArgument { + return http.StatusBadRequest + } else if e.Status == Unauthorized { + return http.StatusUnauthorized + } else if e.Status == Forbidden { + return http.StatusForbidden + } else if e.Status == NotFound { + return http.StatusNotFound + } else if e.Status == Conflict { + return http.StatusConflict + } else if e.Status == General { + return http.StatusInternalServerError + } + default: + return http.StatusInternalServerError + } + + return -1 +} + +func errCodeToStr(err error) string { + var e *serviceError + ok := errors.As(err, &e) + + if ok { + return string(e.Status) + } + + return string(General) +} + +func validatorErrorToText(e *validator.FieldError) (string, string) { + x := *e + + switch x.Tag() { + case "required": + return x.Field(), fmt.Sprintf("%s is required", x.Field()) + case "max": + return x.Field(), fmt.Sprintf("%s cannot be longer than %s", x.Field(), x.Param()) + case "min": + return x.Field(), fmt.Sprintf("%s must be longer than %s", x.Field(), x.Param()) + case "len": + return x.Field(), fmt.Sprintf("%s must be %s characters long", x.Field(), x.Param()) + } + return x.Field(), fmt.Sprintf("%s is not valid", x.Field()) +} diff --git a/server/api_handler_event.go b/server/api_handler_event.go new file mode 100644 index 0000000..2eccd63 --- /dev/null +++ b/server/api_handler_event.go @@ -0,0 +1,62 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +type eventHandler struct { + service eventService +} + +func newEventHandler(s *eventService) *eventHandler { + return &eventHandler{service: *s} +} + +func (h *eventHandler) window(c *gin.Context) { + var queryParams api.EventWindowRequest + var err error + if err = c.ShouldBindQuery(&queryParams); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + var events []*Event + if events, err = h.service.window(queryParams.Size, queryParams.Skip, queryParams.OrderBy, queryParams.Order); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + var data []*api.EventResponse + data = make([]*api.EventResponse, 0) + + for _, e := range events { + data = append(data, &api.EventResponse{ + ID: e.ID, + Name: e.Name, + State: e.State, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Payload: e.Payload, + }) + } + + var hasNext bool + if hasNext, err = h.service.windowHasNext(queryParams.Size, queryParams.Skip, queryParams.OrderBy, queryParams.Order); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewEventWindowResponse(data, queryParams.Size, queryParams.Skip, queryParams.OrderBy, queryParams.Order, hasNext))) +} + +func (h *eventHandler) delete(c *gin.Context) { + if err := h.service.delete(c.Param("id")); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.Header(headerContentType, headerContentTypeApplicationJson) + c.Status(http.StatusNoContent) +} diff --git a/server/api_handler_health.go b/server/api_handler_health.go new file mode 100644 index 0000000..16d54a1 --- /dev/null +++ b/server/api_handler_health.go @@ -0,0 +1,20 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +type healthHandler struct { +} + +func newHealthHandler() *healthHandler { + return &healthHandler{} +} + +func (h *healthHandler) showHealth(c *gin.Context) { + c.JSON(http.StatusOK, api.DataResponse{Data: gin.H{ + "healthy": true, + }}) +} diff --git a/server/api_handler_info.go b/server/api_handler_info.go new file mode 100644 index 0000000..fc2a744 --- /dev/null +++ b/server/api_handler_info.go @@ -0,0 +1,23 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +type infoHandler struct { + appConfig appConfig +} + +func newInfoHandler(a *appConfig) *infoHandler { + return &infoHandler{appConfig: *a} +} + +func (h *infoHandler) showInfo(c *gin.Context) { + c.JSON(http.StatusOK, api.DataResponse{Data: gin.H{ + "name": Name, + "version": Version, + "timeZone": h.appConfig.timeZone, + }}) +} diff --git a/server/api_handler_update.go b/server/api_handler_update.go new file mode 100644 index 0000000..5206978 --- /dev/null +++ b/server/api_handler_update.go @@ -0,0 +1,106 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +type updateHandler struct { + service updateService + appConfig appConfig +} + +func newUpdateHandler(s *updateService, c *appConfig) *updateHandler { + return &updateHandler{service: *s, appConfig: *c} +} + +func (h *updateHandler) paginate(c *gin.Context) { + var queryParams api.PaginateUpdateRequest + + if err := c.ShouldBindQuery(&queryParams); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + var updates []*Update + var err error + + s, stateQueryContainsAtLeastOne := c.GetQueryArray("state") + + var states []api.UpdateState + if stateQueryContainsAtLeastOne { + for _, state := range s { + states = append(states, api.UpdateState(state)) + } + } + + if updates, err = h.service.paginate(queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order, queryParams.SearchTerm, queryParams.SearchIn, states...); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + var data []*api.UpdateResponse + data = make([]*api.UpdateResponse, 0) + + for _, e := range updates { + data = append(data, &api.UpdateResponse{ + ID: e.ID, + Application: e.Application, + Provider: e.Provider, + Host: e.Host, + Version: e.Version, + State: e.State, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + }) + } + + var totalElements int64 + if totalElements, err = h.service.count(queryParams.SearchTerm, queryParams.SearchIn, states...); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + totalPages := (totalElements + int64(queryParams.PageSize) - 1) / int64(queryParams.PageSize) + c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewUpdatePageResponse(data, queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order, totalElements, totalPages))) +} + +func (h *updateHandler) get(c *gin.Context) { + e, err := h.service.get(c.Param("id")) + if err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.JSON(http.StatusOK, api.NewUpdateSingleResponse(e.ID, e.Application, e.Provider, e.Host, e.Version, e.State, e.CreatedAt, e.UpdatedAt, e.Metadata)) +} + +func (h *updateHandler) updateState(c *gin.Context) { + var e *Update + var err error + + var req api.ModifyUpdateStateRequest + + if err := c.ShouldBindJSON(&req); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + if e, err = h.service.updateState(c.Param("id"), api.UpdateState(req.State)); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.JSON(http.StatusOK, api.NewUpdateSingleResponse(e.ID, e.Application, e.Provider, e.Host, e.Version, e.State, e.CreatedAt, e.UpdatedAt, e.Metadata)) +} + +func (h *updateHandler) delete(c *gin.Context) { + if err := h.service.delete(c.Param("id")); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.Header(headerContentType, headerContentTypeApplicationJson) + c.Status(http.StatusNoContent) +} diff --git a/server/api_handler_webhook.go b/server/api_handler_webhook.go new file mode 100644 index 0000000..2e2a0fb --- /dev/null +++ b/server/api_handler_webhook.go @@ -0,0 +1,120 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +type webhookHandler struct { + service webhookService +} + +func newWebhookHandler(s *webhookService) *webhookHandler { + return &webhookHandler{service: *s} +} + +func (h *webhookHandler) paginate(c *gin.Context) { + var queryParams api.PaginateWebhookRequest + var err error + if err = c.ShouldBindQuery(&queryParams); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + var webhooks []*Webhook + if webhooks, err = h.service.paginate(queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + var data []*api.WebhookResponse + data = make([]*api.WebhookResponse, 0) + + for _, e := range webhooks { + data = append(data, &api.WebhookResponse{ + ID: e.ID, + Label: e.Label, + Type: e.Type, + IgnoreHost: e.IgnoreHost, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + }) + } + + var totalElements int64 + if totalElements, err = h.service.count(); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + totalPages := (totalElements + int64(queryParams.PageSize) - 1) / int64(queryParams.PageSize) + c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewWebhookPageResponse(data, queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order, totalElements, totalPages))) +} + +func (h *webhookHandler) create(c *gin.Context) { + var e *Webhook + var err error + + var req api.CreateWebhookRequest + + if err := c.ShouldBindJSON(&req); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + if e, err = h.service.create(req.Label, api.WebhookType(req.Type), req.IgnoreHost); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.JSON(http.StatusOK, api.NewWebhookSingleResponse(e.ID, e.Label, e.Type, e.IgnoreHost, e.Token, e.CreatedAt, e.UpdatedAt)) +} + +func (h *webhookHandler) updateLabel(c *gin.Context) { + var e *Webhook + var err error + + var req api.ModifyWebhookLabelRequest + + if err := c.ShouldBindJSON(&req); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + if e, err = h.service.updateLabel(c.Param("id"), req.Label); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.JSON(http.StatusOK, api.NewWebhookSingleResponse(e.ID, e.Label, e.Type, e.IgnoreHost, "", e.CreatedAt, e.UpdatedAt)) +} + +func (h *webhookHandler) updateIgnoreHost(c *gin.Context) { + var e *Webhook + var err error + + var req api.ModifyWebhookIgnoreHostRequest + + if err := c.ShouldBindJSON(&req); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + + if e, err = h.service.updateIgnoreHost(c.Param("id"), req.IgnoreHost); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.JSON(http.StatusOK, api.NewWebhookSingleResponse(e.ID, e.Label, e.Type, e.IgnoreHost, "", e.CreatedAt, e.UpdatedAt)) +} + +func (h *webhookHandler) delete(c *gin.Context) { + if err := h.service.delete(c.Param("id")); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.Header(headerContentType, headerContentTypeApplicationJson) + c.Status(http.StatusNoContent) +} diff --git a/server/api_handler_webhook_invocation.go b/server/api_handler_webhook_invocation.go new file mode 100644 index 0000000..c77501f --- /dev/null +++ b/server/api_handler_webhook_invocation.go @@ -0,0 +1,62 @@ +package server + +import ( + "errors" + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +type webhookInvocationHandler struct { + invocationService webhookInvocationService + webhookService webhookService +} + +func newWebhookInvocationHandler(i *webhookInvocationService, w *webhookService) *webhookInvocationHandler { + return &webhookInvocationHandler{invocationService: *i, webhookService: *w} +} + +func (h *webhookInvocationHandler) executeWebhookGeneric(c *gin.Context) { + tokenHeader := c.GetHeader(HeaderWebhookToken) + webhookId := c.Param("id") + + var w *Webhook + var err error + + if w, err = h.webhookService.get(webhookId); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + switch w.Type { + case api.WebhookTypeGeneric.Value(): + var req api.WebhookGenericRequest + if err = c.ShouldBindJSON(&req); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + if err = h.invocationService.executeGeneric(webhookId, tokenHeader, req); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + break + case api.WebhookTypeDiun.Value(): + var req api.WebhookDiunRequest + if err = c.ShouldBindJSON(&req); err != nil { + errAbortWithValidatorPayload(c, err) + return + } + if err = h.invocationService.executeDiun(webhookId, tokenHeader, req); err != nil { + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + break + default: + err = newServiceError(IllegalArgument, errors.New("no default handler for webhook type found")) + _ = c.AbortWithError(errToHttpStatus(err), err) + return + } + + c.Header(headerContentType, headerContentTypeApplicationJson) + c.Status(http.StatusNoContent) +} diff --git a/server/api_middleware.go b/server/api_middleware.go new file mode 100644 index 0000000..3516164 --- /dev/null +++ b/server/api_middleware.go @@ -0,0 +1,55 @@ +package server + +import ( + "fmt" + "git.myservermanager.com/varakh/upda/api" + "github.com/gin-gonic/gin" + "net/http" +) + +func middlewareAppName() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header(HeaderAppName, Name) + c.Next() + } +} + +func middlewareAppVersion() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header(HeaderAppVersion, Version) + c.Next() + } +} + +func middlewareAppContentType() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header(headerContentType, headerContentTypeApplicationJson) + c.Next() + } +} + +// middlewareErrorHandler handles global error handling, does not overwrite any given status (see -1) +func middlewareErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + // call next first, so this is the last in chain + c.Next() + + if len(c.Errors) > 0 { + // status -1 doesn't overwrite existing status code + c.Header(headerContentType, headerContentTypeApplicationJson) + c.JSON(-1, api.NewErrorResponseWithStatusAndMessage(errCodeToStr(c.Errors.Last()), c.Errors.Last().Error())) + return + } + } +} + +func middlewareAppErrorRecoveryHandler() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, api.NewErrorResponseWithStatusAndMessage(string(General), fmt.Sprintf("%s", err))) + } + }() + c.Next() + } +} diff --git a/server/app.go b/server/app.go new file mode 100644 index 0000000..13498a5 --- /dev/null +++ b/server/app.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "errors" + "fmt" + "git.myservermanager.com/varakh/upda/util" + "github.com/gin-contrib/cors" + ginzap "github.com/gin-contrib/zap" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func Start() { + // configuration init + env := bootstrapEnvironment() + + // secure init + util.AssertAvailablePRNG() + + // set gin mode derived from logging level + if zap.L().Level() == zap.DebugLevel { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + router.Use(ginzap.Ginzap(zap.L(), time.RFC3339, false)) + router.Use(ginzap.RecoveryWithZap(zap.L(), true)) + + // metrics + prometheusService := newPrometheusService(router, env.prometheusConfig) + + if env.prometheusConfig.enabled { + prometheusService.init() + router.Use(prometheusService.prometheus.Instrument()) + } + + updateRepo := newUpdateDbRepo(env.db) + webhookRepo := newWebhookDbRepo(env.db) + eventRepo := newEventDbRepo(env.db) + + eventService := newEventService(eventRepo) + updateService := newUpdateService(updateRepo, eventService) + webhookService := newWebhookService(webhookRepo, env.webhookConfig, eventService) + webhookInvocationService := newWebhookInvocationService(webhookService, updateService, env.webhookConfig) + + taskService := newTaskService(updateService, eventService, webhookService, prometheusService, env.appConfig, env.taskConfig, env.prometheusConfig) + taskService.init() + taskService.start() + + updateHandler := newUpdateHandler(updateService, env.appConfig) + webhookHandler := newWebhookHandler(webhookService) + webhookInvocationHandler := newWebhookInvocationHandler(webhookInvocationService, webhookService) + eventHandler := newEventHandler(eventService) + infoHandler := newInfoHandler(env.appConfig) + healthHandler := newHealthHandler() + authHandler := newAuthHandler() + + router.Use(middlewareAppName()) + router.Use(middlewareAppVersion()) + router.Use(middlewareAppContentType()) + router.Use(middlewareErrorHandler()) + router.Use(middlewareAppErrorRecoveryHandler()) + + router.Use(cors.New(cors.Config{ + AllowOrigins: env.serverConfig.corsAllowOrigin, + AllowMethods: env.serverConfig.corsAllowMethods, + AllowHeaders: env.serverConfig.corsAllowHeaders, + AllowCredentials: true, + })) + + apiPublicGroup := router.Group("/api/v1") + apiPublicGroup.GET("/health", healthHandler.showHealth) + apiPublicGroup.GET("/info", infoHandler.showInfo) + + apiPublicGroup.POST("/webhooks/:id", webhookInvocationHandler.executeWebhookGeneric) + + apiAuthGroup := router.Group("/api/v1", gin.BasicAuth(gin.Accounts{ + env.authConfig.adminUser: env.authConfig.adminPassword, + })) + + apiAuthGroup.GET("/login", authHandler.login) + + apiAuthGroup.GET("/updates", updateHandler.paginate) + apiAuthGroup.GET("/updates/:id", updateHandler.get) + apiAuthGroup.PATCH("/updates/:id/state", updateHandler.updateState) + apiAuthGroup.DELETE("/updates/:id", updateHandler.delete) + + apiAuthGroup.GET("/webhooks", webhookHandler.paginate) + apiAuthGroup.POST("/webhooks", webhookHandler.create) + apiAuthGroup.PATCH("/webhooks/:id/label", webhookHandler.updateLabel) + apiAuthGroup.PATCH("/webhooks/:id/ignore-host", webhookHandler.updateIgnoreHost) + apiAuthGroup.DELETE("/webhooks/:id", webhookHandler.delete) + + apiAuthGroup.GET("/events", eventHandler.window) + apiAuthGroup.DELETE("/events/:id", eventHandler.delete) + + // start server + serverAddress := fmt.Sprintf("%s:%d", env.serverConfig.listen, env.serverConfig.port) + srv := &http.Server{ + Addr: serverAddress, + Handler: router, + } + + go func() { + var err error + + if env.serverConfig.tlsEnabled { + err = srv.ListenAndServeTLS(env.serverConfig.tlsCertPath, env.serverConfig.tlsKeyPath) + } else { + err = srv.ListenAndServe() + } + + if err != nil && !errors.Is(err, http.ErrServerClosed) { + zap.L().Sugar().Fatalf("Application cannot be started: %v", err) + } + }() + + // gracefully handle shut down + // Wait for interrupt signal to gracefully shut down the server with + // a timeout of x seconds. + quit := make(chan os.Signal) + // kill (no param) default send syscall.SIGTERM + // kill -2 is syscall.SIGINT + // kill -9 is syscall. SIGKILL but cannot be caught, thus no need to add + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + zap.L().Info("Shutting down...") + taskService.stop() + + ctx, cancel := context.WithTimeout(context.Background(), env.serverConfig.timeout) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + zap.L().Sugar().Fatalf("Shutdown failed, exited directly: %v", err) + } + // catching ctx.Done() for configured timeout + select { + case <-ctx.Done(): + zap.L().Sugar().Infof("Shutdown timeout of '%v' expired, exiting...", env.serverConfig.timeout) + + } + zap.L().Info("Exited") +} diff --git a/server/constants_api.go b/server/constants_api.go new file mode 100644 index 0000000..209774c --- /dev/null +++ b/server/constants_api.go @@ -0,0 +1,11 @@ +package server + +const ( + HeaderAppName = "X-App-Name" + HeaderAppVersion = "X-App-Version" + + HeaderWebhookToken = "X-Webhook-Token" + + headerContentType = "Content-Type" + headerContentTypeApplicationJson = "application/json" +) diff --git a/server/constants_app.go b/server/constants_app.go new file mode 100644 index 0000000..2b55dfc --- /dev/null +++ b/server/constants_app.go @@ -0,0 +1,6 @@ +package server + +const ( + Name = "upda" + Version = "1.0.0" +) diff --git a/server/constants_env.go b/server/constants_env.go new file mode 100644 index 0000000..15ba733 --- /dev/null +++ b/server/constants_env.go @@ -0,0 +1,77 @@ +package server + +const ( + envTZ = "TZ" + tzDefault = "Europe/Berlin" + + envAdminUser = "ADMIN_USER" + envAdminPassword = "ADMIN_PASSWORD" + + envLoggingLevel = "LOGGING_LEVEL" + + envServerPort = "SERVER_PORT" + envServerListen = "SERVER_LISTEN" + envServerTlsEnabled = "SERVER_TLS_ENABLED" + envServerTlsCertPath = "SERVER_TLS_CERT_PATH" + envServerTlsKeyPath = "SERVER_TLS_KEY_PATH" + envServerTimeout = "SERVER_TIMEOUT" + serverListenDefault = "" + serverPortDefault = "8080" + serverTlsEnabledDefault = "false" + serverTimeoutDefault = "1s" + + envCorsAllowOrigin = "CORS_ALLOW_ORIGIN" + envCorsAllowMethods = "CORS_ALLOW_METHODS" + envCorsAllowHeaders = "CORS_ALLOW_HEADERS" + corsAllowOriginDefault = "*" + corsAllowMethodsDefault = "HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS" + corsAllowHeadersDefault = "Authorization, Content-Type" + + dbTypeSqlite = "sqlite" + dbTypePostgres = "postgres" + + envDbType = "DB_TYPE" + envDbSqliteFile = "DB_SQLITE_FILE" + envDbPostgresHost = "DB_POSTGRES_HOST" + envDbPostgresPort = "DB_POSTGRES_PORT" + envDbPostgresName = "DB_POSTGRES_NAME" + envDbPostgresTimeZone = "DB_POSTGRES_TZ" + envDbPostgresUser = "DB_POSTGRES_USER" + envDbPostgresPassword = "DB_POSTGRES_PASSWORD" + dbTypeSqliteDbNameDefault = "upda.db" + dbTypePostgresHostDefault = "localhost" + dbTypePostgresPortDefault = "5432" + dbTypePostgresTZDefault = "Europe/Berlin" + + envTaskPrometheusRefreshInterval = "TASK_PROMETHEUS_REFRESH_INTERVAL" + taskPrometheusRefreshDefault = "60s" + + envWebhooksTokenLength = "WEBHOOKS_TOKEN_LENGTH" + webhooksTokenLengthDefault = "16" + + envPrometheusEnabled = "PROMETHEUS_ENABLED" + envPrometheusMetricsPath = "PROMETHEUS_METRICS_PATH" + envPrometheusSecureTokenEnabled = "PROMETHEUS_SECURE_TOKEN_ENABLED" + envPrometheusSecureToken = "PROMETHEUS_SECURE_TOKEN" + prometheusEnabledDefault = "false" + prometheusMetricsPathDefault = "/metrics" + prometheusSecureTokenEnabledDefault = "true" + + envTaskUpdateCleanStaleEnabled = "TASK_UPDATE_CLEAN_STALE_ENABLED" + envTaskUpdateCleanStaleInterval = "TASK_UPDATE_CLEAN_STALE_INTERVAL" + envTaskUpdateCleanStaleMaxAge = "TASK_UPDATE_CLEAN_STALE_MAX_AGE" + taskUpdateCleanStaleEnabledDefault = "true" + taskUpdateCleanStaleIntervalDefault = "1h" + taskUpdateCleanStaleMaxAgeDefault = "168h" + + envTaskEventCleanStaleEnabled = "TASK_EVENT_CLEAN_STALE_ENABLED" + envTaskEventCleanStaleInterval = "TASK_EVENT_CLEAN_STALE_INTERVAL" + envTaskEventCleanStaleMaxAge = "TASK_EVENT_CLEAN_STALE_MAX_AGE" + taskEventCleanStaleEnabledDefault = "true" + taskEventCleanStaleIntervalDefault = "8h" + taskEventCleanStaleMaxAgeDefault = "2190h" + + envTaskLockRedisEnabled = "TASK_LOCK_REDIS_ENABLED" + envTaskLockRedisUrl = "TASK_LOCK_REDIS_URL" + taskLockRedisEnabledDefault = "false" +) diff --git a/server/constants_prometheus.go b/server/constants_prometheus.go new file mode 100644 index 0000000..033d534 --- /dev/null +++ b/server/constants_prometheus.go @@ -0,0 +1,24 @@ +package server + +const ( + metricUpdatesTotal = "updates_all" + metricUpdatesTotalHelp = "amount of all updates" + + metricUpdatesPending = "updates_pending" + metricUpdatesPendingHelp = "amount of all updates in pending state" + + metricUpdatesIgnored = "updates_ignored" + metricUpdatesIgnoredHelp = "amount of all updates in ignored state" + + metricUpdatesApproved = "updates_approved" + metricUpdatesApprovedHelp = "amount of all updates in approved state" + + metricUpdates = "updates" + metricUpdatesHelp = "details for all updates, 0=pending, 1=approved, 2=ignored" + + metricWebhooks = "webhooks" + metricWebhooksHelp = "amount of all webhooks" + + metricEvents = "events" + metricEventsHelp = "amount of all events" +) diff --git a/server/datatype_json_map.go b/server/datatype_json_map.go new file mode 100644 index 0000000..751e5b3 --- /dev/null +++ b/server/datatype_json_map.go @@ -0,0 +1,87 @@ +package server + +import ( + "database/sql/driver" + "fmt" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +import ( + "context" + "encoding/json" + "errors" + "gorm.io/gorm/clause" +) + +// JSONMap defined JSON data type, need to implements driver.Valuer, sql.Scanner interface +type JSONMap map[string]interface { +} + +// Value return json value, implement driver.Valuer interface +func (m JSONMap) Value() (driver.Value, error) { + if m == nil { + return nil, nil + } + ba, err := m.MarshalJSON() + return string(ba), err +} + +// Scan scan value into Jsonb, implements sql.Scanner interface +func (m *JSONMap) Scan(val interface{}) error { + if val == nil { + *m = make(JSONMap) + return nil + } + var ba []byte + switch v := val.(type) { + case []byte: + ba = v + case string: + ba = []byte(v) + default: + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", val)) + } + t := map[string]interface{}{} + err := json.Unmarshal(ba, &t) + *m = t + return err +} + +// MarshalJSON to output non base64 encoded []byte +func (m JSONMap) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + t := (map[string]interface{})(m) + return json.Marshal(t) +} + +// UnmarshalJSON to deserialize []byte +func (m *JSONMap) UnmarshalJSON(b []byte) error { + t := map[string]interface{}{} + err := json.Unmarshal(b, &t) + *m = t + return err +} + +// GormDataType gorm common data type +func (m JSONMap) GormDataType() string { + return "jsonmap" +} + +// GormDBDataType gorm db data type +func (JSONMap) GormDBDataType(db *gorm.DB, field *schema.Field) string { + switch db.Dialector.Name() { + case "sqlite": + return "JSON" + case "postgres": + return "JSONB" + } + return "" +} + +func (jm JSONMap) GormValue(ctx context.Context, db *gorm.DB) clause.Expr { + data, _ := jm.MarshalJSON() + return gorm.Expr("?", string(data)) +} diff --git a/server/entity.go b/server/entity.go new file mode 100644 index 0000000..5e7bc32 --- /dev/null +++ b/server/entity.go @@ -0,0 +1,56 @@ +package server + +import ( + "github.com/google/uuid" + "gorm.io/gorm" + "time" +) + +func (u *Update) BeforeCreate(tx *gorm.DB) (err error) { + u.ID = uuid.New() + return +} + +func (wh *Webhook) BeforeCreate(tx *gorm.DB) (err error) { + wh.ID = uuid.New() + return +} + +func (e *Event) BeforeCreate(tx *gorm.DB) (err error) { + e.ID = uuid.New() + return +} + +// Update entity holding information for updates +type Update struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"` + Application string `gorm:"uniqueIndex:idx_a_p_h;not null"` + Provider string `gorm:"uniqueIndex:idx_a_p_h;not null"` + Host string `gorm:"uniqueIndex:idx_a_p_h;not null"` + Version string `gorm:"not null"` + State string `gorm:"not null"` + Metadata JSONMap `gorm:"jsonb"` + CreatedAt time.Time `gorm:"time;autoCreateTime;not null"` + UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"` +} + +// Webhook entity holding information for webhooks +type Webhook struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"` + Type string `gorm:"not null"` + Label string `gorm:"not null"` + Token string `gorm:"not null"` + IgnoreHost bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"time;autoCreateTime;not null"` + UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"` +} + +// Event entity holding information for events +type Event struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"` + Name string `gorm:"not null"` + State string `gorm:"not null"` + Payload JSONMap `gorm:"jsonb"` + CreatedAt time.Time `gorm:"time;autoCreateTime;not null"` + UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"` +} diff --git a/server/environment.go b/server/environment.go new file mode 100644 index 0000000..037c591 --- /dev/null +++ b/server/environment.go @@ -0,0 +1,324 @@ +package server + +import ( + "fmt" + "github.com/adrg/xdg" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "log" + "moul.io/zapgorm2" + "os" + "strconv" + "time" +) + +type appConfig struct { + timeZone string +} + +type serverConfig struct { + port int + listen string + tlsEnabled bool + tlsCertPath string + tlsKeyPath string + timeout time.Duration + corsAllowOrigin []string + corsAllowMethods []string + corsAllowHeaders []string +} + +type authConfig struct { + adminUser string + adminPassword string +} + +type taskConfig struct { + updateCleanStaleEnabled bool + updateCleanStaleInterval string + updateCleanStaleMaxAge time.Duration + eventCleanStaleEnabled bool + eventCleanStaleInterval string + eventCleanStaleMaxAge time.Duration + prometheusRefreshInterval string + lockRedisEnabled bool + lockRedisUrl string +} + +type webhookConfig struct { + tokenLength int +} + +type prometheusConfig struct { + enabled bool + path string + secureTokenEnabled bool + secureToken string +} + +type Environment struct { + appConfig *appConfig + authConfig *authConfig + serverConfig *serverConfig + taskConfig *taskConfig + webhookConfig *webhookConfig + prometheusConfig *prometheusConfig + db *gorm.DB +} + +func bootstrapEnvironment() *Environment { + // logging (configured independently) + var logger *zap.Logger + var err error + level := zap.NewAtomicLevelAt(zapcore.InfoLevel) + + envLoggingLevel := os.Getenv(envLoggingLevel) + if envLoggingLevel != "" { + if level, err = zap.ParseAtomicLevel(envLoggingLevel); err != nil { + log.Fatalf("Cannot parse logging level: %v", err) + } + } + + logger, err = zap.NewDevelopment(zap.IncreaseLevel(level)) + if err != nil { + log.Fatalf("Can't initialize logger: %v", err) + } + // flushes buffer, if any + defer logger.Sync() + + zap.ReplaceGlobals(logger) + + // assign defaults from given environment variables and validate + bootstrapFromEnvironmentAndValidate() + + // parse environment variables in actual configuration structs + // app config + appConfig := &appConfig{ + timeZone: os.Getenv(envTZ), + } + + // server config + var sc *serverConfig + + var serverPort int + if serverPort, err = strconv.Atoi(os.Getenv(envServerPort)); err != nil { + zap.L().Sugar().Fatalf("Invalid server port. Reason: %v", err) + } + + serverTlsEnabled := os.Getenv(envServerTlsEnabled) == "true" + + if serverTlsEnabled { + failIfEnvKeyNotPresent(envServerTlsCertPath) + failIfEnvKeyNotPresent(envServerTlsKeyPath) + } + + var serverTimeout time.Duration + var errParse error + if serverTimeout, errParse = time.ParseDuration(os.Getenv(envServerTimeout)); errParse != nil { + zap.L().Sugar().Fatalf("Could not parse timeout. Reason: %s", errParse.Error()) + } + + sc = &serverConfig{ + port: serverPort, + timeout: serverTimeout, + listen: os.Getenv(envServerListen), + tlsEnabled: serverTlsEnabled, + tlsCertPath: os.Getenv(envServerTlsCertPath), + tlsKeyPath: os.Getenv(envServerTlsKeyPath), + corsAllowOrigin: []string{os.Getenv(envCorsAllowOrigin)}, + corsAllowMethods: []string{os.Getenv(envCorsAllowMethods)}, + corsAllowHeaders: []string{os.Getenv(envCorsAllowHeaders)}, + } + + authConfig := &authConfig{ + adminUser: os.Getenv(envAdminUser), + adminPassword: os.Getenv(envAdminPassword), + } + + // task config + var tc *taskConfig + + var updateCleanStaleMaxAge time.Duration + if updateCleanStaleMaxAge, errParse = time.ParseDuration(os.Getenv(envTaskUpdateCleanStaleMaxAge)); errParse != nil { + zap.L().Sugar().Fatalf("Could not parse max age for cleaning stale updates. Reason: %s", errParse.Error()) + } + + var eventCleanStaleMaxAge time.Duration + if eventCleanStaleMaxAge, errParse = time.ParseDuration(os.Getenv(envTaskEventCleanStaleMaxAge)); errParse != nil { + zap.L().Sugar().Fatalf("Could not parse max age for cleaning stale events. Reason: %s", errParse.Error()) + } + + tc = &taskConfig{ + updateCleanStaleEnabled: os.Getenv(envTaskUpdateCleanStaleEnabled) == "true", + updateCleanStaleInterval: os.Getenv(envTaskUpdateCleanStaleInterval), + updateCleanStaleMaxAge: updateCleanStaleMaxAge, + eventCleanStaleEnabled: os.Getenv(envTaskEventCleanStaleEnabled) == "true", + eventCleanStaleInterval: os.Getenv(envTaskEventCleanStaleInterval), + eventCleanStaleMaxAge: eventCleanStaleMaxAge, + prometheusRefreshInterval: os.Getenv(envTaskPrometheusRefreshInterval), + lockRedisEnabled: os.Getenv(envTaskLockRedisEnabled) == "true", + lockRedisUrl: os.Getenv(envTaskLockRedisUrl), + } + + webhookTokenLength := 32 + if webhookTokenLength, err = strconv.Atoi(os.Getenv(envWebhooksTokenLength)); err != nil { + zap.L().Sugar().Fatalf("Invalid webhook token length. Reason: %v", err) + } + if webhookTokenLength <= 0 { + zap.L().Sugar().Fatalln("Invalid webhook token length. Reason: must be a positive number") + } + + webhookConfig := &webhookConfig{ + tokenLength: webhookTokenLength, + } + + prometheusConfig := &prometheusConfig{ + enabled: os.Getenv(envPrometheusEnabled) == "true", + path: os.Getenv(envPrometheusMetricsPath), + secureTokenEnabled: os.Getenv(envPrometheusSecureTokenEnabled) == "true", + secureToken: os.Getenv(envPrometheusSecureToken), + } + + if prometheusConfig.enabled && prometheusConfig.secureTokenEnabled { + failIfEnvKeyNotPresent(envPrometheusSecureToken) + } + + // database setup + gormLogger := zapgorm2.New(logger) + gormLogger.SetAsDefault() + + var db *gorm.DB + zap.L().Sugar().Infof("Using database type '%s'", os.Getenv(envDbType)) + + if os.Getenv(envDbType) == dbTypeSqlite { + if os.Getenv(envDbSqliteFile) == "" { + var defaultDbFile string + if defaultDbFile, err = xdg.DataFile(Name + "/" + dbTypeSqliteDbNameDefault); err != nil { + zap.L().Sugar().Fatalf("Database file '%s' could not be created. Reason: %v", defaultDbFile, err) + } + setEnvKeyDefault(envDbSqliteFile, defaultDbFile) + } + + dbFile := os.Getenv(envDbSqliteFile) + zap.L().Sugar().Infof("Using database file '%s'", dbFile) + + if db, err = gorm.Open(sqlite.Open(dbFile), &gorm.Config{Logger: gormLogger}); err != nil { + zap.L().Sugar().Fatalf("Could not setup database: %v", err) + } + + if res := db.Exec("PRAGMA foreign_keys = ON"); res.Error != nil { + zap.L().Sugar().Fatalf("Could not invoke foreign key for SQLite: %v", res.Error) + } + + sqlDb, _ := db.DB() + sqlDb.SetMaxOpenConns(1) + zap.L().Sugar().Infof("SQLite: restricting max connections to '1'") + } else if os.Getenv(envDbType) == dbTypePostgres { + host := os.Getenv(envDbPostgresHost) + port := os.Getenv(envDbPostgresPort) + dbUser := os.Getenv(envDbPostgresUser) + dbPass := os.Getenv(envDbPostgresPassword) + dbName := os.Getenv(envDbPostgresName) + dbTZ := os.Getenv(envDbPostgresTimeZone) + + if host == "" || port == "" || dbUser == "" || dbPass == "" || dbName == "" || dbTZ == "" { + zap.L().Sugar().Fatalf("Some configuration for database type '%s' is missing", os.Getenv(envDbType)) + } + + dsn := fmt.Sprintf("host=%v user=%v password=%v dbname=%v port=%v sslmode=disable TimeZone=%v", host, dbUser, dbPass, dbName, port, dbTZ) + if db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormLogger}); err != nil { + zap.L().Sugar().Fatalf("Could not setup database: %v", err) + } + } else { + zap.L().Sugar().Fatalf("Database type '%s' or '%s' is required", dbTypeSqlite, dbTypePostgres) + } + + if db == nil { + zap.L().Sugar().Fatalf("Could not setup database") + } + + env := &Environment{appConfig: appConfig, + authConfig: authConfig, + serverConfig: sc, + taskConfig: tc, + webhookConfig: webhookConfig, + prometheusConfig: prometheusConfig, + db: db} + + if err = env.db.AutoMigrate(&Update{}, &Webhook{}, &Event{}); err != nil { + zap.L().Sugar().Fatalf("Could not migrate database schema: %s", err) + } + + zap.L().Sugar().Infof("appConfig %+v", env.appConfig) + zap.L().Sugar().Infof("serverConfig %+v", env.serverConfig) + zap.L().Sugar().Infof("taskConfig %+v", env.taskConfig) + zap.L().Sugar().Infof("webhookConfig %+v", env.webhookConfig) + + return env +} + +func bootstrapFromEnvironmentAndValidate() { + // app + setEnvKeyDefault(envTZ, tzDefault) + + failIfEnvKeyNotPresent(envAdminUser) + failIfEnvKeyNotPresent(envAdminPassword) + + // webhook + setEnvKeyDefault(envWebhooksTokenLength, webhooksTokenLengthDefault) + + // task + setEnvKeyDefault(envTaskUpdateCleanStaleEnabled, taskUpdateCleanStaleEnabledDefault) + setEnvKeyDefault(envTaskUpdateCleanStaleInterval, taskUpdateCleanStaleIntervalDefault) + setEnvKeyDefault(envTaskUpdateCleanStaleMaxAge, taskUpdateCleanStaleMaxAgeDefault) + + setEnvKeyDefault(envTaskEventCleanStaleEnabled, taskEventCleanStaleEnabledDefault) + setEnvKeyDefault(envTaskEventCleanStaleInterval, taskEventCleanStaleIntervalDefault) + setEnvKeyDefault(envTaskEventCleanStaleMaxAge, taskEventCleanStaleMaxAgeDefault) + + setEnvKeyDefault(envTaskPrometheusRefreshInterval, taskPrometheusRefreshDefault) + setEnvKeyDefault(envTaskLockRedisEnabled, taskLockRedisEnabledDefault) + + // prometheus + setEnvKeyDefault(envPrometheusEnabled, prometheusEnabledDefault) + setEnvKeyDefault(envPrometheusMetricsPath, prometheusMetricsPathDefault) + setEnvKeyDefault(envPrometheusSecureTokenEnabled, prometheusSecureTokenEnabledDefault) + + // db + setEnvKeyDefault(envDbType, dbTypeSqlite) + + if os.Getenv(envDbType) == dbTypePostgres { + setEnvKeyDefault(envDbPostgresHost, dbTypePostgresHostDefault) + setEnvKeyDefault(envDbPostgresPort, dbTypePostgresPortDefault) + setEnvKeyDefault(envDbPostgresTimeZone, dbTypePostgresTZDefault) + } + + // server + setEnvKeyDefault(envServerPort, serverPortDefault) + setEnvKeyDefault(envServerListen, serverListenDefault) + setEnvKeyDefault(envServerTlsEnabled, serverTlsEnabledDefault) + setEnvKeyDefault(envCorsAllowOrigin, corsAllowOriginDefault) + setEnvKeyDefault(envCorsAllowMethods, corsAllowMethodsDefault) + setEnvKeyDefault(envCorsAllowHeaders, corsAllowHeadersDefault) + setEnvKeyDefault(envServerTimeout, serverTimeoutDefault) +} + +func failIfEnvKeyNotPresent(key string) { + if os.Getenv(key) == "" { + zap.L().Sugar().Fatalf("Not all required ENV variables given. Please set '%s'", key) + } +} + +func setEnvKeyDefault(key string, defaultValue string) { + var err error + if os.Getenv(key) == "" { + if err = os.Setenv(key, defaultValue); err != nil { + zap.L().Sugar().Fatalf("Could not set default value for ENV variable '%s'", key) + } + + zap.L().Sugar().Infof("Set '%s' to '%s'", key, defaultValue) + } +} diff --git a/server/errors.go b/server/errors.go new file mode 100644 index 0000000..70ab376 --- /dev/null +++ b/server/errors.go @@ -0,0 +1,48 @@ +package server + +import ( + "errors" + "fmt" +) + +var ( + errorValidationNotEmpty = newServiceError(IllegalArgument, errors.New("assert: empty values are not allowed")) + errorValidationNotBlank = newServiceError(IllegalArgument, errors.New("assert: blank values are not allowed")) + errorValidationPageGreaterZero = newServiceError(IllegalArgument, errors.New("assert: page has to be greater 0")) + errorValidationPageSizeGreaterZero = newServiceError(IllegalArgument, errors.New("assert: pageSize has to be greater 0")) + + errorResourceNotFound = newServiceError(NotFound, errors.New("resource not found")) + errorResourceAccessDenied = newServiceError(Forbidden, errors.New("resource access denied")) + + errorDatabaseRowsExpected = newServiceDatabaseError(errors.New("action failed, expected affected rows, but got none")) +) + +type ErrorCode string + +const ( + IllegalArgument ErrorCode = "IllegalArgument" + Unauthorized ErrorCode = "Unauthorized" + Forbidden ErrorCode = "Forbidden" + NotFound ErrorCode = "NotFound" + Conflict ErrorCode = "Conflict" + General ErrorCode = "General" +) + +// newServiceError returns an error that formats as the given text and aligns with builtin error +func newServiceError(status ErrorCode, err error) error { + return &serviceError{status, fmt.Errorf("service error (%v): %w", status, err)} +} + +// newServiceDatabaseError returns an error that formats as the given text and aligns with builtin error +func newServiceDatabaseError(error error) error { + return newServiceError(General, fmt.Errorf("database error: %w", error)) +} + +type serviceError struct { + Status ErrorCode + Cause error +} + +func (e *serviceError) Error() string { + return fmt.Sprintf("%v", e.Cause) +} diff --git a/server/repository_event.go b/server/repository_event.go new file mode 100644 index 0000000..a2e1139 --- /dev/null +++ b/server/repository_event.go @@ -0,0 +1,180 @@ +package server + +import ( + "encoding/json" + "git.myservermanager.com/varakh/upda/api" + "gorm.io/gorm" + "time" +) + +type eventRepository interface { + find(id string) (*Event, error) + window(size int, skip int, orderBy string, order string) ([]*Event, error) + windowHasNext(size int, skip int, orderBy string, order string) (bool, error) + count(state ...api.EventState) (int64, error) + create(name api.EventName, state api.EventState, payload interface{}) (*Event, error) + delete(id string) (int64, error) + deleteByUpdatedAtBeforeAndStates(time time.Time, state ...api.EventState) (int64, error) +} + +type eventDbRepo struct { + db *gorm.DB +} + +func newEventDbRepo(db *gorm.DB) *eventDbRepo { + return &eventDbRepo{ + db: db, + } +} + +func (r *eventDbRepo) find(id string) (*Event, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + var e Event + var res *gorm.DB + + if res = r.db.Find(&e, "id = ?", id); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + if res.RowsAffected == 0 { + return nil, errorResourceNotFound + } + + return &e, nil +} + +func (r *eventDbRepo) create(name api.EventName, state api.EventState, payload interface{}) (*Event, error) { + if name == "" || state == "" { + return nil, errorValidationNotBlank + } + + var e *Event + unmarshalledPayload := JSONMap{} + + if payload != nil { + marshalledMetadata, err := json.Marshal(payload) + if err != nil { + return nil, err + } + err = unmarshalledPayload.UnmarshalJSON(marshalledMetadata) + if err != nil { + return nil, err + } + } + + e = &Event{ + Name: name.Value(), + State: state.Value(), + Payload: unmarshalledPayload, + } + + var res *gorm.DB + if res = r.db.Create(&e); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + if res.RowsAffected == 0 { + return nil, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *eventDbRepo) delete(id string) (int64, error) { + if id == "" { + return 0, errorValidationNotBlank + } + + var res *gorm.DB + if res = r.db.Delete(&Event{}, "id = ?", id); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + return res.RowsAffected, nil +} + +func (r *eventDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, state ...api.EventState) (int64, error) { + if len(state) == 0 { + return 0, errorValidationNotEmpty + } + + states := make([]string, 0) + for _, i := range state { + states = append(states, i.Value()) + } + + var res *gorm.DB + if res = r.db.Where("state IN ?", states).Where("updated_at < ?", time).Delete(&Event{}); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + + return res.RowsAffected, nil +} + +func (r *eventDbRepo) window(size int, skip int, orderBy string, order string) ([]*Event, error) { + var e []*Event + + if orderBy == "" { + orderBy = "created_at" + } + if order == "" { + order = "asc" + } + + if res := r.db.Order(orderBy + " " + order).Offset(skip).Limit(size).Find(&e); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + + return e, nil +} + +func (r *eventDbRepo) windowHasNext(size int, skip int, orderBy string, order string) (bool, error) { + if orderBy == "" { + orderBy = "created_at" + } + if order == "" { + order = "asc" + } + + var e []*Event + + if res := r.db.Order(orderBy + " " + order).Offset(skip + size).Find(&e); res.Error != nil { + return false, newServiceDatabaseError(res.Error) + } + + return len(e) > 0, nil +} + +func (r *eventDbRepo) count(state ...api.EventState) (int64, error) { + var c int64 + + states := make([]string, 0) + if len(state) > 0 { + for _, s := range state { + states = append(states, s.Value()) + } + } + + if res := r.db.Model(&Event{}).Scopes(allGetEventCriterion(states)).Count(&c); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + + return c, nil +} + +func criterionEventState(states []string) func(db *gorm.DB) *gorm.DB { + if states != nil && len(states) > 0 { + return func(db *gorm.DB) *gorm.DB { + return db.Where("state IN (?)", states) + } + } + return func(db *gorm.DB) *gorm.DB { + return db + } +} + +func allGetEventCriterion(states []string) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Scopes(criterionEventState(states)) + } +} diff --git a/server/repository_update.go b/server/repository_update.go new file mode 100644 index 0000000..c7fdc32 --- /dev/null +++ b/server/repository_update.go @@ -0,0 +1,302 @@ +package server + +import ( + "encoding/json" + "git.myservermanager.com/varakh/upda/api" + "gorm.io/gorm" + "time" +) + +type updateRepository interface { + paginate(page int, pageSize int, orderBy string, order string, searchTerm string, searchIn string, state ...api.UpdateState) ([]*Update, error) + count(searchTerm string, searchIn string, state ...api.UpdateState) (int64, error) + findAll() ([]*Update, error) + find(id string) (*Update, error) + findBy(application string, provider string, host string) (*Update, error) + create(application string, provider string, host string, version string, metadata interface{}) (*Update, error) + update(id string, version string, metadata interface{}) (*Update, error) + updateState(id string, state api.UpdateState) (*Update, error) + delete(id string) (int64, error) + deleteByUpdatedAtBeforeAndStates(time time.Time, state ...api.UpdateState) (int64, error) +} + +type updateDbRepo struct { + db *gorm.DB +} + +func newUpdateDbRepo(db *gorm.DB) *updateDbRepo { + return &updateDbRepo{ + db: db, + } +} + +func (r *updateDbRepo) findAll() ([]*Update, error) { + var e []*Update + var res *gorm.DB + + if res = r.db.Find(&e); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + + return e, nil +} + +func (r *updateDbRepo) find(id string) (*Update, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + var e Update + var res *gorm.DB + + if res = r.db.Find(&e, "id = ?", id); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + if res.RowsAffected == 0 { + return nil, errorResourceNotFound + } + + return &e, nil +} + +func (r *updateDbRepo) findBy(application string, provider string, host string) (*Update, error) { + if application == "" || provider == "" || host == "" { + return nil, errorValidationNotBlank + } + + var e *Update + var res *gorm.DB + + if res = r.db.Find(&e, &Update{Application: application, Provider: provider, Host: host}).Limit(1); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + + if res.RowsAffected == 0 || e == nil { + return nil, errorResourceNotFound + + } + + return e, nil +} + +func (r *updateDbRepo) create(application string, provider string, host string, version string, metadata interface{}) (*Update, error) { + if application == "" || provider == "" || host == "" || version == "" { + return nil, errorValidationNotBlank + } + + var e *Update + unmarshalledMetadata := JSONMap{} + + if metadata != nil { + marshalledMetadata, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + err = unmarshalledMetadata.UnmarshalJSON(marshalledMetadata) + if err != nil { + return nil, err + } + } + + e = &Update{ + Application: application, + Provider: provider, + Host: host, + Version: version, + State: api.UpdateStatePending.Value(), + Metadata: unmarshalledMetadata, + } + + var res *gorm.DB + if res = r.db.Create(&e); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + if res.RowsAffected == 0 { + return nil, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *updateDbRepo) updateState(id string, state api.UpdateState) (*Update, error) { + if id == "" || state == "" { + return nil, errorValidationNotBlank + } + + var err error + var e *Update + + if e, err = r.find(id); err != nil { + return nil, err + } + + e.State = state.Value() + + var res *gorm.DB + if res = r.db.Save(&e); res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return e, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *updateDbRepo) update(id string, version string, metadata interface{}) (*Update, error) { + if id == "" || version == "" { + return nil, errorValidationNotBlank + } + + var err error + var e *Update + + if e, err = r.find(id); err != nil { + return nil, err + } + + unmarshalledMetadata := JSONMap{} + + if metadata != nil { + marshalledMetadata, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + err = unmarshalledMetadata.UnmarshalJSON(marshalledMetadata) + if err != nil { + return nil, err + } + } + + e.Version = version + e.Metadata = unmarshalledMetadata + + var res *gorm.DB + if res = r.db.Save(&e); res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return e, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *updateDbRepo) delete(id string) (int64, error) { + if id == "" { + return 0, errorValidationNotBlank + } + + var res *gorm.DB + if res = r.db.Delete(&Update{}, "id = ?", id); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + return res.RowsAffected, nil +} + +func (r *updateDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, state ...api.UpdateState) (int64, error) { + if len(state) == 0 { + return 0, errorValidationNotEmpty + } + + states := make([]string, 0) + for _, i := range state { + states = append(states, i.Value()) + } + + var res *gorm.DB + if res = r.db.Where("state IN ?", states).Where("updated_at < ?", time).Delete(&Update{}); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + + return res.RowsAffected, nil +} + +func (r *updateDbRepo) paginate(page int, pageSize int, orderBy string, order string, searchTerm string, searchIn string, state ...api.UpdateState) ([]*Update, error) { + if page == 0 || pageSize <= 0 { + return nil, errorValidationPageGreaterZero + } + if pageSize <= 0 { + return nil, errorValidationPageSizeGreaterZero + } + + offset := (page - 1) * pageSize + + var e []*Update + + if orderBy == "" { + orderBy = "updated_at" + } + if order == "" { + order = "desc" + } + + states := make([]string, 0) + if len(state) > 0 { + for _, s := range state { + states = append(states, s.Value()) + } + } + + if res := r.db.Scopes(allGetUpdateCriterion(searchTerm, searchIn, states)).Order(orderBy + " " + order).Offset(offset).Limit(pageSize).Find(&e); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + + return e, nil +} + +func (r *updateDbRepo) count(searchTerm string, searchIn string, state ...api.UpdateState) (int64, error) { + var c int64 + + states := make([]string, 0) + if len(state) > 0 { + for _, s := range state { + states = append(states, s.Value()) + } + } + + if res := r.db.Model(&Update{}).Scopes(allGetUpdateCriterion(searchTerm, searchIn, states)).Count(&c); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + + return c, nil +} + +func criterionUpdateSearch(searchTerm string, searchIn string) func(db *gorm.DB) *gorm.DB { + if searchTerm == "" || searchIn == "" { + return func(db *gorm.DB) *gorm.DB { + return db + } + } + switch searchIn { + case "host": + return func(db *gorm.DB) *gorm.DB { + return db.Where("host LIKE ?", "%"+searchTerm+"%") + } + case "provider": + return func(db *gorm.DB) *gorm.DB { + return db.Where("provider LIKE ?", "%"+searchTerm+"%") + } + default: + return func(db *gorm.DB) *gorm.DB { + return db.Where("application LIKE ?", "%"+searchTerm+"%") + } + } +} + +func criterionUpdateState(states []string) func(db *gorm.DB) *gorm.DB { + if states != nil && len(states) > 0 { + return func(db *gorm.DB) *gorm.DB { + return db.Where("state IN (?)", states) + } + } + return func(db *gorm.DB) *gorm.DB { + return db + } +} + +func allGetUpdateCriterion(searchTerm string, searchIn string, states []string) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Scopes(criterionUpdateSearch(searchTerm, searchIn), criterionUpdateState(states)) + } +} diff --git a/server/repository_webhook.go b/server/repository_webhook.go new file mode 100644 index 0000000..8c6e248 --- /dev/null +++ b/server/repository_webhook.go @@ -0,0 +1,166 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "gorm.io/gorm" +) + +type WebhookRepository interface { + paginate(page int, pageSize int, orderBy string, order string) ([]*Webhook, error) + count() (int64, error) + find(id string) (*Webhook, error) + create(label string, t api.WebhookType, token string, ignoreHost bool) (*Webhook, error) + updateLabel(id string, label string) (*Webhook, error) + updateIgnoreHost(id string, ignoreHost bool) (*Webhook, error) + delete(id string) (int64, error) +} + +type webhookDbRepo struct { + db *gorm.DB +} + +func newWebhookDbRepo(db *gorm.DB) *webhookDbRepo { + return &webhookDbRepo{ + db: db, + } +} + +func (r *webhookDbRepo) find(id string) (*Webhook, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + var e Webhook + var res *gorm.DB + if res = r.db.Find(&e, "id = ?", id); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + if res.RowsAffected == 0 { + return nil, errorResourceNotFound + } + + return &e, nil +} + +func (r *webhookDbRepo) create(label string, t api.WebhookType, token string, ignoreHost bool) (*Webhook, error) { + if label == "" || t == "" || token == "" { + return nil, errorValidationNotBlank + } + + e := &Webhook{ + Label: label, + Type: t.Value(), + Token: token, + IgnoreHost: ignoreHost, + } + + var res *gorm.DB + if res = r.db.Create(&e); res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + if res.RowsAffected == 0 { + return nil, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *webhookDbRepo) updateLabel(id string, label string) (*Webhook, error) { + if id == "" || label == "" { + return nil, errorValidationNotBlank + } + + var err error + var e *Webhook + + if e, err = r.find(id); err != nil { + return nil, err + } + + e.Label = label + + var res *gorm.DB + if res = r.db.Save(&e); res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return e, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *webhookDbRepo) updateIgnoreHost(id string, ignoreHost bool) (*Webhook, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + var err error + var e *Webhook + + if e, err = r.find(id); err != nil { + return nil, err + } + + e.IgnoreHost = ignoreHost + + var res *gorm.DB + if res = r.db.Save(&e); res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return e, errorDatabaseRowsExpected + } + + return e, nil +} + +func (r *webhookDbRepo) delete(id string) (int64, error) { + if id == "" { + return 0, errorValidationNotBlank + } + + var res *gorm.DB + if res = r.db.Delete(&Webhook{}, "id = ?", id); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + + return res.RowsAffected, nil +} + +func (r *webhookDbRepo) paginate(page int, pageSize int, orderBy string, order string) ([]*Webhook, error) { + if page == 0 || pageSize <= 0 { + return nil, errorValidationPageGreaterZero + } + if pageSize <= 0 { + return nil, errorValidationPageSizeGreaterZero + } + + offset := (page - 1) * pageSize + + var e []*Webhook + var res *gorm.DB + + if orderBy != "" && order != "" { + res = r.db.Order(orderBy + " " + order).Offset(offset).Limit(pageSize).Find(&e) + } else { + res = r.db.Offset(offset).Limit(pageSize).Find(&e) + } + + if res.Error != nil { + return nil, newServiceDatabaseError(res.Error) + } + + return e, nil +} + +func (r *webhookDbRepo) count() (int64, error) { + var c int64 + var res *gorm.DB + + if res = r.db.Model(&Webhook{}).Count(&c); res.Error != nil { + return 0, newServiceDatabaseError(res.Error) + } + + return c, nil +} diff --git a/server/service_event.go b/server/service_event.go new file mode 100644 index 0000000..31d2833 --- /dev/null +++ b/server/service_event.go @@ -0,0 +1,214 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "go.uber.org/zap" + "time" +) + +type eventService struct { + repo eventRepository +} + +func newEventService(r eventRepository) *eventService { + return &eventService{ + repo: r, + } +} + +func (s *eventService) createUpdateCreated(e *Update) *Event { + if e == nil { + return nil + } + + s.createWithWarnOnly(api.EventNameUpdateCreated, &api.EventPayloadUpdateCreatedDto{ + ID: e.ID, + Application: e.Application, + Provider: e.Provider, + Host: e.Host, + Version: e.Version, + State: e.State, + }) + + return nil +} + +func (s *eventService) createUpdateUpdated(old *Update, new *Update) *Event { + if old == nil || new == nil { + return nil + } + + var eventName api.EventName + + if old.State == new.State { + eventName = api.EventNameUpdateUpdated + } else { + switch old.State { + case api.UpdateStatePending.Value(): + eventName = api.EventNameUpdateUpdatedPending + break + case api.UpdateStateApproved.Value(): + eventName = api.EventNameUpdateUpdatedApproved + break + case api.UpdateStateIgnored.Value(): + eventName = api.EventNameUpdateUpdatedIgnored + break + } + } + + s.createWithWarnOnly(eventName, &api.EventPayloadUpdateUpdatedDto{ + ID: new.ID, + Application: new.Application, + Provider: new.Provider, + Host: new.Host, + VersionPrior: old.Version, + Version: new.Version, + StatePrior: old.State, + State: new.State, + }) + + return nil +} + +func (s *eventService) createUpdateDeleted(e *Update) *Event { + if e == nil { + return nil + } + + s.createWithWarnOnly(api.EventNameUpdateDeleted, &api.EventPayloadUpdateDeletedDto{ + Application: e.Application, + Provider: e.Provider, + Host: e.Host, + Version: e.Version, + }) + + return nil +} + +func (s *eventService) createWebhookCreated(e *Webhook) *Event { + if e == nil { + return nil + } + + s.createWithWarnOnly(api.EventNameWebhookCreated, &api.EventPayloadWebhookCreatedDto{ + ID: e.ID, + Label: e.Label, + Type: e.Type, + IgnoreHost: e.IgnoreHost, + }) + + return nil +} + +func (s *eventService) createWebhookUpdated(old *Webhook, new *Webhook) *Event { + if old == nil || new == nil { + return nil + } + + var eventName api.EventName + + if old.Label == new.Label { + eventName = api.EventNameWebhookUpdatedIgnoreHost + } else { + eventName = api.EventNameWebhookUpdatedLabel + } + + s.createWithWarnOnly(eventName, &api.EventPayloadWebhookUpdatedDto{ + ID: new.ID, + LabelPrior: old.Label, + Label: new.Label, + IgnoreHostPrior: old.IgnoreHost, + IgnoreHost: new.IgnoreHost, + Type: new.Type, + }) + + return nil +} + +func (s *eventService) createWebhookDeleted(e *Webhook) *Event { + if e == nil { + return nil + } + + s.createWithWarnOnly(api.EventNameWebhookDeleted, &api.EventPayloadWebhookDeletedDto{ + Label: e.Label, + Type: e.Type, + IgnoreHost: e.IgnoreHost, + }) + + return nil +} + +func (s *eventService) createWithWarnOnly(name api.EventName, payload interface{}) *Event { + var e *Event + var err error + + if e, err = s.create(name, payload); err != nil { + zap.L().Sugar().Warnf("Could not create event '%s': %v", name, err) + return nil + } + + return e +} + +func (s *eventService) create(name api.EventName, payload interface{}) (*Event, error) { + if name == "" { + return nil, errorValidationNotBlank + } + + var e *Event + var err error + + if e, err = s.repo.create(name, api.EventStateCreated, payload); err != nil { + return nil, err + } + + zap.L().Sugar().Infof("Created event '%v'", e.Name) + + return e, err +} + +func (s *eventService) get(id string) (*Event, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + return s.repo.find(id) +} + +func (s *eventService) delete(id string) error { + if id == "" { + return errorValidationNotBlank + } + + if _, err := s.get(id); err != nil { + return err + } + + if _, err := s.repo.delete(id); err != nil { + return err + } + + zap.L().Sugar().Infof("Deleted event '%v'", id) + return nil +} + +func (s *eventService) cleanStale(time time.Time, state ...api.EventState) (int64, error) { + if len(state) == 0 { + return 0, errorValidationNotEmpty + } + + return s.repo.deleteByUpdatedAtBeforeAndStates(time, state...) +} + +func (s *eventService) window(size int, skip int, orderBy string, order string) ([]*Event, error) { + return s.repo.window(size, skip, orderBy, order) +} + +func (s *eventService) windowHasNext(size int, skip int, orderBy string, order string) (bool, error) { + return s.repo.windowHasNext(size, skip, orderBy, order) +} + +func (s *eventService) count(state ...api.EventState) (int64, error) { + return s.repo.count(state...) +} diff --git a/server/service_prometheus.go b/server/service_prometheus.go new file mode 100644 index 0000000..9dc556f --- /dev/null +++ b/server/service_prometheus.go @@ -0,0 +1,139 @@ +package server + +import ( + "github.com/Depado/ginprom" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type prometheusService struct { + router *gin.Engine + prometheus *ginprom.Prometheus + config *prometheusConfig +} + +func newPrometheusService(r *gin.Engine, c *prometheusConfig) *prometheusService { + var p *ginprom.Prometheus + + if c.enabled { + if c.secureTokenEnabled { + p = ginprom.New( + ginprom.Engine(r), + ginprom.Namespace(Name), + ginprom.Subsystem(""), + ginprom.Path(c.path), + ginprom.Token(c.secureToken), + ) + } else { + p = ginprom.New( + ginprom.Engine(r), + ginprom.Namespace(Name), + ginprom.Subsystem(""), + ginprom.Path(c.path), + ginprom.Token(c.secureToken), + ) + } + } + + return &prometheusService{ + prometheus: p, + config: c, + } +} + +func (s *prometheusService) init() { + if !s.config.enabled { + return + } + + var err error + + err = s.registerGaugeNoLabels(metricUpdatesTotal, metricUpdatesTotalHelp) + err = s.registerGaugeNoLabels(metricUpdatesPending, metricUpdatesPendingHelp) + err = s.registerGaugeNoLabels(metricUpdatesIgnored, metricUpdatesIgnoredHelp) + err = s.registerGaugeNoLabels(metricUpdatesApproved, metricUpdatesApprovedHelp) + err = s.registerGauge(metricUpdates, metricUpdatesHelp, []string{"application", "provider", "host"}) + + err = s.registerGaugeNoLabels(metricWebhooks, metricWebhooksHelp) + err = s.registerGaugeNoLabels(metricEvents, metricEventsHelp) + + if err != nil { + zap.L().Sugar().Fatalf("Cannot initialize service. Reason: %v", err) + } +} + +func (s *prometheusService) registerGaugeNoLabels(name string, help string) error { + return s.registerGauge(name, help, make([]string, 0)) +} + +func (s *prometheusService) registerGauge(name string, help string, labels []string) error { + if !s.config.enabled { + return nil + } + + if name == "" || help == "" { + return errorValidationNotBlank + } + + s.prometheus.AddCustomGauge(name, help, labels) + + return nil +} + +func (s *prometheusService) registerCounterNoLabels(name string, help string) error { + return s.registerCounter(name, help, make([]string, 0)) +} + +func (s *prometheusService) registerCounter(name string, help string, labels []string) error { + if !s.config.enabled { + return nil + } + + if name == "" || help == "" { + return errorValidationNotBlank + } + + s.prometheus.AddCustomCounter(name, help, labels) + + return nil +} + +func (s *prometheusService) setGaugeNoLabels(name string, value float64) error { + return s.setGauge(name, make([]string, 0), value) +} + +func (s *prometheusService) setGauge(name string, labelValues []string, value float64) error { + if !s.config.enabled { + return nil + } + + if name == "" { + return errorValidationNotBlank + } + + if err := s.prometheus.SetGaugeValue(name, labelValues, value); err != nil { + return err + } + + return nil +} + +func (s *prometheusService) increaseCounterNoLabels(name string) error { + return s.increaseCounter(name, make([]string, 0)) +} + +func (s *prometheusService) increaseCounter(name string, labelValues []string) error { + if !s.config.enabled { + return nil + } + + if name == "" { + return errorValidationNotBlank + } + + if err := s.prometheus.IncrementCounterValue(name, labelValues); err != nil { + return err + } + + return nil +} diff --git a/server/service_task.go b/server/service_task.go new file mode 100644 index 0000000..e26f732 --- /dev/null +++ b/server/service_task.go @@ -0,0 +1,206 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "github.com/go-co-op/gocron" + redislock "github.com/go-co-op/gocron-redis-lock" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "time" +) + +type taskService struct { + updateService *updateService + eventService *eventService + webhookService *webhookService + prometheusService *prometheusService + appConfig *appConfig + taskConfig *taskConfig + prometheusConfig *prometheusConfig + scheduler *gocron.Scheduler +} + +func newTaskService(u *updateService, e *eventService, w *webhookService, p *prometheusService, ac *appConfig, tc *taskConfig, pc *prometheusConfig) *taskService { + location, err := time.LoadLocation(ac.timeZone) + + if err != nil { + zap.L().Sugar().Fatalf("Could not initialize correct timezone for scheduler. Reason: %s", err.Error()) + } + + gocron.SetPanicHandler(func(jobName string, value any) { + zap.L().Sugar().Errorf("Job '%s' had a panic %v", jobName, value) + }) + + scheduler := gocron.NewScheduler(location) + + if tc.lockRedisEnabled { + var redisOptions *redis.Options + redisOptions, err = redis.ParseURL(tc.lockRedisUrl) + + if err != nil { + zap.L().Sugar().Fatalf("Cannot parse REDIS URL '%s' to set up locking. Reason: %s", tc.lockRedisUrl, err.Error()) + } + redisClient := redis.NewClient(redisOptions) + locker, err := redislock.NewRedisLocker(redisClient, redislock.WithTries(1)) + if err != nil { + zap.L().Sugar().Fatalf("Cannot set up REDIS locker. Reason: %s", err.Error()) + } + scheduler.WithDistributedLocker(locker) + } + + return &taskService{ + updateService: u, + eventService: e, + webhookService: w, + prometheusService: p, + appConfig: ac, + taskConfig: tc, + prometheusConfig: pc, + scheduler: scheduler, + } +} + +func (s *taskService) init() { + s.configureCleanupStaleUpdatesTask() + s.configureCleanupStaleEventsTask() + s.configurePrometheusRefreshTask() +} + +func (s *taskService) stop() { + zap.L().Sugar().Infof("Stopping %d periodic tasks...", len(s.scheduler.Jobs())) + s.scheduler.Stop() + zap.L().Info("Stopped all periodic tasks") +} + +func (s *taskService) start() { + s.scheduler.StartAsync() + zap.L().Sugar().Infof("Started %d periodic tasks", len(s.scheduler.Jobs())) +} + +func (s *taskService) configureCleanupStaleUpdatesTask() { + if !s.taskConfig.updateCleanStaleEnabled { + return + } + + _, err := s.scheduler.Every(s.taskConfig.updateCleanStaleInterval). + Do(func() { + t := time.Now() + t = t.Add(-s.taskConfig.updateCleanStaleMaxAge) + + var err error + var c int64 + + if c, err = s.updateService.cleanStale(t, api.UpdateStateApproved, api.UpdateStateIgnored); err != nil { + zap.L().Sugar().Errorf("Could not clean up ignored or approved updates older than %s (%s). Reason: %s", s.taskConfig.updateCleanStaleMaxAge, t, err.Error()) + return + } + + if c > 0 { + zap.L().Sugar().Infof("Cleaned up '%d' stale updates", c) + } else { + zap.L().Info("No stale updates found to clean up") + } + }) + + if err != nil { + zap.L().Sugar().Fatalf("Could not create task for cleaning stale updates. Reason: %s", err.Error()) + } +} + +func (s *taskService) configureCleanupStaleEventsTask() { + if !s.taskConfig.eventCleanStaleEnabled { + return + } + + _, err := s.scheduler.Every(s.taskConfig.eventCleanStaleInterval). + Do(func() { + t := time.Now() + t = t.Add(-s.taskConfig.eventCleanStaleMaxAge) + + var err error + var c int64 + + if c, err = s.eventService.cleanStale(t, api.EventStateCreated); err != nil { + zap.L().Sugar().Errorf("Could not clean up stale events older than %s (%s). Reason: %s", s.taskConfig.eventCleanStaleMaxAge, t, err.Error()) + return + } + + if c > 0 { + zap.L().Sugar().Infof("Cleaned up '%d' stale events", c) + } else { + zap.L().Info("No stale events found to clean up") + } + }) + + if err != nil { + zap.L().Sugar().Fatalf("Could not create task for cleaning stale events. Reason: %s", err.Error()) + } +} + +func (s *taskService) configurePrometheusRefreshTask() { + if !s.prometheusConfig.enabled { + return + } + + _, err := s.scheduler.Every(s.taskConfig.prometheusRefreshInterval). + Do(func() { + // updates with labels and collect stats about state + updates, updatesError := s.updateService.getAll() + + if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesTotal, float64(len(updates))); updatesError != nil { + zap.L().Sugar().Errorf("Could not refresh updates all prometheus metric. Reason: %s", updatesError.Error()) + } + + var pendingTotal int64 + var ignoredTotal int64 + var ackTotal int64 + + for _, update := range updates { + var updateState float64 + if api.UpdateStatePending.Value() == update.State { + pendingTotal += 1 + updateState = 0 + } else if api.UpdateStateIgnored.Value() == update.State { + ignoredTotal += 1 + updateState = 2 + } else if api.UpdateStateApproved.Value() == update.State { + ackTotal += 1 + updateState = 1 + } + + if updatesError = s.prometheusService.setGauge(metricUpdates, []string{update.Application, update.Provider, update.Host}, updateState); updatesError != nil { + zap.L().Sugar().Errorf("Could not refresh updates prometheus metric. Reason: %s", updatesError.Error()) + } + } + + if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesPending, float64(pendingTotal)); updatesError != nil { + zap.L().Sugar().Errorf("Could not refresh updates pending prometheus metric. Reason: %s", updatesError.Error()) + } + if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesIgnored, float64(ignoredTotal)); updatesError != nil { + zap.L().Sugar().Errorf("Could not refresh updates ignored prometheus metric. Reason: %s", updatesError.Error()) + } + if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesApproved, float64(ackTotal)); updatesError != nil { + zap.L().Sugar().Errorf("Could not refresh updates approved prometheus metric. Reason: %s", updatesError.Error()) + } + + // webhooks + var webhooksTotal int64 + var webhooksError error + webhooksTotal, webhooksError = s.webhookService.count() + if webhooksError = s.prometheusService.setGaugeNoLabels(metricWebhooks, float64(webhooksTotal)); webhooksError != nil { + zap.L().Sugar().Errorf("Could not refresh webhooks prometheus metric. Reason: %s", webhooksError.Error()) + } + + // events + var eventsTotal int64 + var eventsError error + eventsTotal, eventsError = s.eventService.count() + if eventsError = s.prometheusService.setGaugeNoLabels(metricEvents, float64(eventsTotal)); eventsError != nil { + zap.L().Sugar().Errorf("Could not refresh events prometheus metric. Reason: %s", eventsError.Error()) + } + }) + + if err != nil { + zap.L().Sugar().Fatalf("Could not create task for refreshing prometheus. Reason: %s", err.Error()) + } +} diff --git a/server/service_update.go b/server/service_update.go new file mode 100644 index 0000000..1798202 --- /dev/null +++ b/server/service_update.go @@ -0,0 +1,137 @@ +package server + +import ( + "errors" + "git.myservermanager.com/varakh/upda/api" + "go.uber.org/zap" + "time" +) + +type updateService struct { + repo updateRepository + eventService *eventService +} + +func newUpdateService(r updateRepository, e *eventService) *updateService { + return &updateService{ + repo: r, + eventService: e, + } +} + +func (s *updateService) get(id string) (*Update, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + return s.repo.find(id) +} + +func (s *updateService) getAll() ([]*Update, error) { + return s.repo.findAll() +} + +func (s *updateService) upsert(application string, provider string, host string, version string, metadata interface{}) (*Update, error) { + if application == "" || provider == "" || host == "" || version == "" { + return nil, errorValidationNotBlank + } + + var e *Update + var err error + + e, err = s.repo.findBy(application, provider, host) + + if err != nil && !errors.Is(err, errorResourceNotFound) { + return nil, err + } else if err != nil && errors.Is(err, errorResourceNotFound) { + if e, err = s.repo.create(application, provider, host, version, metadata); err != nil { + return nil, err + } + s.eventService.createUpdateCreated(e) + zap.L().Sugar().Infof("Created update '%v'", e) + } else { + old := e + skip := e.State == api.UpdateStateIgnored.Value() + + if skip { + zap.L().Sugar().Infof("Skipping ignored update '%v'", e.ID) + return nil, nil + } + + if e, err = s.repo.update(e.ID.String(), version, metadata); err != nil { + return nil, err + } + + s.eventService.createUpdateUpdated(old, e) + zap.L().Sugar().Infof("Updated update '%v'", e) + + if api.UpdateStateApproved.Value() == e.State { + zap.L().Sugar().Infof("Setting update '%v' state to '%v'", e.ID, api.UpdateStatePending) + if e, err = s.repo.updateState(e.ID.String(), api.UpdateStatePending); err != nil { + return nil, err + } + } + } + + return e, err +} + +func (s *updateService) updateState(id string, state api.UpdateState) (*Update, error) { + if id == "" || state == "" { + return nil, errorValidationNotBlank + } + + var e *Update + var err error + + if e, err = s.get(id); err != nil { + return nil, err + } + + oldUpdate := e + if e, err = s.repo.updateState(id, state); err != nil { + return nil, err + } + + s.eventService.createUpdateUpdated(oldUpdate, e) + + zap.L().Sugar().Infof("Modified update '%v'", id) + return e, nil +} + +func (s *updateService) delete(id string) error { + if id == "" { + return errorValidationNotBlank + } + + var e *Update + var err error + if e, err = s.get(id); err != nil { + return err + } + + if _, err = s.repo.delete(id); err != nil { + return err + } + + s.eventService.createUpdateDeleted(e) + + zap.L().Sugar().Infof("Deleted update '%v'", id) + return nil +} + +func (s *updateService) cleanStale(time time.Time, state ...api.UpdateState) (int64, error) { + if len(state) == 0 { + return 0, errorValidationNotEmpty + } + + return s.repo.deleteByUpdatedAtBeforeAndStates(time, state...) +} + +func (s *updateService) paginate(page int, pageSize int, orderBy string, order string, searchTerm string, searchIn string, state ...api.UpdateState) ([]*Update, error) { + return s.repo.paginate(page, pageSize, orderBy, order, searchTerm, searchIn, state...) +} + +func (s *updateService) count(searchTerm string, searchIn string, state ...api.UpdateState) (int64, error) { + return s.repo.count(searchTerm, searchIn, state...) +} diff --git a/server/service_webhook.go b/server/service_webhook.go new file mode 100644 index 0000000..47b0a34 --- /dev/null +++ b/server/service_webhook.go @@ -0,0 +1,130 @@ +package server + +import ( + "fmt" + "git.myservermanager.com/varakh/upda/api" + "git.myservermanager.com/varakh/upda/util" + "go.uber.org/zap" +) + +type webhookService struct { + repo WebhookRepository + webhookConfig *webhookConfig + eventService *eventService +} + +func newWebhookService(r WebhookRepository, c *webhookConfig, e *eventService) *webhookService { + return &webhookService{ + repo: r, + webhookConfig: c, + eventService: e, + } +} + +func (s *webhookService) get(id string) (*Webhook, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + e, err := s.repo.find(id) + + if err != nil { + return nil, err + } + + return e, nil +} + +func (s *webhookService) create(label string, t api.WebhookType, ignoreHost bool) (*Webhook, error) { + if label == "" || t == "" { + return nil, errorValidationNotBlank + } + + var err error + var token string + var e *Webhook + + if token, err = util.GenerateSecureRandomString(s.webhookConfig.tokenLength); err != nil { + return nil, newServiceError(General, fmt.Errorf("token generation failed: %w", err)) + } + + if e, err = s.repo.create(label, t, token, ignoreHost); err != nil { + return nil, err + } else { + s.eventService.createWebhookCreated(e) + zap.L().Sugar().Info("Created webhook") + return e, nil + } +} + +func (s *webhookService) updateLabel(id string, label string) (*Webhook, error) { + if id == "" || label == "" { + return nil, errorValidationNotBlank + } + + var e *Webhook + var err error + + if e, err = s.get(id); err != nil { + return nil, err + } + + old := e + if e, err = s.repo.updateLabel(id, label); err != nil { + return nil, err + } + + s.eventService.createWebhookUpdated(old, e) + zap.L().Sugar().Infof("Modified webhook '%v'", id) + return e, nil +} + +func (s *webhookService) updateIgnoreHost(id string, ignoreHost bool) (*Webhook, error) { + if id == "" { + return nil, errorValidationNotBlank + } + + var e *Webhook + var err error + + if e, err = s.get(id); err != nil { + return nil, err + } + + old := e + if e, err = s.repo.updateIgnoreHost(id, ignoreHost); err != nil { + return nil, err + } + + s.eventService.createWebhookUpdated(old, e) + zap.L().Sugar().Infof("Modified webhook '%v'", id) + return e, nil +} + +func (s *webhookService) delete(id string) error { + if id == "" { + return errorValidationNotBlank + } + + e, err := s.get(id) + if err != nil { + return err + } + + if _, err = s.repo.delete(e.ID.String()); err != nil { + return err + } + + s.eventService.createWebhookDeleted(e) + zap.L().Sugar().Infof("Deleted webhook '%v'", id) + + return nil +} + +func (s *webhookService) paginate(page int, pageSize int, orderBy string, order string) ([]*Webhook, error) { + return s.repo.paginate(page, pageSize, orderBy, order) +} + +func (s *webhookService) count() (int64, error) { + return s.repo.count() +} diff --git a/server/service_webhook_invocation.go b/server/service_webhook_invocation.go new file mode 100644 index 0000000..b8080e1 --- /dev/null +++ b/server/service_webhook_invocation.go @@ -0,0 +1,105 @@ +package server + +import ( + "git.myservermanager.com/varakh/upda/api" + "strings" +) + +const ( + providerDiun = "oci" + hostIgnoreReplacement = "global" +) + +type webhookInvocationService struct { + updateService *updateService + webhookService *webhookService + webhookConfig *webhookConfig +} + +func newWebhookInvocationService(w *webhookService, u *updateService, c *webhookConfig) *webhookInvocationService { + return &webhookInvocationService{ + updateService: u, + webhookService: w, + webhookConfig: c, + } +} + +func (s *webhookInvocationService) executeGeneric(id string, token string, req api.WebhookGenericRequest) error { + if id == "" || token == "" { + return errorValidationNotBlank + } + + var e *Webhook + var err error + + if e, err = s.webhookService.get(id); err != nil { + return errorResourceNotFound + } + + if e.Token != token { + return errorResourceAccessDenied + } + + host := req.Host + if e.IgnoreHost { + host = hostIgnoreReplacement + } + + var provider string + if req.Provider == "" { + provider = e.Label + } else { + provider = req.Provider + } + + if _, err = s.updateService.upsert(req.Application, provider, host, req.Version, req); err != nil { + return err + } + + return nil +} + +func (s *webhookInvocationService) executeDiun(id string, token string, req api.WebhookDiunRequest) error { + if id == "" || token == "" { + return errorValidationNotBlank + } + + var e *Webhook + var err error + + if e, err = s.webhookService.get(id); err != nil { + return errorResourceNotFound + } + + if e.Token != token { + return errorResourceAccessDenied + } + + host := req.Hostname + if e.IgnoreHost { + host = hostIgnoreReplacement + } + + // assume the "image" attribute has a : separator at the end + ss := strings.Split(req.Image, ":") + version := ss[len(ss)-1] + app := strings.Join(ss, "") + app = strings.ReplaceAll(app, version, "") + + if version == "" { + version = req.Digest + } + + var provider string + if e.Label == "" { + provider = providerDiun + } else { + provider = e.Label + } + + if _, err = s.updateService.upsert(app, provider, host, version, req); err != nil { + return err + } + + return nil +} diff --git a/terminal/app.go b/terminal/app.go new file mode 100644 index 0000000..d3378c5 --- /dev/null +++ b/terminal/app.go @@ -0,0 +1,337 @@ +package terminal + +import ( + "encoding/json" + "errors" + "fmt" + "git.myservermanager.com/varakh/upda/api" + "git.myservermanager.com/varakh/upda/server" + "git.myservermanager.com/varakh/upda/util" + "github.com/go-resty/resty/v2" + "github.com/urfave/cli/v2" + "log" + "os" + "text/tabwriter" +) + +const ( + name = "upda-cli" + desc = "a commandline helper for upda" + version = server.Version + + envServerUrl = "UPDA_SERVER_URL" + envAdminUser = "UPDA_ADMIN_USER" + envAdminPassword = "UPDA_ADMIN_PASSWORD" + envWebhookId = "UPDA_WEBHOOK_ID" + envWebhookToken = "UPDA_WEBHOOK_TOKEN" + + flagServerUrl = "server-url" + flagAdminUser = "admin-user" + flagAdminPass = "admin-pass" + flagWebhookId = "webhook-id" + flagWebhookToken = "webhook-token" + flagUpdatePageSize = "page-size" + + flagRaw = "raw" + + webhooksUrlPath = "/api/v1/webhooks" + updatesUrlPath = "/api/v1/updates" +) + +func Start() { + var raw bool + var serverUrl string + var adminUser string + var adminPassword string + var webhookId string + var webhookToken string + var updatePageSize int64 + + rawFlag := &cli.BoolFlag{ + Name: flagRaw, + Usage: "on success raw JSON data from response is returned", + Aliases: []string{"r"}, + Value: false, + Destination: &raw, + } + + serverUrlFlag := &cli.StringFlag{ + Name: flagServerUrl, + Usage: "the server url (FQDN without context path)", + Required: true, + Aliases: []string{"s"}, + EnvVars: []string{envServerUrl}, + Destination: &serverUrl, + } + adminUserFlag := &cli.StringFlag{ + Name: flagAdminUser, + Usage: "admin user", + Required: true, + Aliases: []string{"u"}, + EnvVars: []string{envAdminUser}, + Destination: &adminUser, + } + adminPasswordFlag := &cli.StringFlag{ + Name: flagAdminPass, + Usage: "admin password", + Required: true, + Aliases: []string{"p"}, + EnvVars: []string{envAdminPassword}, + Destination: &adminPassword, + } + webhookIdFlag := &cli.StringFlag{ + Name: flagWebhookId, + Usage: "webhook id", + Required: true, + Aliases: []string{"i"}, + EnvVars: []string{envWebhookId}, + Destination: &webhookId, + } + webhookTokenFlag := &cli.StringFlag{ + Name: flagWebhookToken, + Usage: "webhook token", + Required: true, + Aliases: []string{"t"}, + EnvVars: []string{envWebhookToken}, + Destination: &webhookToken, + } + updatePageSizeFlag := &cli.Int64Flag{ + Name: flagUpdatePageSize, + Usage: "update show page size", + Value: 10000, + Required: false, + Aliases: []string{"ps"}, + Destination: &updatePageSize, + } + + cli.VersionFlag = &cli.BoolFlag{ + Name: "version", + Aliases: []string{"v"}, + Usage: "show version", + } + app := &cli.App{ + Name: name, + Usage: desc, + Version: version, + EnableBashCompletion: true, + Commands: []*cli.Command{ + { + Name: "webhook", + Aliases: []string{"w"}, + Usage: "Options for webhook", + Subcommands: []*cli.Command{ + { + Name: "create", + Usage: "Creates a webhook", + Aliases: []string{"c"}, + Flags: []cli.Flag{ + serverUrlFlag, + adminUserFlag, + adminPasswordFlag, + rawFlag, + }, + ArgsUsage: "